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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -13,7 +13,10 @@ import type { Workspace } from "../types/workspace";
|
||||
export function WorkspacesPage() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(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() {
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
if (workspaces.length > 0) {
|
||||
const first = workspaces[0];
|
||||
setCreateTarget({ projectId: first.project_id, repoId: first.repo_id });
|
||||
setShowCreate(true);
|
||||
} else {
|
||||
alert("Navigate to a project to create your first workspace.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
if (workspaces.length > 0) {
|
||||
const first = workspaces[0];
|
||||
setCreateTarget({
|
||||
projectId: first.project_id,
|
||||
repoId: first.repo_id,
|
||||
});
|
||||
setShowCreate(true);
|
||||
} else {
|
||||
alert("Navigate to a project to create your first workspace.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<Tab>("files");
|
||||
const { workspace, loading } = useWorkspace(workspaceId);
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
if (!workspace) return <NotFoundPage />;
|
||||
|
||||
return (
|
||||
<div className="workspace-detail">
|
||||
<WorkspaceHeader workspace={workspace} />
|
||||
<WorkspaceTabs active={activeTab} onChange={setActiveTab} />
|
||||
<div className="workspace-content">
|
||||
{activeTab === "files" && <WorkspaceFilePanel workspace={workspace} />}
|
||||
{activeTab === "git" && <WorkspaceGitPanel workspace={workspace} />}
|
||||
{activeTab === "tools" && <WorkspaceToolsPanel workspace={workspace} />}
|
||||
{activeTab === "settings" && <WorkspaceSettingsPanel workspace={workspace} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceFilePanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceFilePanel({ workspace }: { workspace: Workspace }) {
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { entries, loading } = useWorkspaceFiles(workspace.id);
|
||||
const { content } = useWorkspaceFileContent(workspace.id, selectedPath);
|
||||
const { status } = useWorkspaceGitStatus(workspace.id);
|
||||
|
||||
return (
|
||||
<div className="file-panel">
|
||||
<GitToolbar workspace={workspace} status={status} />
|
||||
<div className="file-panel-body">
|
||||
<FileTree
|
||||
entries={entries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
gitStatus={status}
|
||||
/>
|
||||
<FileViewer
|
||||
path={selectedPath}
|
||||
content={content}
|
||||
isEditing={isEditing}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
onSave={async (newContent, message) => {
|
||||
await saveWorkspaceFile(workspace.id, selectedPath, newContent, message);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: GitToolbar
|
||||
|
||||
```tsx
|
||||
export function GitToolbar({ workspace, status }: GitToolbarProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
|
||||
return (
|
||||
<div className={`git-toolbar ${expanded ? "expanded" : ""}`}>
|
||||
<div className="git-toolbar-summary">
|
||||
<span className="git-status modified">M {status.modified.length}</span>
|
||||
<span className="git-status added">A {status.added.length}</span>
|
||||
<span className="git-status deleted">D {status.deleted.length}</span>
|
||||
<button onClick={() => setExpanded(!expanded)}>Commit ▼</button>
|
||||
<button onClick={() => pushWorkspace(workspace.id)}>Push</button>
|
||||
<button onClick={() => pullWorkspace(workspace.id)}>Pull</button>
|
||||
<button onClick={() => fetchWorkspace(workspace.id)}>Fetch</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="git-toolbar-commit">
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Commit message"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
commitWorkspace(workspace.id, commitMessage);
|
||||
setCommitMessage("");
|
||||
setExpanded(false);
|
||||
}}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceGitPanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceGitPanel({ workspace }: { workspace: Workspace }) {
|
||||
const { history, loading } = useWorkspaceGitHistory(workspace.id);
|
||||
const [selectedCommit, setSelectedCommit] = useState<Commit | null>(null);
|
||||
|
||||
return (
|
||||
<div className="git-panel">
|
||||
<div className="git-panel-header">
|
||||
<BranchSelector workspace={workspace} />
|
||||
<button>New Branch</button>
|
||||
</div>
|
||||
<div className="git-panel-body">
|
||||
<CommitHistory
|
||||
commits={history}
|
||||
selected={selectedCommit}
|
||||
onSelect={setSelectedCommit}
|
||||
/>
|
||||
{selectedCommit && (
|
||||
<CommitDetail commit={selectedCommit} workspace={workspace} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceToolsPanel
|
||||
|
||||
```tsx
|
||||
export function WorkspaceToolsPanel({ workspace }: { workspace: Workspace }) {
|
||||
const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="tools-panel">
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="terminal"
|
||||
title="No tools running"
|
||||
description="Start a tool to begin coding in this workspace"
|
||||
action={{ label: "Start Tool", onClick: () => setShowModal(true) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="tools-grid">
|
||||
{instances.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
onStop={refresh}
|
||||
onStart={refresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowModal(true)}>Start Another Tool</button>
|
||||
</>
|
||||
)}
|
||||
{showModal && (
|
||||
<StartToolModal
|
||||
workspace={workspace}
|
||||
onClose={() => setShowModal(false)}
|
||||
onStart={async (toolTypeId, configProfileId) => {
|
||||
await createWorkspaceInstance(workspace.id, toolTypeId, configProfileId);
|
||||
setShowModal(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: ProjectCard (refreshed)
|
||||
|
||||
```tsx
|
||||
export function ProjectCard({ project }: { project: EnrichedProject }) {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-header">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-repos">
|
||||
{project.repositories.map((repo) => (
|
||||
<RepoSection key={repo.id} repo={repo} projectId={project.id} />
|
||||
))}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link to={`/projects/${project.id}`}>Open</Link>
|
||||
<button>Edit</button>
|
||||
<button>Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: RepoSection
|
||||
|
||||
```tsx
|
||||
export function RepoSection({ repo, projectId }: RepoSectionProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="repo-section">
|
||||
<button className="repo-header" onClick={() => setExpanded(!expanded)}>
|
||||
<Icon name={expanded ? "arrow-down" : "arrow-right"} />
|
||||
<span>{repo.name}</span>
|
||||
<span className="muted">{repo.remote_url}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="repo-workspaces">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<Link key={ws.id} to={`/workspaces/${ws.id}`} className="workspace-chip">
|
||||
<span className="workspace-name">{ws.name}</span>
|
||||
<span className={`status-badge ${ws.status}`}>{ws.status}</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="instance-count">● {ws.instance_count}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
{showForm ? (
|
||||
<NewWorkspaceInline
|
||||
projectId={projectId}
|
||||
repoId={repo.id}
|
||||
onCreated={() => setShowForm(false)}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<button className="new-workspace-btn" onClick={() => setShowForm(true)}>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Mobile Layout
|
||||
|
||||
### Bottom Tab Bar
|
||||
|
||||
```tsx
|
||||
export function MobileTabBar({ active, onChange }: MobileTabBarProps) {
|
||||
const tabs: { id: Tab; icon: IconName; label: string }[] = [
|
||||
{ id: "files", icon: "folder", label: "Files" },
|
||||
{ id: "git", icon: "branch", label: "Git" },
|
||||
{ id: "tools", icon: "terminal", label: "Tools" },
|
||||
{ id: "settings", icon: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="mobile-tab-bar" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
|
||||
onClick={() => onChange(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={active === tab.id}
|
||||
>
|
||||
<Icon name={tab.icon} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Mobile Workspace Detail
|
||||
|
||||
```tsx
|
||||
export function MobileWorkspaceDetail({ workspace }: { workspace: Workspace }) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("files");
|
||||
|
||||
return (
|
||||
<div className="workspace-detail mobile">
|
||||
<WorkspaceHeader workspace={workspace} compact />
|
||||
<div className="workspace-content">
|
||||
{activeTab === "files" && <MobileFilePanel workspace={workspace} />}
|
||||
{activeTab === "git" && <MobileGitPanel workspace={workspace} />}
|
||||
{activeTab === "tools" && <MobileToolsPanel workspace={workspace} />}
|
||||
{activeTab === "settings" && <WorkspaceSettingsPanel workspace={workspace} />}
|
||||
</div>
|
||||
<MobileTabBar active={activeTab} onChange={setActiveTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Mobile Files tab**: Full-screen file tree. Tap file → opens viewer in slide-up panel.
|
||||
**Mobile Git tab**: Commit history list. Tap commit → diff in slide-up panel.
|
||||
**Mobile Tools tab**: Instance cards stacked, full width.
|
||||
**Mobile Settings tab**: Same as desktop, scrollable.
|
||||
|
||||
## Routing
|
||||
|
||||
```tsx
|
||||
// router.tsx changes
|
||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId" element={<ProjectDetailPage />} />
|
||||
// Remove old repo-workspace route
|
||||
// <Route path="projects/:projectId" element={<RepoWorkspace />} /> — DELETED
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | Fetches | Polling |
|
||||
|---|---|---|
|
||||
| `useWorkspace(id)` | `GET /workspaces/{id}` | No |
|
||||
| `useWorkspaceFiles(id, path?)` | `GET /workspaces/{id}/files` | No |
|
||||
| `useWorkspaceFileContent(id, path?)` | `GET /workspaces/{id}/files/content` | No |
|
||||
| `useWorkspaceGitStatus(id)` | `GET /workspaces/{id}/git/status` | 10s when visible |
|
||||
| `useWorkspaceGitHistory(id)` | `GET /workspaces/{id}/git/history` | No |
|
||||
| `useWorkspaceInstances(id)` | `GET /workspaces/{id}/instances` | 10s when visible |
|
||||
| `useProjectsEnriched()` | `GET /projects/` | 30s |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend
|
||||
- Unit: FileService.list_directory, read_file, write_file
|
||||
- Unit: GitOperations.status, commit, push, pull, history
|
||||
- Integration: GET/POST /workspaces/{id}/files
|
||||
- Integration: /workspaces/{id}/git/* endpoints
|
||||
- Integration: /workspaces/{id}/instances
|
||||
- Integration: Enriched /projects/ response
|
||||
|
||||
### Frontend
|
||||
- Component: WorkspaceFilePanel renders file tree + viewer
|
||||
- Component: GitToolbar expands/collapses, commits
|
||||
- Component: WorkspaceToolsPanel shows empty state + modal
|
||||
- Component: ProjectCard renders repos + workspace chips
|
||||
- Hook: useWorkspaceGitStatus polls correctly
|
||||
- Hook: useProjectsEnriched caches correctly
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multi-file search/replace
|
||||
- Real-time collaborative editing
|
||||
- Workspace backup/restore
|
||||
- Git merge conflict UI
|
||||
- Terminal inside workspace page
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New Files (Backend)
|
||||
- `apps/api/src/api/workspace_files.py`
|
||||
- `apps/api/src/api/workspace_git.py`
|
||||
- `apps/api/src/api/workspace_instances.py`
|
||||
- `apps/api/src/services/file_service.py`
|
||||
- `apps/api/src/services/git_operations.py`
|
||||
- `apps/api/tests/integration/test_workspace_files.py`
|
||||
- `apps/api/tests/integration/test_workspace_git.py`
|
||||
- `apps/api/tests/unit/test_file_service.py`
|
||||
- `apps/api/tests/unit/test_git_operations.py`
|
||||
|
||||
### Modified Files (Backend)
|
||||
- `apps/api/src/api/workspaces.py` (add repo_id to POST, enrich responses)
|
||||
- `apps/api/src/api/projects.py` (enriched list response)
|
||||
- `apps/api/src/main.py` (register new routers)
|
||||
|
||||
### New Files (Frontend)
|
||||
- `apps/web/src/pages/workspace-detail.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-header.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-tabs.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-file-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-git-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-tools-panel.tsx`
|
||||
- `apps/web/src/components/workspace/workspace-settings-panel.tsx`
|
||||
- `apps/web/src/components/workspace/git-toolbar.tsx`
|
||||
- `apps/web/src/components/workspace/file-tree.tsx`
|
||||
- `apps/web/src/components/workspace/file-viewer.tsx`
|
||||
- `apps/web/src/components/project/project-card.tsx`
|
||||
- `apps/web/src/components/project/repo-section.tsx`
|
||||
- `apps/web/src/components/project/workspace-chip.tsx`
|
||||
- `apps/web/src/components/project/new-workspace-inline.tsx`
|
||||
- `apps/web/src/hooks/use-workspace-files.ts`
|
||||
- `apps/web/src/hooks/use-workspace-git.ts`
|
||||
- `apps/web/src/hooks/use-workspace-instances.ts`
|
||||
- `apps/web/src/hooks/use-projects-enriched.ts`
|
||||
- `apps/web/src/api/workspace-files.ts`
|
||||
- `apps/web/src/api/workspace-git.ts`
|
||||
- `apps/web/src/api/workspace-instances.ts`
|
||||
|
||||
### Modified Files (Frontend)
|
||||
- `apps/web/src/pages/projects.tsx` (full rewrite)
|
||||
- `apps/web/src/pages/workspaces.tsx` (link to detail)
|
||||
- `apps/web/src/components/app-shell.tsx` (nav order)
|
||||
- `apps/web/src/router.tsx` (routes)
|
||||
- `apps/web/src/api/projects.ts` (enriched response types)
|
||||
|
||||
### Deleted Files
|
||||
- `apps/web/src/pages/repo-workspace.tsx`
|
||||
- `apps/web/src/components/workspace-header.tsx`
|
||||
- `apps/web/src/components/git-toolbar.tsx` (old standalone version)
|
||||
- `apps/web/src/components/instance-list.tsx` (replaced by workspace-tools-panel)
|
||||
@@ -0,0 +1,184 @@
|
||||
# Proposal: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Proposal** |
|
||||
| Based on | [Working Copies Spec](../working-copies/spec.md) |
|
||||
| Next | Spec |
|
||||
|
||||
## Problem
|
||||
|
||||
The current UI has two competing "workspace" concepts:
|
||||
|
||||
1. **Old "Repository Workspace"** (`repo-workspace.tsx`): A file browser + editor + git toolbar view tied to a repository. This was the default view when opening a project. It reads files from the repo path directly and offers quick editing.
|
||||
2. **New "Workspace"** (`workspaces.tsx`): A list of persistent writable clones that tool instances mount. These are first-class entities with their own lifecycle.
|
||||
|
||||
These two concepts confuse users. The old workspace is redundant now that workspaces are persistent clones — users should work inside a workspace, not directly on the repo.
|
||||
|
||||
Additionally:
|
||||
- The Projects page only shows a list of projects with "Open Workspace" links — no visibility into repos or workspaces
|
||||
- Tool instances are spawned from the repo-workspace view, not from the workspace view
|
||||
- Mobile layout of the old workspace is cramped and not well-suited for the new paradigm
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the old "Repository Workspace" with a **Workspace-First** navigation model:
|
||||
|
||||
### New Information Architecture
|
||||
|
||||
```
|
||||
Projects
|
||||
└── Project Card (inline repos + workspaces)
|
||||
└── Repo: "my-app"
|
||||
├── Workspace: "main" → /workspaces/{id}
|
||||
├── Workspace: "feature-auth" → /workspaces/{id}
|
||||
└── [+ New Workspace]
|
||||
Workspaces
|
||||
└── All Workspaces (grid/list)
|
||||
└── Workspace Card → /workspaces/{id}
|
||||
```
|
||||
|
||||
### Workspace Detail Page (`/workspaces/:workspaceId`)
|
||||
|
||||
The workspace detail page is the primary work surface. It replaces the old repo-workspace:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ {project} / {repo} / {workspace-name} [Start Tool ▼] │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ │ Tabs: [Files] [Git] [Tools] │
|
||||
│ File Tree ├───────────────────────────────────────────────┤
|
||||
│ (workspace │ │
|
||||
│ clone) │ {active tab content} │
|
||||
│ │ │
|
||||
│ 📁 src/ │ │
|
||||
│ 📄 README │ │
|
||||
│ │ │
|
||||
├──────────────┤ │
|
||||
│ Git Status │ │
|
||||
│ (compact) │ │
|
||||
└──────────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Panels (collapsible, IDE-style):**
|
||||
- **Files**: File tree from workspace clone path + file viewer/editor
|
||||
- **Git**: Commit panel, branch selector, push/pull/fetch actions (operating on workspace clone)
|
||||
- **Tools**: List of active tool instances on this workspace + spawn new tool
|
||||
|
||||
**Start Tool**: Inline modal (not page navigation) to spawn a tool instance on this workspace.
|
||||
|
||||
### Projects Page Refresh
|
||||
|
||||
Project cards now show:
|
||||
- Project name + description
|
||||
- Repositories (accordion/list)
|
||||
- For each repo: its workspaces as clickable chips/cards
|
||||
- "New Workspace" button per repo
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ My Project │
|
||||
│ A web application │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Repositories: │
|
||||
│ │
|
||||
│ ▼ my-app (git@github.com:...) │
|
||||
│ ┌─────────┐ ┌─────────────┐ [+ New] │
|
||||
│ │ main │ │ feature-auth│ │
|
||||
│ │ ● 2 │ │ ● 0 │ │
|
||||
│ └─────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ▶ api-service │
|
||||
│ ┌─────────┐ [+ New] │
|
||||
│ │ main │ │
|
||||
│ └─────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Mobile Layout
|
||||
|
||||
Bottom tab bar (4 tabs):
|
||||
- **Files**: Full-screen file tree + viewer
|
||||
- **Git**: Compact commit panel + action buttons
|
||||
- **Tools**: Instance list + spawn button
|
||||
- **Menu**: Workspace switcher, settings
|
||||
|
||||
Swipe between tabs. File tree is always accessible.
|
||||
|
||||
### Deleted
|
||||
|
||||
- `pages/repo-workspace.tsx` — old repository workspace (file browser + editor on repo path)
|
||||
- Route `/projects/:projectId` → now shows project detail, not file browser
|
||||
- Old workspace header component
|
||||
- Git toolbar component (replaced by panel in workspace detail)
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- [ ] New workspace detail page (`/workspaces/:workspaceId`)
|
||||
- [ ] File browser reading from workspace clone path
|
||||
- [ ] File viewer/editor for workspace files
|
||||
- [ ] Git operations on workspace clone (status, commit, push, pull, fetch, branch)
|
||||
- [ ] Tool instance list per workspace
|
||||
- [ ] Inline tool spawn modal
|
||||
- [ ] Collapsible IDE-style panels (desktop)
|
||||
- [ ] Bottom tab bar layout (mobile)
|
||||
- [ ] Projects page refresh (inline repos + workspaces)
|
||||
- [ ] Workspace list page improvements (link to detail page)
|
||||
- [ ] Backend: file endpoints for workspace path
|
||||
- [ ] Backend: git endpoints for workspace path
|
||||
- [ ] Delete old `repo-workspace.tsx` and related components
|
||||
- [ ] Update routing
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Git history / diff view (deferred, can reuse existing page)
|
||||
- Workspace sharing between users
|
||||
- Advanced IDE features (search, multi-file edit)
|
||||
- Auto-sync on schedule
|
||||
- Terminal integration inside workspace page
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|---|---|
|
||||
| 1 | Projects page → what happens on "Open"? | **A** — Show project detail with repos + workspaces inline |
|
||||
| 2 | Workspace page layout? | **C** — Collapsible panels, IDE-style |
|
||||
| 3 | File browser source? | Workspace clone path (`/data/working-copies/{repo-id}/{name}/`) |
|
||||
| 4 | Git actions scope? | Workspace clone |
|
||||
| 5 | Tool spawning? | Inline modal on workspace page |
|
||||
| 6 | Mobile layout? | Bottom tab bar (Files / Git / Tools / Menu), swipeable |
|
||||
| 7 | Old workspace fallback? | **No fallback** — delete immediately |
|
||||
| 8 | Projects page detail level? | Inline workspace cards on project page |
|
||||
|
||||
## Open Questions for Spec
|
||||
|
||||
1. Should the workspace detail page URL be `/workspaces/:id` or nested under project/repo?
|
||||
2. Should we keep the sidebar Workspaces nav entry, or rely on Projects → Workspace flow?
|
||||
3. How does "New Workspace" flow work from Projects page — inline form or navigate to create page?
|
||||
4. Should workspace detail show repo remote URL and allow switching branches?
|
||||
5. What happens when a workspace has no tool instances yet — show empty state or prompt to spawn?
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Users confused by navigation change | Keep "Workspaces" in sidebar, add breadcrumbs |
|
||||
| Large frontend refactor | Break into 3 PRs: backend endpoints, workspace detail page, projects refresh |
|
||||
| Mobile layout complexity | Prototype with CSS grid first, test on actual device |
|
||||
| Git operations on workspace path | Reuse existing git service, just change the path argument |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Old `repo-workspace.tsx` is deleted
|
||||
- [ ] `/projects/:id` shows project detail with repos and workspaces
|
||||
- [ ] `/workspaces/:id` shows workspace detail with Files, Git, Tools panels
|
||||
- [ ] File browser reads from workspace clone path
|
||||
- [ ] Git commit/push/pull work on workspace clone
|
||||
- [ ] Tool spawn modal creates instance with workspace mounted
|
||||
- [ ] Mobile layout uses bottom tabs
|
||||
- [ ] All existing tests pass (or updated)
|
||||
- [ ] ruff clean, TypeScript clean, eslint clean
|
||||
@@ -0,0 +1,366 @@
|
||||
# Spec: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Spec** |
|
||||
| Based on | [Proposal](proposal.md) |
|
||||
| Next | Design |
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the old "Repository Workspace" (direct repo file browser) with a **Workspace-First** model. The workspace detail page becomes the primary work surface. Projects page shows inline repos + workspaces. Old `repo-workspace.tsx` is deleted.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|---|---|
|
||||
| 1 | URL structure | `/workspaces/:id` (flat) |
|
||||
| 2 | Sidebar nav order | Workspaces → Projects |
|
||||
| 3 | New Workspace flow | Inline form on project page |
|
||||
| 4 | Branch switching | Dropdown in workspace header |
|
||||
| 5 | Empty tool state | "Start a tool" prompt card |
|
||||
| 6 | Mobile tabs | Files / Git / Tools / Settings |
|
||||
| 7 | Git toolbar | Collapsible top bar on Files tab |
|
||||
| 8 | Git tab content | History, diff, full commit log |
|
||||
|
||||
## User Flows
|
||||
|
||||
### Flow 1: Open a Project
|
||||
|
||||
1. User clicks "Projects" in sidebar
|
||||
2. Sees project cards with inline repositories
|
||||
3. Each repo shows its workspaces as clickable cards
|
||||
4. User clicks a workspace → navigates to `/workspaces/:id`
|
||||
|
||||
### Flow 2: Work in a Workspace
|
||||
|
||||
1. User is on `/workspaces/:id`
|
||||
2. **Files tab** (default): File tree (left) + file viewer/editor (right). Git toolbar at top.
|
||||
3. User edits a file, commits via git toolbar
|
||||
4. **Git tab**: Full history, diff view, detailed commit log
|
||||
5. **Tools tab**: See running instances, click "Start Tool" → inline modal
|
||||
6. **Settings tab**: Sync workspace, rename, delete
|
||||
|
||||
### Flow 3: Create a Workspace
|
||||
|
||||
1. User on Projects page, expands a repo
|
||||
2. Clicks "+ New Workspace" next to a repo
|
||||
3. Inline form appears: name input, branch dropdown
|
||||
4. Submits → workspace created, appears in list
|
||||
|
||||
### Flow 4: Start a Tool
|
||||
|
||||
1. User on workspace detail, Tools tab
|
||||
2. If no instances: "Start a tool on this workspace" card
|
||||
3. If instances: list of cards + "Start Another" button
|
||||
4. Click → inline modal: tool type picker, config profile (optional)
|
||||
5. Submit → instance created, appears in list with status
|
||||
|
||||
## Backend API
|
||||
|
||||
### New Endpoints (workspace-scoped)
|
||||
|
||||
All endpoints operate on the workspace clone path (`workspace.path`).
|
||||
|
||||
```
|
||||
# Files
|
||||
GET /workspaces/{workspace_id}/files?path=&branch=
|
||||
→ List directory entries
|
||||
GET /workspaces/{workspace_id}/files/content?path=&branch=
|
||||
→ Get file content
|
||||
POST /workspaces/{workspace_id}/files/content
|
||||
Body: { path, content, message }
|
||||
→ Commit file change
|
||||
|
||||
# Git
|
||||
GET /workspaces/{workspace_id}/git/status
|
||||
→ { modified, added, deleted, untracked, branch }
|
||||
GET /workspaces/{workspace_id}/git/branches
|
||||
→ { branches, default_branch, current_branch }
|
||||
POST /workspaces/{workspace_id}/git/commit
|
||||
Body: { message, files? }
|
||||
→ Commit staged changes
|
||||
POST /workspaces/{workspace_id}/git/push
|
||||
→ Push current branch
|
||||
POST /workspaces/{workspace_id}/git/pull
|
||||
→ Pull current branch
|
||||
POST /workspaces/{workspace_id}/git/fetch
|
||||
→ Fetch from origin
|
||||
POST /workspaces/{workspace_id}/git/checkout
|
||||
Body: { branch }
|
||||
→ Switch branch
|
||||
GET /workspaces/{workspace_id}/git/history
|
||||
Query: ?path=&limit=50
|
||||
→ Commit history for file or entire repo
|
||||
|
||||
# Tools (instances on this workspace)
|
||||
GET /workspaces/{workspace_id}/instances
|
||||
→ List tool instances using this workspace
|
||||
POST /workspaces/{workspace_id}/instances
|
||||
Body: { tool_type_id, display_name?, config_profile_id? }
|
||||
→ Create instance on this workspace
|
||||
```
|
||||
|
||||
### Existing Endpoints (unchanged)
|
||||
|
||||
```
|
||||
GET /workspaces/
|
||||
POST /workspaces/ (body: { repo_id, name, branch })
|
||||
DELETE /workspaces/{id}
|
||||
POST /workspaces/{id}/sync
|
||||
PATCH /workspaces/{id}
|
||||
```
|
||||
|
||||
Note: `POST /workspaces/` now accepts `repo_id` directly instead of nested under `/projects/{pid}/repositories/{rid}/workspaces`.
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
```
|
||||
GET /projects/
|
||||
→ Now includes `repositories` array with `workspaces` sub-array
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
No changes. Existing `workspaces` table is sufficient.
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
```
|
||||
/ → Dashboard (unchanged)
|
||||
/workspaces → All workspaces list (refreshed)
|
||||
/workspaces/:id → Workspace detail (NEW, replaces repo-workspace)
|
||||
/projects → Projects list (refreshed)
|
||||
/projects/:id → Project detail with repos + workspaces (NEW)
|
||||
/sessions → Sessions list (unchanged)
|
||||
/settings → Settings (unchanged)
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### WorkspaceDetailPage (`/workspaces/:id`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Breadcrumb: Projects > {project} > {repo} > {workspace} │
|
||||
│ [Branch ▼ main] [Sync] [Start Tool] [Settings] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Tab bar: [Files] [Git] [Tools] [Settings] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ {Active Tab Content} │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Files Tab (default)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Git Toolbar (collapsible) │
|
||||
│ [Modified: 3] [Staged: 2] [Commit ▼] [Push] [Pull] [Fetch] │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ File Tree │ File Viewer / Editor │
|
||||
│ (workspace │ │
|
||||
│ path) │ Breadcrumbs: src > utils > helpers.ts │
|
||||
│ │ │
|
||||
│ 📁 src/ │ [Edit] [History] │
|
||||
│ 📄 README │ │
|
||||
│ │ export function ... │
|
||||
│ │ │
|
||||
└──────────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Git Toolbar**: Collapsible bar above file content. Shows:
|
||||
- Status counters: Modified, Added, Deleted, Untracked
|
||||
- Commit button (with message input when expanded)
|
||||
- Push, Pull, Fetch buttons
|
||||
- Branch selector dropdown
|
||||
|
||||
#### Git Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Branch: [main ▼] [New Branch] [Merge] [Compare] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Commit History │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ● abc123 Fix auth middleware │ │
|
||||
│ │ ● def456 Add user profile page │ │
|
||||
│ │ ● 789abc Initial commit │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Show Diff] [Checkout] [Revert] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Tools Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Active Tool Instances │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ [+ Start Tool] │
|
||||
│ │ Code Server │ │ Terminal │ │
|
||||
│ │ ● Running │ │ ● Stopped │ │
|
||||
│ │ [Open] [Stop│ │ [Start] [×] │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ─ or ─ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ No tools running on this workspace │ │
|
||||
│ │ Start a tool to begin coding │ │
|
||||
│ │ [Start Tool] │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Settings Tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ Name: [my-feature-branch ] │
|
||||
│ Branch: main (tracks origin/main) │
|
||||
│ Path: /data/working-copies/{repo-id}/{name} │
|
||||
│ Created: 2024-01-15 │
|
||||
│ Last Sync: 2024-01-20 14:32 │
|
||||
│ │
|
||||
│ [Rename] [Sync Now] [Delete Workspace] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### ProjectsPage (`/projects`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Projects [+ New Project] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ My Web App │ │
|
||||
│ │ A full-stack application │ │
|
||||
│ ├────────────────────────────────────────────────────┤ │
|
||||
│ │ Repositories: │ │
|
||||
│ │ │ │
|
||||
│ │ ▼ frontend (git@github.com:me/frontend.git) │ │
|
||||
│ │ ┌──────────┐ ┌─────────────┐ [+ New Workspace] │ │
|
||||
│ │ │ main │ │ feature-ui │ │ │
|
||||
│ │ │ ● 2 inst │ │ ● 0 inst │ │ │
|
||||
│ │ └──────────┘ └─────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ▶ backend (git@github.com:me/backend.git) │ │
|
||||
│ │ ┌──────────┐ [+ New Workspace] │ │
|
||||
│ │ │ main │ │ │
|
||||
│ │ │ ● 1 inst │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ [Edit Project] [Delete] │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Workspace Card**: Small card showing:
|
||||
- Name
|
||||
- Status badge (ready/syncing/error)
|
||||
- Instance count (dot + number)
|
||||
- Click navigates to `/workspaces/:id`
|
||||
|
||||
**New Workspace Button**: Inline form on click:
|
||||
```
|
||||
[Name: __________] [Branch: main ▼] [Create] [Cancel]
|
||||
```
|
||||
|
||||
### Mobile Layout
|
||||
|
||||
Bottom tab bar (4 tabs, always visible):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ {Tab Content - full screen} │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
├────────────────────────────────────┤
|
||||
│ 📁 Files 🔀 Git 🛠 Tools ⚙ Settings│
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Files tab**: File tree full screen, tap file → viewer overlay
|
||||
**Git tab**: Commit history list, tap commit → diff overlay
|
||||
**Tools tab**: Instance cards stacked vertically
|
||||
**Settings tab**: Same as desktop settings, scrollable
|
||||
|
||||
## State & Data Flow
|
||||
|
||||
### Workspace Detail Page
|
||||
|
||||
```
|
||||
useWorkspace(workspaceId) → fetch /workspaces/{id}
|
||||
useWorkspaceFiles(workspaceId, path?, branch?) → fetch /workspaces/{id}/files
|
||||
useWorkspaceGitStatus(workspaceId) → fetch /workspaces/{id}/git/status
|
||||
useWorkspaceInstances(workspaceId) → fetch /workspaces/{id}/instances
|
||||
```
|
||||
|
||||
All hooks poll/refetch on:
|
||||
- Tab switch
|
||||
- User action (commit, push, etc.)
|
||||
- 30s background refresh
|
||||
|
||||
### Projects Page
|
||||
|
||||
```
|
||||
useProjects() → fetch /projects/
|
||||
useProjectWorkspaces(projectId) → derived from project.repositories.workspaces
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | UX |
|
||||
|---|---|
|
||||
| Workspace not found | 404 page with "Workspace not found" + link to workspaces |
|
||||
| Git operation fails | Toast with git stderr, retry button |
|
||||
| File read fails | "File not found" in viewer, check if on correct branch |
|
||||
| No tool types available | "No tools configured" + link to Tool Workshop |
|
||||
| Workspace path missing | "Workspace files not found — try syncing" |
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Tab bar: `role="tablist"`, keyboard arrow navigation
|
||||
- File tree: `role="tree"`, arrow key expansion
|
||||
- Git toolbar: All buttons have `aria-label`
|
||||
- Focus management: Modal traps focus, returns on close
|
||||
|
||||
## Performance
|
||||
|
||||
- File tree: Virtualized for repos > 1000 files
|
||||
- Git history: Paginated (50 commits per page)
|
||||
- Image files: Lazy loaded in viewer
|
||||
- Polling: 30s for instances, 10s for git status when visible
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `repo-workspace.tsx` and related components deleted
|
||||
- [ ] `/projects/:id` route shows project detail, not file browser
|
||||
- [ ] `/workspaces/:id` route shows workspace detail page
|
||||
- [ ] File browser reads from workspace clone path
|
||||
- [ ] File viewer/editor works on workspace files
|
||||
- [ ] Git toolbar on Files tab supports commit/push/pull/fetch
|
||||
- [ ] Git tab shows commit history with diff
|
||||
- [ ] Tools tab lists instances + spawn modal
|
||||
- [ ] Settings tab shows workspace info + rename/sync/delete
|
||||
- [ ] Projects page shows inline repos + workspace cards
|
||||
- [ ] Inline "New Workspace" form on project page
|
||||
- [ ] Mobile: 4-tab bottom navigation
|
||||
- [ ] All existing tests pass or updated
|
||||
- [ ] ruff clean, TypeScript clean, eslint clean
|
||||
@@ -0,0 +1,132 @@
|
||||
# Tasks: Workspace-First UI Refresh
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Tasks** |
|
||||
| Based on | [Design](design.md) |
|
||||
| Next | Apply |
|
||||
|
||||
## PR Breakdown
|
||||
|
||||
### PR-1: Backend — Workspace File, Git & Instance Endpoints
|
||||
**Scope**: All new backend endpoints for workspace-scoped operations
|
||||
**Est. lines**: ~900 backend, ~400 tests
|
||||
**Files touched**: 10 new, 3 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [ ] Create `FileService` (`apps/api/src/services/file_service.py`)
|
||||
2. [ ] Create `GitOperations` service (`apps/api/src/services/git_operations.py`)
|
||||
3. [ ] Create `workspace_files` API router (`apps/api/src/api/workspace_files.py`)
|
||||
4. [ ] Create `workspace_git` API router (`apps/api/src/api/workspace_git.py`)
|
||||
5. [ ] Create `workspace_instances` API router (`apps/api/src/api/workspace_instances.py`)
|
||||
6. [ ] Register new routers in `main.py`
|
||||
7. [ ] Update `workspaces.py` POST to accept `repo_id` directly
|
||||
8. [ ] Enrich `projects.py` list response with repos + workspaces
|
||||
9. [ ] Write unit tests for FileService
|
||||
10. [ ] Write unit tests for GitOperations
|
||||
11. [ ] Write integration tests for workspace file endpoints
|
||||
12. [ ] Write integration tests for workspace git endpoints
|
||||
13. [ ] Write integration tests for workspace instance endpoints
|
||||
|
||||
### PR-2: Frontend — Workspace Detail Page
|
||||
**Scope**: Workspace detail page with 4 tabs, replaces old repo-workspace
|
||||
**Est. lines**: ~1,400 frontend, ~300 tests
|
||||
**Files touched**: 14 new, 3 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [ ] Create `useWorkspaceFiles` hook
|
||||
2. [ ] Create `useWorkspaceGit` hook
|
||||
3. [ ] Create `useWorkspaceInstances` hook
|
||||
4. [ ] Create workspace API clients (`workspace-files.ts`, `workspace-git.ts`, `workspace-instances.ts`)
|
||||
5. [ ] Create `WorkspaceHeader` component
|
||||
6. [ ] Create `WorkspaceTabs` component
|
||||
7. [ ] Create `WorkspaceFilePanel` component (file tree + viewer + git toolbar)
|
||||
8. [ ] Create `GitToolbar` component (collapsible)
|
||||
9. [ ] Create `WorkspaceGitPanel` component (history + diff)
|
||||
10. [ ] Create `WorkspaceToolsPanel` component (instances + spawn modal)
|
||||
11. [ ] Create `WorkspaceSettingsPanel` component
|
||||
12. [ ] Create `WorkspaceDetailPage` page
|
||||
13. [ ] Add `/workspaces/:id` route
|
||||
14. [ ] Delete `repo-workspace.tsx` and related components
|
||||
15. [ ] Write component tests for WorkspaceFilePanel
|
||||
16. [ ] Write component tests for GitToolbar
|
||||
17. [ ] Write component tests for WorkspaceToolsPanel
|
||||
|
||||
### PR-3: Frontend — Projects Page Refresh & Routing
|
||||
**Scope**: Projects page with inline repos + workspaces, mobile layout
|
||||
**Est. lines**: ~800 frontend, ~200 tests
|
||||
**Files touched**: 5 new, 5 modified
|
||||
|
||||
**Tasks**:
|
||||
1. [ ] Update `projects.ts` API client for enriched response
|
||||
2. [ ] Create `useProjectsEnriched` hook
|
||||
3. [ ] Create `ProjectCard` component
|
||||
4. [ ] Create `RepoSection` component
|
||||
5. [ ] Create `WorkspaceChip` component
|
||||
6. [ ] Create `NewWorkspaceInline` component
|
||||
7. [ ] Rewrite `ProjectsPage`
|
||||
8. [ ] Create `ProjectDetailPage` (or inline detail on ProjectsPage)
|
||||
9. [ ] Update `AppShell` nav order (Workspaces → Projects)
|
||||
10. [ ] Update `WorkspacesPage` to link to detail
|
||||
11. [ ] Add mobile tab bar to workspace detail
|
||||
12. [ ] Update router: `/projects/:id` → project detail, remove old repo-workspace
|
||||
13. [ ] Write component tests for ProjectCard
|
||||
14. [ ] Write component tests for RepoSection
|
||||
15. [ ] Write tests for NewWorkspaceInline
|
||||
|
||||
## Acceptance Criteria (All PRs)
|
||||
|
||||
- [ ] Old `repo-workspace.tsx` is deleted
|
||||
- [ ] `/projects/:id` shows project detail with repos + workspaces
|
||||
- [ ] `/workspaces/:id` shows workspace detail with 4 tabs
|
||||
- [ ] File browser reads from workspace clone path
|
||||
- [ ] File viewer/editor works on workspace files
|
||||
- [ ] Git toolbar on Files tab supports commit/push/pull/fetch
|
||||
- [ ] Git tab shows commit history
|
||||
- [ ] Tools tab lists instances + spawn modal (or empty prompt)
|
||||
- [ ] Settings tab shows workspace info + rename/sync/delete
|
||||
- [ ] Projects page shows inline repos + workspace cards
|
||||
- [ ] Inline "New Workspace" form on project page
|
||||
- [ ] Mobile: 4-tab bottom navigation
|
||||
- [ ] All existing tests pass or updated
|
||||
- [ ] ruff clean
|
||||
- [ ] TypeScript compilation clean
|
||||
- [ ] eslint clean
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
PR-1 (Backend endpoints)
|
||||
→ PR-2 (Workspace detail page)
|
||||
→ PR-3 (Projects refresh + routing)
|
||||
```
|
||||
|
||||
Each PR depends on the previous. No parallel work.
|
||||
|
||||
## Verification Steps per PR
|
||||
|
||||
### PR-1
|
||||
```bash
|
||||
cd apps/api
|
||||
pytest tests/unit/test_file_service.py tests/unit/test_git_operations.py -v
|
||||
pytest tests/integration/test_workspace_files.py tests/integration/test_workspace_git.py tests/integration/test_workspace_instances.py -v
|
||||
python -m ruff check src/services/file_service.py src/services/git_operations.py src/api/workspace_*.py
|
||||
```
|
||||
|
||||
### PR-2
|
||||
```bash
|
||||
cd apps/web
|
||||
npx tsc --noEmit
|
||||
npx eslint src/pages/workspace-detail.tsx src/components/workspace/
|
||||
npm run test -- --run workspace-detail
|
||||
```
|
||||
|
||||
### PR-3
|
||||
```bash
|
||||
cd apps/web
|
||||
npx tsc --noEmit
|
||||
npx eslint src/pages/projects.tsx src/components/project/
|
||||
npm run test -- --run projects
|
||||
```
|
||||
Reference in New Issue
Block a user