Files
Socrates/apps/web/lib/sync/ModelStore.tsx
dtoro 384cbb4ae9 MVP M5 (in-memory): bidirectional sync via ModelOp + applyOps + ModelStore
Both canvases now share a canonical SysMLModel through React context.
Renames in the diagram inspector ripple to every chip in the narrative
referencing the same refId, and double-clicking a chip emits an update op
that re-renders the diagram block. Validation re-runs on every successful
apply. State is in-memory; M5.9 adds Postgres persistence.

apps/web/lib/sync (new)
- ops.ts: ModelOp alphabet (18 op kinds across block / property /
  association / constraint / requirement / relation, add/update/remove for
  each). tempId() helper + per-kind constructors.
- applyOps.ts: pure (model, ops) → { model, idMapping, errors, applied }
  reducer. Atomic per batch. Cascades on remove-block (drops incident
  associations + constraint applies-to + requirement satisfiers). tempId
  resolution rewrites to canonical ids on duplicate-id collision.
- ModelStore.tsx: React provider exposing { model, apply, issues,
  issuesByElement }. Validation memoized on every model change.

Editor refactor
- EditorShell wraps in ModelStoreProvider. Initial model derived from
  fixture (+ optional ?break= corruptions). Validation now lives in the
  store, not duplicated here.
- LeftRail consumes useModel(): Model section lists real blocks +
  constraints (sorted by kind), Requirements section lists real
  requirements with traced/untraced status from r.relations.

Diagram refactor (the tricky piece)
- React Flow now owns ephemeral state via useNodesState / useEdgesState.
  Positions, drag-in-progress, selection are all RF-internal.
- Model → RF: a useEffect runs on model change, applies targeted setNodes
  updates only for elements whose semantic data (label, kind, properties)
  changed. Object identity preserved for unchanged nodes — fixes the
  "re-render storm on drag" + RF measurement-cache loss.
- RF → Model: onNodesChange / onEdgesChange / onConnect / onDrop emit
  ops via useApply(). Constraint-applies edges decompose into
  updateConstraint ops. deleteKeyCode={[Backspace, Delete]}.
- onNodesChange now handles type:'remove' too (was missing — that's why
  selecting a block + Del removed only the edges, leaving the block).

Chip refactor
- ChipView resolves displayed label from useModel() via refId lookup
  (block / requirement / association / property). Double-click chip →
  inline rename input → emit update-{block,requirement,association} op.
  All other chips with the same refId update on the next render.
- Slash-menu inserted chips have refId=null and skip the rename
  affordance (until M6 wires real model element resolution).

Removed obsolete components/diagram-canvas/fixtureToFlow.ts; replaced
with modelToFlow.ts. Bumped CSS for the chip-rename input.
2026-04-29 00:42:18 +02:00

84 lines
2.7 KiB
TypeScript

// React provider exposing the canonical SysMLModel + applyOps.
// Used by both canvases (and the rail) so they stay in sync.
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { applyOps, type ApplyResult } from "./applyOps";
import { validate, type ValidationIssue } from "../sysml/validate";
import type { SysMLModel } from "../sysml/model";
import type { ModelOp } from "./ops";
export interface ModelStoreValue {
model: SysMLModel;
apply: (ops: ModelOp[]) => ApplyResult;
/** Validation issues, recomputed on each successful apply. */
issues: ValidationIssue[];
issuesByElement: Map<string, ValidationIssue[]>;
}
const ModelStoreContext = createContext<ModelStoreValue | null>(null);
interface ModelStoreProviderProps {
initialModel: SysMLModel;
children: React.ReactNode;
}
export function ModelStoreProvider({ initialModel, children }: ModelStoreProviderProps) {
const [model, setModel] = useState<SysMLModel>(initialModel);
const apply = useCallback((ops: ModelOp[]): ApplyResult => {
let result: ApplyResult = { model, idMapping: {}, errors: [], applied: false };
setModel(current => {
result = applyOps(current, ops);
return result.applied ? result.model : current;
});
return result;
}, [model]);
const { issues, issuesByElement } = useMemo(() => {
const issues = validate(model);
const issuesByElement = new Map<string, ValidationIssue[]>();
for (const i of issues) {
const k = anchorKey(i.anchor);
if (!k) continue;
const prev = issuesByElement.get(k) ?? [];
prev.push(i);
issuesByElement.set(k, prev);
}
return { issues, issuesByElement };
}, [model]);
const value: ModelStoreValue = useMemo(
() => ({ model, apply, issues, issuesByElement }),
[model, apply, issues, issuesByElement]
);
return <ModelStoreContext.Provider value={value}>{children}</ModelStoreContext.Provider>;
}
export function useModelStore(): ModelStoreValue {
const ctx = useContext(ModelStoreContext);
if (!ctx) throw new Error("useModelStore must be used inside ModelStoreProvider");
return ctx;
}
export function useModel(): SysMLModel {
return useModelStore().model;
}
export function useApply(): (ops: ModelOp[]) => ApplyResult {
return useModelStore().apply;
}
function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
switch (anchor.kind) {
case "block": return anchor.id;
case "association": return `assoc:${anchor.id}`;
case "constraint": return `constraint:${anchor.id}`;
case "requirement": return `req:${anchor.id}`;
case "property": return anchor.blockId;
case "model": return null;
}
}