feat: add workspace management editor

This commit is contained in:
2026-08-04 06:27:59 +02:00
parent b686ffe271
commit 48fd316b90
10 changed files with 744 additions and 7 deletions
+200
View File
@@ -0,0 +1,200 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, ClipboardCheck, FlaskConical, Plus, Trash2, Copy, X } from "lucide-react";
import {
asWorkspaceApiError, getWorkspace, getWorkspaceRegistryStatus, listWorkspaces, testWorkspace,
validateWorkspace, type CanonicalWorkspace, type PublishWorkspaceRequest, type WorkspaceRecord,
} from "../api/workspaces";
import { workspaceDeletionDrafts, workspaceDrafts, type WorkspaceDeletionDraft, type WorkspaceDraft } from "../workspaces/drafts";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { WorkspaceEditor } from "./WorkspaceEditor";
const EMPTY_COMMIT = "0".repeat(40);
function newWorkspace(): CanonicalWorkspace {
return {
workspace: { schema_version: 2, id: "new-workspace", name: "New workspace", language: "en" },
dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] },
semantic_index: {
vector_store: { engine: "pgvector", database: "vectors", schema: "public", collection: "documents", dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"] },
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
},
llm_policy: { allowed: ["zai/glm-5.2"] },
};
}
function draftFromRecord(record: WorkspaceRecord): WorkspaceDraft {
return {
workspaceId: record.workspace.workspace.id,
baseCommit: record.revision.commit,
baseBlob: record.revision.blob,
workspace: record.workspace,
updatedAt: new Date().toISOString(),
};
}
function proposedId(id: string): string {
return `${id}-copy`.slice(0, 63);
}
export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) {
const [selectedId, setSelectedId] = useState<string>();
const [localDraft, setLocalDraft] = useState<WorkspaceDraft>();
const [notice, setNotice] = useState<string>();
const [diagnostics, setDiagnostics] = useState<string[]>([]);
const [deletionDraft, setDeletionDraft] = useState<WorkspaceDeletionDraft>();
const { data: status } = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open });
const { data: workspaces = [], isLoading: workspacesLoading } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open });
const { data: record, isLoading: recordLoading } = useQuery({
queryKey: ["workspace", selectedId],
queryFn: () => getWorkspace(selectedId!),
enabled: Boolean(open && selectedId && !localDraft),
});
const savedDraft = selectedId && !localDraft ? workspaceDrafts.load(selectedId) : undefined;
const savedDeletionDraft = selectedId && !deletionDraft ? workspaceDeletionDrafts.load(selectedId) : undefined;
const currentDraft = localDraft ?? savedDraft ?? (record ? draftFromRecord(record) : undefined);
const activeDeletionDraft = deletionDraft ?? savedDeletionDraft;
const canTest = Boolean(record && currentDraft?.workspaceId === record.workspace.workspace.id);
const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
function selectWorkspace(id: string) {
setSelectedId(id);
setLocalDraft(undefined);
setDeletionDraft(undefined);
setNotice(undefined);
setDiagnostics([]);
}
function createWorkspace() {
const draft: WorkspaceDraft = { workspaceId: "new-workspace", baseCommit: EMPTY_COMMIT, workspace: newWorkspace(), updatedAt: new Date().toISOString() };
setSelectedId(draft.workspaceId);
setLocalDraft(draft);
setDeletionDraft(undefined);
setNotice("New draft. Choose its immutable workspace ID before saving.");
setDiagnostics([]);
}
function duplicateWorkspace() {
if (!currentDraft) return;
const id = proposedId(currentDraft.workspace.workspace.id);
const duplicate: WorkspaceDraft = {
...currentDraft,
workspaceId: id,
workspace: { ...currentDraft.workspace, workspace: { ...currentDraft.workspace.workspace, id, name: `${currentDraft.workspace.workspace.name} copy` } },
updatedAt: new Date().toISOString(),
};
setSelectedId(id);
setLocalDraft(duplicate);
setDeletionDraft(undefined);
setNotice("Duplicate draft. Give it a new immutable workspace ID before publishing.");
}
function saveDraft(draft: WorkspaceDraft) {
workspaceDrafts.save(draft);
setSelectedId(draft.workspaceId);
setLocalDraft(draft);
setNotice("Draft saved in this browser.");
}
async function validateCurrent() {
if (!currentDraft) return;
setNotice(undefined);
setDiagnostics([]);
try {
const result = await validateWorkspace(currentDraft.workspace);
setLocalDraft({ ...currentDraft, workspace: result.workspace, updatedAt: new Date().toISOString() });
setNotice("Workspace definition is valid.");
} catch (error) {
const safe = asWorkspaceApiError(error);
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"]);
}
}
async function testCurrent() {
if (!record) return;
setNotice(undefined);
setDiagnostics([]);
try {
const result = await testWorkspace(record.workspace.workspace.id);
setDiagnostics(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`));
if (result.diagnostics.length === 0) setNotice(result.activatable ? "Installation test passed." : "Installation test completed.");
} catch (error) {
const safe = asWorkspaceApiError(error);
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "connector_unavailable: Installation test could not be completed"]);
}
}
function stageDeletion() {
if (!currentDraft?.baseBlob || !record || currentDraft.workspaceId !== record.workspace.workspace.id) return;
const draft = { id: currentDraft.workspaceId, baseCommit: currentDraft.baseCommit, baseBlob: currentDraft.baseBlob, updatedAt: new Date().toISOString() };
workspaceDeletionDrafts.save(draft);
setDeletionDraft(draft);
setNotice("Deletion draft staged locally.");
setDiagnostics([]);
}
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
<DialogContent showCloseButton={false} className="max-h-[min(90vh,54rem)] max-w-[min(94vw,74rem)] overflow-hidden p-0">
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
<DialogTitle>Workspace management</DialogTitle>
<DialogDescription>Draft shared workspace definitions locally. Installation bindings and secrets stay outside this page.</DialogDescription>
</DialogHeader>
<button type="button" aria-label="Close workspace management" onClick={onClose} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"><X className="size-4" /></button>
<div className="grid max-h-[calc(min(90vh,54rem)-6.75rem)] min-h-[32rem] grid-cols-[14rem_minmax(0,1fr)] overflow-hidden">
<nav aria-label="Workspaces" className="flex min-h-0 flex-col border-r border-border/70 bg-muted/30 p-3">
<Button size="sm" className="mb-3 w-full" onClick={createWorkspace}><Plus />New workspace</Button>
<div className="min-h-0 flex-1 overflow-y-auto">
{workspacesLoading ? <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p> : workspaces.map((workspace) => (
<button key={workspace.id} type="button" aria-label={workspace.displayName} aria-current={selectedId === workspace.id ? "page" : undefined} onClick={() => selectWorkspace(workspace.id)} className="mb-1 w-full rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted aria-[current=page]:bg-primary/10 aria-[current=page]:font-semibold">
<span className="block truncate">{workspace.displayName}</span>
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
</button>
))}
{!workspacesLoading && workspaces.length === 0 && <p className="p-2 text-xs text-muted-foreground">No published workspaces.</p>}
</div>
<div className="border-t border-border/70 pt-3 text-xs text-muted-foreground">
<p className="font-semibold text-foreground">Git status</p>
<p>{status?.branch ?? "Loading…"}</p>
{status && <p>{status.degraded ? "Degraded" : "Current"} · ↑{status.ahead} ↓{status.behind}</p>}
</div>
</nav>
<div className="min-w-0 overflow-y-auto px-5 py-5">
{!currentDraft && !recordLoading && <div className="grid min-h-64 place-items-center text-center"><div><h3 className="font-heading font-semibold">Select a workspace</h3><p className="mt-1 text-sm text-muted-foreground">Review an existing definition or start a browser-only draft.</p></div></div>}
{(currentDraft || recordLoading) && (
<>
{recordLoading && !currentDraft ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : currentDraft && <>
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-border/70 pb-4">
<div>
<p className="thot-label">Workspace definition</p>
<h2 className="font-heading text-lg font-semibold">{currentDraft.workspace.workspace.name}</h2>
<p className="font-mono text-xs text-muted-foreground">{currentDraft.workspaceId}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={duplicateWorkspace}><Copy />Duplicate workspace</Button>
<Button size="sm" variant="outline" onClick={validateCurrent}><ClipboardCheck />Validate workspace</Button>
<Button size="sm" variant="outline" disabled={!canTest} onClick={testCurrent}><FlaskConical />Test on this installation</Button>
<Button size="sm" variant="destructive" disabled={!canTest || !currentDraft.baseBlob} onClick={stageDeletion}><Trash2 />Delete workspace</Button>
</div>
</div>
{notice && <p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm"><CheckCircle2 className="size-4 text-primary" />{notice}</p>}
{diagnostics.length > 0 && <div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">{diagnostics.map((diagnostic) => <p key={diagnostic} className="flex items-start gap-2"><AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}</p>)}</div>}
{activeDeletionDraft && <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm"><p className="font-semibold">Deletion draft</p><p>The published workspace is unchanged. Publishing this staged deletion is a Task 10 action.</p><Button size="xs" variant="ghost" className="mt-2" onClick={() => { workspaceDeletionDrafts.discard(activeDeletionDraft.id); setDeletionDraft(undefined); setNotice(undefined); }}>Discard deletion draft</Button></div>}
<WorkspaceEditor draft={currentDraft} idLocked={Boolean(currentDraft.baseBlob && selectedSummary?.id === currentDraft.workspaceId)} onSaveDraft={saveDraft} onPublish={async () => undefined} />
<section className="mt-6 border-t border-border/70 pt-5" aria-label="Git status and history">
<h3 className="font-heading text-sm font-semibold">Git status &amp; history</h3>
<p className="mt-2 text-sm text-muted-foreground">{status?.degraded ? "Using the last valid local snapshot." : "Registry checkout is current."}</p>
{record && <p className="mt-1 font-mono text-xs text-muted-foreground">Revision {record.revision.commit.slice(0, 12)} · {record.revision.state}</p>}
</section>
</>}
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}