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
26 changes: 24 additions & 2 deletions docs/studio-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ connection, not the geometry.
| Action | How |
| --- | --- |
| Rename a node or flow | Double-click it and type. |
| Delete the selection | `Delete` key. |
| Select several objects | Hold `Cmd` (`Ctrl` on Windows/Linux) and click, or drag a selection box with `Shift`. |
| Delete the selection | `Delete` key. Deleting an element takes its flows with it, and the whole deletion is a single undo step. |
| Resize a trust boundary | Drag its handles (it's a resizable region). |
| Tidy the diagram | Click **Tidy** to fit labels, separate overlapping shapes and peer trust boundaries, route flows, and deconflict flow labels. Nested boundaries remain nested, and each boundary moves with its members. |
| Pan / zoom | Drag the canvas / scroll; use the minimap and **fit** control to navigate. |
Expand All @@ -75,7 +76,7 @@ the CLI or API into `tmforge-json`) shows each source diagram on its own page.

### The inspector

The right-hand panel edits the selected element or flow. It is **schema-driven**: it lists a typed
The right-hand panel edits the selected elements or flows. It is **schema-driven**: it lists a typed
control for every property the engine declares for that primitive, so every property an analysis
rule can read is reachable, and you can clear any finding without leaving the canvas. A data flow,
for example, exposes **Protocol**, **Port**, **Channel**, **DataType**, **Algorithm**, **Identity**,
Expand All @@ -90,6 +91,27 @@ and the control is not there. `Unknown` does **not** clear a finding: the rule s
the property is not evidenced rather than that the control is absent. See
[`Unknown` and the three states of a control](analysis-rules.md#unknown-and-the-three-states-of-a-control).

#### Editing several objects at once

Select any number of elements of the same kind, or any number of data flows, and the inspector edits
all of them. Each control writes the whole selection in one step, so one change is one undo.

A property the selection disagrees about shows as **(mixed)** rather than picking one object's value
to display. `(mixed)` cannot be chosen, so simply opening the inspector on a mixed selection never
flattens it — only a property you actually change is written, and the rest are left as they are.
Clearing a property to **(none)** removes it from every selected object.

Names are not offered across a selection: a name identifies one thing, so renaming several objects to
the same string is not an edit worth making. Delete still applies to the whole selection.

Two cases deliberately offer delete but no property editing:

- **Mixed kinds** (say processes and data stores together). The same property name does not accept
the same values on every kind — `AuthenticationScheme` allows `Token` and `PublicKey` on an
external entity but not on a process — so one shared control would offer values the schema rejects
for part of the selection. Narrow the selection to one kind.
- **Elements and flows together.** They have no properties in common at all.

## Validating against the engine

Click **Analyze** to send the whole model (every page) to the live `/v1` engine. Findings come back
Expand Down
54 changes: 54 additions & 0 deletions src/ThreatModelForge.Studio/src/dfd/Editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
buildAnalysis,
applyCanvasEdgeChanges,
applyFlags,
deleteFromGraph,
sameIds,
initialTheme,
STORAGE_KEY,
LEGACY_MODEL_KEY,
Expand Down Expand Up @@ -369,3 +371,55 @@ describe('Editor — initialTheme', () => {
expect(initialTheme()).toBe('light');
});
});

describe('Editor — deleting a selection', () => {
const a: DfdNode = { id: 'a', type: 'process', position: { x: 0, y: 0 }, data: { label: 'A' } };
const b: DfdNode = { id: 'b', type: 'process', position: { x: 0, y: 0 }, data: { label: 'B' } };
const c: DfdNode = { id: 'c', type: 'datastore', position: { x: 0, y: 0 }, data: { label: 'C' } };
const ab: DfdEdge = { id: 'ab', source: 'a', target: 'b' };
const bc: DfdEdge = { id: 'bc', source: 'b', target: 'c' };

it('deletes every selected element, not just the first', () => {
const result = deleteFromGraph([a, b, c], [], ['a', 'b'], []);
expect(result.nodes.map((n) => n.id)).toEqual(['c']);
});

it('takes the flows connected to a deleted element with it, at either end', () => {
const result = deleteFromGraph([a, b, c], [ab, bc], ['b'], []);
// 'b' is the target of ab and the source of bc; both go.
expect(result.nodes.map((n) => n.id)).toEqual(['a', 'c']);
expect(result.edges).toEqual([]);
});

it('deletes a selected flow without touching its endpoints', () => {
const result = deleteFromGraph([a, b, c], [ab, bc], [], ['ab']);
expect(result.nodes.map((n) => n.id)).toEqual(['a', 'b', 'c']);
expect(result.edges.map((e) => e.id)).toEqual(['bc']);
});

it('handles a selection holding both elements and flows', () => {
const result = deleteFromGraph([a, b, c], [ab, bc], ['a'], ['bc']);
expect(result.nodes.map((n) => n.id)).toEqual(['b', 'c']);
// 'ab' went with 'a' even though only 'bc' was selected.
expect(result.edges).toEqual([]);
});

it('leaves the graph alone when nothing is selected', () => {
const result = deleteFromGraph([a, b], [ab], [], []);
expect(result.nodes).toEqual([a, b]);
expect(result.edges).toEqual([ab]);
});
});

