refactor: make browser workspace writes bootstrap-only
This commit is contained in:
@@ -1,12 +1,22 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Plus, Trash2, Copy, Upload, X } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Upload, X } from "lucide-react";
|
||||
import {
|
||||
asWorkspaceApiError, exportWorkspace, getWorkspace, getWorkspaceRegistryStatus, importWorkspace,
|
||||
listWorkspaces, pullWorkspaceRegistry, testWorkspace, validateWorkspace, type CanonicalWorkspace,
|
||||
type PublishWorkspaceRequest, type WorkspaceRecord, type WorkspaceRevision,
|
||||
asWorkspaceApiError,
|
||||
exportWorkspace,
|
||||
getWorkspace,
|
||||
getWorkspaceRegistryStatus,
|
||||
importWorkspace,
|
||||
listWorkspaces,
|
||||
pullWorkspaceRegistry,
|
||||
testWorkspace,
|
||||
validateWorkspace,
|
||||
type CanonicalWorkspace,
|
||||
type PublishWorkspaceRequest,
|
||||
type WorkspaceRecord,
|
||||
type WorkspaceSummary,
|
||||
} from "../api/workspaces";
|
||||
import { workspaceDeletionDrafts, workspaceDrafts, type WorkspaceDeletionDraft, type WorkspaceDraft } from "../workspaces/drafts";
|
||||
import { workspaceBootstrapDrafts, type WorkspaceBootstrapDraft } from "../workspaces/drafts";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
import { WorkspaceEditor } from "./WorkspaceEditor";
|
||||
@@ -14,34 +24,6 @@ import { WorkspacePublishDialog } from "./WorkspacePublishDialog";
|
||||
|
||||
const EMPTY_COMMIT = "0".repeat(40);
|
||||
|
||||
function newWorkspace(): CanonicalWorkspace {
|
||||
return {
|
||||
workspace: { schema_version: 3, id: "new-workspace", name: "New workspace", language: "en" },
|
||||
dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: "documents", dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
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 {
|
||||
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;
|
||||
@@ -51,98 +33,99 @@ function QueryError({ name, message, retryLabel, onRetry }: {
|
||||
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>;
|
||||
}
|
||||
|
||||
function defaultBootstrapWorkspace(summary: WorkspaceSummary): CanonicalWorkspace {
|
||||
return {
|
||||
workspace: {
|
||||
schema_version: 3,
|
||||
id: summary.id,
|
||||
name: summary.displayName,
|
||||
...(summary.description ? { description: summary.description } : {}),
|
||||
language: "en",
|
||||
},
|
||||
dwh: {
|
||||
engine: "postgres",
|
||||
database: "database",
|
||||
schema: "public",
|
||||
supported_transports: ["postgres_direct"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant",
|
||||
collection: summary.id.replaceAll("-", "_"),
|
||||
dimensions: 1024,
|
||||
distance: "cosine",
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_internal",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
dimensions: 1024,
|
||||
},
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"], default: "zai/glm-5.2" },
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapDraftFor(summary: WorkspaceSummary, baseCommit: string): WorkspaceBootstrapDraft {
|
||||
return {
|
||||
workspaceId: summary.id,
|
||||
baseCommit,
|
||||
workspace: defaultBootstrapWorkspace(summary),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [localDraft, setLocalDraft] = useState<WorkspaceDraft>();
|
||||
const [localDraft, setLocalDraft] = useState<WorkspaceBootstrapDraft>();
|
||||
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 workspaces = workspacesQuery.data ?? [];
|
||||
const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
|
||||
const selectedSummaryIncomplete = Boolean(selectedSummary && !selectedSummary.revision);
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workspace", selectedId],
|
||||
queryFn: () => getWorkspace(selectedId!),
|
||||
enabled: Boolean(open && selectedId && !localDraft && !selectedSummaryIncomplete),
|
||||
enabled: Boolean(open && selectedId && selectedSummary?.configurationState === "ready"),
|
||||
});
|
||||
const status = statusQuery.data;
|
||||
const record = detailQuery.data;
|
||||
|
||||
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 activeBootstrapDraft = selectedSummary?.configurationState === "configuration_required"
|
||||
? (localDraft?.workspaceId === selectedSummary.id ? localDraft : workspaceBootstrapDrafts.load(selectedSummary.id) ?? bootstrapDraftFor(selectedSummary, statusQuery.data?.head ?? EMPTY_COMMIT))
|
||||
: undefined;
|
||||
|
||||
function resetTransientState() {
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
setPublishRequest(undefined);
|
||||
}
|
||||
|
||||
function selectWorkspace(id: string) {
|
||||
setSelectedId(id);
|
||||
setLocalDraft(undefined);
|
||||
setDeletionDraft(undefined);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
setPublishRequest(undefined);
|
||||
resetTransientState();
|
||||
}
|
||||
|
||||
function createWorkspace() {
|
||||
const draft: WorkspaceDraft = { workspaceId: "new-workspace", baseCommit: EMPTY_COMMIT, workspace: newWorkspace(), updatedAt: new Date().toISOString() };
|
||||
setSelectedId(draft.workspaceId);
|
||||
function saveDraft(draft: WorkspaceBootstrapDraft) {
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setLocalDraft(draft);
|
||||
setDeletionDraft(undefined);
|
||||
setNotice("New draft. Choose its immutable workspace ID before saving.");
|
||||
setDiagnostics([]);
|
||||
setPublishRequest(undefined);
|
||||
}
|
||||
|
||||
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.");
|
||||
setPublishRequest(undefined);
|
||||
}
|
||||
|
||||
function saveDraft(draft: WorkspaceDraft) {
|
||||
workspaceDrafts.save(draft);
|
||||
setSelectedId(draft.workspaceId);
|
||||
setLocalDraft(draft);
|
||||
setNotice("Draft saved in this browser.");
|
||||
setDiagnostics([]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
function requestCreate(request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) {
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setLocalDraft(draft);
|
||||
setSelectedId(draft.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([]);
|
||||
@@ -153,47 +136,35 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
|
||||
} 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);
|
||||
async function validateSelectedWorkspace() {
|
||||
if (!record) return;
|
||||
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.");
|
||||
await validateWorkspace(record.workspace);
|
||||
setNotice("Workspace definition is valid.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]);
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"]);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSelectedWorkspace() {
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,80 +190,62 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
async function importBundle(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setTransferring(true);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
try {
|
||||
const result = await importWorkspace(file);
|
||||
const importedId = result.draft.workspace.workspace.id;
|
||||
const catalog = workspaces.length > 0 ? workspaces : ((await workspacesQuery.refetch()).data ?? []);
|
||||
const matchingSummary = catalog.find((workspace) => workspace.id === importedId && workspace.configurationState === "configuration_required");
|
||||
if (!matchingSummary) {
|
||||
setDiagnostics(["workspace_invalid: Imported bundle can only bootstrap a matching catalog slot"]);
|
||||
return;
|
||||
}
|
||||
const draft: WorkspaceBootstrapDraft = {
|
||||
workspaceId: importedId,
|
||||
baseCommit: statusQuery.data?.head ?? EMPTY_COMMIT,
|
||||
workspace: result.draft.workspace,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setNotice("Imported bootstrap draft saved in this browser. Validate it before creating the descriptor.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]);
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
}
|
||||
}
|
||||
|
||||
function published() {
|
||||
if (publishRequest) {
|
||||
workspaceBootstrapDrafts.discard(publishRequest.workspace.workspace.id);
|
||||
setSelectedId(publishRequest.workspace.workspace.id);
|
||||
setNotice(`To change this workspace, edit ${publishRequest.workspace.workspace.id}/workspace.yaml, commit/push, then Pull.`);
|
||||
} else {
|
||||
setNotice("Workspace created.");
|
||||
}
|
||||
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()]);
|
||||
}
|
||||
|
||||
function saveResolvedDraft(draft: WorkspaceDraft) {
|
||||
workspaceDrafts.save(draft);
|
||||
setSelectedId(draft.workspaceId);
|
||||
setLocalDraft(draft);
|
||||
setDeletionDraft(undefined);
|
||||
setPublishRequest(undefined);
|
||||
setDiagnostics([]);
|
||||
setNotice(`Revised draft saved with registry revision ${draft.baseCommit.slice(0, 12)}. Validate it before publishing.`);
|
||||
void Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]).then(() => detailQuery.refetch());
|
||||
}
|
||||
|
||||
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([]);
|
||||
}
|
||||
const titleReady = workspacesQuery.isSuccess || workspacesQuery.isError;
|
||||
|
||||
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>
|
||||
<DialogTitle>{titleReady ? "Workspace management" : ""}</DialogTitle>
|
||||
<DialogDescription>{titleReady ? "Draft bootstrap-only workspace definitions locally. Existing published descriptors stay read-only." : ""}</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>
|
||||
<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">
|
||||
@@ -302,54 +255,53 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
|
||||
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
|
||||
</button>
|
||||
))}
|
||||
{!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>
|
||||
{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>{status.head && <p className="font-mono">Commit {status.head.slice(0, 12)}</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">
|
||||
{selectedSummaryIncomplete ? (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm">
|
||||
<p className="font-semibold">Workspace summary unavailable</p>
|
||||
<p className="mt-1">This workspace summary is incomplete. Refresh the registry or choose another workspace before creating sessions or editing drafts.</p>
|
||||
</div>
|
||||
) : 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>}
|
||||
{!selectedSummaryIncomplete && !detailQuery.isError && (currentDraft || detailQuery.isLoading) && (
|
||||
{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>}
|
||||
|
||||
{!selectedSummary && !workspacesQuery.isLoading && !workspacesQuery.isError && <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 bootstrap a configuration-required slot.</p></div></div>}
|
||||
|
||||
{selectedSummary?.configurationState === "configuration_required" && activeBootstrapDraft && (
|
||||
<>
|
||||
{detailQuery.isLoading && !currentDraft ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : currentDraft && <>
|
||||
<div className="mb-5 border-b border-border/70 pb-4">
|
||||
<p className="thot-label">Bootstrap workspace</p>
|
||||
<h2 className="font-heading text-lg font-semibold">{selectedSummary.displayName}</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
|
||||
</div>
|
||||
<WorkspaceEditor
|
||||
mode={{ kind: "bootstrap", catalog: selectedSummary, draft: activeBootstrapDraft }}
|
||||
onSaveDraft={saveDraft}
|
||||
onRequestCreate={publishRequest ? undefined : requestCreate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedSummary?.configurationState === "ready" && (
|
||||
detailQuery.isError ? <QueryError name="Workspace details failed" message="Could not load workspace details." retryLabel="Retry workspace details" onRetry={() => { void detailQuery.refetch(); }} /> : detailQuery.isLoading || !record ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : (
|
||||
<>
|
||||
<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>
|
||||
<h2 className="font-heading text-lg font-semibold">{record.workspace.workspace.name}</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">{record.workspace.workspace.id}</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>
|
||||
<Button size="sm" variant="destructive" disabled={!canTest || !currentDraft.baseBlob} onClick={stageDeletion}><Trash2 />Delete workspace</Button>
|
||||
<Button size="sm" variant="outline" disabled={transferring} onClick={() => { void downloadBundle(); }}><Download />Export workspace bundle</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { void validateSelectedWorkspace(); }}><ClipboardCheck />Validate workspace</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { void testSelectedWorkspace(); }}><FlaskConical />Test on this installation</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. 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 & 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)}</p>}
|
||||
</section>
|
||||
</>}
|
||||
</>
|
||||
<WorkspaceEditor mode={{ kind: "read_only", catalog: selectedSummary, record: record as WorkspaceRecord }} />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
{publishRequest && <WorkspacePublishDialog open={Boolean(publishRequest)} request={publishRequest} onOpenChange={(nextOpen) => { if (!nextOpen) setPublishRequest(undefined); }} onPublished={published} onResolved={saveResolvedDraft} onPull={pullLatest} onReload={reloadWorkspace} />}
|
||||
{publishRequest && <WorkspacePublishDialog open request={publishRequest} onOpenChange={(nextOpen) => { if (!nextOpen) setPublishRequest(undefined); }} onPublished={published} />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user