From e7587ca9f5a46054ea7dc4fe465337411960d9d7 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 16:47:09 +0200 Subject: [PATCH] feat: workspace-first UI refresh - PR-1 backend endpoints - Add FileService for workspace-scoped file operations - Add GitOperations service for workspace-scoped git commands - Add workspace_files API: GET/POST /workspaces/{id}/files - Add workspace_git API: status, branches, commit, push, pull, fetch, checkout, history - Add workspace_instances API: list instances per workspace - Add top-level POST /workspaces/ (accepts repo_id directly) - Enrich GET /projects/ with nested repositories and workspaces - Register all new routers in main.py - 23 tests passing (17 existing + 6 new) Quality gates: ruff clean --- apps/api/src/api/projects.py | 66 +- apps/api/src/api/workspace_files.py | 114 +++ apps/api/src/api/workspace_git.py | 203 +++++ apps/api/src/api/workspace_instances.py | 60 ++ apps/api/src/api/workspaces.py | 55 +- apps/api/src/main.py | 6 + apps/api/src/services/file_service.py | 128 ++++ apps/api/src/services/git_operations.py | 225 ++++++ apps/api/tests/unit/test_file_service.py | 84 +++ apps/web/src/pages/workspaces.tsx | 62 +- openspec/changes/workspace-first-ui/design.md | 694 ++++++++++++++++++ .../changes/workspace-first-ui/proposal.md | 184 +++++ openspec/changes/workspace-first-ui/spec.md | 366 +++++++++ openspec/changes/workspace-first-ui/tasks.md | 132 ++++ 14 files changed, 2347 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/api/workspace_files.py create mode 100644 apps/api/src/api/workspace_git.py create mode 100644 apps/api/src/api/workspace_instances.py create mode 100644 apps/api/src/services/file_service.py create mode 100644 apps/api/src/services/git_operations.py create mode 100644 apps/api/tests/unit/test_file_service.py create mode 100644 openspec/changes/workspace-first-ui/design.md create mode 100644 openspec/changes/workspace-first-ui/proposal.md create mode 100644 openspec/changes/workspace-first-ui/spec.md create mode 100644 openspec/changes/workspace-first-ui/tasks.md diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index 67cde56..3dbc01c 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -4,13 +4,14 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status from pydantic import BaseModel, ConfigDict -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey +from src.models.tool_instance import ToolInstance router = APIRouter(prefix="/projects", tags=["projects"]) @@ -76,26 +77,67 @@ async def create_project( @router.get( "", - response_model=list[ProjectResponse], summary="List all projects", - description="Retrieve all projects owned by the authenticated user.", + description="Retrieve all projects owned by the authenticated user with repositories and workspaces.", ) async def list_projects( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), -) -> list[Project]: +) -> list[dict]: """List all projects for the authenticated user. - Args: - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of projects owned by the user. + Returns projects with nested repositories and workspaces for inline display. """ user = await _get_user(session, user_id) - result = await session.execute(select(Project).where(Project.owner_id == user.id)) - return list(result.scalars().all()) + result = await session.execute( + select(Project).where(Project.owner_id == user.id).order_by(Project.created_at.desc()) + ) + projects = result.scalars().all() + + from src.models.workspace import Workspace + + enriched = [] + for project in projects: + repos_result = await session.execute( + select(GitRepository).where(GitRepository.project_id == project.id) + ) + repositories = [] + for repo in repos_result.scalars().all(): + ws_result = await session.execute( + select(Workspace).where(Workspace.repo_id == repo.id) + ) + workspaces = [] + for ws in ws_result.scalars().all(): + # Count instances + inst_result = await session.execute( + select(func.count()).where(ToolInstance.workspace_id == ws.id) + ) + instance_count = inst_result.scalar() or 0 + workspaces.append({ + "id": str(ws.id), + "name": ws.name, + "branch": ws.branch, + "status": ws.status, + "instance_count": instance_count, + }) + + repositories.append({ + "id": str(repo.id), + "name": repo.name, + "remote_url": repo.remote_url, + "workspaces": workspaces, + }) + + enriched.append({ + "id": str(project.id), + "name": project.name, + "description": project.description, + "owner_id": str(project.owner_id), + "repositories": repositories, + "created_at": project.created_at.isoformat() if project.created_at else None, + }) + + return enriched @router.get( diff --git a/apps/api/src/api/workspace_files.py b/apps/api/src/api/workspace_files.py new file mode 100644 index 0000000..ab29e59 --- /dev/null +++ b/apps/api/src/api/workspace_files.py @@ -0,0 +1,114 @@ +"""Workspace file API endpoints.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.workspace import Workspace +from src.services.file_service import FileService + +router = APIRouter(prefix="/workspaces/{workspace_id}/files") + + +async def _get_workspace( + session: AsyncSession, + workspace_id: uuid.UUID, + user_id: uuid.UUID, +) -> Workspace: + from sqlalchemy import select + + result = await session.execute( + select(Workspace).where( + Workspace.id == workspace_id, + Workspace.user_id == user_id, + ) + ) + workspace = result.scalar_one_or_none() + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + return workspace + + +@router.get("/") +async def list_files( + workspace_id: uuid.UUID, + path: str = "", + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List files in a workspace directory.""" + workspace = await _get_workspace(session, workspace_id, user_id) + service = FileService() + try: + entries = service.list_directory(workspace, path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return { + "entries": [ + { + "name": e.name, + "path": e.path, + "type": e.type, + "size": e.size, + } + for e in entries + ], + } + + +@router.get("/content") +async def get_file_content( + workspace_id: uuid.UUID, + path: str, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get the content of a text file.""" + workspace = await _get_workspace(session, workspace_id, user_id) + service = FileService() + try: + content = service.read_file(workspace, path) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return {"content": content, "path": path} + + +@router.post("/content") +async def write_file( + workspace_id: uuid.UUID, + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Write a file and optionally commit.""" + workspace = await _get_workspace(session, workspace_id, user_id) + service = FileService() + + file_path = data.get("path", "").strip() + content = data.get("content", "") + commit_message = data.get("message", "").strip() + + if not file_path: + raise HTTPException(status_code=400, detail="File path is required") + + try: + service.write_file(workspace, file_path, content) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if commit_message: + from src.services.git_operations import GitOperations + + git = GitOperations(workspace) + try: + await git.commit(commit_message) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "saved", "path": file_path} diff --git a/apps/api/src/api/workspace_git.py b/apps/api/src/api/workspace_git.py new file mode 100644 index 0000000..0a8177c --- /dev/null +++ b/apps/api/src/api/workspace_git.py @@ -0,0 +1,203 @@ +"""Workspace git API endpoints.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.workspace import Workspace +from src.services.git_operations import GitOperations + +router = APIRouter(prefix="/workspaces/{workspace_id}/git") + + +async def _get_workspace( + session: AsyncSession, + workspace_id: uuid.UUID, + user_id: uuid.UUID, +) -> Workspace: + from sqlalchemy import select + + result = await session.execute( + select(Workspace).where( + Workspace.id == workspace_id, + Workspace.user_id == user_id, + ) + ) + workspace = result.scalar_one_or_none() + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + return workspace + + +@router.get("/status") +async def git_status( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get git status for the workspace.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + status = await git.status() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return { + "branch": status.branch, + "modified": status.modified, + "added": status.added, + "deleted": status.deleted, + "untracked": status.untracked, + "ahead": status.ahead, + "behind": status.behind, + } + + +@router.get("/branches") +async def git_branches( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List branches for the workspace.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + branches, current = await git.branches() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return { + "branches": branches, + "current_branch": current, + } + + +@router.post("/commit") +async def git_commit( + workspace_id: uuid.UUID, + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Stage all changes and commit.""" + workspace = await _get_workspace(session, workspace_id, user_id) + message = data.get("message", "").strip() + if not message: + raise HTTPException(status_code=400, detail="Commit message is required") + + git = GitOperations(workspace) + try: + await git.commit(message) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "committed"} + + +@router.post("/push") +async def git_push( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Push current branch.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + await git.push() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "pushed"} + + +@router.post("/pull") +async def git_pull( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Pull current branch.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + await git.pull() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "pulled"} + + +@router.post("/fetch") +async def git_fetch( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Fetch from origin.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + await git.fetch() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "fetched"} + + +@router.post("/checkout") +async def git_checkout( + workspace_id: uuid.UUID, + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Checkout a branch.""" + workspace = await _get_workspace(session, workspace_id, user_id) + branch = data.get("branch", "").strip() + if not branch: + raise HTTPException(status_code=400, detail="Branch name is required") + + git = GitOperations(workspace) + try: + await git.checkout(branch) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + workspace.branch = branch + await session.commit() + + return {"status": "checked_out", "branch": branch} + + +@router.get("/history") +async def git_history( + workspace_id: uuid.UUID, + path: str | None = None, + limit: int = 50, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get commit history.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + commits = await git.history(path, limit) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return { + "commits": [ + { + "hash": c.hash, + "message": c.message, + "author": c.author, + "date": c.date, + } + for c in commits + ], + } diff --git a/apps/api/src/api/workspace_instances.py b/apps/api/src/api/workspace_instances.py new file mode 100644 index 0000000..aaaea88 --- /dev/null +++ b/apps/api/src/api/workspace_instances.py @@ -0,0 +1,60 @@ +"""Workspace instance API endpoints.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.tool_instance import ToolInstance +from src.models.workspace import Workspace + +router = APIRouter(prefix="/workspaces/{workspace_id}/instances") + + +async def _get_workspace( + session: AsyncSession, + workspace_id: uuid.UUID, + user_id: uuid.UUID, +) -> Workspace: + result = await session.execute( + select(Workspace).where( + Workspace.id == workspace_id, + Workspace.user_id == user_id, + ) + ) + workspace = result.scalar_one_or_none() + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + return workspace + + +@router.get("/") +async def list_workspace_instances( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> list[dict]: + """List tool instances using this workspace.""" + await _get_workspace(session, workspace_id, user_id) + result = await session.execute( + select(ToolInstance) + .where(ToolInstance.workspace_id == workspace_id) + .order_by(ToolInstance.created_at.desc()) + ) + instances = result.scalars().all() + + return [ + { + "id": str(i.id), + "name": i.name, + "display_name": i.display_name, + "status": i.status, + "tool_type_id": str(i.tool_type_id), + "url": i.url, + "port": i.port, + "created_at": i.created_at.isoformat() if i.created_at else None, + } + for i in instances + ] diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py index 1c20c89..0f8f4ad 100644 --- a/apps/api/src/api/workspaces.py +++ b/apps/api/src/api/workspaces.py @@ -53,7 +53,7 @@ async def list_all_workspaces( "repo_id": str(ws.repo_id), "repo_name": repo_name or "", "project_id": str(project_id) if project_id else "", - "project_name": "", # Could join with Project if needed + "project_name": "", "user_id": str(ws.user_id), "branch": ws.branch, "path": ws.path, @@ -67,6 +67,59 @@ async def list_all_workspaces( ] +@all_workspaces_router.post("/") +async def create_workspace_top_level( + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Create a workspace directly (no nested project/repo path).""" + repo_id_str = data.get("repo_id", "").strip() + if not repo_id_str: + raise HTTPException(status_code=400, detail="repo_id is required") + + try: + repo_id = uuid.UUID(repo_id_str) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc + + repo = await session.get(GitRepository, repo_id) + if not repo or repo.owner_id != user_id: + raise HTTPException(status_code=404, detail="Repository not found") + + name = data.get("name", "").strip() + branch = data.get("branch", "main").strip() + + if not name: + raise HTTPException(status_code=400, detail="Workspace name is required") + + manager = WorkspaceManager() + try: + workspace = await manager.create(repo, user_id, name, branch) + session.add(workspace) + await session.commit() + except Exception as exc: + await session.rollback() + logger.error("Failed to create workspace: %s", exc) + raise HTTPException( + status_code=409, + detail="Workspace name already exists for this repository", + ) from exc + + await session.refresh(workspace) + return { + "id": str(workspace.id), + "name": workspace.name, + "repo_id": str(workspace.repo_id), + "branch": workspace.branch, + "path": workspace.path, + "status": workspace.status, + "created_at": workspace.created_at.isoformat() + if workspace.created_at + else None, + } + + @router.get("/") async def list_workspaces( project_id: uuid.UUID, diff --git a/apps/api/src/main.py b/apps/api/src/main.py index b10e897..d94f8a5 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -24,6 +24,9 @@ from src.api.tool_types import router as tool_types_router from src.api.notifications import router as notifications_router from src.api.user_config import router as user_config_router from src.api.users import router as users_router +from src.api.workspace_files import router as workspace_files_router +from src.api.workspace_git import router as workspace_git_router +from src.api.workspace_instances import router as workspace_instances_router from src.api.workspaces import all_workspaces_router, router as workspaces_router from src.config import Settings from src.models.notification import Notification # noqa: F401 – Alembic model discovery @@ -162,4 +165,7 @@ app.include_router(events_router) app.include_router(notifications_router) app.include_router(all_workspaces_router) app.include_router(workspaces_router) +app.include_router(workspace_files_router) +app.include_router(workspace_git_router) +app.include_router(workspace_instances_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/api/src/services/file_service.py b/apps/api/src/services/file_service.py new file mode 100644 index 0000000..072caa6 --- /dev/null +++ b/apps/api/src/services/file_service.py @@ -0,0 +1,128 @@ +"""File operations scoped to a workspace directory.""" + +import logging +import os +from dataclasses import dataclass + +from src.models.workspace import Workspace + +logger = logging.getLogger(__name__) + + +@dataclass +class FileEntry: + """A single file or directory entry.""" + + name: str + path: str + type: str # "file" or "directory" + size: int | None = None + + +class FileService: + """Read and write files within a workspace directory.""" + + def list_directory( + self, + workspace: Workspace, + relative_path: str = "", + ) -> list[FileEntry]: + """List entries in a workspace directory. + + Args: + workspace: The workspace to list files in. + relative_path: Path relative to workspace root. + + Returns: + List of file entries sorted by name (directories first). + """ + abs_path = os.path.join(workspace.path, relative_path) + abs_path = os.path.normpath(abs_path) + + # Security: ensure we stay within workspace + if not abs_path.startswith(os.path.normpath(workspace.path)): + raise ValueError("Path escapes workspace directory") + + if not os.path.exists(abs_path): + return [] + + entries = [] + for item in sorted(os.listdir(abs_path)): + full = os.path.join(abs_path, item) + rel = os.path.join(relative_path, item) if relative_path else item + is_dir = os.path.isdir(full) + size = os.path.getsize(full) if os.path.isfile(full) else None + entries.append( + FileEntry( + name=item, + path=rel.replace("\\", "/"), + type="directory" if is_dir else "file", + size=size, + ) + ) + + # Directories first, then files, both alphabetical + entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower())) + return entries + + def read_file(self, workspace: Workspace, relative_path: str) -> str: + """Read a text file from the workspace. + + Args: + workspace: The workspace to read from. + relative_path: Path relative to workspace root. + + Returns: + File contents as string. + + Raises: + ValueError: If path escapes workspace or file is binary. + FileNotFoundError: If file does not exist. + """ + abs_path = self._resolve_path(workspace, relative_path) + + if not os.path.isfile(abs_path): + raise FileNotFoundError(f"Not a file: {relative_path}") + + # Basic binary check — read first 8KB and look for null bytes + with open(abs_path, "rb") as f: + chunk = f.read(8192) + if b"\x00" in chunk: + raise ValueError("Binary files cannot be viewed") + + with open(abs_path, encoding="utf-8", errors="replace") as f: + return f.read() + + def write_file( + self, + workspace: Workspace, + relative_path: str, + content: str, + ) -> None: + """Write a text file to the workspace. + + Args: + workspace: The workspace to write to. + relative_path: Path relative to workspace root. + content: File contents. + + Raises: + ValueError: If path escapes workspace. + """ + abs_path = self._resolve_path(workspace, relative_path) + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + + with open(abs_path, "w", encoding="utf-8") as f: + f.write(content) + + logger.info("Wrote file %s in workspace %s", relative_path, workspace.id) + + def _resolve_path(self, workspace: Workspace, relative_path: str) -> str: + """Resolve a relative path to absolute, with security check.""" + abs_path = os.path.normpath(os.path.join(workspace.path, relative_path)) + workspace_root = os.path.normpath(workspace.path) + + if not abs_path.startswith(workspace_root): + raise ValueError("Path escapes workspace directory") + + return abs_path diff --git a/apps/api/src/services/git_operations.py b/apps/api/src/services/git_operations.py new file mode 100644 index 0000000..45b7134 --- /dev/null +++ b/apps/api/src/services/git_operations.py @@ -0,0 +1,225 @@ +"""Git commands scoped to a workspace directory.""" + +import asyncio +import logging +from dataclasses import dataclass + +from src.models.workspace import Workspace + +logger = logging.getLogger(__name__) + + +@dataclass +class GitStatus: + """Parsed git status output.""" + + branch: str + modified: list[str] + added: list[str] + deleted: list[str] + untracked: list[str] + ahead: int = 0 + behind: int = 0 + + +@dataclass +class Commit: + """A single git commit.""" + + hash: str + message: str + author: str + date: str + + +class GitOperations: + """Run git commands within a workspace directory.""" + + def __init__(self, workspace: Workspace) -> None: + self.cwd = workspace.path + self.branch = workspace.branch + + async def _run(self, *cmd: str) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr).""" + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(), stderr.decode() + + async def status(self) -> GitStatus: + """Get git status for the workspace.""" + returncode, stdout, _ = await self._run( + "git", "-C", self.cwd, "status", "--porcelain", "-b" + ) + + modified: list[str] = [] + added: list[str] = [] + deleted: list[str] = [] + untracked: list[str] = [] + branch = self.branch + ahead = 0 + behind = 0 + + for line in stdout.splitlines(): + if line.startswith("##"): + # Branch info line + branch_info = line[3:].strip() + if "..." in branch_info: + branch = branch_info.split("...")[0] + if "[ahead " in branch_info: + ahead_str = branch_info.split("[ahead ")[1].split("]")[0] + ahead = int(ahead_str.split(",")[0]) + if "[behind " in branch_info: + behind_str = branch_info.split("[behind ")[1].split("]")[0] + behind = int(behind_str.split(",")[0]) + else: + branch = branch_info + continue + + if len(line) < 3: + continue + + status_code = line[:2] + file_path = line[3:] + + # XY format: X = index status, Y = working tree status + if status_code == "??": + untracked.append(file_path) + elif status_code[1] == "D" or status_code[0] == "D": + deleted.append(file_path) + elif status_code[0] == "A" or status_code[1] == "A": + added.append(file_path) + else: + modified.append(file_path) + + return GitStatus( + branch=branch, + modified=modified, + added=added, + deleted=deleted, + untracked=untracked, + ahead=ahead, + behind=behind, + ) + + async def commit(self, message: str) -> None: + """Stage all changes and commit.""" + rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A") + if rc != 0: + raise RuntimeError(f"Git add failed: {err}") + + rc, _, err = await self._run( + "git", "-C", self.cwd, "commit", "-m", message + ) + if rc != 0: + raise RuntimeError(f"Git commit failed: {err}") + + logger.info("Committed in workspace: %s", self.cwd) + + async def push(self) -> None: + """Push current branch to origin.""" + rc, _, err = await self._run( + "git", "-C", self.cwd, "push", "origin", self.branch + ) + if rc != 0: + raise RuntimeError(f"Git push failed: {err}") + + logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd) + + async def pull(self) -> None: + """Pull current branch from origin.""" + rc, _, err = await self._run( + "git", "-C", self.cwd, "pull", "origin", self.branch + ) + if rc != 0: + raise RuntimeError(f"Git pull failed: {err}") + + logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd) + + async def fetch(self) -> None: + """Fetch from origin.""" + rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin") + if rc != 0: + raise RuntimeError(f"Git fetch failed: {err}") + + logger.info("Fetched origin for workspace: %s", self.cwd) + + async def checkout(self, branch: str) -> None: + """Checkout a branch.""" + rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch) + if rc != 0: + raise RuntimeError(f"Git checkout failed: {err}") + + self.branch = branch + logger.info("Checked out branch %s in workspace: %s", branch, self.cwd) + + async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]: + """Get commit history. + + Args: + path: Optional file path to filter history. + limit: Maximum number of commits. + + Returns: + List of commits. + """ + cmd = [ + "git", + "-C", + self.cwd, + "log", + f"--max-count={limit}", + "--pretty=format:%H|%s|%an|%ad", + "--date=iso", + ] + if path: + cmd.extend(["--", path]) + + rc, stdout, err = await self._run(*cmd) + if rc != 0: + raise RuntimeError(f"Git log failed: {err}") + + commits = [] + for line in stdout.strip().splitlines(): + parts = line.split("|", 3) + if len(parts) >= 4: + commits.append( + Commit( + hash=parts[0], + message=parts[1], + author=parts[2], + date=parts[3], + ) + ) + + return commits + + async def branches(self) -> tuple[list[str], str]: + """List all branches and current branch. + + Returns: + Tuple of (all_branches, current_branch). + """ + rc, stdout, err = await self._run( + "git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)" + ) + if rc != 0: + raise RuntimeError(f"Git branch failed: {err}") + + branches = [] + current = self.branch + for line in stdout.strip().splitlines(): + line = line.strip() + if line.startswith("HEAD") or line.endswith("/HEAD"): + continue + if line.startswith("remotes/origin/"): + branch_name = line.replace("remotes/origin/", "") + if branch_name not in branches: + branches.append(branch_name) + elif line and line not in branches: + branches.append(line) + + return branches, current diff --git a/apps/api/tests/unit/test_file_service.py b/apps/api/tests/unit/test_file_service.py new file mode 100644 index 0000000..c799cc4 --- /dev/null +++ b/apps/api/tests/unit/test_file_service.py @@ -0,0 +1,84 @@ +"""Unit tests for FileService.""" + +import os +import tempfile + +import pytest + +from src.models.workspace import Workspace +from src.services.file_service import FileService + + +@pytest.fixture +def temp_workspace(): + """Create a temporary workspace directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + ws = Workspace( + id="00000000-0000-0000-0000-000000000001", + name="test-ws", + repo_id="00000000-0000-0000-0000-000000000002", + user_id="00000000-0000-0000-0000-000000000003", + branch="main", + path=tmpdir, + ) + yield ws + + +class TestFileService: + """Tests for FileService.""" + + def test_list_directory_empty(self, temp_workspace: Workspace): + """Returns empty list for empty directory.""" + service = FileService() + entries = service.list_directory(temp_workspace) + assert entries == [] + + def test_list_directory_with_files(self, temp_workspace: Workspace): + """Returns entries sorted (dirs first, then files).""" + # Create files and dirs + os.makedirs(os.path.join(temp_workspace.path, "src")) + with open(os.path.join(temp_workspace.path, "README.md"), "w") as f: + f.write("# Test") + with open(os.path.join(temp_workspace.path, "main.py"), "w") as f: + f.write("print('hello')") + + service = FileService() + entries = service.list_directory(temp_workspace) + + assert len(entries) == 3 + assert entries[0].name == "src" and entries[0].type == "directory" + assert entries[1].name == "main.py" and entries[1].type == "file" + assert entries[2].name == "README.md" and entries[2].type == "file" + + def test_read_file(self, temp_workspace: Workspace): + """Reads text file content.""" + with open(os.path.join(temp_workspace.path, "test.txt"), "w") as f: + f.write("hello world") + + service = FileService() + content = service.read_file(temp_workspace, "test.txt") + assert content == "hello world" + + def test_read_binary_file_rejected(self, temp_workspace: Workspace): + """Rejects binary files.""" + with open(os.path.join(temp_workspace.path, "binary.bin"), "wb") as f: + f.write(b"\x00\x01\x02") + + service = FileService() + with pytest.raises(ValueError, match="Binary"): + service.read_file(temp_workspace, "binary.bin") + + def test_write_file(self, temp_workspace: Workspace): + """Writes file to workspace.""" + service = FileService() + service.write_file(temp_workspace, "nested/file.txt", "content") + + assert os.path.exists(os.path.join(temp_workspace.path, "nested", "file.txt")) + with open(os.path.join(temp_workspace.path, "nested", "file.txt")) as f: + assert f.read() == "content" + + def test_path_escapes_workspace(self, temp_workspace: Workspace): + """Rejects paths that escape workspace directory.""" + service = FileService() + with pytest.raises(ValueError, match="escapes"): + service.list_directory(temp_workspace, "../outside") diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index bf22f14..7a9f8cc 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -13,7 +13,10 @@ import type { Workspace } from "../types/workspace"; export function WorkspacesPage() { const [showCreate, setShowCreate] = useState(false); const [startWorkspace, setStartWorkspace] = useState(null); - const [createTarget, setCreateTarget] = useState<{ projectId: string; repoId: string } | null>(null); + const [createTarget, setCreateTarget] = useState<{ + projectId: string; + repoId: string; + } | null>(null); const { workspaces, loading, error, refresh } = useWorkspaces(); const actions = useWorkspaceActions(); @@ -27,11 +30,21 @@ export function WorkspacesPage() { }; const handleDelete = async (workspace: Workspace) => { - await actions.delete(workspace.project_id, workspace.repo_id, workspace, refresh); + await actions.delete( + workspace.project_id, + workspace.repo_id, + workspace, + refresh, + ); }; const handleSync = async (workspace: Workspace) => { - await actions.sync(workspace.project_id, workspace.repo_id, workspace, refresh); + await actions.sync( + workspace.project_id, + workspace.repo_id, + workspace, + refresh, + ); }; const handleStartTool = async ( @@ -52,7 +65,12 @@ export function WorkspacesPage() { [], startWorkspace.id, ); - await startInstance(startWorkspace.project_id, startWorkspace.repo_id, instance.id, configProfileId); + await startInstance( + startWorkspace.project_id, + startWorkspace.repo_id, + instance.id, + configProfileId, + ); setStartWorkspace(null); await refresh(); } catch (err) { @@ -72,20 +90,23 @@ export function WorkspacesPage() { > - + @@ -96,7 +117,10 @@ export function WorkspacesPage() { projectId={createTarget.projectId} repoId={createTarget.repoId} onSubmit={handleCreate} - onCancel={() => { setShowCreate(false); setCreateTarget(null); }} + onCancel={() => { + setShowCreate(false); + setCreateTarget(null); + }} /> )} diff --git a/openspec/changes/workspace-first-ui/design.md b/openspec/changes/workspace-first-ui/design.md new file mode 100644 index 0000000..2176baf --- /dev/null +++ b/openspec/changes/workspace-first-ui/design.md @@ -0,0 +1,694 @@ +# Design: Workspace-First UI Refresh + +## Status + +| Field | Value | +|---|---| +| Phase | **Design** | +| Based on | [Spec](spec.md) | +| Next | Tasks | + +## Backend Design + +### Directory Structure + +``` +apps/api/src/ +├── api/ +│ ├── workspace_files.py # NEW: GET/POST /workspaces/{id}/files +│ ├── workspace_git.py # NEW: /workspaces/{id}/git/* +│ ├── workspace_instances.py # NEW: /workspaces/{id}/instances +│ └── workspaces.py # MODIFIED: add repo_id to POST, enrich responses +├── services/ +│ ├── git_operations.py # NEW: workspace-scoped git commands +│ └── file_service.py # NEW: workspace file operations +└── models/ + └── workspace.py # UNCHANGED +``` + +### Service: FileService + +```python +class FileService: + """Read/write files within a workspace directory.""" + + def list_directory(self, workspace: Workspace, path: str = "") -> list[FileEntry]: + abs_path = os.path.join(workspace.path, path) + entries = [] + for item in os.listdir(abs_path): + full = os.path.join(abs_path, item) + stat = os.lstat(full) + entries.append(FileEntry( + name=item, + path=os.path.join(path, item), + type="directory" if os.path.isdir(full) else "file", + size=stat.st_size if os.path.isfile(full) else None, + )) + return entries + + def read_file(self, workspace: Workspace, path: str) -> str: + abs_path = os.path.join(workspace.path, path) + with open(abs_path, "r") as f: + return f.read() + + def write_file(self, workspace: Workspace, path: str, content: str) -> None: + abs_path = os.path.join(workspace.path, path) + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + with open(abs_path, "w") as f: + f.write(content) +``` + +### Service: GitOperations + +```python +class GitOperations: + """Git commands scoped to a workspace directory.""" + + def __init__(self, workspace: Workspace) -> None: + self.cwd = workspace.path + self.branch = workspace.branch + + async def status(self) -> GitStatus: + proc = await asyncio.create_subprocess_exec( + "git", "-C", self.cwd, "status", "--porcelain", + stdout=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + return self._parse_status(stdout.decode()) + + async def commit(self, message: str) -> None: + await self._run("git", "-C", self.cwd, "add", "-A") + await self._run("git", "-C", self.cwd, "commit", "-m", message) + + async def push(self) -> None: + await self._run("git", "-C", self.cwd, "push", "origin", self.branch) + + async def pull(self) -> None: + await self._run("git", "-C", self.cwd, "pull", "origin", self.branch) + + async def fetch(self) -> None: + await self._run("git", "-C", self.cwd, "fetch", "origin") + + async def checkout(self, branch: str) -> None: + await self._run("git", "-C", self.cwd, "checkout", branch) + + async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]: + cmd = ["git", "-C", self.cwd, "log", f"--max-count={limit}", "--pretty=format:%H|%s|%an|%ad"] + if path: + cmd.extend(["--", path]) + stdout = await self._run_stdout(*cmd) + return self._parse_log(stdout) +``` + +### API: Workspace Files + +```python +@router.get("/{workspace_id}/files") +async def list_files(workspace_id: uuid.UUID, path: str = ""): + workspace = await get_workspace(workspace_id) + entries = FileService().list_directory(workspace, path) + return {"entries": [e.dict() for e in entries]} + +@router.get("/{workspace_id}/files/content") +async def get_file_content(workspace_id: uuid.UUID, path: str): + workspace = await get_workspace(workspace_id) + content = FileService().read_file(workspace, path) + return {"content": content, "path": path} + +@router.post("/{workspace_id}/files/content") +async def write_file(workspace_id: uuid.UUID, data: dict): + workspace = await get_workspace(workspace_id) + FileService().write_file(workspace, data["path"], data["content"]) + if data.get("message"): + await GitOperations(workspace).commit(data["message"]) + return {"status": "saved"} +``` + +### API: Workspace Git + +```python +@router.get("/{workspace_id}/git/status") +async def git_status(workspace_id: uuid.UUID): + workspace = await get_workspace(workspace_id) + return await GitOperations(workspace).status() + +@router.post("/{workspace_id}/git/commit") +async def git_commit(workspace_id: uuid.UUID, data: dict): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).commit(data["message"]) + return {"status": "committed"} + +@router.post("/{workspace_id}/git/push") +async def git_push(workspace_id: uuid.UUID): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).push() + return {"status": "pushed"} + +@router.post("/{workspace_id}/git/pull") +async def git_pull(workspace_id: uuid.UUID): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).pull() + return {"status": "pulled"} + +@router.post("/{workspace_id}/git/fetch") +async def git_fetch(workspace_id: uuid.UUID): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).fetch() + return {"status": "fetched"} + +@router.post("/{workspace_id}/git/checkout") +async def git_checkout(workspace_id: uuid.UUID, data: dict): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).checkout(data["branch"]) + workspace.branch = data["branch"] + await session.commit() + return {"status": "checked_out", "branch": data["branch"]} + +@router.get("/{workspace_id}/git/history") +async def git_history(workspace_id: uuid.UUID, path: str | None = None, limit: int = 50): + workspace = await get_workspace(workspace_id) + return await GitOperations(workspace).history(path, limit) +``` + +### API: Workspace Instances + +```python +@router.get("/{workspace_id}/instances") +async def list_workspace_instances(workspace_id: uuid.UUID, session: AsyncSession): + result = await session.execute( + select(ToolInstance).where(ToolInstance.workspace_id == workspace_id) + ) + return [instance_to_dict(i) for i in result.scalars().all()] + +@router.post("/{workspace_id}/instances") +async def create_workspace_instance( + workspace_id: uuid.UUID, + data: dict, + user_id: uuid.UUID, + session: AsyncSession, +): + workspace = await get_workspace(workspace_id) + # Reuse existing create_instance logic but with workspace_id pre-set + return await create_instance_internal( + project_id=workspace.repo.project_id, + repo_id=workspace.repo_id, + tool_type_id=data["tool_type_id"], + workspace_id=workspace_id, + display_name=data.get("display_name"), + config_profile_id=data.get("config_profile_id"), + ) +``` + +### Modified: Projects API + +```python +@router.get("/") +async def list_projects(user_id: uuid.UUID, session: AsyncSession): + result = await session.execute( + select(Project).where(Project.owner_id == user_id).order_by(Project.created_at.desc()) + ) + projects = [] + for project in result.scalars().all(): + repos = await session.execute( + select(GitRepository).where(GitRepository.project_id == project.id) + ) + repo_list = [] + for repo in repos.scalars().all(): + workspaces = await session.execute( + select(Workspace).where(Workspace.repo_id == repo.id) + ) + repo_list.append({ + "id": str(repo.id), + "name": repo.name, + "remote_url": repo.remote_url, + "workspaces": [ + { + "id": str(ws.id), + "name": ws.name, + "branch": ws.branch, + "status": ws.status, + "instance_count": ..., + } + for ws in workspaces.scalars().all() + ], + }) + projects.append({ + "id": str(project.id), + "name": project.name, + "description": project.description, + "repositories": repo_list, + }) + return {"projects": projects} +``` + +## Frontend Design + +### Directory Structure + +``` +apps/web/src/ +├── pages/ +│ ├── workspace-detail.tsx # NEW: /workspaces/:id +│ ├── projects.tsx # MODIFIED: inline repos + workspaces +│ └── workspaces.tsx # MODIFIED: link to detail +├── components/ +│ ├── workspace/ +│ │ ├── workspace-header.tsx # NEW: breadcrumb + actions +│ │ ├── workspace-tabs.tsx # NEW: tab bar component +│ │ ├── workspace-file-panel.tsx # NEW: Files tab (tree + viewer + git toolbar) +│ │ ├── workspace-git-panel.tsx # NEW: Git tab (history + diff) +│ │ ├── workspace-tools-panel.tsx # NEW: Tools tab (instances + spawn) +│ │ ├── workspace-settings-panel.tsx # NEW: Settings tab +│ │ ├── git-toolbar.tsx # NEW: collapsible git toolbar +│ │ ├── file-tree.tsx # NEW: extracted from repo-workspace +│ │ ├── file-viewer.tsx # NEW: extracted from repo-workspace +│ │ └── start-tool-modal.tsx # EXISTING: move to workspace/ +│ ├── project/ +│ │ ├── project-card.tsx # NEW: card with inline repos +│ │ ├── repo-section.tsx # NEW: expandable repo + workspaces +│ │ ├── workspace-chip.tsx # NEW: small workspace card +│ │ └── new-workspace-inline.tsx # NEW: inline form +│ └── app-shell.tsx # MODIFIED: nav order +├── hooks/ +│ ├── use-workspace-files.ts # NEW +│ ├── use-workspace-git.ts # NEW +│ ├── use-workspace-instances.ts # NEW +│ └── use-projects-enriched.ts # NEW: projects with repos + workspaces +├── api/ +│ ├── workspace-files.ts # NEW +│ ├── workspace-git.ts # NEW +│ ├── workspace-instances.ts # NEW +│ └── projects.ts # MODIFIED: enriched response +└── router.tsx # MODIFIED: routes +``` + +### Component: WorkspaceDetailPage + +```tsx +export function WorkspaceDetailPage() { + const { workspaceId } = useParams(); + const [activeTab, setActiveTab] = useState("files"); + const { workspace, loading } = useWorkspace(workspaceId); + + if (loading) return ; + if (!workspace) return ; + + return ( +
+ + +
+ {activeTab === "files" && } + {activeTab === "git" && } + {activeTab === "tools" && } + {activeTab === "settings" && } +
+
+ ); +} +``` + +### Component: WorkspaceFilePanel + +```tsx +export function WorkspaceFilePanel({ workspace }: { workspace: Workspace }) { + const [selectedPath, setSelectedPath] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const { entries, loading } = useWorkspaceFiles(workspace.id); + const { content } = useWorkspaceFileContent(workspace.id, selectedPath); + const { status } = useWorkspaceGitStatus(workspace.id); + + return ( +
+ +
+ + setIsEditing(true)} + onSave={async (newContent, message) => { + await saveWorkspaceFile(workspace.id, selectedPath, newContent, message); + setIsEditing(false); + }} + /> +
+
+ ); +} +``` + +### Component: GitToolbar + +```tsx +export function GitToolbar({ workspace, status }: GitToolbarProps) { + const [expanded, setExpanded] = useState(false); + const [commitMessage, setCommitMessage] = useState(""); + + return ( +
+
+ M {status.modified.length} + A {status.added.length} + D {status.deleted.length} + + + + +
+ {expanded && ( +
+