fix: harden workspace manager drafts

This commit is contained in:
2026-08-04 06:36:16 +02:00
parent 48fd316b90
commit 6b40fa0cc5
5 changed files with 161 additions and 12 deletions
+24 -11
View File
@@ -35,7 +35,18 @@ function draftFromRecord(record: WorkspaceRecord): WorkspaceDraft {
}
function proposedId(id: string): string {
return `${id}-copy`.slice(0, 63);
const copy = `${id.slice(0, 58)}-copy`;
// A max-length source ending in "-copy" would otherwise reproduce itself.
return copy === id ? `${id.slice(0, 61)}-2` : copy;
}
function QueryError({ name, message, retryLabel, onRetry }: {
name: string;
message: string;
retryLabel: string;
onRetry: () => void;
}) {
return <div role="alert" aria-label={name} className="grid gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm"><p>{message}</p><div><Button size="sm" variant="outline" onClick={onRetry}>{retryLabel}</Button></div></div>;
}
export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) {
@@ -44,13 +55,16 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
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({
const statusQuery = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open });
const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open });
const detailQuery = useQuery({
queryKey: ["workspace", selectedId],
queryFn: () => getWorkspace(selectedId!),
enabled: Boolean(open && selectedId && !localDraft),
});
const status = statusQuery.data;
const workspaces = workspacesQuery.data ?? [];
const record = detailQuery.data;
const savedDraft = selectedId && !localDraft ? workspaceDrafts.load(selectedId) : undefined;
const savedDeletionDraft = selectedId && !deletionDraft ? workspaceDeletionDrafts.load(selectedId) : undefined;
@@ -147,26 +161,25 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<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) => (
{workspacesQuery.isLoading ? <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p> : workspacesQuery.isError ? <QueryError name="Workspace list failed" message="Could not load workspaces." retryLabel="Retry workspace list" onRetry={() => { void workspacesQuery.refetch(); }} /> : 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>}
{!workspacesQuery.isLoading && !workspacesQuery.isError && 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>}
{statusQuery.isError ? <QueryError name="Workspace registry status failed" message="Could not load registry status." retryLabel="Retry registry status" onRetry={() => { void statusQuery.refetch(); }} /> : statusQuery.isLoading ? <p>Loading…</p> : status && <><p>{status.branch}</p><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) && (
{detailQuery.isError && selectedId && !localDraft ? <QueryError name="Workspace details failed" message="Could not load workspace details." retryLabel="Retry workspace details" onRetry={() => { void detailQuery.refetch(); }} /> : !currentDraft && !detailQuery.isLoading && <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>}
{!detailQuery.isError && (currentDraft || detailQuery.isLoading) && (
<>
{recordLoading && !currentDraft ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : currentDraft && <>
{detailQuery.isLoading && !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>