describe('Editor — sameIds', () => {
it('recognizes an unchanged selection so the Inspector is not remounted mid-edit', () => {
expect(sameIds([], [])).toBe(true);
expect(sameIds(['a', 'b'], ['a', 'b'])).toBe(true);
});

it('sees a changed selection', () => {
expect(sameIds(['a'], ['a', 'b'])).toBe(false);
expect(sameIds(['a', 'b'], ['b', 'a'])).toBe(false);
expect(sameIds(['a'], ['b'])).toBe(false);
});
});
129 changes: 94 additions & 35 deletions src/ThreatModelForge.Studio/src/dfd/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,32 @@ export function applyCanvasEdgeChanges(changes: EdgeChange<DfdEdge>[], current:
return applyEdgeChangesToGraph(geometrySafe, current);
}

/** True when two id lists hold the same ids in the same order. */
export function sameIds(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((id, index) => id === b[index]);
}

/**
* The graph left after deleting a selection. Deleting an element takes its flows with it, because a
* flow with a missing endpoint is not a model — so a selection of elements removes more edges than
* the ones the author selected.
*/
export function deleteFromGraph(
nodes: DfdNode[],
edges: DfdEdge[],
doomedNodeIds: readonly string[],
doomedEdgeIds: readonly string[],
): { nodes: DfdNode[]; edges: DfdEdge[] } {
const doomedNodes = new Set(doomedNodeIds);
const doomedEdges = new Set(doomedEdgeIds);
return {
nodes: nodes.filter((n) => !doomedNodes.has(n.id)),
edges: edges.filter(
(e) => !doomedEdges.has(e.id) && !doomedNodes.has(e.source) && !doomedNodes.has(e.target),
),
};
}

