Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions canvas/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +31,17 @@ function Canvas() {
// The graph, the inspector and the modals now live in <ModelCanvas>; the shell
// reaches them through this handle for its toolbar and keyboard shortcuts.
const canvasRef = useRef<ModelCanvasHandle | null>(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<string>("");
// 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.
Expand All @@ -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(() => {
Expand All @@ -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()
Expand All @@ -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<string, unknown>) => {
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) {
Expand All @@ -94,6 +125,8 @@ function Canvas() {
setToast(e instanceof Error ? e.message : String(e));
}
throw e;
} finally {
execInFlight.current = false;
}
},
[doc, refresh],
Expand Down Expand Up @@ -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}
/>
)}
<div className="canvas-wrap">
Expand Down
36 changes: 35 additions & 1 deletion canvas/src/ModelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ReactFlow, {
BackgroundVariant,
Controls,
MiniMap,
getRectOfNodes,
useReactFlow,
type Connection,
type Edge,
Expand Down Expand Up @@ -102,7 +103,7 @@ export function ModelCanvas({
onError,
handleRef,
}: ModelCanvasProps) {
const { fitView } = useReactFlow();
const { fitView, fitBounds, getNodes } = useReactFlow();
const [nodes, setNodes] = useState<Node<EntityNodeData>[]>([]);
const [edges, setEdges] = useState<Edge<RelationshipEdgeData>[]>([]);
const layoutedRef = useRef(false);
Expand Down Expand Up @@ -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<string>();
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 }));
Expand Down
27 changes: 27 additions & 0 deletions canvas/src/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -18,6 +20,10 @@ export function TopBar({
panelTab,
onPanelTab,
onNewEntity,
exec,
onImported,
onImportBatch,
openImport,
}: {
doc: ModelDoc;
diagnostics: DiagnosticsDoc | null;
Expand All @@ -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<string, unknown> }[]) => Promise<unknown>;
/** 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;
Expand Down Expand Up @@ -123,6 +137,19 @@ export function TopBar({
<button className="tool-btn" onClick={onFitView} title="Fit view">
{"⛶"}
</button>
<span className="divider" />
{/* 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. */}
<ImportExportMenu
exec={exec}
canEdit={!readOnly}
onImported={onImported}
submitLabel="Import"
buttonClass="tool-btn"
applyBatch={onImportBatch}
openImport={openImport}
/>
</div>
</header>
);
Expand Down
24 changes: 22 additions & 2 deletions canvas/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,16 @@ export const ontologyStack = () => get<StackDoc>("/api/ontology/stack");
export const ontologyCoverage = () => get<CoverageDoc>("/api/ontology/coverage");

// mutation (E2)
export const sendCommand = (op: string, payload: Record<string, unknown>, fingerprint: string) =>
post<CommandResponse>("/api/command", { op, payload, fingerprint });
export const sendCommand = (
op: string,
payload: Record<string, unknown>,
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<CommandResponse>("/api/command", { op, payload, fingerprint: fingerprint ?? "" });

// git panel
export const gitStatus = () => get<GitStatus>("/api/git/status");
Expand Down Expand Up @@ -177,6 +185,18 @@ export interface ImportResult {
export const importModel = (format: string, content: string, dialect?: string) =>
post<ImportResult>("/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<string, unknown> }[],
) => {
for (const c of changes) {
await sendCommand(c.op, c.payload); // no fingerprint -> no stale-check
}
};

export const previewChanges = (
changes: { op: string; payload: Record<string, unknown> }[],
subjectArea?: string,
Expand Down
141 changes: 141 additions & 0 deletions canvas/src/sme/ImportExportMenu.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading