fix: add from __future__ import annotations to workspace_manager.py

Fixes NameError: ToolInstance not defined at runtime because
type annotations are evaluated at class definition time.
Deferring annotation evaluation with __future__ annotations
keeps TYPE_CHECKING imports from causing runtime crashes.

Also includes ruff formatting cleanup on workspace-related files.
This commit is contained in:
2026-05-31 23:41:45 +02:00
parent 5bba2bbd92
commit a5d64d1859
15 changed files with 831 additions and 699 deletions
+1
View File
@@ -1684,6 +1684,7 @@ async def start_instance(
repo_path = "" repo_path = ""
if instance.workspace_id: if instance.workspace_id:
from src.models.workspace import Workspace as WorkspaceModel from src.models.workspace import Workspace as WorkspaceModel
workspace = await session.get(WorkspaceModel, instance.workspace_id) workspace = await session.get(WorkspaceModel, instance.workspace_id)
if workspace: if workspace:
repo_path = workspace.path repo_path = workspace.path
+4 -4
View File
@@ -108,7 +108,9 @@ async def create_workspace(
"branch": workspace.branch, "branch": workspace.branch,
"path": workspace.path, "path": workspace.path,
"status": workspace.status, "status": workspace.status,
"created_at": workspace.created_at.isoformat() if workspace.created_at else None, "created_at": workspace.created_at.isoformat()
if workspace.created_at
else None,
} }
@@ -216,9 +218,7 @@ async def delete_workspace(
status_code=409, status_code=409,
detail={ detail={
"message": "Workspace has running tool instances", "message": "Workspace has running tool instances",
"instances": [ "instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
{"id": str(i.id), "name": i.name} for i in exc.instances
],
}, },
) from exc ) from exc
except Exception as exc: except Exception as exc:
+3 -3
View File
@@ -1,5 +1,7 @@
"""Workspace lifecycle management service.""" """Workspace lifecycle management service."""
from __future__ import annotations
import logging import logging
import os import os
import shutil import shutil
@@ -170,6 +172,4 @@ class WorkspaceManager:
TODO(PR-2): Wire up to actual instance stop/delete logic. TODO(PR-2): Wire up to actual instance stop/delete logic.
For now, this is a placeholder. For now, this is a placeholder.
""" """
logger.warning( logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
"Placeholder: stopping and deleting instance %s", instance.id
)
@@ -60,7 +60,9 @@ def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
class TestListWorkspaces: class TestListWorkspaces:
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces.""" """Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
def test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository): def test_list_empty(
self, authenticated_client: TestClient, test_repo: GitRepository
):
"""Returns empty list when no workspaces exist.""" """Returns empty list when no workspaces exist."""
response = authenticated_client.get( response = authenticated_client.get(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces" f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
@@ -69,7 +71,10 @@ class TestListWorkspaces:
assert response.json() == [] assert response.json() == []
def test_list_with_workspaces( def test_list_with_workspaces(
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
): ):
"""Returns workspaces with instance counts.""" """Returns workspaces with instance counts."""
ws = Workspace( ws = Workspace(
@@ -99,7 +104,9 @@ class TestListWorkspaces:
class TestCreateWorkspace: class TestCreateWorkspace:
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces.""" """Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
def test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository): def test_create_success(
self, authenticated_client: TestClient, test_repo: GitRepository
):
"""Creates a workspace and clones the repo.""" """Creates a workspace and clones the repo."""
mock_ws = Workspace( mock_ws = Workspace(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -110,7 +117,9 @@ class TestCreateWorkspace:
path="/data/working-copies/test/feature-branch", path="/data/working-copies/test/feature-branch",
) )
with patch.object(WorkspaceManager, "create", return_value=mock_ws) as mock_create: with patch.object(
WorkspaceManager, "create", return_value=mock_ws
) as mock_create:
response = authenticated_client.post( response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
json={"name": "feature-branch", "branch": "feature"}, json={"name": "feature-branch", "branch": "feature"},
@@ -121,7 +130,9 @@ class TestCreateWorkspace:
assert data["branch"] == "feature" assert data["branch"] == "feature"
mock_create.assert_called_once() mock_create.assert_called_once()
def test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository): def test_create_missing_name(
self, authenticated_client: TestClient, test_repo: GitRepository
):
"""Returns 400 when name is missing.""" """Returns 400 when name is missing."""
response = authenticated_client.post( response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
@@ -131,7 +142,10 @@ class TestCreateWorkspace:
assert "name" in response.json()["detail"] assert "name" in response.json()["detail"]
def test_create_duplicate_name( def test_create_duplicate_name(
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
): ):
"""Returns 409 when workspace name already exists.""" """Returns 409 when workspace name already exists."""
ws = Workspace( ws = Workspace(
@@ -148,7 +162,9 @@ class TestCreateWorkspace:
asyncio.run(_commit()) asyncio.run(_commit())
with patch.object(WorkspaceManager, "create", side_effect=Exception("duplicate")): with patch.object(
WorkspaceManager, "create", side_effect=Exception("duplicate")
):
response = authenticated_client.post( response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
json={"name": "dev", "branch": "main"}, json={"name": "dev", "branch": "main"},
@@ -160,7 +176,10 @@ class TestDeleteWorkspace:
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}.""" """Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
def test_delete_without_instances( def test_delete_without_instances(
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
): ):
"""Deletes workspace when no instances exist.""" """Deletes workspace when no instances exist."""
ws = Workspace( ws = Workspace(
@@ -185,9 +204,14 @@ class TestDeleteWorkspace:
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "deleted" assert response.json()["status"] == "deleted"
@pytest.mark.skip(reason="Async fixture interaction with sync tests — endpoint logic verified manually") @pytest.mark.skip(
reason="Async fixture interaction with sync tests — endpoint logic verified manually"
)
def test_delete_with_instances_no_force( def test_delete_with_instances_no_force(
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
): ):
"""Returns 409 when workspace has instances and force=False.""" """Returns 409 when workspace has instances and force=False."""
ws = Workspace( ws = Workspace(
@@ -239,7 +263,10 @@ class TestDeleteWorkspace:
assert len(detail["instances"]) == 1 assert len(detail["instances"]) == 1
def test_delete_with_instances_force( def test_delete_with_instances_force(
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
): ):
"""Deletes workspace when force=True even with instances.""" """Deletes workspace when force=True even with instances."""
ws = Workspace( ws = Workspace(
@@ -268,7 +295,10 @@ class TestSyncWorkspace:
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync.""" """Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
def test_sync_success( def test_sync_success(
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
): ):
"""Sync succeeds and updates last_sync_at.""" """Sync succeeds and updates last_sync_at."""
ws = Workspace( ws = Workspace(
@@ -298,7 +328,10 @@ class TestSyncWorkspace:
assert data["pulled"] is True assert data["pulled"] is True
def test_sync_branch_deleted( def test_sync_branch_deleted(
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
): ):
"""Returns 409 when branch was deleted from remote.""" """Returns 409 when branch was deleted from remote."""
ws = Workspace( ws = Workspace(
+3 -1
View File
@@ -21,7 +21,9 @@ class TestGitServiceClone:
with patch( with patch(
"asyncio.create_subprocess_exec", return_value=mock_proc "asyncio.create_subprocess_exec", return_value=mock_proc
) as mock_exec: ) as mock_exec:
await GitService.clone("https://github.com/test/repo.git", "main", "/tmp/ws") await GitService.clone(
"https://github.com/test/repo.git", "main", "/tmp/ws"
)
mock_exec.assert_called_once_with( mock_exec.assert_called_once_with(
"git", "git",
+154 -140
View File
@@ -2,185 +2,199 @@ import { AxiosError } from "axios";
import { apiClient } from "./client"; import { apiClient } from "./client";
export interface ToolInstance { export interface ToolInstance {
id: string; id: string;
name: string; name: string;
display_name: string; display_name: string;
tool_type_id: string; tool_type_id: string;
tool_type_name: string; tool_type_name: string;
tool_type_interfaces: string[]; tool_type_interfaces: string[];
status: string; status: string;
url: string | null; url: string | null;
port: number | null; port: number | null;
selected_config_profile_id: string | null; selected_config_profile_id: string | null;
ssh_key_ids: string[]; ssh_key_ids: string[];
created_at: string; created_at: string;
} }
export interface Session { export interface Session {
id: string; id: string;
display_name: string; display_name: string;
tool_type_name: string; tool_type_name: string;
tool_icon: string; tool_icon: string;
tool_type_interfaces: string[]; tool_type_interfaces: string[];
repository_name: string; repository_name: string;
repository_id: string; repository_id: string;
project_name: string; project_name: string;
project_id: string; project_id: string;
status: string; status: string;
url: string | null; url: string | null;
container_status?: string; container_status?: string;
probe_status?: string; probe_status?: string;
clone_mode?: string; clone_mode?: string;
branch?: string | null; branch?: string | null;
created_at?: string; created_at?: string;
} }
export async function listInstances( export async function listInstances(
projectId: string, projectId: string,
repoId: string repoId: string,
): Promise<ToolInstance[]> { ): Promise<ToolInstance[]> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances` `/projects/${projectId}/repositories/${repoId}/instances`,
); );
return response.data.instances; return response.data.instances;
} }
export async function createInstance( export async function createInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
toolTypeId: string, toolTypeId: string,
displayName?: string, displayName?: string,
cloneMode?: string, cloneMode?: string,
branch?: string, branch?: string,
newBranch?: string, newBranch?: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[], sshKeyIds?: string[],
workspaceId?: string workspaceId?: string,
): Promise<ToolInstance> { ): Promise<ToolInstance> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`, `/projects/${projectId}/repositories/${repoId}/instances`,
{ {
tool_type_id: toolTypeId, tool_type_id: toolTypeId,
display_name: displayName, display_name: displayName,
workspace_id: workspaceId || undefined, workspace_id: workspaceId || undefined,
clone_mode: cloneMode || "mount", clone_mode: cloneMode || "mount",
branch: branch || undefined, branch: branch || undefined,
new_branch: newBranch || undefined, new_branch: newBranch || undefined,
config_profile_id: configProfileId, config_profile_id: configProfileId,
ssh_key_ids: sshKeyIds || [], ssh_key_ids: sshKeyIds || [],
} },
); );
return response.data; return response.data;
} }
export async function startInstance( export async function startInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[], sshKeyIds?: string[],
retries = 2 retries = 2,
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] } { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces) // Retry on network errors (e.g. Docker creating network interfaces)
const axiosError = error as AxiosError; const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) { if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1); return startInstance(
} projectId,
throw error; repoId,
} instanceId,
configProfileId,
sshKeyIds,
retries - 1,
);
}
throw error;
}
} }
export async function stopInstance( export async function stopInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<{ status: string }> { ): Promise<{ status: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
); );
return response.data; return response.data;
} }
export async function restartInstance( export async function restartInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[], sshKeyIds?: string[],
retries = 2 retries = 2,
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] } { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces) // Retry on network errors (e.g. Docker creating network interfaces)
const axiosError = error as AxiosError; const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) { if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1); return restartInstance(
} projectId,
throw error; repoId,
} instanceId,
configProfileId,
sshKeyIds,
retries - 1,
);
}
throw error;
}
} }
export async function deleteInstance( export async function deleteInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string,
force?: boolean force?: boolean,
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
{ params: { force } } { params: { force } },
); );
} }
export async function getUserSessions(): Promise<Session[]> { export async function getUserSessions(): Promise<Session[]> {
const response = await apiClient.get("/users/me/sessions"); const response = await apiClient.get("/users/me/sessions");
return response.data.sessions; return response.data.sessions;
} }
export interface InstanceHealth { export interface InstanceHealth {
healthy: boolean; healthy: boolean;
container_status: string; container_status: string;
container_health: string | null; container_health: string | null;
container_exit_code: number | null; container_exit_code: number | null;
tunnel_status: string; tunnel_status: string;
tunnel_status_code: number | null; tunnel_status_code: number | null;
probe_status: string; probe_status: string;
last_probe_output: string | null; last_probe_output: string | null;
error: string | null; error: string | null;
} }
export async function checkInstanceHealth( export async function checkInstanceHealth(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<InstanceHealth> { ): Promise<InstanceHealth> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
); );
return response.data; return response.data;
} }
export async function recreateInstanceTunnel( export async function recreateInstanceTunnel(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
); );
return response.data; return response.data;
} }
+54 -37
View File
@@ -1,65 +1,82 @@
/** Workspace API client. */ /** Workspace API client. */
import { apiClient } from "./client"; import { apiClient } from "./client";
import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace"; import type {
Workspace,
CreateWorkspaceRequest,
SyncResult,
} from "../types/workspace";
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) { function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`; const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
return workspaceId ? `${base}/${workspaceId}` : base; return workspaceId ? `${base}/${workspaceId}` : base;
} }
export async function listWorkspaces(projectId: string, repoId: string): Promise<Workspace[]> { export async function listWorkspaces(
const response = await apiClient.get<Workspace[]>(workspaceUrl(projectId, repoId)); projectId: string,
return response.data; repoId: string,
): Promise<Workspace[]> {
const response = await apiClient.get<Workspace[]>(
workspaceUrl(projectId, repoId),
);
return response.data;
} }
export async function createWorkspace( export async function createWorkspace(
projectId: string, projectId: string,
repoId: string, repoId: string,
data: CreateWorkspaceRequest, data: CreateWorkspaceRequest,
): Promise<Workspace> { ): Promise<Workspace> {
const response = await apiClient.post<Workspace>(workspaceUrl(projectId, repoId), data); const response = await apiClient.post<Workspace>(
return response.data; workspaceUrl(projectId, repoId),
data,
);
return response.data;
} }
export async function getWorkspace( export async function getWorkspace(
projectId: string, projectId: string,
repoId: string, repoId: string,
workspaceId: string, workspaceId: string,
): Promise<Workspace> { ): Promise<Workspace> {
const response = await apiClient.get<Workspace>(workspaceUrl(projectId, repoId, workspaceId)); const response = await apiClient.get<Workspace>(
return response.data; workspaceUrl(projectId, repoId, workspaceId),
);
return response.data;
} }
export async function updateWorkspace( export async function updateWorkspace(
projectId: string, projectId: string,
repoId: string, repoId: string,
workspaceId: string, workspaceId: string,
data: Partial<CreateWorkspaceRequest>, data: Partial<CreateWorkspaceRequest>,
): Promise<Workspace> { ): Promise<Workspace> {
const response = await apiClient.patch<Workspace>(workspaceUrl(projectId, repoId, workspaceId), data); const response = await apiClient.patch<Workspace>(
return response.data; workspaceUrl(projectId, repoId, workspaceId),
data,
);
return response.data;
} }
export async function deleteWorkspace( export async function deleteWorkspace(
projectId: string, projectId: string,
repoId: string, repoId: string,
workspaceId: string, workspaceId: string,
force = false, force = false,
): Promise<{ status: string }> { ): Promise<{ status: string }> {
const response = await apiClient.delete<{ status: string }>( const response = await apiClient.delete<{ status: string }>(
`${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`, `${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
); );
return response.data; return response.data;
} }
export async function syncWorkspace( export async function syncWorkspace(
projectId: string, projectId: string,
repoId: string, repoId: string,
workspaceId: string, workspaceId: string,
): Promise<SyncResult> { ): Promise<SyncResult> {
const response = await apiClient.post<SyncResult>( const response = await apiClient.post<SyncResult>(
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`, `${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
); );
return response.data; return response.data;
} }
+88 -75
View File
@@ -5,83 +5,96 @@ import { Icon } from "./icon";
import type { Workspace } from "../types/workspace"; import type { Workspace } from "../types/workspace";
export interface StartToolModalProps { export interface StartToolModalProps {
workspace: Workspace; workspace: Workspace;
onClose: () => void; onClose: () => void;
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>; onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
} }
export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) { export function StartToolModal({
const [toolTypeId, setToolTypeId] = useState(""); workspace,
const [configProfileId, setConfigProfileId] = useState(""); onClose,
const [submitting, setSubmitting] = useState(false); onStart,
const [error, setError] = useState<string | null>(null); }: StartToolModalProps) {
const [toolTypeId, setToolTypeId] = useState("");
const [configProfileId, setConfigProfileId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!toolTypeId) { if (!toolTypeId) {
setError("Please select a tool type"); setError("Please select a tool type");
return; return;
} }
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
await onStart(toolTypeId, configProfileId || undefined); await onStart(toolTypeId, configProfileId || undefined);
onClose(); onClose();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool"); setError(err instanceof Error ? err.message : "Failed to start tool");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
return ( return (
<div className="modal-overlay" onClick={onClose}> <div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h3> <h3>
<Icon name="play" size="sm" /> Start Tool on {workspace.name} <Icon name="play" size="sm" /> Start Tool on {workspace.name}
</h3> </h3>
<button className="btn btn-icon" onClick={onClose}> <button className="btn btn-icon" onClick={onClose}>
<Icon name="cancel" size="sm" /> <Icon name="cancel" size="sm" />
</button> </button>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
<label htmlFor="tool-type">Tool Type</label> <label htmlFor="tool-type">Tool Type</label>
<select <select
id="tool-type" id="tool-type"
value={toolTypeId} value={toolTypeId}
onChange={(e) => setToolTypeId(e.target.value)} onChange={(e) => setToolTypeId(e.target.value)}
disabled={submitting} disabled={submitting}
> >
<option value="">Select a tool...</option> <option value="">Select a tool...</option>
<option value="code-server">Code Server</option> <option value="code-server">Code Server</option>
<option value="jupyter-notebook">Jupyter Notebook</option> <option value="jupyter-notebook">Jupyter Notebook</option>
<option value="terminal">Terminal</option> <option value="terminal">Terminal</option>
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="config-profile">Config Profile (optional)</label> <label htmlFor="config-profile">Config Profile (optional)</label>
<input <input
id="config-profile" id="config-profile"
type="text" type="text"
value={configProfileId} value={configProfileId}
onChange={(e) => setConfigProfileId(e.target.value)} onChange={(e) => setConfigProfileId(e.target.value)}
placeholder="Profile ID" placeholder="Profile ID"
disabled={submitting} disabled={submitting}
/> />
</div> </div>
{error && <p className="form-error">{error}</p>} {error && <p className="form-error">{error}</p>}
<div className="form-actions"> <div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}> <button
Cancel type="button"
</button> className="btn btn-secondary"
<button type="submit" className="btn btn-primary" disabled={submitting}> onClick={onClose}
{submitting ? "Starting..." : "Start Tool"} disabled={submitting}
</button> >
</div> Cancel
</form> </button>
</div> <button
</div> type="submit"
); className="btn btn-primary"
disabled={submitting}
>
{submitting ? "Starting..." : "Start Tool"}
</button>
</div>
</form>
</div>
</div>
);
} }
+63 -61
View File
@@ -4,70 +4,72 @@ import { Icon } from "./icon";
import type { Workspace } from "../types/workspace"; import type { Workspace } from "../types/workspace";
export interface WorkspaceCardProps { export interface WorkspaceCardProps {
workspace: Workspace; workspace: Workspace;
loading?: boolean; loading?: boolean;
onStartTool: (workspace: Workspace) => void; onStartTool: (workspace: Workspace) => void;
onSync: (workspace: Workspace) => void; onSync: (workspace: Workspace) => void;
onDelete: (workspace: Workspace) => void; onDelete: (workspace: Workspace) => void;
} }
export function WorkspaceCard({ export function WorkspaceCard({
workspace, workspace,
loading = false, loading = false,
onStartTool, onStartTool,
onSync, onSync,
onDelete, onDelete,
}: WorkspaceCardProps) { }: WorkspaceCardProps) {
const statusClass = const statusClass =
workspace.status === "ready" workspace.status === "ready"
? "status-ready" ? "status-ready"
: workspace.status === "syncing" : workspace.status === "syncing"
? "status-syncing" ? "status-syncing"
: "status-error"; : "status-error";
return ( return (
<article className={`card workspace-card ${loading ? "loading" : ""}`}> <article className={`card workspace-card ${loading ? "loading" : ""}`}>
<div className="workspace-header"> <div className="workspace-header">
<h4>{workspace.name}</h4> <h4>{workspace.name}</h4>
<span className={`status-badge ${statusClass}`}>{workspace.status}</span> <span className={`status-badge ${statusClass}`}>
</div> {workspace.status}
<div className="workspace-meta"> </span>
<p className="workspace-project"> </div>
{workspace.project_name} / {workspace.repo_name} <div className="workspace-meta">
</p> <p className="workspace-project">
<p className="workspace-branch"> {workspace.project_name} / {workspace.repo_name}
<Icon name="branch" size="sm" /> {workspace.branch} </p>
</p> <p className="workspace-branch">
{workspace.instance_count > 0 && ( <Icon name="branch" size="sm" /> {workspace.branch}
<p className="workspace-instances"> </p>
{workspace.instance_count} active tool {workspace.instance_count > 0 && (
{workspace.instance_count > 1 ? "s" : ""} <p className="workspace-instances">
</p> {workspace.instance_count} active tool
)} {workspace.instance_count > 1 ? "s" : ""}
</div> </p>
<div className="workspace-actions"> )}
<button </div>
className="btn btn-primary" <div className="workspace-actions">
onClick={() => onStartTool(workspace)} <button
disabled={loading} className="btn btn-primary"
> onClick={() => onStartTool(workspace)}
<Icon name="play" size="sm" /> Start Tool disabled={loading}
</button> >
<button <Icon name="play" size="sm" /> Start Tool
className="btn btn-secondary" </button>
onClick={() => onSync(workspace)} <button
disabled={loading} className="btn btn-secondary"
> onClick={() => onSync(workspace)}
<Icon name="refresh" size="sm" /> Sync disabled={loading}
</button> >
<button <Icon name="refresh" size="sm" /> Sync
className="btn btn-danger" </button>
onClick={() => onDelete(workspace)} <button
disabled={loading} className="btn btn-danger"
> onClick={() => onDelete(workspace)}
<Icon name="delete" size="sm" /> Delete disabled={loading}
</button> >
</div> <Icon name="delete" size="sm" /> Delete
</article> </button>
); </div>
</article>
);
} }
@@ -5,78 +5,85 @@ import { Icon } from "./icon";
import type { CreateWorkspaceRequest } from "../types/workspace"; import type { CreateWorkspaceRequest } from "../types/workspace";
export interface WorkspaceCreateFormProps { export interface WorkspaceCreateFormProps {
projectId: string; projectId: string;
repoId: string; repoId: string;
defaultBranch?: string; defaultBranch?: string;
onSubmit: (data: CreateWorkspaceRequest) => Promise<void>; onSubmit: (data: CreateWorkspaceRequest) => Promise<void>;
onCancel: () => void; onCancel: () => void;
} }
export function WorkspaceCreateForm({ export function WorkspaceCreateForm({
defaultBranch = "main", defaultBranch = "main",
onSubmit, onSubmit,
onCancel, onCancel,
}: WorkspaceCreateFormProps) { }: WorkspaceCreateFormProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [branch, setBranch] = useState(defaultBranch); const [branch, setBranch] = useState(defaultBranch);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
setError("Workspace name is required"); setError("Workspace name is required");
return; return;
} }
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
await onSubmit({ name: name.trim(), branch: branch.trim() }); await onSubmit({ name: name.trim(), branch: branch.trim() });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to create workspace"); setError(
} finally { err instanceof Error ? err.message : "Failed to create workspace",
setSubmitting(false); );
} } finally {
}; setSubmitting(false);
}
};
return ( return (
<form className="workspace-create-form card" onSubmit={handleSubmit}> <form className="workspace-create-form card" onSubmit={handleSubmit}>
<h3> <h3>
<Icon name="add" size="sm" /> Create Workspace <Icon name="add" size="sm" /> Create Workspace
</h3> </h3>
<div className="form-group"> <div className="form-group">
<label htmlFor="ws-name">Name</label> <label htmlFor="ws-name">Name</label>
<input <input
id="ws-name" id="ws-name"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g., feature-branch" placeholder="e.g., feature-branch"
disabled={submitting} disabled={submitting}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="ws-branch"> <label htmlFor="ws-branch">
<Icon name="branch" size="sm" /> Branch <Icon name="branch" size="sm" /> Branch
</label> </label>
<input <input
id="ws-branch" id="ws-branch"
type="text" type="text"
value={branch} value={branch}
onChange={(e) => setBranch(e.target.value)} onChange={(e) => setBranch(e.target.value)}
placeholder="main" placeholder="main"
disabled={submitting} disabled={submitting}
/> />
</div> </div>
{error && <p className="form-error">{error}</p>} {error && <p className="form-error">{error}</p>}
<div className="form-actions"> <div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={submitting}> <button
Cancel type="button"
</button> className="btn btn-secondary"
<button type="submit" className="btn btn-primary" disabled={submitting}> onClick={onCancel}
{submitting ? "Creating..." : "Create"} disabled={submitting}
</button> >
</div> Cancel
</form> </button>
); <button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Creating..." : "Create"}
</button>
</div>
</form>
);
} }
+133 -126
View File
@@ -2,145 +2,152 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { import {
createWorkspace, createWorkspace,
deleteWorkspace, deleteWorkspace,
syncWorkspace, syncWorkspace,
updateWorkspace, updateWorkspace,
} from "../api/workspaces"; } from "../api/workspaces";
import type { Workspace, CreateWorkspaceRequest } from "../types/workspace"; import type { Workspace, CreateWorkspaceRequest } from "../types/workspace";
export interface UseWorkspaceActionsResult { export interface UseWorkspaceActionsResult {
loadingId: string | null; loadingId: string | null;
create: ( create: (
projectId: string, projectId: string,
repoId: string, repoId: string,
data: CreateWorkspaceRequest, data: CreateWorkspaceRequest,
) => Promise<Workspace>; ) => Promise<Workspace>;
delete: ( delete: (
projectId: string, projectId: string,
repoId: string, repoId: string,
workspace: Workspace, workspace: Workspace,
onRefresh: () => Promise<void>, onRefresh: () => Promise<void>,
) => Promise<void>; ) => Promise<void>;
sync: ( sync: (
projectId: string, projectId: string,
repoId: string, repoId: string,
workspace: Workspace, workspace: Workspace,
onRefresh: () => Promise<void>, onRefresh: () => Promise<void>,
) => Promise<void>; ) => Promise<void>;
update: ( update: (
projectId: string, projectId: string,
repoId: string, repoId: string,
workspaceId: string, workspaceId: string,
data: Partial<CreateWorkspaceRequest>, data: Partial<CreateWorkspaceRequest>,
) => Promise<Workspace>; ) => Promise<Workspace>;
} }
interface ApiError { interface ApiError {
response?: { response?: {
status?: number; status?: number;
data?: { data?: {
detail?: { detail?: {
message?: string; message?: string;
instances?: Array<{ id: string; name: string }>; instances?: Array<{ id: string; name: string }>;
branch_deleted?: boolean; branch_deleted?: boolean;
}; };
}; };
}; };
} }
export function useWorkspaceActions(): UseWorkspaceActionsResult { export function useWorkspaceActions(): UseWorkspaceActionsResult {
const [loadingId, setLoadingId] = useState<string | null>(null); const [loadingId, setLoadingId] = useState<string | null>(null);
const create = useCallback( const create = useCallback(
async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => { async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
return createWorkspace(projectId, repoId, data); return createWorkspace(projectId, repoId, data);
}, },
[], [],
); );
const deleteAction = useCallback( const deleteAction = useCallback(
async ( async (
projectId: string, projectId: string,
repoId: string, repoId: string,
workspace: Workspace, workspace: Workspace,
onRefresh: () => Promise<void>, onRefresh: () => Promise<void>,
) => { ) => {
setLoadingId(workspace.id); setLoadingId(workspace.id);
try { try {
await deleteWorkspace(projectId, repoId, workspace.id); await deleteWorkspace(projectId, repoId, workspace.id);
await onRefresh(); await onRefresh();
} catch (err) { } catch (err) {
const error = err as ApiError; const error = err as ApiError;
if (error.response?.status === 409) { if (error.response?.status === 409) {
const detail = error.response.data?.detail; const detail = error.response.data?.detail;
const instances = detail?.instances || []; const instances = detail?.instances || [];
const confirmed = window.confirm( const confirmed = window.confirm(
`This workspace has ${instances.length} running tool instance(s):\n` + `This workspace has ${instances.length} running tool instance(s):\n` +
instances.map((i) => `- ${i.name}`).join("\n") + instances.map((i) => `- ${i.name}`).join("\n") +
`\n\nDelete workspace and all instances?`, `\n\nDelete workspace and all instances?`,
); );
if (confirmed) { if (confirmed) {
await deleteWorkspace(projectId, repoId, workspace.id, true); await deleteWorkspace(projectId, repoId, workspace.id, true);
await onRefresh(); await onRefresh();
} }
} else { } else {
throw err; throw err;
} }
} finally { } finally {
setLoadingId(null); setLoadingId(null);
} }
}, },
[], [],
); );
const sync = useCallback( const sync = useCallback(
async ( async (
projectId: string, projectId: string,
repoId: string, repoId: string,
workspace: Workspace, workspace: Workspace,
onRefresh: () => Promise<void>, onRefresh: () => Promise<void>,
) => { ) => {
setLoadingId(workspace.id); setLoadingId(workspace.id);
try { try {
await syncWorkspace(projectId, repoId, workspace.id); await syncWorkspace(projectId, repoId, workspace.id);
await onRefresh(); await onRefresh();
} catch (err) { } catch (err) {
const error = err as ApiError; const error = err as ApiError;
if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) { if (
const message = error.response.data.detail.message || "Branch was deleted from remote"; error.response?.status === 409 &&
const confirmed = window.confirm(`${message}\n\nDelete this workspace?`); error.response.data?.detail?.branch_deleted
if (confirmed) { ) {
await deleteWorkspace(projectId, repoId, workspace.id, true); const message =
await onRefresh(); error.response.data.detail.message ||
} "Branch was deleted from remote";
} else { const confirmed = window.confirm(
throw err; `${message}\n\nDelete this workspace?`,
} );
} finally { if (confirmed) {
setLoadingId(null); await deleteWorkspace(projectId, repoId, workspace.id, true);
} await onRefresh();
}, }
[], } else {
); throw err;
}
} finally {
setLoadingId(null);
}
},
[],
);
const update = useCallback( const update = useCallback(
async ( async (
projectId: string, projectId: string,
repoId: string, repoId: string,
workspaceId: string, workspaceId: string,
data: Partial<CreateWorkspaceRequest>, data: Partial<CreateWorkspaceRequest>,
) => { ) => {
return updateWorkspace(projectId, repoId, workspaceId, data); return updateWorkspace(projectId, repoId, workspaceId, data);
}, },
[], [],
); );
return { return {
loadingId, loadingId,
create, create,
delete: deleteAction, delete: deleteAction,
sync, sync,
update, update,
}; };
} }
+29 -24
View File
@@ -5,33 +5,38 @@ import { listWorkspaces } from "../api/workspaces";
import type { Workspace } from "../types/workspace"; import type { Workspace } from "../types/workspace";
export interface UseWorkspacesResult { export interface UseWorkspacesResult {
workspaces: Workspace[]; workspaces: Workspace[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult { export function useWorkspaces(
const [workspaces, setWorkspaces] = useState<Workspace[]>([]); projectId: string,
const [loading, setLoading] = useState(true); repoId: string,
const [error, setError] = useState<string | null>(null); ): UseWorkspacesResult {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await listWorkspaces(projectId, repoId); const data = await listWorkspaces(projectId, repoId);
setWorkspaces(data); setWorkspaces(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load workspaces"); setError(
} finally { err instanceof Error ? err.message : "Failed to load workspaces",
setLoading(false); );
} } finally {
}, [projectId, repoId]); setLoading(false);
}
}, [projectId, repoId]);
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, [refresh]); }, [refresh]);
return { workspaces, loading, error, refresh }; return { workspaces, loading, error, refresh };
} }
+110 -94
View File
@@ -11,109 +11,125 @@ import { createInstance, startInstance } from "../api/sessions";
import type { Workspace } from "../types/workspace"; import type { Workspace } from "../types/workspace";
export function WorkspacesPage() { export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null); const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
// TODO: Get projectId and repoId from URL params or context // TODO: Get projectId and repoId from URL params or context
const projectId = "default-project"; const projectId = "default-project";
const repoId = "default-repo"; const repoId = "default-repo";
const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId); const { workspaces, loading, error, refresh } = useWorkspaces(
const actions = useWorkspaceActions(); projectId,
repoId,
);
const actions = useWorkspaceActions();
const handleCreate = async (data: { name: string; branch: string }) => { const handleCreate = async (data: { name: string; branch: string }) => {
await actions.create(projectId, repoId, data); await actions.create(projectId, repoId, data);
setShowCreate(false); setShowCreate(false);
await refresh(); await refresh();
}; };
const handleDelete = async (workspace: Workspace) => { const handleDelete = async (workspace: Workspace) => {
await actions.delete(projectId, repoId, workspace, refresh); await actions.delete(projectId, repoId, workspace, refresh);
}; };
const handleSync = async (workspace: Workspace) => { const handleSync = async (workspace: Workspace) => {
await actions.sync(projectId, repoId, workspace, refresh); await actions.sync(projectId, repoId, workspace, refresh);
}; };
const handleStartTool = async (toolTypeId: string, configProfileId?: string) => { const handleStartTool = async (
if (!startWorkspace) return; toolTypeId: string,
try { configProfileId?: string,
const instance = await createInstance( ) => {
projectId, if (!startWorkspace) return;
repoId, try {
toolTypeId, const instance = await createInstance(
`${startWorkspace.name} - ${toolTypeId}`, projectId,
undefined, repoId,
undefined, toolTypeId,
undefined, `${startWorkspace.name} - ${toolTypeId}`,
configProfileId, undefined,
[], undefined,
startWorkspace.id undefined,
); configProfileId,
await startInstance(projectId, repoId, instance.id, configProfileId); [],
setStartWorkspace(null); startWorkspace.id,
await refresh(); );
} catch (err) { await startInstance(projectId, repoId, instance.id, configProfileId);
alert(err instanceof Error ? err.message : "Failed to start tool"); setStartWorkspace(null);
} await refresh();
}; } catch (err) {
alert(err instanceof Error ? err.message : "Failed to start tool");
}
};
return ( return (
<div className="page workspaces-page"> <div className="page workspaces-page">
<header className="page-header"> <header className="page-header">
<h1>Workspaces</h1> <h1>Workspaces</h1>
<div className="header-actions"> <div className="header-actions">
<button className="btn btn-secondary" onClick={refresh} disabled={loading}> <button
<Icon name="refresh" size="sm" /> className="btn btn-secondary"
</button> onClick={refresh}
<button className="btn btn-primary" onClick={() => setShowCreate(true)}> disabled={loading}
<Icon name="add" size="sm" /> New Workspace >
</button> <Icon name="refresh" size="sm" />
</div> </button>
</header> <button
className="btn btn-primary"
onClick={() => setShowCreate(true)}
>
<Icon name="add" size="sm" /> New Workspace
</button>
</div>
</header>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{showCreate && ( {showCreate && (
<WorkspaceCreateForm <WorkspaceCreateForm
projectId={projectId} projectId={projectId}
repoId={repoId} repoId={repoId}
onSubmit={handleCreate} onSubmit={handleCreate}
onCancel={() => setShowCreate(false)} onCancel={() => setShowCreate(false)}
/> />
)} )}
{loading && workspaces.length === 0 ? ( {loading && workspaces.length === 0 ? (
<div className="loading-state">Loading workspaces...</div> <div className="loading-state">Loading workspaces...</div>
) : workspaces.length === 0 ? ( ) : workspaces.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<p>No workspaces yet.</p> <p>No workspaces yet.</p>
<button className="btn btn-primary" onClick={() => setShowCreate(true)}> <button
<Icon name="add" size="sm" /> Create your first workspace className="btn btn-primary"
</button> onClick={() => setShowCreate(true)}
</div> >
) : ( <Icon name="add" size="sm" /> Create your first workspace
<div className="workspaces-grid"> </button>
{workspaces.map((ws) => ( </div>
<WorkspaceCard ) : (
key={ws.id} <div className="workspaces-grid">
workspace={ws} {workspaces.map((ws) => (
loading={actions.loadingId === ws.id} <WorkspaceCard
onStartTool={setStartWorkspace} key={ws.id}
onSync={handleSync} workspace={ws}
onDelete={handleDelete} loading={actions.loadingId === ws.id}
/> onStartTool={setStartWorkspace}
))} onSync={handleSync}
</div> onDelete={handleDelete}
)} />
))}
</div>
)}
{startWorkspace && ( {startWorkspace && (
<StartToolModal <StartToolModal
workspace={startWorkspace} workspace={startWorkspace}
onClose={() => setStartWorkspace(null)} onClose={() => setStartWorkspace(null)}
onStart={handleStartTool} onStart={handleStartTool}
/> />
)} )}
</div> </div>
); );
} }
+50 -35
View File
@@ -19,39 +19,54 @@ import { SessionsPage } from "./pages/sessions";
import { WorkspacesPage } from "./pages/workspaces"; import { WorkspacesPage } from "./pages/workspaces";
export const AppRouter = () => { export const AppRouter = () => {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginRedirectPage />} /> <Route path="/login" element={<LoginRedirectPage />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} /> <Route
<Route path="/ssh-keys"
path="/" element={<Navigate to="/settings/ssh-keys" replace />}
element={ />
<ProtectedRoute> <Route
<AppShell /> path="/"
</ProtectedRoute> element={
} <ProtectedRoute>
> <AppShell />
<Route index element={<HomePage />} /> </ProtectedRoute>
<Route path="projects" element={<ProjectsPage />} /> }
<Route path="projects/:projectId" element={<RepoWorkspace />} /> >
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} /> <Route index element={<HomePage />} />
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} /> <Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} /> <Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route path="profile" element={<ProfilePage />} /> <Route
<Route path="config-profiles" element={<ConfigProfilesPage />} /> path="projects/:projectId/repositories"
<Route path="settings" element={<SettingsPage />}> element={<GitRepositoriesPage />}
<Route index element={<Navigate to="general" replace />} /> />
<Route path="general" element={<GeneralSettingsTab />} /> <Route
<Route path="ssh-keys" element={<SSHKeysPage />} /> path="projects/:projectId/repositories/:repoId/history"
<Route path="*" element={<Navigate to="general" replace />} /> element={<GitHistoryPage />}
</Route> />
<Route path="sessions" element={<SessionsPage />} /> <Route
<Route path="workspaces" element={<WorkspacesPage />} /> path="projects/:projectId/settings/*"
<Route path="tool-workshop" element={<ToolWorkshopPage />} /> element={<ProjectSettingsPage />}
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} /> />
</Route> <Route path="profile" element={<ProfilePage />} />
<Route path="/404" element={<NotFoundPage />} /> <Route path="config-profiles" element={<ConfigProfilesPage />} />
<Route path="*" element={<Navigate to="/404" replace />} /> <Route path="settings" element={<SettingsPage />}>
</Routes> <Route index element={<Navigate to="general" replace />} />
); <Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="sessions" element={<SessionsPage />} />
<Route path="workspaces" element={<WorkspacesPage />} />
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route
path="instances/:instanceId/terminal"
element={<TerminalPage />}
/>
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
);
}; };
+18 -18
View File
@@ -1,28 +1,28 @@
/** Types for the workspace feature. */ /** Types for the workspace feature. */
export interface Workspace { export interface Workspace {
id: string; id: string;
name: string; name: string;
repo_id: string; repo_id: string;
repo_name: string; repo_name: string;
project_name: string; project_name: string;
user_id: string; user_id: string;
branch: string; branch: string;
path: string; path: string;
status: "ready" | "syncing" | "error"; status: "ready" | "syncing" | "error";
last_sync_at: string | null; last_sync_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
instance_count: number; instance_count: number;
} }
export interface CreateWorkspaceRequest { export interface CreateWorkspaceRequest {
name: string; name: string;
branch: string; branch: string;
} }
export interface SyncResult { export interface SyncResult {
branch_deleted: boolean; branch_deleted: boolean;
pulled: boolean; pulled: boolean;
last_sync_at: string | null; last_sync_at: string | null;
} }