diff --git a/canvas/src/App.tsx b/canvas/src/App.tsx index e8d710f..d268918 100644 --- a/canvas/src/App.tsx +++ b/canvas/src/App.tsx @@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlowProvider } from "reactflow"; import "reactflow/dist/style.css"; -import { ApiError, fetchDiagnostics, fetchModel, gitStatus, sendCommand } from "./api"; +import { + ApiError, + applyImportBatch, + fetchDiagnostics, + fetchModel, + gitStatus, + sendCommand, +} from "./api"; import { directCapabilities } from "./exec"; import { ModelCanvas, PALETTE, type ModelCanvasHandle } from "./ModelCanvas"; import { SidePanel, type PanelTab } from "./SidePanel"; @@ -24,6 +31,17 @@ function Canvas() { // The graph, the inspector and the modals now live in ; the shell // reaches them through this handle for its toolbar and keyboard shortcuts. const canvasRef = useRef(null); + // The latest server fingerprint. exec() sends this and advances it from each + // command's response, so a rapid SEQUENCE of commands (an import batch, a + // create-then-edit) each carries a fresh fingerprint instead of the stale one from + // the last render — which otherwise 409s every op after the first. The poll and + // refresh keep it current, so a genuine external edit is still caught. + const fpRef = useRef(""); + // While a command (or a batch of them, e.g. an import) is applying, the 3s poll + // must not overwrite fpRef with an intermediate on-disk fingerprint — that would + // make the next command in the batch send a stale fingerprint and 409. exec holds + // this for the duration of each call. + const execInFlight = useRef(false); // TopBar renders the subject-area legend, so it needs the same colour map the // canvas uses — one palette, exported from ModelCanvas. @@ -39,6 +57,9 @@ function Canvas() { const caps = useMemo(() => directCapabilities(readOnly), [readOnly]); const urlParams = useMemo(() => new URLSearchParams(window.location.search), []); const minimal = urlParams.get("minimal") === "1"; + // The VS Code "Modelith: Import to Model" command reveals the canvas with + // `?import=1`, so the Import wizard is already open when the panel appears. + const openImport = urlParams.get("import") === "1"; const initialFocusDone = useRef(false); const refresh = useCallback(() => { @@ -62,6 +83,8 @@ function Canvas() { .then((m) => { setDoc((cur) => { if (cur && m.fingerprint === cur.fingerprint) return cur; // unchanged + // don't clobber the batch's own fingerprint while a command is applying + if (!execInFlight.current) fpRef.current = m.fingerprint; if (!minimal) { fetchDiagnostics().then(setDiagnostics).catch(() => setDiagnostics(null)); gitStatus() @@ -77,13 +100,21 @@ function Canvas() { return () => clearInterval(id); }, [minimal]); - /** The single mutation path: send a command with the fingerprint we based our - * view on; refresh on success; surface stale-model and validation errors. */ + // Keep the fingerprint ref in step with whatever the model currently reflects. + useEffect(() => { + if (doc?.fingerprint) fpRef.current = doc.fingerprint; + }, [doc]); + + /** The single mutation path: send a command with the latest known fingerprint, + * advance the ref from the response so a following command in the same batch is + * valid, refresh on success, and surface stale-model and validation errors. */ const exec = useCallback( async (op: string, payload: Record) => { if (!doc) return; + execInFlight.current = true; try { - const r = await sendCommand(op, payload, doc.fingerprint); + const r = await sendCommand(op, payload, fpRef.current || doc.fingerprint); + if (r?.fingerprint) fpRef.current = r.fingerprint; // next op sees the new state refresh(); return r; } catch (e) { @@ -94,6 +125,8 @@ function Canvas() { setToast(e instanceof Error ? e.message : String(e)); } throw e; + } finally { + execInFlight.current = false; } }, [doc, refresh], @@ -189,6 +222,10 @@ function Canvas() { panelTab={panelTab} onPanelTab={(t) => setPanelTab((cur) => (cur === t ? null : t))} onNewEntity={() => canvasRef.current?.newEntity()} + exec={exec} + onImported={refresh} + onImportBatch={applyImportBatch} + openImport={openImport} /> )}
diff --git a/canvas/src/ModelCanvas.tsx b/canvas/src/ModelCanvas.tsx index 29ec4e8..f19f83b 100644 --- a/canvas/src/ModelCanvas.tsx +++ b/canvas/src/ModelCanvas.tsx @@ -4,6 +4,7 @@ import ReactFlow, { BackgroundVariant, Controls, MiniMap, + getRectOfNodes, useReactFlow, type Connection, type Edge, @@ -102,7 +103,7 @@ export function ModelCanvas({ onError, handleRef, }: ModelCanvasProps) { - const { fitView } = useReactFlow(); + const { fitView, fitBounds, getNodes } = useReactFlow(); const [nodes, setNodes] = useState[]>([]); const [edges, setEdges] = useState[]>([]); const layoutedRef = useRef(false); @@ -292,6 +293,39 @@ export function ModelCanvas({ fitView, ]); + // Frame the matches. In a large model the entities a search hits may be off-screen, + // so highlighting alone is not enough — pan/zoom to the matched nodes (like Fit, but + // scoped to the hits). Fires only on a query change, so it doesn't fight the main + // derivation effect's own fitView on structural edits. + // + // We compute the bounds from the matched nodes' POSITIONS (getRectOfNodes) and call + // setViewport via fitBounds, rather than fitView({nodes}): with + // onlyRenderVisibleElements an off-screen node has no measured size, so fitView + // can't bound it — reading positions works regardless of what is currently rendered. + useEffect(() => { + const q = query.trim().toLowerCase(); + if (!q) return; + const hitIds = new Set(); + for (const e of doc.entities) { + const hay = + e.name.toLowerCase() + + " " + + (e.conceptual?.name.toLowerCase() ?? "") + + " " + + e.attributes.map((a) => a.name.toLowerCase()).join(" "); + if (hay.includes(q)) hitIds.add(e.id); + } + if (!hitIds.size) return; + const raf = requestAnimationFrame(() => { + const hitNodes = getNodes().filter((n) => hitIds.has(n.id)); + if (!hitNodes.length) return; + const rect = getRectOfNodes(hitNodes); + // clamp the zoom so a single small entity doesn't fill the whole viewport + fitBounds(rect, { padding: 0.35, duration: 400 }); + }); + return () => cancelAnimationFrame(raf); + }, [query, doc, getNodes, fitBounds]); + const relayout = useCallback(() => { setNodes((prev) => layoutGraph(prev, edges, entityIndex)); requestAnimationFrame(() => fitView({ padding: 0.15, duration: 300 })); diff --git a/canvas/src/TopBar.tsx b/canvas/src/TopBar.tsx index adf3a25..ae75c7a 100644 --- a/canvas/src/TopBar.tsx +++ b/canvas/src/TopBar.tsx @@ -1,4 +1,6 @@ +import type { Exec } from "./exec"; import type { PanelTab } from "./SidePanel"; +import { ImportExportMenu } from "./sme/ImportExportMenu"; import type { DiagnosticsDoc, ModelDoc } from "./types"; export function TopBar({ @@ -18,6 +20,10 @@ export function TopBar({ panelTab, onPanelTab, onNewEntity, + exec, + onImported, + onImportBatch, + openImport, }: { doc: ModelDoc; diagnostics: DiagnosticsDoc | null; @@ -35,6 +41,14 @@ export function TopBar({ panelTab: PanelTab | null; onPanelTab: (t: PanelTab) => void; onNewEntity: () => void; + /** the mutation seam, for the import/export menu (direct-write in this canvas) */ + exec: Exec; + /** called after an import applies, so the shell can refresh from disk */ + onImported?: (tables: number) => void; + /** batch applier for an import (avoids the per-op fingerprint race) */ + onImportBatch?: (changes: { op: string; payload: Record }[]) => Promise; + /** open the Import wizard immediately (VS Code "Import to Model" via `?import=1`) */ + openImport?: boolean; }) { const errors = diagnostics?.items.filter((d) => d.severity === "error").length ?? 0; const warnings = diagnostics?.items.filter((d) => d.severity === "warning").length ?? 0; @@ -123,6 +137,19 @@ export function TopBar({ + + {/* Interchange: export the model / import SQL DDL, Mermaid, JSON Schema. + Direct-write here (the engineer canvas has no review screen), so the + import applies to the working tree and the shell refreshes. */} +
); diff --git a/canvas/src/api.ts b/canvas/src/api.ts index 263b347..7cdc107 100644 --- a/canvas/src/api.ts +++ b/canvas/src/api.ts @@ -82,8 +82,16 @@ export const ontologyStack = () => get("/api/ontology/stack"); export const ontologyCoverage = () => get("/api/ontology/coverage"); // mutation (E2) -export const sendCommand = (op: string, payload: Record, fingerprint: string) => - post("/api/command", { op, payload, fingerprint }); +export const sendCommand = ( + op: string, + payload: Record, + fingerprint?: string, +) => + // An empty/omitted fingerprint skips the server's stale-model check — correct for a + // programmatic batch (e.g. import) that applies its own commands in sequence; the + // concurrency guard exists to catch an EXTERNAL edit between a human's view and + // their action, not to serialise a machine's own batch. + post("/api/command", { op, payload, fingerprint: fingerprint ?? "" }); // git panel export const gitStatus = () => get("/api/git/status"); @@ -177,6 +185,18 @@ export interface ImportResult { export const importModel = (format: string, content: string, dialect?: string) => post("/api/import", { format, content, dialect }); +/** Apply an imported change list to the working tree as one batch (direct-write + * canvas). Each command is sent WITHOUT a fingerprint so the server's stale-model + * check is skipped: the guard exists to catch an external edit between a human's + * view and their action, not to serialise a machine applying its own batch. */ +export const applyImportBatch = async ( + changes: { op: string; payload: Record }[], +) => { + for (const c of changes) { + await sendCommand(c.op, c.payload); // no fingerprint -> no stale-check + } +}; + export const previewChanges = ( changes: { op: string; payload: Record }[], subjectArea?: string, diff --git a/canvas/src/sme/ImportExportMenu.css b/canvas/src/sme/ImportExportMenu.css new file mode 100644 index 0000000..c424bd8 --- /dev/null +++ b/canvas/src/sme/ImportExportMenu.css @@ -0,0 +1,141 @@ +/* Import / Export menu styles, imported by ImportExportMenu.tsx so the component is + self-contained and works in BOTH surfaces: the SME Studio Model tab (sme.css) and + the architect canvas (styles.css). Both define the CSS variables used here. The + button chrome itself comes from the host toolbar's button class (erd-action ghost + in Studio, tool-btn in the architect canvas), passed as buttonClass. */ + +.ie-wrap { + position: relative; + display: inline-flex; + gap: 6px; +} + +/* icon + caret inside a menu trigger button */ +.erd-ico { + font-size: 13px; + line-height: 1; + opacity: 0.85; + margin-right: 1px; +} +.ie-wrap button:hover .erd-ico, +.ie-wrap button.on .erd-ico, +.ie-wrap button.active .erd-ico { + opacity: 1; +} +.erd-caret { + opacity: 0.55; + margin-left: 1px; +} + +.ie-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + min-width: 220px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 6px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 2px; +} +.ie-item { + display: block; + padding: 8px 10px; + border-radius: 6px; + color: var(--text); + text-decoration: none; + font-size: 13px; + cursor: pointer; +} +.ie-item:hover { + background: rgba(94, 234, 212, 0.14); + color: var(--accent); +} +.ie-dialect { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px 8px; + margin-bottom: 4px; + border-bottom: 1px solid var(--border); + font-size: 12px; + color: var(--text-dim); +} +.ie-dialect select { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 6px; + font-size: 12px; +} +.ie-import { + width: 380px; + padding: 12px; + gap: 8px; +} +.ie-tabs { + display: flex; + gap: 4px; +} +.ie-tab { + flex: 1; + padding: 6px 8px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + background: none; + border: 1px solid var(--border); + color: var(--text-dim); +} +.ie-tab.active { + border-color: var(--accent); + color: var(--accent); + background: rgba(94, 234, 212, 0.1); +} +.ie-hint { + font-size: 11px; + color: var(--text-dim); + margin: 0; +} +.ie-textarea { + width: 100%; + font-family: var(--mono); + font-size: 12px; + resize: vertical; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px; + box-sizing: border-box; +} +.ie-textarea:focus { + border-color: var(--accent); + outline: none; +} +.ie-file { + font-size: 11px; + color: var(--text-dim); +} +.ie-warn { + font-size: 11px; + color: var(--pk); + margin: 0; +} +.ie-error { + font-size: 12px; + color: var(--err); + margin: 0; +} +.ie-foot { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} diff --git a/canvas/src/sme/ImportExportMenu.tsx b/canvas/src/sme/ImportExportMenu.tsx index ab7ab92..04ad18e 100644 --- a/canvas/src/sme/ImportExportMenu.tsx +++ b/canvas/src/sme/ImportExportMenu.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { type ExportFormat, exportUrl, fetchExportFormats, importModel } from "../api"; import type { Exec } from "../exec"; +import "./ImportExportMenu.css"; /** Model-tab Import / Export (issue: interchange with a wide toolset). * @@ -12,13 +13,33 @@ export function ImportExportMenu({ exec, canEdit, onImported, + submitLabel = "Import & review", + buttonClass = "erd-action ghost", + applyBatch, + openImport = false, }: { exec: Exec; canEdit: boolean; /** called after a successful import so the shell can jump to the review screen */ onImported?: (tables: number) => void; + /** the import button's label — "Import & review" in the staged (SME) app, plain + * "Import" in the direct-write architect canvas where there is no review screen */ + submitLabel?: string; + /** the host toolbar's button class, so the trigger buttons match their toolbar + * (erd-action ghost in Studio, tool-btn in the architect canvas) */ + buttonClass?: string; + /** apply the whole parsed change list at once. The direct-write canvas passes this + * to avoid the per-command fingerprint race; when absent, changes go one at a time + * through the staging exec, which is already batch-safe. */ + applyBatch?: (changes: { op: string; payload: Record }[]) => Promise; + /** pop the Import panel open on mount — the VS Code "Import to Model" command + * reveals the canvas with `?import=1`, so the same wizard the toolbar button + * opens is already up when the panel appears. */ + openImport?: boolean; }) { - const [open, setOpen] = useState<"export" | "import" | null>(null); + const [open, setOpen] = useState<"export" | "import" | null>( + openImport && canEdit ? "import" : null, + ); const [formats, setFormats] = useState([]); const [dialect, setDialect] = useState("postgres"); const wrapRef = useRef(null); @@ -44,17 +65,23 @@ export function ImportExportMenu({ return (
{canEdit && ( )} @@ -91,6 +118,9 @@ export function ImportExportMenu({ {open === "import" && ( setOpen(null)} onImported={(n) => { setOpen(null); @@ -110,10 +140,16 @@ const IMPORT_FORMATS = [ function ImportPanel({ exec, + submitLabel, + buttonClass, + applyBatch, onClose, onImported, }: { exec: Exec; + submitLabel: string; + buttonClass: string; + applyBatch?: (changes: { op: string; payload: Record }[]) => Promise; onClose: () => void; onImported: (tables: number) => void; }) { @@ -140,10 +176,16 @@ function ImportPanel({ setBusy(false); return; } - // Stage every parsed change through the exec seam. The client mints no ids — - // the server already did — so preview and propose replay them intact. - for (const c of res.changes) { - await exec(c.op, c.payload as Record); + // Apply the parsed change list. The client mints no ids — the server already + // did — so preview/propose (staging) or a direct batch replay them intact. + // A dedicated batch applier avoids the per-op fingerprint race in the + // direct-write canvas; without one, the staging exec batches safely anyway. + if (applyBatch) { + await applyBatch(res.changes); + } else { + for (const c of res.changes) { + await exec(c.op, c.payload as Record); + } } setWarnings(res.warnings); onImported(res.tables); @@ -204,11 +246,11 @@ function ImportPanel({ ))} {error &&

{error}

}
- -
diff --git a/canvas/src/sme/ModelWorkspace.tsx b/canvas/src/sme/ModelWorkspace.tsx index ed815eb..911b003 100644 --- a/canvas/src/sme/ModelWorkspace.tsx +++ b/canvas/src/sme/ModelWorkspace.tsx @@ -54,6 +54,7 @@ export function ModelWorkspace({ direct = false, onSelectEntity, onImported, + query = "", busy, }: { /** the PREVIEWED model when anything is staged, otherwise the model on disk */ @@ -66,13 +67,14 @@ export function ModelWorkspace({ onSelectEntity?: (id: string) => void; /** after an import stages its changes, jump the shell to the review screen */ onImported?: (tables: number) => void; + /** the top-bar search text — highlights matching entities and dims the rest */ + query?: string; busy?: boolean; }) { const [selectedId, setSelectedId] = useState(null); const [panel, setPanel] = useState(null); const [creatingArea, setCreatingArea] = useState(false); const [error, setError] = useState(null); - const [query] = useState(""); const canvasRef = useRef(null); const caps: Capabilities = useMemo( @@ -194,12 +196,17 @@ export function ModelWorkspace({ )} drag between entities to relate them - + + {/* View controls: operate on the diagram itself (arrange / frame). */} + + + +