export function Editor() {
const [pages, setPages] = useState<PageGraph[]>(INITIAL_WORKSPACE.pages);
const [activePageId, setActivePageId] = useState<string>(INITIAL_ACTIVE.id);
Expand All @@ -354,7 +380,9 @@ export function Editor() {
const [engine, setEngine] = useState<IEngineClient>(offlineEngine);
const [engineOnline, setEngineOnline] = useState(false);
const [formats, setFormats] = useState<FormatInfo[]>([]);
const [selection, setSelection] = useState<{ node: string | null; edge: string | null }>({ node: null, edge: null });
// The whole selection, not just its first member: the Inspector edits every selected element, and
// a bulk delete has to take the connected flows with it.
const [selection, setSelection] = useState<{ nodes: string[]; edges: string[] }>({ nodes: [], edges: [] });
const [stencils, setStencils] = useState<StencilInfo[]>(FALLBACK_STENCILS);
const [recentStencilIds, setRecentStencilIds] = useState<string[]>(loadRecentStencilIds);
const [packs, setPacks] = useState<PackInfo[]>(FALLBACK_PACKS);
Expand Down Expand Up @@ -604,7 +632,7 @@ export function Editor() {
: { nodes: target.nodes, edges: target.edges };
setNodes(applied.nodes);
setEdges(applied.edges);
setSelection({ node: null, edge: null });
setSelection({ nodes: [], edges: [] });
reset();
window.setTimeout(() => fitView({ padding: 0.25, maxZoom: 1.15, duration: 200 }), 0);
},
Expand All @@ -617,7 +645,7 @@ export function Editor() {
setActivePageId(id);
setNodes([]);
setEdges([]);
setSelection({ node: null, edge: null });
setSelection({ nodes: [], edges: [] });
reset();
}, [allPages, pages.length, setNodes, setEdges, reset]);

Expand Down Expand Up @@ -650,7 +678,7 @@ export function Editor() {
: { nodes: next.nodes, edges: next.edges };
setNodes(applied.nodes);
setEdges(applied.edges);
setSelection({ node: null, edge: null });
setSelection({ nodes: [], edges: [] });
reset();
}
},
Expand Down Expand Up @@ -784,10 +812,7 @@ export function Editor() {
takeSnapshot();
setNodes((nds) => [...nds.map((n) => (n.selected ? { ...n, selected: false } : n)), ...clone.nodes]);
setEdges((eds) => [...eds.map((e) => (e.selected ? { ...e, selected: false } : e)), ...clone.edges]);
setSelection({
node: clone.nodes[0]?.id ?? null,
edge: clone.nodes.length === 0 ? clone.edges[0]?.id ?? null : null,
});
setSelection({ nodes: clone.nodes.map((n) => n.id), edges: clone.edges.map((e) => e.id) });
},
[setNodes, setEdges, takeSnapshot],
);
Expand Down Expand Up @@ -1010,7 +1035,15 @@ export function Editor() {

const onSelectionChange = useCallback(
({ nodes: selectedNodes, edges: selectedEdges }: { nodes: DfdNode[]; edges: DfdEdge[] }) => {
setSelection({ node: selectedNodes[0]?.id ?? null, edge: selectedEdges[0]?.id ?? null });
setSelection((prev) => {
const nextNodes = selectedNodes.map((n) => n.id);
const nextEdges = selectedEdges.map((e) => e.id);
// React Flow re-emits the selection on every graph change. Returning the previous object when
// nothing moved keeps the Inspector from remounting mid-edit (which would drop focus).
return sameIds(prev.nodes, nextNodes) && sameIds(prev.edges, nextEdges)
? prev
: { nodes: nextNodes, edges: nextEdges };
});
},
[],
);
Expand All @@ -1037,12 +1070,18 @@ export function Editor() {
[setEdges],
);

// The property writers take a set of ids so a bulk edit is one state update under one snapshot.
// A single selection is just the one-element case, so there is only ever one code path.
const setEdgeProperty = useCallback(
(id: string, key: string, value: string) => {
(ids: string[], key: string, value: string) => {
if (ids.length === 0) {
return;
}
takeSnapshot();
const targets = new Set(ids);
setEdges((eds) =>
eds.map((e) => {
if (e.id !== id) {
if (!targets.has(e.id)) {
return e;
}
const properties = { ...(e.data?.properties ?? {}) };
Expand All @@ -1059,11 +1098,15 @@ export function Editor() {
);

const setNodeProperty = useCallback(
(id: string, key: string, value: string) => {
(ids: string[], key: string, value: string) => {
if (ids.length === 0) {
return;
}
takeSnapshot();
const targets = new Set(ids);
setNodes((nds) =>
nds.map((n) => {
if (n.id !== id) {
if (!targets.has(n.id)) {
return n;
}
const properties = { ...(n.data.properties ?? {}) };
Expand All @@ -1076,11 +1119,15 @@ export function Editor() {
);

const removeNodeProperty = useCallback(
(id: string, key: string) => {
(ids: string[], key: string) => {
if (ids.length === 0) {
return;
}
takeSnapshot();
const targets = new Set(ids);
setNodes((nds) =>
nds.map((n) => {
if (n.id !== id) {
if (!targets.has(n.id)) {
return n;
}
const properties = { ...(n.data.properties ?? {}) };
Expand All @@ -1093,21 +1140,28 @@ export function Editor() {
);

const deleteSelected = useCallback(() => {
if (!selection.node && !selection.edge) {
if (selection.nodes.length === 0 && selection.edges.length === 0) {
return;
}
takeSnapshot();
if (selection.node) {
const nodeId = selection.node;
setNodes((nds) => nds.filter((n) => n.id !== nodeId));
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
} else if (selection.edge) {
const edgeId = selection.edge;
setEdges((eds) => eds.filter((e) => e.id !== edgeId));
}
setSelection({ node: null, edge: null });
setNodes((nds) => deleteFromGraph(nds, [], selection.nodes, selection.edges).nodes);
setEdges((eds) => deleteFromGraph([], eds, selection.nodes, selection.edges).edges);
setSelection({ nodes: [], edges: [] });
}, [selection, setNodes, setEdges, takeSnapshot]);

// React Flow fires onNodesDelete AND onEdgesDelete for a single deletion (removing an element takes
// its flows with it), so snapshotting in both would cost two undos for one Backspace. onBeforeDelete
// fires exactly once, before anything is removed, with the connected flows already resolved.
const beforeDelete = useCallback(
({ nodes: doomedNodes, edges: doomedEdges }: { nodes: DfdNode[]; edges: DfdEdge[] }) => {
if (doomedNodes.length > 0 || doomedEdges.length > 0) {
takeSnapshot();
}
return Promise.resolve(true);
},
[takeSnapshot],
);

const clearFlags = useCallback(() => {
setNodes((nds) => nds.map((n) => (n.className ? { ...n, className: undefined } : n)));
setEdges((eds) => eds.map((e) => (e.className ? { ...e, className: undefined } : e)));
Expand Down Expand Up @@ -1316,7 +1370,7 @@ export function Editor() {
if (item.kind === 'flow') {
setNodes((nds) => nds.map((n) => (n.selected ? { ...n, selected: false } : n)));
setEdges((eds) => eds.map((e) => (e.selected !== (e.id === item.id) ? { ...e, selected: e.id === item.id } : e)));
setSelection({ node: null, edge: item.id });
setSelection({ nodes: [], edges: [item.id] });
const edge = allPages.find((p) => p.id === item.pageId)?.edges.find((e) => e.id === item.id);
const ends = [edge?.source, edge?.target].filter((id): id is string => Boolean(id)).map((id) => ({ id }));
if (ends.length > 0) {
Expand All @@ -1325,7 +1379,7 @@ export function Editor() {
} else {
setEdges((eds) => eds.map((e) => (e.selected ? { ...e, selected: false } : e)));
setNodes((nds) => nds.map((n) => (n.selected !== (n.id === item.id) ? { ...n, selected: n.id === item.id } : n)));
setSelection({ node: item.id, edge: null });
setSelection({ nodes: [item.id], edges: [] });
fitView({ nodes: [{ id: item.id }], padding: 0.6, duration: 400, maxZoom: 1.4 });
}
};
Expand Down Expand Up @@ -1400,7 +1454,7 @@ export function Editor() {
setThreatTriage(model.threats ?? []);
flaggedIdsRef.current = new Set();
analysisActiveRef.current = false;
setSelection({ node: null, edge: null });
setSelection({ nodes: [], edges: [] });
reset();
// A freshly loaded model is the new saved baseline, so it does not read as dirty.
setSavedJson(
Expand Down Expand Up @@ -1477,7 +1531,7 @@ export function Editor() {
setThreatTriage([]);
flaggedIdsRef.current = new Set();
analysisActiveRef.current = false;
setSelection({ node: null, edge: null });
setSelection({ nodes: [], edges: [] });
reset();
}, [setNodes, setEdges, reset]);

Expand All @@ -1501,8 +1555,14 @@ export function Editor() {
[takeSnapshot, renameNode, renameEdge, setEdgeLabelOffset],
);

const selectedNode = nodes.find((n) => n.id === selection.node) ?? null;
const selectedEdge = edges.find((e) => e.id === selection.edge) ?? null;
const selectedNodes = useMemo(() => {
const ids = new Set(selection.nodes);
return nodes.filter((n) => ids.has(n.id));
}, [nodes, selection.nodes]);
const selectedEdges = useMemo(() => {
const ids = new Set(selection.edges);
return edges.filter((e) => ids.has(e.id));
}, [edges, selection.edges]);

return (
<DfdActionsContext.Provider value={actions}>
Expand Down Expand Up @@ -1550,8 +1610,7 @@ export function Editor() {
onConnect={onConnect}
onReconnect={onReconnect}
onNodeDragStart={takeSnapshot}
onNodesDelete={takeSnapshot}
onEdgesDelete={takeSnapshot}
onBeforeDelete={beforeDelete}
onSelectionChange={onSelectionChange}
onPaneClick={findings.length || threats.length ? clearFlags : undefined}
nodeTypes={nodeTypes}
Expand Down Expand Up @@ -1684,8 +1743,8 @@ export function Editor() {
/>
</div>
<Inspector
node={selectedNode}
edge={selectedEdge}
nodes={selectedNodes}
edges={selectedEdges}
stencils={stencils}
propertySchema={propertySchema}
onBeginNameEdit={takeSnapshot}
Expand Down
Loading
Loading