feat: workspace frontend core (PR-3)
- Workspace types, API client, hooks (useWorkspaces, useWorkspaceActions) - WorkspaceCard, WorkspaceCreateForm, StartToolModal components - WorkspacesPage with list, create, sync, delete, start-tool flow - Sidebar navigation: new 'Workspaces' entry - Router: /workspaces route - TypeScript + eslint clean
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
/** Workspace API client. */
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace";
|
||||||
|
|
||||||
|
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
|
||||||
|
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
||||||
|
return workspaceId ? `${base}/${workspaceId}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaces(projectId: string, repoId: string): Promise<Workspace[]> {
|
||||||
|
const response = await apiClient.get<Workspace[]>(workspaceUrl(projectId, repoId));
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
data: CreateWorkspaceRequest,
|
||||||
|
): Promise<Workspace> {
|
||||||
|
const response = await apiClient.post<Workspace>(workspaceUrl(projectId, repoId), data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<Workspace> {
|
||||||
|
const response = await apiClient.get<Workspace>(workspaceUrl(projectId, repoId, workspaceId));
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
data: Partial<CreateWorkspaceRequest>,
|
||||||
|
): Promise<Workspace> {
|
||||||
|
const response = await apiClient.patch<Workspace>(workspaceUrl(projectId, repoId, workspaceId), data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
force = false,
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
const response = await apiClient.delete<{ status: string }>(
|
||||||
|
`${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const response = await apiClient.post<SyncResult>(
|
||||||
|
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ const NAV_ITEMS: {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{ to: "/", label: "Home", icon: "dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
||||||
|
{ to: "/workspaces", label: "Workspaces", icon: "folder" },
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/** Modal for starting a tool on a workspace. */
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
|
export interface StartToolModalProps {
|
||||||
|
workspace: Workspace;
|
||||||
|
onClose: () => void;
|
||||||
|
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StartToolModal({ workspace, onClose, onStart }: 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) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!toolTypeId) {
|
||||||
|
setError("Please select a tool type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onStart(toolTypeId, configProfileId || undefined);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>
|
||||||
|
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
|
||||||
|
</h3>
|
||||||
|
<button className="btn btn-icon" onClick={onClose}>
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="tool-type">Tool Type</label>
|
||||||
|
<select
|
||||||
|
id="tool-type"
|
||||||
|
value={toolTypeId}
|
||||||
|
onChange={(e) => setToolTypeId(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<option value="">Select a tool...</option>
|
||||||
|
<option value="code-server">Code Server</option>
|
||||||
|
<option value="jupyter-notebook">Jupyter Notebook</option>
|
||||||
|
<option value="terminal">Terminal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="config-profile">Config Profile (optional)</label>
|
||||||
|
<input
|
||||||
|
id="config-profile"
|
||||||
|
type="text"
|
||||||
|
value={configProfileId}
|
||||||
|
onChange={(e) => setConfigProfileId(e.target.value)}
|
||||||
|
placeholder="Profile ID"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? "Starting..." : "Start Tool"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/** Card component for displaying a workspace. */
|
||||||
|
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
|
export interface WorkspaceCardProps {
|
||||||
|
workspace: Workspace;
|
||||||
|
loading?: boolean;
|
||||||
|
onStartTool: (workspace: Workspace) => void;
|
||||||
|
onSync: (workspace: Workspace) => void;
|
||||||
|
onDelete: (workspace: Workspace) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceCard({
|
||||||
|
workspace,
|
||||||
|
loading = false,
|
||||||
|
onStartTool,
|
||||||
|
onSync,
|
||||||
|
onDelete,
|
||||||
|
}: WorkspaceCardProps) {
|
||||||
|
const statusClass =
|
||||||
|
workspace.status === "ready"
|
||||||
|
? "status-ready"
|
||||||
|
: workspace.status === "syncing"
|
||||||
|
? "status-syncing"
|
||||||
|
: "status-error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||||
|
<div className="workspace-header">
|
||||||
|
<h4>{workspace.name}</h4>
|
||||||
|
<span className={`status-badge ${statusClass}`}>{workspace.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-meta">
|
||||||
|
<p className="workspace-project">
|
||||||
|
{workspace.project_name} / {workspace.repo_name}
|
||||||
|
</p>
|
||||||
|
<p className="workspace-branch">
|
||||||
|
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||||
|
</p>
|
||||||
|
{workspace.instance_count > 0 && (
|
||||||
|
<p className="workspace-instances">
|
||||||
|
{workspace.instance_count} active tool
|
||||||
|
{workspace.instance_count > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="workspace-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => onStartTool(workspace)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" /> Start Tool
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onSync(workspace)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" /> Sync
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => onDelete(workspace)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/** Form for creating a new workspace. */
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import type { CreateWorkspaceRequest } from "../types/workspace";
|
||||||
|
|
||||||
|
export interface WorkspaceCreateFormProps {
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
defaultBranch?: string;
|
||||||
|
onSubmit: (data: CreateWorkspaceRequest) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceCreateForm({
|
||||||
|
defaultBranch = "main",
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: WorkspaceCreateFormProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [branch, setBranch] = useState(defaultBranch);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Workspace name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onSubmit({ name: name.trim(), branch: branch.trim() });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create workspace");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="workspace-create-form card" onSubmit={handleSubmit}>
|
||||||
|
<h3>
|
||||||
|
<Icon name="add" size="sm" /> Create Workspace
|
||||||
|
</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="ws-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="ws-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., feature-branch"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="ws-branch">
|
||||||
|
<Icon name="branch" size="sm" /> Branch
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ws-branch"
|
||||||
|
type="text"
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
placeholder="main"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/** Hook for workspace CRUD actions with confirmation handling. */
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
createWorkspace,
|
||||||
|
deleteWorkspace,
|
||||||
|
syncWorkspace,
|
||||||
|
updateWorkspace,
|
||||||
|
} from "../api/workspaces";
|
||||||
|
import type { Workspace, CreateWorkspaceRequest } from "../types/workspace";
|
||||||
|
|
||||||
|
export interface UseWorkspaceActionsResult {
|
||||||
|
loadingId: string | null;
|
||||||
|
create: (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
data: CreateWorkspaceRequest,
|
||||||
|
) => Promise<Workspace>;
|
||||||
|
delete: (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspace: Workspace,
|
||||||
|
onRefresh: () => Promise<void>,
|
||||||
|
) => Promise<void>;
|
||||||
|
sync: (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspace: Workspace,
|
||||||
|
onRefresh: () => Promise<void>,
|
||||||
|
) => Promise<void>;
|
||||||
|
update: (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
data: Partial<CreateWorkspaceRequest>,
|
||||||
|
) => Promise<Workspace>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiError {
|
||||||
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
data?: {
|
||||||
|
detail?: {
|
||||||
|
message?: string;
|
||||||
|
instances?: Array<{ id: string; name: string }>;
|
||||||
|
branch_deleted?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceActions(): UseWorkspaceActionsResult {
|
||||||
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const create = useCallback(
|
||||||
|
async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
|
||||||
|
return createWorkspace(projectId, repoId, data);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteAction = useCallback(
|
||||||
|
async (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspace: Workspace,
|
||||||
|
onRefresh: () => Promise<void>,
|
||||||
|
) => {
|
||||||
|
setLoadingId(workspace.id);
|
||||||
|
try {
|
||||||
|
await deleteWorkspace(projectId, repoId, workspace.id);
|
||||||
|
await onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as ApiError;
|
||||||
|
if (error.response?.status === 409) {
|
||||||
|
const detail = error.response.data?.detail;
|
||||||
|
const instances = detail?.instances || [];
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`This workspace has ${instances.length} running tool instance(s):\n` +
|
||||||
|
instances.map((i) => `- ${i.name}`).join("\n") +
|
||||||
|
`\n\nDelete workspace and all instances?`,
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sync = useCallback(
|
||||||
|
async (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspace: Workspace,
|
||||||
|
onRefresh: () => Promise<void>,
|
||||||
|
) => {
|
||||||
|
setLoadingId(workspace.id);
|
||||||
|
try {
|
||||||
|
await syncWorkspace(projectId, repoId, workspace.id);
|
||||||
|
await onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as ApiError;
|
||||||
|
if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
|
||||||
|
const message = error.response.data.detail.message || "Branch was deleted from remote";
|
||||||
|
const confirmed = window.confirm(`${message}\n\nDelete this workspace?`);
|
||||||
|
if (confirmed) {
|
||||||
|
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
async (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
data: Partial<CreateWorkspaceRequest>,
|
||||||
|
) => {
|
||||||
|
return updateWorkspace(projectId, repoId, workspaceId, data);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadingId,
|
||||||
|
create,
|
||||||
|
delete: deleteAction,
|
||||||
|
sync,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Hook for fetching workspaces. */
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { listWorkspaces } from "../api/workspaces";
|
||||||
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
|
export interface UseWorkspacesResult {
|
||||||
|
workspaces: Workspace[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult {
|
||||||
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await listWorkspaces(projectId, repoId);
|
||||||
|
setWorkspaces(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load workspaces");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, repoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { workspaces, loading, error, refresh };
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** Workspaces list page. */
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Icon } from "../components/icon";
|
||||||
|
import { useWorkspaces } from "../hooks/use-workspaces";
|
||||||
|
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
|
||||||
|
import { WorkspaceCard } from "../components/workspace-card";
|
||||||
|
import { WorkspaceCreateForm } from "../components/workspace-create-form";
|
||||||
|
import { StartToolModal } from "../components/start-tool-modal";
|
||||||
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
|
export function WorkspacesPage() {
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
||||||
|
|
||||||
|
// TODO: Get projectId and repoId from URL params or context
|
||||||
|
const projectId = "default-project";
|
||||||
|
const repoId = "default-repo";
|
||||||
|
|
||||||
|
const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId);
|
||||||
|
const actions = useWorkspaceActions();
|
||||||
|
|
||||||
|
const handleCreate = async (data: { name: string; branch: string }) => {
|
||||||
|
await actions.create(projectId, repoId, data);
|
||||||
|
setShowCreate(false);
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (workspace: Workspace) => {
|
||||||
|
await actions.delete(projectId, repoId, workspace, refresh);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async (workspace: Workspace) => {
|
||||||
|
await actions.sync(projectId, repoId, workspace, refresh);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartTool = async (toolTypeId: string, configProfileId?: string) => {
|
||||||
|
if (!startWorkspace) return;
|
||||||
|
// TODO: Call instance creation API with workspace_id
|
||||||
|
console.log("Start tool", { toolTypeId, configProfileId, workspace: startWorkspace.id });
|
||||||
|
setStartWorkspace(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page workspaces-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<h1>Workspaces</h1>
|
||||||
|
<div className="header-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={refresh} disabled={loading}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
<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>}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<WorkspaceCreateForm
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={repoId}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
onCancel={() => setShowCreate(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && workspaces.length === 0 ? (
|
||||||
|
<div className="loading-state">Loading workspaces...</div>
|
||||||
|
) : workspaces.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No workspaces yet.</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||||
|
<Icon name="add" size="sm" /> Create your first workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="workspaces-grid">
|
||||||
|
{workspaces.map((ws) => (
|
||||||
|
<WorkspaceCard
|
||||||
|
key={ws.id}
|
||||||
|
workspace={ws}
|
||||||
|
loading={actions.loadingId === ws.id}
|
||||||
|
onStartTool={setStartWorkspace}
|
||||||
|
onSync={handleSync}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{startWorkspace && (
|
||||||
|
<StartToolModal
|
||||||
|
workspace={startWorkspace}
|
||||||
|
onClose={() => setStartWorkspace(null)}
|
||||||
|
onStart={handleStartTool}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { ToolWorkshopPage } from "./pages/tool-workshop";
|
|||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
import { ConfigProfilesPage } from "./pages/config-profiles";
|
import { ConfigProfilesPage } from "./pages/config-profiles";
|
||||||
import { SessionsPage } from "./pages/sessions";
|
import { SessionsPage } from "./pages/sessions";
|
||||||
|
import { WorkspacesPage } from "./pages/workspaces";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +46,7 @@ export const AppRouter = () => {
|
|||||||
<Route path="*" element={<Navigate to="general" replace />} />
|
<Route path="*" element={<Navigate to="general" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="sessions" element={<SessionsPage />} />
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
|
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/** Types for the workspace feature. */
|
||||||
|
|
||||||
|
export interface Workspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
repo_id: string;
|
||||||
|
repo_name: string;
|
||||||
|
project_name: string;
|
||||||
|
user_id: string;
|
||||||
|
branch: string;
|
||||||
|
path: string;
|
||||||
|
status: "ready" | "syncing" | "error";
|
||||||
|
last_sync_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
instance_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWorkspaceRequest {
|
||||||
|
name: string;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
branch_deleted: boolean;
|
||||||
|
pulled: boolean;
|
||||||
|
last_sync_at: string | null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user