feat: publish and synchronize workspace drafts

This commit is contained in:
2026-08-04 06:47:10 +02:00
parent 6b40fa0cc5
commit b75b3af28e
9 changed files with 545 additions and 16 deletions
+131 -5
View File
@@ -1,14 +1,16 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, ClipboardCheck, FlaskConical, Plus, Trash2, Copy, X } from "lucide-react";
import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Plus, Trash2, Copy, Upload, X } from "lucide-react";
import {
asWorkspaceApiError, getWorkspace, getWorkspaceRegistryStatus, listWorkspaces, testWorkspace,
validateWorkspace, type CanonicalWorkspace, type PublishWorkspaceRequest, type WorkspaceRecord,
asWorkspaceApiError, exportWorkspace, getWorkspace, getWorkspaceRegistryStatus, importWorkspace,
listWorkspaces, pullWorkspaceRegistry, testWorkspace, validateWorkspace, type CanonicalWorkspace,
type PublishWorkspaceRequest, type WorkspaceRecord, type WorkspaceRevision,
} 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";
import { WorkspacePublishDialog } from "./WorkspacePublishDialog";
const EMPTY_COMMIT = "0".repeat(40);
@@ -55,6 +57,8 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
const [notice, setNotice] = useState<string>();
const [diagnostics, setDiagnostics] = useState<string[]>([]);
const [deletionDraft, setDeletionDraft] = useState<WorkspaceDeletionDraft>();
const [publishRequest, setPublishRequest] = useState<PublishWorkspaceRequest>();
const [transferring, setTransferring] = useState(false);
const statusQuery = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open });
const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open });
const detailQuery = useQuery({
@@ -79,6 +83,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
setDeletionDraft(undefined);
setNotice(undefined);
setDiagnostics([]);
setPublishRequest(undefined);
}
function createWorkspace() {
@@ -88,6 +93,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
setDeletionDraft(undefined);
setNotice("New draft. Choose its immutable workspace ID before saving.");
setDiagnostics([]);
setPublishRequest(undefined);
}
function duplicateWorkspace() {
@@ -103,6 +109,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
setLocalDraft(duplicate);
setDeletionDraft(undefined);
setNotice("Duplicate draft. Give it a new immutable workspace ID before publishing.");
setPublishRequest(undefined);
}
function saveDraft(draft: WorkspaceDraft) {
@@ -112,6 +119,121 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
setNotice("Draft saved in this browser.");
}
function requestPublish(request: PublishWorkspaceRequest) {
if (request.action !== "delete") {
const nextDraft: WorkspaceDraft = {
workspaceId: request.workspace.workspace.id,
baseCommit: request.baseCommit,
...(request.action === "update" ? { baseBlob: request.baseBlob } : {}),
workspace: request.workspace,
updatedAt: new Date().toISOString(),
};
workspaceDrafts.save(nextDraft);
setLocalDraft(nextDraft);
setSelectedId(nextDraft.workspaceId);
}
setNotice(undefined);
setDiagnostics([]);
setPublishRequest(request);
}
function requestDeletionPublish() {
if (!activeDeletionDraft) return;
requestPublish({ action: "delete", id: activeDeletionDraft.id, baseCommit: activeDeletionDraft.baseCommit, baseBlob: activeDeletionDraft.baseBlob });
}
async function pullLatest() {
setNotice(undefined);
setDiagnostics([]);
try {
await pullWorkspaceRegistry();
await Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]);
setNotice("Registry updated. Reload a workspace to review its latest revision.");
} catch (error) {
const safe = asWorkspaceApiError(error);
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "git_unavailable: Registry pull could not be completed"]);
throw error;
}
}
function reloadWorkspace() {
if (selectedId) {
workspaceDrafts.discard(selectedId);
workspaceDeletionDrafts.discard(selectedId);
}
setLocalDraft(undefined);
setDeletionDraft(undefined);
setPublishRequest(undefined);
setNotice("Workspace reloaded from the registry. Your prior browser draft was discarded.");
setDiagnostics([]);
void detailQuery.refetch();
}
async function importBundle(file: File | undefined) {
if (!file) return;
setTransferring(true);
setNotice(undefined);
setDiagnostics([]);
try {
const result = await importWorkspace(file);
const imported: WorkspaceDraft = {
workspaceId: result.draft.workspace.workspace.id,
baseCommit: EMPTY_COMMIT,
workspace: result.draft.workspace,
updatedAt: new Date().toISOString(),
};
workspaceDrafts.save(imported);
setSelectedId(imported.workspaceId);
setLocalDraft(imported);
setDeletionDraft(undefined);
setPublishRequest(undefined);
setNotice("Imported draft saved in this browser. Validate it before publishing.");
} catch (error) {
const safe = asWorkspaceApiError(error);
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]);
} finally {
setTransferring(false);
}
}
async function downloadBundle() {
if (!record) return;
setTransferring(true);
setNotice(undefined);
setDiagnostics([]);
try {
const bundle = await exportWorkspace(record.workspace.workspace.id);
const url = URL.createObjectURL(bundle);
const link = document.createElement("a");
link.href = url;
link.download = `${record.workspace.workspace.id}.zip`;
link.click();
URL.revokeObjectURL(url);
setNotice("Workspace bundle downloaded.");
} catch (error) {
const safe = asWorkspaceApiError(error);
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be exported"]);
} finally {
setTransferring(false);
}
}
function published(revision: WorkspaceRevision | undefined) {
const publishedRequest = publishRequest;
if (publishedRequest?.action === "delete") {
workspaceDeletionDrafts.discard(publishedRequest.id);
setSelectedId(undefined);
} else if (publishedRequest) {
workspaceDrafts.discard(publishedRequest.workspace.workspace.id);
setSelectedId(publishedRequest.workspace.workspace.id);
}
setLocalDraft(undefined);
setDeletionDraft(undefined);
setPublishRequest(undefined);
setNotice(revision ? `Published revision ${revision.commit.slice(0, 12)}.` : "Workspace published.");
void Promise.all([statusQuery.refetch(), workspacesQuery.refetch(), detailQuery.refetch()]);
}
async function validateCurrent() {
if (!currentDraft) return;
setNotice(undefined);
@@ -160,6 +282,8 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<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>
<label className="mb-2 inline-flex h-7 cursor-pointer items-center justify-center gap-1 rounded-md border border-input px-3 text-[0.8rem] font-semibold text-foreground shadow-xs hover:bg-muted has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50"><Upload className="size-3.5" />Import bundle<input aria-label="Import workspace bundle" type="file" accept="application/zip,.zip" className="sr-only" disabled={transferring} onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; void importBundle(file); }} /></label>
<Button size="sm" variant="outline" className="mb-3 w-full" disabled={transferring} onClick={() => { void pullLatest(); }}><GitPullRequest />Pull latest registry</Button>
<div className="min-h-0 flex-1 overflow-y-auto">
{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">
@@ -187,6 +311,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<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" disabled={!record || transferring} onClick={() => { void downloadBundle(); }}><Download />Export workspace bundle</Button>
<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>
@@ -195,8 +320,8 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
</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} />
{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. Validation and a separate confirmation are required before this deletion is published.</p><div className="mt-2 flex gap-2"><Button size="xs" variant="ghost" onClick={() => { workspaceDeletionDrafts.discard(activeDeletionDraft.id); setDeletionDraft(undefined); setNotice(undefined); }}>Discard deletion draft</Button><Button size="xs" variant="destructive" onClick={requestDeletionPublish}>Publish deletion</Button></div></div>}
<WorkspaceEditor draft={currentDraft} idLocked={Boolean(currentDraft.baseBlob && selectedSummary?.id === currentDraft.workspaceId)} onSaveDraft={saveDraft} onPublish={async (request) => { requestPublish(request); }} />
<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>
@@ -208,6 +333,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
</div>
</div>
</DialogContent>
{publishRequest && <WorkspacePublishDialog open={Boolean(publishRequest)} request={publishRequest} onOpenChange={(nextOpen) => { if (!nextOpen) setPublishRequest(undefined); }} onPublished={published} onPull={pullLatest} onReload={reloadWorkspace} />}
</Dialog>
);
}