e7587ca9f5
- 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
695 lines
24 KiB
Markdown
695 lines
24 KiB
Markdown
# 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)
|