From 27c77af5912a25a9595df691689708108258be53 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 17:04:44 +0200 Subject: [PATCH] feat: workspace-first UI refresh - PR-2 workspace detail page - Add workspace detail page (/workspaces/:id) with 4 tabs: - Files: file tree, viewer, editor, git toolbar (commit/push/pull/fetch) - Git: branch selector, commit history - Tools: instance grid, start tool modal - Settings: workspace info read-only - Add workspace API clients: workspace-files, workspace-git, workspace-instances - Add hooks: useWorkspaceFiles, useWorkspaceGit, useWorkspaceInstances - WorkspaceCard links to detail page via router Link - Add comprehensive CSS for workspace detail layout - Mobile: bottom tab bar, responsive file tree/split - TypeScript + eslint clean Quality gates: tsc --noEmit clean, eslint clean --- apps/api/src/api/projects.py | 65 ++- apps/api/src/services/git_operations.py | 4 +- apps/web/src/api/workspace-files.ts | 45 ++ apps/web/src/api/workspace-git.ts | 75 +++ apps/web/src/api/workspace-instances.ts | 30 ++ apps/web/src/components/workspace-card.tsx | 15 +- apps/web/src/hooks/use-workspace-files.ts | 68 +++ apps/web/src/hooks/use-workspace-git.ts | 109 ++++ apps/web/src/hooks/use-workspace-instances.ts | 65 +++ apps/web/src/pages/workspace-detail.tsx | 466 ++++++++++++++++++ apps/web/src/router.tsx | 2 + apps/web/src/styles.css | 455 +++++++++++++++++ 12 files changed, 1366 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/api/workspace-files.ts create mode 100644 apps/web/src/api/workspace-git.ts create mode 100644 apps/web/src/api/workspace-instances.ts create mode 100644 apps/web/src/hooks/use-workspace-files.ts create mode 100644 apps/web/src/hooks/use-workspace-git.ts create mode 100644 apps/web/src/hooks/use-workspace-instances.ts create mode 100644 apps/web/src/pages/workspace-detail.tsx diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index 3dbc01c..d68cacb 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -7,7 +7,12 @@ from pydantic import BaseModel, ConfigDict 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.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 @@ -90,7 +95,9 @@ async def list_projects( """ user = await _get_user(session, user_id) result = await session.execute( - select(Project).where(Project.owner_id == user.id).order_by(Project.created_at.desc()) + select(Project) + .where(Project.owner_id == user.id) + .order_by(Project.created_at.desc()) ) projects = result.scalars().all() @@ -113,29 +120,37 @@ async def list_projects( 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, - }) + 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, - }) + 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, - }) + 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 @@ -226,7 +241,9 @@ async def delete_project( project = await _get_owned_project(project_id, user_id, session) # Delete repositories from disk and database - result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id)) + result = await session.execute( + select(GitRepository).where(GitRepository.project_id == project_id) + ) repositories = result.scalars().all() for repo in repositories: if os.path.exists(repo.path): diff --git a/apps/api/src/services/git_operations.py b/apps/api/src/services/git_operations.py index 45b7134..ecbd8d6 100644 --- a/apps/api/src/services/git_operations.py +++ b/apps/api/src/services/git_operations.py @@ -111,9 +111,7 @@ class GitOperations: if rc != 0: raise RuntimeError(f"Git add failed: {err}") - rc, _, err = await self._run( - "git", "-C", self.cwd, "commit", "-m", message - ) + rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message) if rc != 0: raise RuntimeError(f"Git commit failed: {err}") diff --git a/apps/web/src/api/workspace-files.ts b/apps/web/src/api/workspace-files.ts new file mode 100644 index 0000000..0702732 --- /dev/null +++ b/apps/web/src/api/workspace-files.ts @@ -0,0 +1,45 @@ +/** Workspace file API client. */ + +import { apiClient } from "./client"; + +export interface FileEntry { + name: string; + path: string; + type: "file" | "directory"; + size?: number; +} + +export async function listWorkspaceFiles( + workspaceId: string, + path: string = "", +): Promise { + const response = await apiClient.get<{ entries: FileEntry[] }>( + `/workspaces/${workspaceId}/files/`, + { params: { path } }, + ); + return response.data.entries; +} + +export async function getWorkspaceFileContent( + workspaceId: string, + path: string, +): Promise { + const response = await apiClient.get<{ content: string }>( + `/workspaces/${workspaceId}/files/content`, + { params: { path } }, + ); + return response.data.content; +} + +export async function saveWorkspaceFile( + workspaceId: string, + path: string, + content: string, + commitMessage?: string, +): Promise { + await apiClient.post(`/workspaces/${workspaceId}/files/content`, { + path, + content, + message: commitMessage, + }); +} diff --git a/apps/web/src/api/workspace-git.ts b/apps/web/src/api/workspace-git.ts new file mode 100644 index 0000000..9f897f2 --- /dev/null +++ b/apps/web/src/api/workspace-git.ts @@ -0,0 +1,75 @@ +/** Workspace git API client. */ + +import { apiClient } from "./client"; + +export interface GitStatus { + branch: string; + modified: string[]; + added: string[]; + deleted: string[]; + untracked: string[]; + ahead: number; + behind: number; +} + +export interface Commit { + hash: string; + message: string; + author: string; + date: string; +} + +export async function getGitStatus(workspaceId: string): Promise { + const response = await apiClient.get( + `/workspaces/${workspaceId}/git/status`, + ); + return response.data; +} + +export async function getGitBranches( + workspaceId: string, +): Promise<{ branches: string[]; current_branch: string }> { + const response = await apiClient.get<{ + branches: string[]; + current_branch: string; + }>(`/workspaces/${workspaceId}/git/branches`); + return response.data; +} + +export async function gitCommit( + workspaceId: string, + message: string, +): Promise { + await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message }); +} + +export async function gitPush(workspaceId: string): Promise { + await apiClient.post(`/workspaces/${workspaceId}/git/push`); +} + +export async function gitPull(workspaceId: string): Promise { + await apiClient.post(`/workspaces/${workspaceId}/git/pull`); +} + +export async function gitFetch(workspaceId: string): Promise { + await apiClient.post(`/workspaces/${workspaceId}/git/fetch`); +} + +export async function gitCheckout( + workspaceId: string, + branch: string, +): Promise { + await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch }); +} + +export async function getGitHistory( + workspaceId: string, + path?: string, + limit: number = 50, +): Promise { + const response = await apiClient.get<{ commits: Commit[] }>( + `/workspaces/${workspaceId}/git/history`, + { params: { path, limit } }, + ); + return response.data.commits; +} diff --git a/apps/web/src/api/workspace-instances.ts b/apps/web/src/api/workspace-instances.ts new file mode 100644 index 0000000..67dbefe --- /dev/null +++ b/apps/web/src/api/workspace-instances.ts @@ -0,0 +1,30 @@ +/** Workspace instance API client. */ + +import { apiClient } from "./client"; +import type { ToolInstance } from "./sessions"; + +export async function listWorkspaceInstances( + workspaceId: string, +): Promise { + const response = await apiClient.get( + `/workspaces/${workspaceId}/instances/`, + ); + return response.data; +} + +export async function createWorkspaceInstance( + workspaceId: string, + toolTypeId: string, + displayName?: string, + configProfileId?: string, +): Promise { + const response = await apiClient.post( + `/workspaces/${workspaceId}/instances/`, + { + tool_type_id: toolTypeId, + display_name: displayName, + config_profile_id: configProfileId, + }, + ); + return response.data; +} diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx index d6e4e06..4e92079 100644 --- a/apps/web/src/components/workspace-card.tsx +++ b/apps/web/src/components/workspace-card.tsx @@ -1,5 +1,6 @@ /** Card component for displaying a workspace. */ +import { Link } from "react-router-dom"; import { Icon } from "./icon"; import type { Workspace } from "../types/workspace"; @@ -27,12 +28,14 @@ export function WorkspaceCard({ return (
-
-

{workspace.name}

- - {workspace.status} - -
+ +
+

{workspace.name}

+ + {workspace.status} + +
+

{workspace.project_name} / {workspace.repo_name} diff --git a/apps/web/src/hooks/use-workspace-files.ts b/apps/web/src/hooks/use-workspace-files.ts new file mode 100644 index 0000000..4ac8073 --- /dev/null +++ b/apps/web/src/hooks/use-workspace-files.ts @@ -0,0 +1,68 @@ +/** Hook for workspace file operations. */ + +import { useCallback, useEffect, useState } from "react"; +import { + listWorkspaceFiles, + getWorkspaceFileContent, + saveWorkspaceFile, + type FileEntry, +} from "../api/workspace-files"; + +export interface UseWorkspaceFilesResult { + entries: FileEntry[]; + content: string | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + loadFile: (path: string) => Promise; + saveFile: (path: string, content: string, message?: string) => Promise; +} + +export function useWorkspaceFiles( + workspaceId: string, +): UseWorkspaceFilesResult { + const [entries, setEntries] = useState([]); + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await listWorkspaceFiles(workspaceId); + setEntries(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load files"); + } finally { + setLoading(false); + } + }, [workspaceId]); + + const loadFile = useCallback( + async (path: string) => { + try { + const data = await getWorkspaceFileContent(workspaceId, path); + setContent(data); + } catch (err) { + setContent(null); + setError(err instanceof Error ? err.message : "Failed to load file"); + } + }, + [workspaceId], + ); + + const saveFile = useCallback( + async (path: string, fileContent: string, message?: string) => { + await saveWorkspaceFile(workspaceId, path, fileContent, message); + await refresh(); + }, + [workspaceId, refresh], + ); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { entries, content, loading, error, refresh, loadFile, saveFile }; +} diff --git a/apps/web/src/hooks/use-workspace-git.ts b/apps/web/src/hooks/use-workspace-git.ts new file mode 100644 index 0000000..e86bad0 --- /dev/null +++ b/apps/web/src/hooks/use-workspace-git.ts @@ -0,0 +1,109 @@ +/** Hook for workspace git operations. */ + +import { useCallback, useEffect, useState } from "react"; +import { + getGitStatus, + getGitBranches, + gitCommit, + gitPush, + gitPull, + gitFetch, + gitCheckout, + getGitHistory, + type GitStatus, + type Commit, +} from "../api/workspace-git"; + +export interface UseWorkspaceGitResult { + status: GitStatus | null; + branches: string[]; + currentBranch: string; + history: Commit[]; + loading: boolean; + error: string | null; + refresh: () => Promise; + commit: (message: string) => Promise; + push: () => Promise; + pull: () => Promise; + fetch: () => Promise; + checkout: (branch: string) => Promise; +} + +export function useWorkspaceGit(workspaceId: string): UseWorkspaceGitResult { + const [status, setStatus] = useState(null); + const [branches, setBranches] = useState([]); + const [currentBranch, setCurrentBranch] = useState(""); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [statusData, branchesData, historyData] = await Promise.all([ + getGitStatus(workspaceId), + getGitBranches(workspaceId), + getGitHistory(workspaceId), + ]); + setStatus(statusData); + setBranches(branchesData.branches); + setCurrentBranch(branchesData.current_branch); + setHistory(historyData); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load git data"); + } finally { + setLoading(false); + } + }, [workspaceId]); + + const commit = useCallback( + async (message: string) => { + await gitCommit(workspaceId, message); + await refresh(); + }, + [workspaceId, refresh], + ); + + const push = useCallback(async () => { + await gitPush(workspaceId); + await refresh(); + }, [workspaceId, refresh]); + + const pull = useCallback(async () => { + await gitPull(workspaceId); + await refresh(); + }, [workspaceId, refresh]); + + const fetch = useCallback(async () => { + await gitFetch(workspaceId); + await refresh(); + }, [workspaceId, refresh]); + + const checkout = useCallback( + async (branch: string) => { + await gitCheckout(workspaceId, branch); + await refresh(); + }, + [workspaceId, refresh], + ); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { + status, + branches, + currentBranch, + history, + loading, + error, + refresh, + commit, + push, + pull, + fetch, + checkout, + }; +} diff --git a/apps/web/src/hooks/use-workspace-instances.ts b/apps/web/src/hooks/use-workspace-instances.ts new file mode 100644 index 0000000..502508f --- /dev/null +++ b/apps/web/src/hooks/use-workspace-instances.ts @@ -0,0 +1,65 @@ +/** Hook for workspace instance operations. */ + +import { useCallback, useEffect, useState } from "react"; +import { + listWorkspaceInstances, + createWorkspaceInstance, +} from "../api/workspace-instances"; +import type { ToolInstance } from "../api/sessions"; + +export interface UseWorkspaceInstancesResult { + instances: ToolInstance[]; + loading: boolean; + error: string | null; + refresh: () => Promise; + create: ( + toolTypeId: string, + displayName?: string, + configProfileId?: string, + ) => Promise; +} + +export function useWorkspaceInstances( + workspaceId: string, +): UseWorkspaceInstancesResult { + const [instances, setInstances] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await listWorkspaceInstances(workspaceId); + setInstances(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load instances"); + } finally { + setLoading(false); + } + }, [workspaceId]); + + const create = useCallback( + async ( + toolTypeId: string, + displayName?: string, + configProfileId?: string, + ) => { + const instance = await createWorkspaceInstance( + workspaceId, + toolTypeId, + displayName, + configProfileId, + ); + await refresh(); + return instance; + }, + [workspaceId, refresh], + ); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { instances, loading, error, refresh, create }; +} diff --git a/apps/web/src/pages/workspace-detail.tsx b/apps/web/src/pages/workspace-detail.tsx new file mode 100644 index 0000000..f3ce712 --- /dev/null +++ b/apps/web/src/pages/workspace-detail.tsx @@ -0,0 +1,466 @@ +/** Workspace detail page — primary work surface. */ + +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { Icon } from "../components/icon"; +import { useWorkspaces } from "../hooks/use-workspaces"; +import { useWorkspaceFiles } from "../hooks/use-workspace-files"; +import { useWorkspaceGit } from "../hooks/use-workspace-git"; +import { useWorkspaceInstances } from "../hooks/use-workspace-instances"; +import { useMobileViewport } from "../hooks/use-mobile-viewport"; +import type { FileEntry } from "../api/workspace-files"; + +type Tab = "files" | "git" | "tools" | "settings"; + +export function WorkspaceDetailPage() { + const { workspaceId } = useParams<{ workspaceId: string }>(); + const [activeTab, setActiveTab] = useState("files"); + const isMobile = useMobileViewport(); + + const { workspaces, loading: wsLoading } = useWorkspaces(); + const workspace = workspaces.find((w) => w.id === workspaceId); + + if (wsLoading) { + return

Loading workspace...
; + } + + if (!workspace) { + return ( +
+

Workspace not found

+

The workspace you are looking for does not exist.

+
+ ); + } + + return ( +
+ + +
+ {activeTab === "files" && } + {activeTab === "git" && } + {activeTab === "tools" && } + {activeTab === "settings" && } +
+ {isMobile && } +
+ ); +} + +function WorkspaceHeader({ + workspace, +}: { + workspace: { + name: string; + repo_name: string; + project_name: string; + branch: string; + }; +}) { + return ( +
+
+ {workspace.project_name} + / + {workspace.repo_name} + / + {workspace.name} +
+
+ + {workspace.branch} + +
+
+ ); +} + +function TabBar({ + active, + onChange, +}: { + active: Tab; + onChange: (t: Tab) => void; +}) { + const tabs: { id: Tab; label: string; icon: string }[] = [ + { id: "files", label: "Files", icon: "folder" }, + { id: "git", label: "Git", icon: "branch" }, + { id: "tools", label: "Tools", icon: "terminal" }, + { id: "settings", label: "Settings", icon: "settings" }, + ]; + + return ( + + ); +} + +function MobileTabBar({ + active, + onChange, +}: { + active: Tab; + onChange: (t: Tab) => void; +}) { + const tabs: { id: Tab; label: string; icon: string }[] = [ + { id: "files", label: "Files", icon: "folder" }, + { id: "git", label: "Git", icon: "branch" }, + { id: "tools", label: "Tools", icon: "terminal" }, + { id: "settings", label: "Settings", icon: "settings" }, + ]; + + return ( + + ); +} + +/* ─── Files Tab ─── */ + +function FilesTab({ workspaceId }: { workspaceId: string }) { + const { entries, content, loadFile, saveFile, loading, error } = + useWorkspaceFiles(workspaceId); + const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId); + const [selectedPath, setSelectedPath] = useState(null); + const [editContent, setEditContent] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [commitMessage, setCommitMessage] = useState(""); + + const handleSelect = (entry: FileEntry) => { + if (entry.type === "directory") return; + setSelectedPath(entry.path); + setIsEditing(false); + setEditContent(null); + loadFile(entry.path); + }; + + const handleEdit = () => { + if (content !== null) { + setEditContent(content); + setIsEditing(true); + } + }; + + const handleSave = async () => { + if (selectedPath && editContent !== null) { + await saveFile(selectedPath, editContent, commitMessage || undefined); + setIsEditing(false); + setCommitMessage(""); + } + }; + + return ( +
+ {status && ( +
+
+ {status.modified.length > 0 && ( + + M {status.modified.length} + + )} + {status.added.length > 0 && ( + A {status.added.length} + )} + {status.deleted.length > 0 && ( + D {status.deleted.length} + )} + {status.untracked.length > 0 && ( + + ? {status.untracked.length} + + )} +
+
+ setCommitMessage(e.target.value)} + placeholder="Commit message" + /> + + + + +
+
+ )} +
+
+ {loading &&

Loading...

} + {error &&

{error}

} + {entries.map((entry) => ( + + ))} +
+
+ {selectedPath ? ( + <> +
+ {selectedPath} + {!isEditing && } +
+ {isEditing ? ( + <> +