codedebrief 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codedebrief/__init__.py +12 -0
- codedebrief/analysis/__init__.py +16 -0
- codedebrief/analysis/common.py +527 -0
- codedebrief/analysis/discovery.py +100 -0
- codedebrief/analysis/languages/__init__.py +6 -0
- codedebrief/analysis/languages/_common.py +68 -0
- codedebrief/analysis/languages/c.py +96 -0
- codedebrief/analysis/languages/cpp.py +146 -0
- codedebrief/analysis/languages/csharp.py +137 -0
- codedebrief/analysis/languages/go.py +157 -0
- codedebrief/analysis/languages/java.py +158 -0
- codedebrief/analysis/languages/php.py +83 -0
- codedebrief/analysis/languages/ruby.py +75 -0
- codedebrief/analysis/languages/rust.py +96 -0
- codedebrief/analysis/project.py +373 -0
- codedebrief/analysis/python.py +939 -0
- codedebrief/analysis/registry.py +320 -0
- codedebrief/analysis/treesitter.py +884 -0
- codedebrief/analysis/typescript.py +1019 -0
- codedebrief/artifacts.py +49 -0
- codedebrief/cli.py +585 -0
- codedebrief/config.py +226 -0
- codedebrief/doctor.py +175 -0
- codedebrief/install.py +441 -0
- codedebrief/mcp_server.py +2720 -0
- codedebrief/model.py +189 -0
- codedebrief/py.typed +1 -0
- codedebrief/quality.py +392 -0
- codedebrief/query.py +641 -0
- codedebrief/render/__init__.py +6 -0
- codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
- codedebrief/render/assets/panels.js +462 -0
- codedebrief/render/assets/shell.js +1649 -0
- codedebrief/render/assets/styles.css +1715 -0
- codedebrief/render/assets/tree.js +616 -0
- codedebrief/render/html.py +191 -0
- codedebrief/render/markdown.py +153 -0
- codedebrief/render/payload.py +326 -0
- codedebrief/render/snapshot.py +769 -0
- codedebrief/schema/codedebrief.schema.json +449 -0
- codedebrief/util.py +65 -0
- codedebrief/validation.py +214 -0
- codedebrief-0.11.0.dist-info/METADATA +426 -0
- codedebrief-0.11.0.dist-info/RECORD +48 -0
- codedebrief-0.11.0.dist-info/WHEEL +4 -0
- codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
- codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
- codedebrief-0.11.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,1649 @@
|
|
|
1
|
+
|
|
2
|
+
const model = JSON.parse(document.getElementById("codedebrief-data").textContent);
|
|
3
|
+
const flows = model.flows || [];
|
|
4
|
+
const byId = new Map(flows.map(flow => [flow.id, flow]));
|
|
5
|
+
|
|
6
|
+
// Shared surface other inlined scripts (tree.js, future panels) bind to. The left
|
|
7
|
+
// rail is owned by tree.js now, so the app shell exposes flow selection here.
|
|
8
|
+
const codeDebrief = (window.CodeDebrief = window.CodeDebrief || {});
|
|
9
|
+
codeDebrief.model = model;
|
|
10
|
+
codeDebrief.flows = flows;
|
|
11
|
+
codeDebrief.byId = byId;
|
|
12
|
+
// The generated HTML now has one official chart renderer: the typed React runtime.
|
|
13
|
+
// The shell still owns shared selection, rails, tree, source details, and toolbar
|
|
14
|
+
// buttons, but it no longer routes users into the retired static renderer.
|
|
15
|
+
codeDebrief.mode = "react";
|
|
16
|
+
|
|
17
|
+
// --- Shared selection store (Phase 4) ---------------------------------------
|
|
18
|
+
// ONE selection model, ONE accent color. Every surface -- a canvas decision block,
|
|
19
|
+
// a source line, a tree file/flow row -- both PUBLISHES into
|
|
20
|
+
// this store (via codeDebrief.select) and SUBSCRIBES to it (via codeDebrief.onSelection) so selecting
|
|
21
|
+
// any one highlights the others. The store holds only ids; each surface maps ids to
|
|
22
|
+
// its own DOM. shell.js drives the canvas highlight (its existing job); panels.js
|
|
23
|
+
// renders the details panels and the tree reflects the active file/flow.
|
|
24
|
+
const selection = {
|
|
25
|
+
path: null,
|
|
26
|
+
flowId: null,
|
|
27
|
+
nodeId: null,
|
|
28
|
+
scope: null,
|
|
29
|
+
edgeId: null,
|
|
30
|
+
line: null,
|
|
31
|
+
endLine: null,
|
|
32
|
+
};
|
|
33
|
+
const selectionSubscribers = [];
|
|
34
|
+
// Re-entrancy guard: a subscriber that calls back into select() must not recurse the
|
|
35
|
+
// notify loop; coalesce to one pass.
|
|
36
|
+
let notifyingSelection = false;
|
|
37
|
+
codeDebrief.selection = selection;
|
|
38
|
+
codeDebrief.onSelection = function (fn) {
|
|
39
|
+
if (typeof fn === "function") selectionSubscribers.push(fn);
|
|
40
|
+
};
|
|
41
|
+
// Merge a partial selection and notify every surface. Passing a key as `undefined`
|
|
42
|
+
// leaves it untouched; pass `null` to explicitly clear it. Always carries the full
|
|
43
|
+
// resolved selection object to subscribers.
|
|
44
|
+
codeDebrief.select = function (partial) {
|
|
45
|
+
partial = partial || {};
|
|
46
|
+
const keys = ["path", "flowId", "nodeId", "scope", "edgeId", "line", "endLine"];
|
|
47
|
+
const explicitEdge = Object.prototype.hasOwnProperty.call(partial, "edgeId");
|
|
48
|
+
const clearsEdge = !explicitEdge && keys.some(key =>
|
|
49
|
+
key !== "edgeId" && Object.prototype.hasOwnProperty.call(partial, key)
|
|
50
|
+
);
|
|
51
|
+
keys.forEach(key => {
|
|
52
|
+
if (Object.prototype.hasOwnProperty.call(partial, key) && partial[key] !== undefined) {
|
|
53
|
+
selection[key] = partial[key];
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
if (clearsEdge) selection.edgeId = null;
|
|
57
|
+
if (notifyingSelection) return;
|
|
58
|
+
notifyingSelection = true;
|
|
59
|
+
try {
|
|
60
|
+
selectionSubscribers.forEach(fn => {
|
|
61
|
+
try { fn(selection); } catch (_) {}
|
|
62
|
+
});
|
|
63
|
+
} finally {
|
|
64
|
+
notifyingSelection = false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const svg =
|
|
69
|
+
document.getElementById("canvas") ||
|
|
70
|
+
document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
71
|
+
const rightRail = document.getElementById("rightRail");
|
|
72
|
+
const leftRail = document.getElementById("leftRail");
|
|
73
|
+
const detailButton = document.getElementById("detailButton");
|
|
74
|
+
const detailsClose = document.getElementById("detailsClose");
|
|
75
|
+
const detailsCollapseAll = document.getElementById("detailsCollapseAll");
|
|
76
|
+
const detailsExpandAll = document.getElementById("detailsExpandAll");
|
|
77
|
+
const menuButton = document.getElementById("menuButton");
|
|
78
|
+
const typedViewerHost = document.getElementById("typedViewerHost");
|
|
79
|
+
const exportPngButton = document.getElementById("exportPng");
|
|
80
|
+
const exportJpgButton = document.getElementById("exportJpg");
|
|
81
|
+
const railWidths = { left: 312, right: 336 };
|
|
82
|
+
const railConfig = {
|
|
83
|
+
left: {
|
|
84
|
+
css: "--left-rail-width",
|
|
85
|
+
storage: "codedebrief-left-rail-width",
|
|
86
|
+
handle: document.getElementById("leftRailResizer"),
|
|
87
|
+
min: 240,
|
|
88
|
+
max: 560,
|
|
89
|
+
},
|
|
90
|
+
right: {
|
|
91
|
+
css: "--right-rail-width",
|
|
92
|
+
storage: "codedebrief-right-rail-width",
|
|
93
|
+
handle: document.getElementById("rightRailResizer"),
|
|
94
|
+
min: 280,
|
|
95
|
+
max: 640,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
let activeFlow = null;
|
|
99
|
+
let view = { x: 0, y: 0, width: 1000, height: 800 };
|
|
100
|
+
let drag = null;
|
|
101
|
+
let railResize = null;
|
|
102
|
+
let railRefreshFrame = 0;
|
|
103
|
+
// Per-flow hand-placed node positions: flowId -> Map(nodeId -> {x, y}). Survives
|
|
104
|
+
// navigating away and back within the session.
|
|
105
|
+
const manualPositions = new Map();
|
|
106
|
+
// Element references for the currently rendered flow, for selection highlighting.
|
|
107
|
+
let currentRender = null;
|
|
108
|
+
const FLOW_NODE_HALF_W = 145;
|
|
109
|
+
const FLOW_RECT_HALF_H = 43;
|
|
110
|
+
const FLOW_DECISION_HALF_H = 58;
|
|
111
|
+
const FLOW_META_BOTTOM = 78;
|
|
112
|
+
const FLOW_NODE_HALF_H = 92;
|
|
113
|
+
const FLOW_LAYER_Y = 230;
|
|
114
|
+
const FLOW_SIBLING_X = 360;
|
|
115
|
+
const FLOW_MIN_X_GAP = 330;
|
|
116
|
+
const EDGE_START_CLEARANCE = 30;
|
|
117
|
+
const EDGE_END_CLEARANCE = 10;
|
|
118
|
+
|
|
119
|
+
function setCanvasLevel(level) {
|
|
120
|
+
const value = String(level);
|
|
121
|
+
svg.setAttribute("data-level", value);
|
|
122
|
+
document.body.dataset.canvasLevel = value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setLeftRailOpen(open) {
|
|
126
|
+
leftRail.classList.toggle("open", !!open);
|
|
127
|
+
document.body.toggleAttribute("data-nav-open", !!open);
|
|
128
|
+
document.body.toggleAttribute("data-nav-closed", !open);
|
|
129
|
+
syncRailControls();
|
|
130
|
+
scheduleCanvasLayoutRefresh();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function setRightRailOpen(open) {
|
|
134
|
+
rightRail.classList.toggle("open", !!open);
|
|
135
|
+
document.body.toggleAttribute("data-detail-open", !!open);
|
|
136
|
+
document.body.toggleAttribute("data-detail-closed", !open);
|
|
137
|
+
if (detailButton) {
|
|
138
|
+
detailButton.setAttribute("aria-pressed", open ? "true" : "false");
|
|
139
|
+
detailButton.title = open ? "Hide source and details" : "Show source and details";
|
|
140
|
+
}
|
|
141
|
+
syncRailControls();
|
|
142
|
+
scheduleCanvasLayoutRefresh();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function leftRailOpen() {
|
|
146
|
+
if (window.innerWidth <= 700) return leftRail.classList.contains("open");
|
|
147
|
+
return !document.body.hasAttribute("data-nav-closed");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function rightRailOpen() {
|
|
151
|
+
if (window.innerWidth <= 1050) return rightRail.classList.contains("open");
|
|
152
|
+
return !document.body.hasAttribute("data-detail-closed");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function syncRailControls() {
|
|
156
|
+
const navOpen = leftRailOpen();
|
|
157
|
+
const detailOpen = rightRailOpen();
|
|
158
|
+
if (menuButton) {
|
|
159
|
+
menuButton.setAttribute("aria-pressed", navOpen ? "true" : "false");
|
|
160
|
+
menuButton.title = navOpen ? "Hide codebase tree" : "Show codebase tree";
|
|
161
|
+
}
|
|
162
|
+
if (detailButton) {
|
|
163
|
+
detailButton.setAttribute("aria-pressed", detailOpen ? "true" : "false");
|
|
164
|
+
detailButton.title = detailOpen ? "Hide source and details" : "Show source and details";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function eventTargetIsTextInput(event) {
|
|
169
|
+
const target = event.target;
|
|
170
|
+
return !!(target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName || ""));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function railViewportMax(side) {
|
|
174
|
+
const cfg = railConfig[side];
|
|
175
|
+
if (!cfg) return 0;
|
|
176
|
+
if (window.innerWidth <= 1050) return cfg.max;
|
|
177
|
+
const other = side === "left" ? railWidths.right : railWidths.left;
|
|
178
|
+
const maxFromViewport = window.innerWidth - other - 460;
|
|
179
|
+
return Math.max(cfg.min, Math.min(cfg.max, maxFromViewport));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clampRailWidth(side, value) {
|
|
183
|
+
const cfg = railConfig[side];
|
|
184
|
+
if (!cfg) return 0;
|
|
185
|
+
const max = railViewportMax(side);
|
|
186
|
+
return Math.min(max, Math.max(cfg.min, Math.round(value)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function applyRailWidth(side, value, persist) {
|
|
190
|
+
const cfg = railConfig[side];
|
|
191
|
+
if (!cfg) return;
|
|
192
|
+
const width = clampRailWidth(side, value);
|
|
193
|
+
railWidths[side] = width;
|
|
194
|
+
document.documentElement.style.setProperty(cfg.css, `${width}px`);
|
|
195
|
+
if (cfg.handle) {
|
|
196
|
+
cfg.handle.setAttribute("aria-valuenow", String(width));
|
|
197
|
+
cfg.handle.setAttribute("aria-valuemax", String(railViewportMax(side)));
|
|
198
|
+
}
|
|
199
|
+
if (persist) {
|
|
200
|
+
try { localStorage.setItem(cfg.storage, String(width)); } catch (_) {}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function loadStoredRailWidths() {
|
|
205
|
+
Object.keys(railConfig).forEach(side => {
|
|
206
|
+
const cfg = railConfig[side];
|
|
207
|
+
let stored = null;
|
|
208
|
+
try { stored = localStorage.getItem(cfg.storage); } catch (_) {}
|
|
209
|
+
const parsed = stored === null ? NaN : Number(stored);
|
|
210
|
+
applyRailWidth(side, Number.isFinite(parsed) ? parsed : railWidths[side], false);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function scheduleCanvasLayoutRefresh() {
|
|
215
|
+
if (railRefreshFrame) return;
|
|
216
|
+
railRefreshFrame = requestAnimationFrame(() => {
|
|
217
|
+
railRefreshFrame = 0;
|
|
218
|
+
if (codeDebrief.refreshCanvasLayout) codeDebrief.refreshCanvasLayout();
|
|
219
|
+
else if (codeDebrief.updateViewBox) codeDebrief.updateViewBox();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resizeRailFromPointer(event) {
|
|
224
|
+
if (!railResize) return;
|
|
225
|
+
const dx = event.clientX - railResize.startX;
|
|
226
|
+
const next = railResize.startWidth + (railResize.side === "left" ? dx : -dx);
|
|
227
|
+
applyRailWidth(railResize.side, next, true);
|
|
228
|
+
scheduleCanvasLayoutRefresh();
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function endRailResize() {
|
|
233
|
+
if (!railResize) return;
|
|
234
|
+
const cfg = railConfig[railResize.side];
|
|
235
|
+
if (cfg && cfg.handle) cfg.handle.removeAttribute("aria-grabbed");
|
|
236
|
+
railResize = null;
|
|
237
|
+
document.body.removeAttribute("data-rail-resizing");
|
|
238
|
+
window.removeEventListener("pointermove", resizeRailFromPointer);
|
|
239
|
+
window.removeEventListener("pointerup", endRailResize);
|
|
240
|
+
window.removeEventListener("pointercancel", endRailResize);
|
|
241
|
+
scheduleCanvasLayoutRefresh();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function beginRailResize(side, event) {
|
|
245
|
+
if (event.button !== 0) return;
|
|
246
|
+
const cfg = railConfig[side];
|
|
247
|
+
if (!cfg || !cfg.handle) return;
|
|
248
|
+
railResize = { side, startX: event.clientX, startWidth: railWidths[side] };
|
|
249
|
+
document.body.dataset.railResizing = side;
|
|
250
|
+
cfg.handle.setAttribute("aria-grabbed", "true");
|
|
251
|
+
window.addEventListener("pointermove", resizeRailFromPointer);
|
|
252
|
+
window.addEventListener("pointerup", endRailResize);
|
|
253
|
+
window.addEventListener("pointercancel", endRailResize);
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resizeRailFromKeyboard(side, event) {
|
|
258
|
+
const cfg = railConfig[side];
|
|
259
|
+
if (!cfg) return;
|
|
260
|
+
let next = railWidths[side];
|
|
261
|
+
if (event.key === "Home") next = cfg.min;
|
|
262
|
+
else if (event.key === "End") next = railViewportMax(side);
|
|
263
|
+
else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
264
|
+
const physical = event.key === "ArrowRight" ? 1 : -1;
|
|
265
|
+
next += (side === "left" ? physical : -physical) * 24;
|
|
266
|
+
} else {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
applyRailWidth(side, next, true);
|
|
270
|
+
scheduleCanvasLayoutRefresh();
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function fitRailsToViewport() {
|
|
275
|
+
applyRailWidth("left", railWidths.left, false);
|
|
276
|
+
applyRailWidth("right", railWidths.right, false);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function initRailResizers() {
|
|
280
|
+
Object.keys(railConfig).forEach(side => {
|
|
281
|
+
const cfg = railConfig[side];
|
|
282
|
+
if (!cfg.handle) return;
|
|
283
|
+
cfg.handle.addEventListener("pointerdown", event => beginRailResize(side, event));
|
|
284
|
+
cfg.handle.addEventListener("keydown", event => resizeRailFromKeyboard(side, event));
|
|
285
|
+
});
|
|
286
|
+
window.addEventListener("resize", () => {
|
|
287
|
+
fitRailsToViewport();
|
|
288
|
+
syncRailControls();
|
|
289
|
+
scheduleCanvasLayoutRefresh();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function panelTitle(panel) {
|
|
294
|
+
const title = panel && panel.querySelector ? panel.querySelector(".rail-title") : null;
|
|
295
|
+
return (title && title.textContent ? title.textContent.trim() : "section") || "section";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function setPanelCollapsed(panel, button, body, collapsed, persist) {
|
|
299
|
+
const title = panelTitle(panel);
|
|
300
|
+
const heading = panel.querySelector("[data-panel-heading]");
|
|
301
|
+
panel.toggleAttribute("data-collapsed", collapsed);
|
|
302
|
+
button.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
303
|
+
button.title = (collapsed ? "Expand " : "Collapse ") + title;
|
|
304
|
+
button.setAttribute("aria-label", (collapsed ? "Expand " : "Collapse ") + title);
|
|
305
|
+
if (heading) {
|
|
306
|
+
heading.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
307
|
+
heading.title = (collapsed ? "Expand " : "Collapse ") + title;
|
|
308
|
+
}
|
|
309
|
+
if (body) body.hidden = collapsed;
|
|
310
|
+
if (persist) {
|
|
311
|
+
const key = panel.getAttribute("data-panel-state") || panel.id || body && body.id;
|
|
312
|
+
if (key) {
|
|
313
|
+
try { localStorage.setItem("codedebrief-panel-collapsed-" + key, collapsed ? "true" : "false"); } catch (_) {}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function initCollapsiblePanels() {
|
|
319
|
+
const panels = Array.from(document.querySelectorAll("[data-collapsible-panel]"));
|
|
320
|
+
function panelParts(panel) {
|
|
321
|
+
const button = panel.querySelector("[data-panel-toggle]");
|
|
322
|
+
if (!button) return null;
|
|
323
|
+
const bodyId = button.getAttribute("aria-controls");
|
|
324
|
+
const body = bodyId ? document.getElementById(bodyId) : null;
|
|
325
|
+
return { body, bodyId, button };
|
|
326
|
+
}
|
|
327
|
+
function setAllPanelsCollapsed(collapsed) {
|
|
328
|
+
panels.forEach(panel => {
|
|
329
|
+
const parts = panelParts(panel);
|
|
330
|
+
if (!parts) return;
|
|
331
|
+
setPanelCollapsed(panel, parts.button, parts.body, collapsed, true);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
panels.forEach(panel => {
|
|
335
|
+
const parts = panelParts(panel);
|
|
336
|
+
if (!parts) return;
|
|
337
|
+
const { body, bodyId, button } = parts;
|
|
338
|
+
const heading = panel.querySelector("[data-panel-heading]");
|
|
339
|
+
const key = panel.getAttribute("data-panel-state") || panel.id || bodyId;
|
|
340
|
+
let stored = null;
|
|
341
|
+
if (heading) {
|
|
342
|
+
heading.setAttribute("role", "button");
|
|
343
|
+
heading.setAttribute("tabindex", "0");
|
|
344
|
+
if (bodyId) heading.setAttribute("aria-controls", bodyId);
|
|
345
|
+
heading.setAttribute("aria-label", "Toggle " + panelTitle(panel));
|
|
346
|
+
}
|
|
347
|
+
if (key) {
|
|
348
|
+
try { stored = localStorage.getItem("codedebrief-panel-collapsed-" + key); } catch (_) {}
|
|
349
|
+
}
|
|
350
|
+
setPanelCollapsed(panel, button, body, stored === "true", false);
|
|
351
|
+
button.addEventListener("click", event => {
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
setPanelCollapsed(panel, button, body, !panel.hasAttribute("data-collapsed"), true);
|
|
354
|
+
});
|
|
355
|
+
if (heading) {
|
|
356
|
+
heading.addEventListener("click", event => {
|
|
357
|
+
const target = event.target;
|
|
358
|
+
if (target && target.closest && target.closest("button, a, input, select, textarea")) return;
|
|
359
|
+
setPanelCollapsed(panel, button, body, !panel.hasAttribute("data-collapsed"), true);
|
|
360
|
+
});
|
|
361
|
+
heading.addEventListener("keydown", event => {
|
|
362
|
+
const target = event.target;
|
|
363
|
+
if (target && target.closest && target.closest("button, a, input, select, textarea")) return;
|
|
364
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
setPanelCollapsed(panel, button, body, !panel.hasAttribute("data-collapsed"), true);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
if (detailsCollapseAll) {
|
|
371
|
+
detailsCollapseAll.addEventListener("click", () => setAllPanelsCollapsed(true));
|
|
372
|
+
}
|
|
373
|
+
if (detailsExpandAll) {
|
|
374
|
+
detailsExpandAll.addEventListener("click", () => setAllPanelsCollapsed(false));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
loadStoredRailWidths();
|
|
379
|
+
initRailResizers();
|
|
380
|
+
initCollapsiblePanels();
|
|
381
|
+
setRightRailOpen(false);
|
|
382
|
+
syncRailControls();
|
|
383
|
+
|
|
384
|
+
document.getElementById("flowCount").textContent = flows.length;
|
|
385
|
+
document.getElementById("entryCount").textContent = flows.filter(item => item.is_entrypoint).length;
|
|
386
|
+
|
|
387
|
+
function headerFlowKind(flow) {
|
|
388
|
+
return `${flow.entry_kind} · ${flow.language} · ${flow.framework}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function setHeaderFlow(flow) {
|
|
392
|
+
activeFlow = flow || null;
|
|
393
|
+
if (!flow) return;
|
|
394
|
+
document.getElementById("flowTitle").textContent = flow.name;
|
|
395
|
+
document.getElementById("flowKind").textContent = headerFlowKind(flow);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function setHeaderScope(scope) {
|
|
399
|
+
activeFlow = null;
|
|
400
|
+
document.getElementById("flowTitle").textContent = scope || "codebase";
|
|
401
|
+
document.getElementById("flowKind").textContent = "scope";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function setHeaderRoot() {
|
|
405
|
+
activeFlow = null;
|
|
406
|
+
document.getElementById("flowTitle").textContent = "Codebase";
|
|
407
|
+
document.getElementById("flowKind").textContent = "progressive flowchart";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function selectionForFlow(flow) {
|
|
411
|
+
return {
|
|
412
|
+
edgeId: null,
|
|
413
|
+
endLine: flow.location?.end_line ?? flow.location?.start_line ?? null,
|
|
414
|
+
flowId: flow.id,
|
|
415
|
+
line: flow.location?.start_line ?? null,
|
|
416
|
+
nodeId: null,
|
|
417
|
+
path: flow.location?.path || null,
|
|
418
|
+
scope: null,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Entry points first, then by name. Shared so the tree lists a file's flows in the
|
|
423
|
+
// same order the old flat list used.
|
|
424
|
+
codeDebrief.sortFlows = list =>
|
|
425
|
+
[...list].sort(
|
|
426
|
+
(a, b) => Number(b.is_entrypoint) - Number(a.is_entrypoint) || a.name.localeCompare(b.name)
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
// Updates the header + the active-flow bookkeeping shared by the tree and details.
|
|
430
|
+
// Rendering belongs to the typed React runtime; the shell only delegates selection and
|
|
431
|
+
// keeps the surrounding HTML controls synchronized.
|
|
432
|
+
function selectFlow(flowId) {
|
|
433
|
+
const flow = byId.get(flowId);
|
|
434
|
+
if (!flow) return;
|
|
435
|
+
setHeaderFlow(flow);
|
|
436
|
+
const typed = activeTypedViewer();
|
|
437
|
+
if (typed && typeof typed.selectFlow === "function") {
|
|
438
|
+
typed.selectFlow(flow.id);
|
|
439
|
+
} else {
|
|
440
|
+
location.hash = "flow=" + encodeURIComponent(flow.id);
|
|
441
|
+
codeDebrief.select(selectionForFlow(flow));
|
|
442
|
+
}
|
|
443
|
+
// On phones the tree is a drawer, so a selection should clear the canvas. On
|
|
444
|
+
// desktop/tablet the tree is working context; keep it open unless the user closes it.
|
|
445
|
+
if (window.innerWidth <= 700) setLeftRailOpen(false);
|
|
446
|
+
// Let other inlined scripts (e.g. tree.js) reflect the active flow.
|
|
447
|
+
if (window.CodeDebrief.onFlowSelected) window.CodeDebrief.onFlowSelected(flow);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function flowLayoutNodes(flow, opts) {
|
|
451
|
+
opts = opts || {};
|
|
452
|
+
return (flow.nodes || []).filter(node => !(opts.omitEntry && node.kind === "entry"));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function flowLayers(nodes, edges, incoming, outgoing, order) {
|
|
456
|
+
const nodeById = new Map(nodes.map(node => [node.id, node]));
|
|
457
|
+
const indegree = new Map(nodes.map(node => [node.id, 0]));
|
|
458
|
+
const layerById = new Map(nodes.map(node => [node.id, 0]));
|
|
459
|
+
edges.forEach(edge => {
|
|
460
|
+
if (edge.source === edge.target) return;
|
|
461
|
+
indegree.set(edge.target, (indegree.get(edge.target) || 0) + 1);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const queue = nodes
|
|
465
|
+
.filter(node => (indegree.get(node.id) || 0) === 0)
|
|
466
|
+
.sort((a, b) => order.get(a.id) - order.get(b.id));
|
|
467
|
+
const visited = new Set();
|
|
468
|
+
|
|
469
|
+
while (queue.length) {
|
|
470
|
+
const node = queue.shift();
|
|
471
|
+
if (!node || visited.has(node.id)) continue;
|
|
472
|
+
visited.add(node.id);
|
|
473
|
+
(outgoing.get(node.id) || []).forEach(edge => {
|
|
474
|
+
const nextLayer = (layerById.get(node.id) || 0) + 1;
|
|
475
|
+
layerById.set(edge.target, Math.max(layerById.get(edge.target) || 0, nextLayer));
|
|
476
|
+
indegree.set(edge.target, (indegree.get(edge.target) || 0) - 1);
|
|
477
|
+
if ((indegree.get(edge.target) || 0) === 0) {
|
|
478
|
+
const target = nodeById.get(edge.target);
|
|
479
|
+
if (target) queue.push(target);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
queue.sort((a, b) => order.get(a.id) - order.get(b.id));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Cycles/backedges are valid in real control flow. Keep those nodes visible by
|
|
486
|
+
// assigning a stable fallback layer from any already-known parents instead of assuming
|
|
487
|
+
// the payload was topologically sorted.
|
|
488
|
+
nodes.forEach(node => {
|
|
489
|
+
if (visited.has(node.id)) return;
|
|
490
|
+
const parentLayers = (incoming.get(node.id) || [])
|
|
491
|
+
.map(edge => layerById.get(edge.source))
|
|
492
|
+
.filter(value => Number.isFinite(value));
|
|
493
|
+
if (parentLayers.length) {
|
|
494
|
+
layerById.set(node.id, Math.max(layerById.get(node.id) || 0, Math.max(...parentLayers) + 1));
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
return layerById;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function layoutFlow(flow, opts) {
|
|
502
|
+
opts = opts || {};
|
|
503
|
+
const nodes = flowLayoutNodes(flow, opts);
|
|
504
|
+
const visibleIds = new Set(nodes.map(node => node.id));
|
|
505
|
+
const order = new Map(nodes.map((node, index) => [node.id, index]));
|
|
506
|
+
const incoming = new Map(nodes.map(node => [node.id, []]));
|
|
507
|
+
const outgoing = new Map(nodes.map(node => [node.id, []]));
|
|
508
|
+
const edges = (flow.edges || []).filter(edge =>
|
|
509
|
+
visibleIds.has(edge.source) && visibleIds.has(edge.target)
|
|
510
|
+
);
|
|
511
|
+
edges.forEach(edge => incoming.get(edge.target)?.push(edge));
|
|
512
|
+
edges.forEach(edge => outgoing.get(edge.source)?.push(edge));
|
|
513
|
+
const layerById = flowLayers(nodes, edges, incoming, outgoing, order);
|
|
514
|
+
const positions = new Map();
|
|
515
|
+
const layerCounts = new Map();
|
|
516
|
+
|
|
517
|
+
[...nodes]
|
|
518
|
+
.sort((a, b) =>
|
|
519
|
+
(layerById.get(a.id) || 0) - (layerById.get(b.id) || 0) ||
|
|
520
|
+
order.get(a.id) - order.get(b.id)
|
|
521
|
+
)
|
|
522
|
+
.forEach(node => {
|
|
523
|
+
const parents = (incoming.get(node.id) || []).filter(edge => positions.has(edge.source));
|
|
524
|
+
const layer = layerById.get(node.id) || 0;
|
|
525
|
+
let x = 0;
|
|
526
|
+
if (parents.length) {
|
|
527
|
+
const parentXs = parents.map(edge => positions.get(edge.source)?.x || 0);
|
|
528
|
+
x = parentXs.reduce((sum, value) => sum + value, 0) / parentXs.length;
|
|
529
|
+
if (parents.length === 1) {
|
|
530
|
+
const parentEdge = parents[0];
|
|
531
|
+
const siblings = outgoing.get(parentEdge.source) || [];
|
|
532
|
+
if (siblings.length > 1) {
|
|
533
|
+
const siblingIndex = siblings.findIndex(edge => edge.target === node.id);
|
|
534
|
+
const centeredIndex = siblingIndex - (siblings.length - 1) / 2;
|
|
535
|
+
x = (positions.get(parentEdge.source)?.x || 0) + centeredIndex * FLOW_SIBLING_X;
|
|
536
|
+
} else {
|
|
537
|
+
const branch = parentEdge.label?.toLowerCase();
|
|
538
|
+
if (["yes", "success"].includes(branch)) x -= FLOW_SIBLING_X / 2;
|
|
539
|
+
if (["no", "error"].includes(branch)) x += FLOW_SIBLING_X / 2;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const occupied = layerCounts.get(layer) || [];
|
|
544
|
+
while (occupied.some(value => Math.abs(value - x) < FLOW_MIN_X_GAP)) x += FLOW_SIBLING_X;
|
|
545
|
+
occupied.push(x);
|
|
546
|
+
layerCounts.set(layer, occupied);
|
|
547
|
+
positions.set(node.id, { x, y: layer * FLOW_LAYER_Y, layer, order: order.get(node.id) || 0 });
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Apply any hand-placed overrides for this flow before measuring bounds.
|
|
551
|
+
const overrides = manualPositions.get(flow.id);
|
|
552
|
+
if (overrides) {
|
|
553
|
+
overrides.forEach((point, nodeId) => {
|
|
554
|
+
const position = positions.get(nodeId);
|
|
555
|
+
if (position) { position.x = point.x; position.y = point.y; position.moved = true; }
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const values = [...positions.values()];
|
|
560
|
+
if (!values.length) {
|
|
561
|
+
return { positions, bounds: { minX: 0, maxX: 0, minY: 0, maxY: 0 }, nodes, edges };
|
|
562
|
+
}
|
|
563
|
+
const minX = Math.min(...values.map(item => item.x), 0);
|
|
564
|
+
const maxX = Math.max(...values.map(item => item.x), 0);
|
|
565
|
+
const minY = Math.min(...values.map(item => item.y), 0);
|
|
566
|
+
const maxY = Math.max(...values.map(item => item.y), 0);
|
|
567
|
+
return { positions, bounds: { minX, maxX, minY, maxY }, nodes, edges };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function nodeHalfHeight(kind) {
|
|
571
|
+
return kind === "decision" ? FLOW_DECISION_HALF_H : FLOW_RECT_HALF_H;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function horizontalLabelX(startX, endX) {
|
|
575
|
+
const dx = endX - startX;
|
|
576
|
+
if (Math.abs(dx) < 24) return startX + 7;
|
|
577
|
+
return startX + dx * 0.34;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Single source for an edge's orthogonal path + label anchor, reused on first render
|
|
581
|
+
// and live during a node drag so connected edges follow. The path is intentionally
|
|
582
|
+
// flowchart-like: leave through a lower port, travel on a horizontal branch lane, then
|
|
583
|
+
// enter the target from above.
|
|
584
|
+
function edgeGeometry(start, end, startKind, endKind) {
|
|
585
|
+
const startPortY = start.y + nodeHalfHeight(startKind);
|
|
586
|
+
const endPortY = end.y - nodeHalfHeight(endKind);
|
|
587
|
+
const verticalRoom = endPortY - startPortY;
|
|
588
|
+
const branchY = verticalRoom > EDGE_START_CLEARANCE + EDGE_END_CLEARANCE + 20
|
|
589
|
+
? startPortY + EDGE_START_CLEARANCE
|
|
590
|
+
: startPortY + verticalRoom / 2;
|
|
591
|
+
const labelIsExitChip = startKind === "decision";
|
|
592
|
+
const curveY = Math.max(90, Math.abs(endPortY - startPortY) * 0.55);
|
|
593
|
+
return {
|
|
594
|
+
d: `M ${start.x} ${startPortY} L ${start.x} ${branchY} L ${end.x} ${branchY} L ${end.x} ${endPortY}`,
|
|
595
|
+
focusD: `M ${start.x} ${startPortY} C ${start.x} ${startPortY + curveY}, ${end.x} ${endPortY - curveY}, ${end.x} ${endPortY}`,
|
|
596
|
+
points: [
|
|
597
|
+
{ x: start.x, y: startPortY },
|
|
598
|
+
{ x: start.x, y: branchY },
|
|
599
|
+
{ x: end.x, y: branchY },
|
|
600
|
+
{ x: end.x, y: endPortY },
|
|
601
|
+
],
|
|
602
|
+
labelX: labelIsExitChip ? horizontalLabelX(start.x, end.x) : (start.x + end.x) / 2 + 7,
|
|
603
|
+
labelY: branchY - 8,
|
|
604
|
+
exitChip: labelIsExitChip,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function bindEdgeActivationParts(group, activate) {
|
|
609
|
+
if (!group || !activate) return;
|
|
610
|
+
group.querySelectorAll("*").forEach(part => {
|
|
611
|
+
part.addEventListener("click", activate);
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function setEdgeHitGeometry(hit, geometry, activate) {
|
|
616
|
+
hit.replaceChildren();
|
|
617
|
+
const points = geometry.points || [];
|
|
618
|
+
const pad = 10;
|
|
619
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
620
|
+
const a = points[index];
|
|
621
|
+
const b = points[index + 1];
|
|
622
|
+
const rect = svgEl("rect");
|
|
623
|
+
rect.setAttribute("class", "edge-hit-segment");
|
|
624
|
+
rect.setAttribute("x", String(Math.min(a.x, b.x) - pad));
|
|
625
|
+
rect.setAttribute("y", String(Math.min(a.y, b.y) - pad));
|
|
626
|
+
rect.setAttribute("width", String(Math.max(Math.abs(a.x - b.x), 1) + pad * 2));
|
|
627
|
+
rect.setAttribute("height", String(Math.max(Math.abs(a.y - b.y), 1) + pad * 2));
|
|
628
|
+
rect.setAttribute("rx", "10");
|
|
629
|
+
if (activate) rect.addEventListener("click", activate);
|
|
630
|
+
hit.appendChild(rect);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function edgeLabel(text, geometry) {
|
|
635
|
+
const value = String(text);
|
|
636
|
+
const width = Math.max(30, value.length * 7 + 18);
|
|
637
|
+
const group = svgEl("g");
|
|
638
|
+
group.setAttribute("class", `edge-label-wrap${geometry.exitChip ? " branch-exit-chip" : ""}`);
|
|
639
|
+
group.setAttribute("transform", `translate(${geometry.labelX} ${geometry.labelY})`);
|
|
640
|
+
const bg = svgEl("rect");
|
|
641
|
+
bg.setAttribute("class", "edge-label-bg");
|
|
642
|
+
bg.setAttribute("x", String(-width / 2));
|
|
643
|
+
bg.setAttribute("y", "-12");
|
|
644
|
+
bg.setAttribute("width", String(width));
|
|
645
|
+
bg.setAttribute("height", "20");
|
|
646
|
+
bg.setAttribute("rx", "10");
|
|
647
|
+
const label = svgEl("text");
|
|
648
|
+
label.setAttribute("class", "edge-label");
|
|
649
|
+
label.setAttribute("text-anchor", "middle");
|
|
650
|
+
label.setAttribute("y", "4");
|
|
651
|
+
label.textContent = value;
|
|
652
|
+
group.append(bg, label);
|
|
653
|
+
return group;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function nodeKindBadge(kind) {
|
|
657
|
+
const labelText = kind === "terminal" ? "outcome" : String(kind || "node");
|
|
658
|
+
const width = Math.max(48, labelText.length * 6 + 16);
|
|
659
|
+
const group = svgEl("g");
|
|
660
|
+
group.setAttribute("class", "node-kind-badge");
|
|
661
|
+
group.setAttribute("transform", "translate(0 -30)");
|
|
662
|
+
const bg = svgEl("rect");
|
|
663
|
+
bg.setAttribute("x", String(-width / 2));
|
|
664
|
+
bg.setAttribute("y", "-9");
|
|
665
|
+
bg.setAttribute("width", String(width));
|
|
666
|
+
bg.setAttribute("height", "18");
|
|
667
|
+
bg.setAttribute("rx", "9");
|
|
668
|
+
const text = svgEl("text");
|
|
669
|
+
text.setAttribute("text-anchor", "middle");
|
|
670
|
+
text.setAttribute("y", "4");
|
|
671
|
+
text.textContent = labelText;
|
|
672
|
+
group.append(bg, text);
|
|
673
|
+
return group;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Decision-flow defs (shadow filters + arrow marker). The visible chart is rendered
|
|
677
|
+
// by the React runtime; these helpers remain for shared source/export compatibility.
|
|
678
|
+
function flowDefs() {
|
|
679
|
+
const defs = svgEl("defs");
|
|
680
|
+
defs.innerHTML = `
|
|
681
|
+
<filter id="nodeShadow" x="-30%" y="-30%" width="160%" height="180%">
|
|
682
|
+
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".10"/>
|
|
683
|
+
</filter>
|
|
684
|
+
<filter id="nodeLift" x="-45%" y="-45%" width="190%" height="210%">
|
|
685
|
+
<feDropShadow dx="0" dy="16" stdDeviation="14" flood-color="#000" flood-opacity=".22"/>
|
|
686
|
+
</filter>
|
|
687
|
+
<marker id="arrow" markerWidth="6.5" markerHeight="6.5" refX="5.7" refY="3.25" viewBox="0 0 6.5 6.5" orient="auto">
|
|
688
|
+
<path class="arrow" d="M0,0 L6.5,3.25 L0,6.5 z"></path>
|
|
689
|
+
</marker>
|
|
690
|
+
<marker id="arrowFocus" markerWidth="6.5" markerHeight="6.5" refX="5.7" refY="3.25" viewBox="0 0 6.5 6.5" orient="auto">
|
|
691
|
+
<path class="arrow-focus" d="M0,0 L6.5,3.25 L0,6.5 z"></path>
|
|
692
|
+
</marker>`;
|
|
693
|
+
return defs;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Reusable decision-graph renderer retained for shared source/export compatibility.
|
|
697
|
+
// It draws `flow`'s nodes/edges into a fresh <g> layer and returns it without touching
|
|
698
|
+
// the SVG, the global `view`, or data-level.
|
|
699
|
+
//
|
|
700
|
+
// opts.originX/originY translate the whole sub-graph (layoutFlow is origin-relative;
|
|
701
|
+
// the caller places it). opts.spine adds the decision spine (full-screen only). Drag
|
|
702
|
+
// and the bidirectional highlight keep working in both modes: drag uses the shared
|
|
703
|
+
// `view` scale (same SVG world units inline or full), and the returned record is set
|
|
704
|
+
// as `currentRender` so inspectNode -> highlightNode lights up incident edges/nodes.
|
|
705
|
+
function drawFlowGraph(flow, opts) {
|
|
706
|
+
opts = opts || {};
|
|
707
|
+
const originX = opts.originX || 0;
|
|
708
|
+
const originY = opts.originY || 0;
|
|
709
|
+
const { positions, bounds, nodes, edges } = layoutFlow(flow, opts);
|
|
710
|
+
const layer = svgEl("g");
|
|
711
|
+
if (opts.layerClass) layer.setAttribute("class", opts.layerClass);
|
|
712
|
+
|
|
713
|
+
if (opts.spine) {
|
|
714
|
+
const spine = svgEl("line");
|
|
715
|
+
spine.setAttribute("class", "decision-spine");
|
|
716
|
+
spine.setAttribute("x1", String(originX));
|
|
717
|
+
spine.setAttribute("y1", String(originY - 20));
|
|
718
|
+
spine.setAttribute("x2", String(originX));
|
|
719
|
+
spine.setAttribute("y2", String(originY + bounds.maxY + 100));
|
|
720
|
+
layer.appendChild(spine);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const at = id => {
|
|
724
|
+
const p = positions.get(id);
|
|
725
|
+
return p ? { x: p.x + originX, y: p.y + originY } : null;
|
|
726
|
+
};
|
|
727
|
+
const flowNodeById = new Map(nodes.map(node => [node.id, node]));
|
|
728
|
+
const edgeRecordId = edge => edge.id || `${edge.source}->${edge.target}`;
|
|
729
|
+
|
|
730
|
+
// Keep edge element references per node so dragging a block re-routes its edges live,
|
|
731
|
+
// and a flat list so selecting a node can highlight its incident edges.
|
|
732
|
+
const nodeEdges = new Map(nodes.map(node => [node.id, []]));
|
|
733
|
+
const edgeRecords = [];
|
|
734
|
+
const edgeLayer = svgEl("g");
|
|
735
|
+
const edgePathLayer = svgEl("g");
|
|
736
|
+
const edgeLabelLayer = svgEl("g");
|
|
737
|
+
edges.forEach(edge => {
|
|
738
|
+
const start = at(edge.source);
|
|
739
|
+
const end = at(edge.target);
|
|
740
|
+
if (!start || !end) return;
|
|
741
|
+
const sourceNode = flowNodeById.get(edge.source);
|
|
742
|
+
const targetNode = flowNodeById.get(edge.target);
|
|
743
|
+
const geometry = edgeGeometry(start, end, sourceNode?.kind, targetNode?.kind);
|
|
744
|
+
let hit = svgEl("g");
|
|
745
|
+
let label = null;
|
|
746
|
+
const activateEdge = event => {
|
|
747
|
+
event.stopPropagation();
|
|
748
|
+
if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
|
|
749
|
+
if (codeDebrief.openDetails) codeDebrief.openDetails();
|
|
750
|
+
codeDebrief.select({
|
|
751
|
+
flowId: flow.id,
|
|
752
|
+
path: flow.location.path,
|
|
753
|
+
nodeId: null,
|
|
754
|
+
edgeId: edgeRecordId(edge),
|
|
755
|
+
line: flow.location.start_line || null,
|
|
756
|
+
endLine: flow.location.end_line || flow.location.start_line || null,
|
|
757
|
+
});
|
|
758
|
+
};
|
|
759
|
+
hit.setAttribute("class", "edge-hit");
|
|
760
|
+
setEdgeHitGeometry(hit, geometry, activateEdge);
|
|
761
|
+
hit.setAttribute("data-edge-id", edgeRecordId(edge));
|
|
762
|
+
hit.setAttribute("data-source-node-id", edge.source);
|
|
763
|
+
hit.setAttribute("data-target-node-id", edge.target);
|
|
764
|
+
const path = svgEl("path");
|
|
765
|
+
path.setAttribute("class", "edge");
|
|
766
|
+
path.setAttribute("d", geometry.d);
|
|
767
|
+
path.setAttribute("tabindex", "0");
|
|
768
|
+
path.setAttribute("role", "button");
|
|
769
|
+
path.setAttribute("aria-label", `link from ${sourceNode?.label || edge.source} to ${targetNode?.label || edge.target}`);
|
|
770
|
+
path.setAttribute("data-edge-id", edgeRecordId(edge));
|
|
771
|
+
path.setAttribute("data-source-node-id", edge.source);
|
|
772
|
+
path.setAttribute("data-target-node-id", edge.target);
|
|
773
|
+
edgePathLayer.appendChild(path);
|
|
774
|
+
const focusPath = svgEl("path");
|
|
775
|
+
focusPath.setAttribute("class", "edge-focus");
|
|
776
|
+
focusPath.setAttribute("d", geometry.focusD || geometry.d);
|
|
777
|
+
edgePathLayer.appendChild(focusPath);
|
|
778
|
+
edgePathLayer.appendChild(hit);
|
|
779
|
+
if (edge.label) {
|
|
780
|
+
label = edgeLabel(edge.label, geometry);
|
|
781
|
+
label.setAttribute("role", "button");
|
|
782
|
+
label.setAttribute("tabindex", "0");
|
|
783
|
+
label.setAttribute("data-edge-id", edgeRecordId(edge));
|
|
784
|
+
label.setAttribute("data-source-node-id", edge.source);
|
|
785
|
+
label.setAttribute("data-target-node-id", edge.target);
|
|
786
|
+
label.setAttribute("aria-label", `link ${edge.label} from ${sourceNode?.label || edge.source} to ${targetNode?.label || edge.target}`);
|
|
787
|
+
edgeLabelLayer.appendChild(label);
|
|
788
|
+
}
|
|
789
|
+
const record = { edge, flow, hit, path, focusPath, label, activateEdge };
|
|
790
|
+
hit.addEventListener("click", activateEdge);
|
|
791
|
+
path.addEventListener("click", activateEdge);
|
|
792
|
+
path.addEventListener("keydown", event => {
|
|
793
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
794
|
+
event.preventDefault();
|
|
795
|
+
activateEdge(event);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
if (label) {
|
|
799
|
+
label.addEventListener("click", activateEdge);
|
|
800
|
+
bindEdgeActivationParts(label, activateEdge);
|
|
801
|
+
label.addEventListener("keydown", event => {
|
|
802
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
803
|
+
event.preventDefault();
|
|
804
|
+
activateEdge(event);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
edgeRecords.push(record);
|
|
809
|
+
nodeEdges.get(edge.source)?.push(record);
|
|
810
|
+
nodeEdges.get(edge.target)?.push(record);
|
|
811
|
+
});
|
|
812
|
+
edgeLayer.append(edgePathLayer, edgeLabelLayer);
|
|
813
|
+
layer.appendChild(edgeLayer);
|
|
814
|
+
|
|
815
|
+
function rerouteFrom(nodeId) {
|
|
816
|
+
(nodeEdges.get(nodeId) || []).forEach(({ edge, hit, path, focusPath, label, activateEdge }) => {
|
|
817
|
+
const start = at(edge.source);
|
|
818
|
+
const end = at(edge.target);
|
|
819
|
+
if (!start || !end) return;
|
|
820
|
+
const sourceNode = flowNodeById.get(edge.source);
|
|
821
|
+
const targetNode = flowNodeById.get(edge.target);
|
|
822
|
+
const geometry = edgeGeometry(start, end, sourceNode?.kind, targetNode?.kind);
|
|
823
|
+
setEdgeHitGeometry(hit, geometry, activateEdge);
|
|
824
|
+
path.setAttribute("d", geometry.d);
|
|
825
|
+
if (focusPath) focusPath.setAttribute("d", geometry.focusD || geometry.d);
|
|
826
|
+
if (label) label.setAttribute("transform", `translate(${geometry.labelX} ${geometry.labelY})`);
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const nodeLayer = svgEl("g");
|
|
831
|
+
const nodeGroups = new Map();
|
|
832
|
+
const nodeDraggable = opts.draggable !== false;
|
|
833
|
+
nodes.forEach(node => {
|
|
834
|
+
const position = positions.get(node.id);
|
|
835
|
+
// Live world-space position of this node (origin-translated). Drag mutates it.
|
|
836
|
+
const place = { x: position.x + originX, y: position.y + originY };
|
|
837
|
+
const group = svgEl("g");
|
|
838
|
+
nodeGroups.set(node.id, group);
|
|
839
|
+
group.setAttribute(
|
|
840
|
+
"class",
|
|
841
|
+
`node ${node.kind}${nodeDraggable ? "" : " static"}`
|
|
842
|
+
);
|
|
843
|
+
group.setAttribute("transform", `translate(${place.x} ${place.y})`);
|
|
844
|
+
group.setAttribute("tabindex", "0");
|
|
845
|
+
group.setAttribute("role", "button");
|
|
846
|
+
group.setAttribute("aria-label", `${node.kind}: ${node.label}`);
|
|
847
|
+
function activateNode() {
|
|
848
|
+
const targetFlow = node.kind === "call" && node.metadata
|
|
849
|
+
? node.metadata.target_flow
|
|
850
|
+
: null;
|
|
851
|
+
if (targetFlow && byId.has(targetFlow) && codeDebrief.expandCallTarget) {
|
|
852
|
+
codeDebrief.expandCallTarget(flow.id, targetFlow, node.id);
|
|
853
|
+
} else {
|
|
854
|
+
inspectNode(flow, node);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Drag to rearrange the block in both full-screen and inline decision charts.
|
|
858
|
+
// A renderer may explicitly pass draggable:false for a read-only preview.
|
|
859
|
+
if (nodeDraggable) {
|
|
860
|
+
let nodeDrag = null;
|
|
861
|
+
const moveNodeDrag = event => {
|
|
862
|
+
if (!nodeDrag) return;
|
|
863
|
+
const dx = (event.clientX - nodeDrag.x) * nodeDrag.scaleX;
|
|
864
|
+
const dy = (event.clientY - nodeDrag.y) * nodeDrag.scaleY;
|
|
865
|
+
nodeDrag.moved = Math.max(nodeDrag.moved, Math.abs(dx) + Math.abs(dy));
|
|
866
|
+
place.x = nodeDrag.ox + dx;
|
|
867
|
+
place.y = nodeDrag.oy + dy;
|
|
868
|
+
// positions is origin-relative; mirror the drag back so rerouteFrom (which
|
|
869
|
+
// re-adds the origin) and any later override store both stay consistent.
|
|
870
|
+
position.x = place.x - originX;
|
|
871
|
+
position.y = place.y - originY;
|
|
872
|
+
group.setAttribute("transform", `translate(${place.x} ${place.y})`);
|
|
873
|
+
rerouteFrom(node.id);
|
|
874
|
+
};
|
|
875
|
+
const endNodeDrag = event => {
|
|
876
|
+
if (!nodeDrag) return;
|
|
877
|
+
group.classList.remove("dragging");
|
|
878
|
+
window.removeEventListener("pointermove", moveNodeDrag);
|
|
879
|
+
window.removeEventListener("pointerup", endNodeDrag);
|
|
880
|
+
window.removeEventListener("pointercancel", endNodeDrag);
|
|
881
|
+
try { group.releasePointerCapture(event.pointerId); } catch (_) {}
|
|
882
|
+
if (nodeDrag.moved < 4 && event.type === "pointerup") {
|
|
883
|
+
activateNode();
|
|
884
|
+
} else {
|
|
885
|
+
const store = manualPositions.get(flow.id) || new Map();
|
|
886
|
+
store.set(node.id, { x: position.x, y: position.y });
|
|
887
|
+
manualPositions.set(flow.id, store);
|
|
888
|
+
}
|
|
889
|
+
nodeDrag = null;
|
|
890
|
+
};
|
|
891
|
+
const startNodeDrag = event => {
|
|
892
|
+
if (event.button !== 0) return;
|
|
893
|
+
event.stopPropagation();
|
|
894
|
+
if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
|
|
895
|
+
nodeDrag = {
|
|
896
|
+
x: event.clientX,
|
|
897
|
+
y: event.clientY,
|
|
898
|
+
ox: place.x,
|
|
899
|
+
oy: place.y,
|
|
900
|
+
scaleX: view.width / svg.clientWidth,
|
|
901
|
+
scaleY: view.height / svg.clientHeight,
|
|
902
|
+
moved: 0
|
|
903
|
+
};
|
|
904
|
+
group.classList.add("dragging");
|
|
905
|
+
window.addEventListener("pointermove", moveNodeDrag);
|
|
906
|
+
window.addEventListener("pointerup", endNodeDrag);
|
|
907
|
+
window.addEventListener("pointercancel", endNodeDrag);
|
|
908
|
+
group.setPointerCapture(event.pointerId);
|
|
909
|
+
};
|
|
910
|
+
group.addEventListener("pointerdown", startNodeDrag);
|
|
911
|
+
} else {
|
|
912
|
+
group.addEventListener("click", event => {
|
|
913
|
+
event.stopPropagation();
|
|
914
|
+
if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
|
|
915
|
+
activateNode();
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
group.addEventListener("keydown", event => {
|
|
919
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
920
|
+
event.preventDefault();
|
|
921
|
+
activateNode();
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
const shape = nodeShape(node.kind);
|
|
925
|
+
shape.setAttribute("class", "shape");
|
|
926
|
+
group.appendChild(shape);
|
|
927
|
+
group.appendChild(nodeKindBadge(node.kind));
|
|
928
|
+
const lines = wrapLabel(node.label, node.kind === "decision" ? 25 : 31);
|
|
929
|
+
lines.forEach((line, index) => {
|
|
930
|
+
const text = svgEl("text");
|
|
931
|
+
text.setAttribute("text-anchor", "middle");
|
|
932
|
+
text.setAttribute("y", String((index - (lines.length - 1) / 2) * 17 + 1));
|
|
933
|
+
text.textContent = line;
|
|
934
|
+
group.appendChild(text);
|
|
935
|
+
});
|
|
936
|
+
const meta = svgEl("text");
|
|
937
|
+
meta.setAttribute("class", "meta");
|
|
938
|
+
meta.setAttribute("text-anchor", "middle");
|
|
939
|
+
meta.setAttribute("y", String(node.kind === "decision" ? FLOW_META_BOTTOM : 62));
|
|
940
|
+
meta.textContent = `${node.location.path}:${node.location.start_line}`;
|
|
941
|
+
group.appendChild(meta);
|
|
942
|
+
nodeLayer.appendChild(group);
|
|
943
|
+
});
|
|
944
|
+
layer.appendChild(nodeLayer);
|
|
945
|
+
|
|
946
|
+
// World-space bounds of the drawn sub-graph, so the caller can reserve room / fit.
|
|
947
|
+
const worldBounds = {
|
|
948
|
+
minX: bounds.minX + originX - FLOW_NODE_HALF_W,
|
|
949
|
+
maxX: bounds.maxX + originX + FLOW_NODE_HALF_W,
|
|
950
|
+
minY: bounds.minY + originY - FLOW_NODE_HALF_H,
|
|
951
|
+
maxY: bounds.maxY + originY + FLOW_NODE_HALF_H,
|
|
952
|
+
};
|
|
953
|
+
return { layer, nodeGroups, edgeRecords, bounds: worldBounds };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Bind a freshly drawn decision graph as the active highlight target.
|
|
957
|
+
function setCurrentRender(render) {
|
|
958
|
+
currentRender = render ? { nodeGroups: render.nodeGroups, edgeRecords: render.edgeRecords } : null;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function renderFlow(flow) {
|
|
962
|
+
svg.replaceChildren();
|
|
963
|
+
// The L2 decision chart is canvas level 2; keep the level attribute correct so a
|
|
964
|
+
// reader (or test) can tell which level is on screen (L0 scopes / L1 flows / L2).
|
|
965
|
+
setCanvasLevel("2");
|
|
966
|
+
if (!flow.nodes.length) {
|
|
967
|
+
document.getElementById("emptyState").style.display = "grid";
|
|
968
|
+
currentRender = null;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
document.getElementById("emptyState").style.display = "none";
|
|
972
|
+
|
|
973
|
+
svg.appendChild(flowDefs());
|
|
974
|
+
const render = drawFlowGraph(flow, { spine: true });
|
|
975
|
+
const bounds = render.bounds;
|
|
976
|
+
const padding = 170;
|
|
977
|
+
const top = Math.min(-90, bounds.minY - 70);
|
|
978
|
+
view = {
|
|
979
|
+
x: bounds.minX - padding,
|
|
980
|
+
y: top,
|
|
981
|
+
width: Math.max(760, bounds.maxX - bounds.minX + padding * 2),
|
|
982
|
+
height: Math.max(600, bounds.maxY - top + 250)
|
|
983
|
+
};
|
|
984
|
+
updateViewBox();
|
|
985
|
+
svg.appendChild(render.layer);
|
|
986
|
+
currentRender = { nodeGroups: render.nodeGroups, edgeRecords: render.edgeRecords };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function clearHighlight() {
|
|
990
|
+
if (!currentRender) return;
|
|
991
|
+
currentRender.nodeGroups.forEach(group =>
|
|
992
|
+
group.classList.remove("selected", "dimmed", "edge-source", "edge-target")
|
|
993
|
+
);
|
|
994
|
+
currentRender.edgeRecords.forEach(record => {
|
|
995
|
+
if (record.hit) record.hit.classList.remove("selected-link", "dimmed");
|
|
996
|
+
record.path.classList.remove("incident", "selected-link", "dimmed", "focus-hidden");
|
|
997
|
+
if (record.focusPath) record.focusPath.classList.remove("selected-link");
|
|
998
|
+
if (record.label) record.label.classList.remove("selected-link", "dimmed");
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function highlightEdge(targetRecord) {
|
|
1003
|
+
if (!currentRender) return;
|
|
1004
|
+
currentRender.edgeRecords.forEach(record => {
|
|
1005
|
+
const selected = record === targetRecord;
|
|
1006
|
+
if (record.hit) {
|
|
1007
|
+
record.hit.classList.toggle("selected-link", selected);
|
|
1008
|
+
record.hit.classList.toggle("dimmed", !selected);
|
|
1009
|
+
}
|
|
1010
|
+
record.path.classList.remove("selected-link", "incident");
|
|
1011
|
+
record.path.classList.toggle("focus-hidden", selected);
|
|
1012
|
+
record.path.classList.toggle("dimmed", !selected);
|
|
1013
|
+
if (record.focusPath) {
|
|
1014
|
+
record.focusPath.classList.toggle("selected-link", selected);
|
|
1015
|
+
}
|
|
1016
|
+
if (record.label) {
|
|
1017
|
+
record.label.classList.toggle("selected-link", selected);
|
|
1018
|
+
record.label.classList.toggle("dimmed", !selected);
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
const endpoints = new Set([targetRecord.edge.source, targetRecord.edge.target]);
|
|
1022
|
+
currentRender.nodeGroups.forEach((group, id) => {
|
|
1023
|
+
group.classList.toggle("selected", endpoints.has(id));
|
|
1024
|
+
group.classList.toggle("dimmed", !endpoints.has(id));
|
|
1025
|
+
group.classList.toggle("edge-source", id === targetRecord.edge.source);
|
|
1026
|
+
group.classList.toggle("edge-target", id === targetRecord.edge.target);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function highlightNode(nodeId) {
|
|
1031
|
+
if (!currentRender) return;
|
|
1032
|
+
const connected = new Set([nodeId]);
|
|
1033
|
+
currentRender.edgeRecords.forEach(record => {
|
|
1034
|
+
const incident = record.edge.source === nodeId || record.edge.target === nodeId;
|
|
1035
|
+
if (record.hit) {
|
|
1036
|
+
record.hit.classList.remove("selected-link");
|
|
1037
|
+
record.hit.classList.toggle("dimmed", !incident);
|
|
1038
|
+
}
|
|
1039
|
+
record.path.classList.remove("selected-link", "focus-hidden");
|
|
1040
|
+
if (record.focusPath) record.focusPath.classList.remove("selected-link");
|
|
1041
|
+
record.path.classList.toggle("incident", incident);
|
|
1042
|
+
record.path.classList.toggle("dimmed", !incident);
|
|
1043
|
+
if (record.label) {
|
|
1044
|
+
record.label.classList.remove("selected-link");
|
|
1045
|
+
record.label.classList.toggle("dimmed", !incident);
|
|
1046
|
+
}
|
|
1047
|
+
if (incident) { connected.add(record.edge.source); connected.add(record.edge.target); }
|
|
1048
|
+
});
|
|
1049
|
+
currentRender.nodeGroups.forEach((group, id) => {
|
|
1050
|
+
group.classList.toggle("selected", id === nodeId);
|
|
1051
|
+
group.classList.toggle("dimmed", !connected.has(id));
|
|
1052
|
+
group.classList.remove("edge-source", "edge-target");
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function decisionEdgeRecordFromElement(element) {
|
|
1057
|
+
if (!currentRender || !element || !element.closest) return null;
|
|
1058
|
+
if (element.closest(".progressive-call-hit, .progressive-call-edge, .progressive-call-label")) {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
const target = element.closest(".edge-hit, .edge, .edge-label-wrap");
|
|
1062
|
+
if (!target) return null;
|
|
1063
|
+
const id = target.getAttribute("data-edge-id");
|
|
1064
|
+
const source = target.getAttribute("data-source-node-id");
|
|
1065
|
+
const destination = target.getAttribute("data-target-node-id");
|
|
1066
|
+
return currentRender.edgeRecords.find(item => {
|
|
1067
|
+
const recordId = item.edge.id || `${item.edge.source}->${item.edge.target}`;
|
|
1068
|
+
return (id && recordId === id) ||
|
|
1069
|
+
(source && destination && item.edge.source === source && item.edge.target === destination);
|
|
1070
|
+
}) || null;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function activateDecisionEdgeRecord(event) {
|
|
1074
|
+
const record = decisionEdgeRecordFromElement(event.target);
|
|
1075
|
+
if (!record || !record.flow) return;
|
|
1076
|
+
event.preventDefault();
|
|
1077
|
+
event.stopPropagation();
|
|
1078
|
+
if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
|
|
1079
|
+
if (codeDebrief.openDetails) codeDebrief.openDetails();
|
|
1080
|
+
codeDebrief.select({
|
|
1081
|
+
flowId: record.flow.id,
|
|
1082
|
+
path: record.flow.location.path,
|
|
1083
|
+
nodeId: null,
|
|
1084
|
+
edgeId: record.edge.id || `${record.edge.source}->${record.edge.target}`,
|
|
1085
|
+
line: record.flow.location.start_line || null,
|
|
1086
|
+
endLine: record.flow.location.end_line || record.flow.location.start_line || null,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function nodeShape(kind) {
|
|
1091
|
+
if (kind === "decision") {
|
|
1092
|
+
const polygon = svgEl("polygon");
|
|
1093
|
+
polygon.setAttribute("points", "0,-58 145,0 0,58 -145,0");
|
|
1094
|
+
return polygon;
|
|
1095
|
+
}
|
|
1096
|
+
const rect = svgEl("rect");
|
|
1097
|
+
rect.setAttribute("x", "-145");
|
|
1098
|
+
rect.setAttribute("y", "-43");
|
|
1099
|
+
rect.setAttribute("width", "290");
|
|
1100
|
+
rect.setAttribute("height", "86");
|
|
1101
|
+
rect.setAttribute("rx", kind === "entry" || kind === "terminal" ? "43" : kind === "call" ? "5" : "12");
|
|
1102
|
+
return rect;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Inspecting a flow: clear the per-node canvas highlight (no single node is active)
|
|
1106
|
+
// and publish the flow selection so the details panels and tree reflect it. shell.js
|
|
1107
|
+
// keeps only its canvas-highlight responsibility plus publishing the shared selection.
|
|
1108
|
+
// nodeId is cleared so the source panel shows the whole flow snippet.
|
|
1109
|
+
function inspectFlow(flow) {
|
|
1110
|
+
if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
|
|
1111
|
+
clearHighlight();
|
|
1112
|
+
setRightRailOpen(true);
|
|
1113
|
+
codeDebrief.select({
|
|
1114
|
+
flowId: flow.id,
|
|
1115
|
+
path: flow.location.path,
|
|
1116
|
+
nodeId: null,
|
|
1117
|
+
line: flow.location.start_line || null,
|
|
1118
|
+
endLine: flow.location.end_line || flow.location.start_line || null,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Inspecting a decision/call node: publish the node selection. The block highlight is
|
|
1123
|
+
// applied by the single shared-selection subscriber below (one accent path, not
|
|
1124
|
+
// duplicated here); the source panel highlights the node's source line(s) via the same
|
|
1125
|
+
// store.
|
|
1126
|
+
function inspectNode(flow, node) {
|
|
1127
|
+
if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
|
|
1128
|
+
setRightRailOpen(true);
|
|
1129
|
+
codeDebrief.select({
|
|
1130
|
+
flowId: flow.id,
|
|
1131
|
+
nodeId: node.id,
|
|
1132
|
+
path: node.location.path,
|
|
1133
|
+
line: node.location.start_line || null,
|
|
1134
|
+
endLine: node.location.end_line || node.location.start_line || null,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function element(tag, className, text) {
|
|
1139
|
+
const item = document.createElement(tag);
|
|
1140
|
+
if (className) item.className = className;
|
|
1141
|
+
item.textContent = text;
|
|
1142
|
+
return item;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function svgEl(tag) {
|
|
1146
|
+
return document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function safeDecodeHashValue(value) {
|
|
1150
|
+
try {
|
|
1151
|
+
return decodeURIComponent(value);
|
|
1152
|
+
} catch (_) {
|
|
1153
|
+
return null;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function wrapLabel(value, width) {
|
|
1158
|
+
const words = value.split(/\s+/);
|
|
1159
|
+
const lines = [];
|
|
1160
|
+
let current = "";
|
|
1161
|
+
words.forEach(word => {
|
|
1162
|
+
if (!current || `${current} ${word}`.length <= width) current = current ? `${current} ${word}` : word;
|
|
1163
|
+
else { lines.push(current); current = word; }
|
|
1164
|
+
});
|
|
1165
|
+
if (current) lines.push(current);
|
|
1166
|
+
return lines.slice(0, 3);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function updateViewBox() {
|
|
1170
|
+
svg.setAttribute("viewBox", `${view.x} ${view.y} ${view.width} ${view.height}`);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function activeTypedViewer() {
|
|
1174
|
+
if (document.body.dataset.runtime !== "react") return null;
|
|
1175
|
+
const viewer = window.codedebriefTypedViewer;
|
|
1176
|
+
return viewer && typeof viewer === "object" ? viewer : null;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function cssVar(name, fallback) {
|
|
1180
|
+
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
1181
|
+
return value || fallback;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function canvasContentBounds() {
|
|
1185
|
+
const nodes = [...svg.children].filter(child => child.tagName.toLowerCase() !== "defs");
|
|
1186
|
+
if (!nodes.length) {
|
|
1187
|
+
return { x: view.x, y: view.y, width: view.width, height: view.height };
|
|
1188
|
+
}
|
|
1189
|
+
const hitboxes = [...svg.querySelectorAll(".edge-hit")];
|
|
1190
|
+
const previousDisplays = hitboxes.map(node => node.style.display);
|
|
1191
|
+
hitboxes.forEach(node => {
|
|
1192
|
+
node.style.display = "none";
|
|
1193
|
+
});
|
|
1194
|
+
let minX = Infinity;
|
|
1195
|
+
let minY = Infinity;
|
|
1196
|
+
let maxX = -Infinity;
|
|
1197
|
+
let maxY = -Infinity;
|
|
1198
|
+
try {
|
|
1199
|
+
nodes.forEach(node => {
|
|
1200
|
+
if (node.classList && node.classList.contains("edge-hit")) return;
|
|
1201
|
+
try {
|
|
1202
|
+
const box = node.getBBox();
|
|
1203
|
+
if (!box || !Number.isFinite(box.width) || !Number.isFinite(box.height)) return;
|
|
1204
|
+
minX = Math.min(minX, box.x);
|
|
1205
|
+
minY = Math.min(minY, box.y);
|
|
1206
|
+
maxX = Math.max(maxX, box.x + box.width);
|
|
1207
|
+
maxY = Math.max(maxY, box.y + box.height);
|
|
1208
|
+
} catch (_) {}
|
|
1209
|
+
});
|
|
1210
|
+
} finally {
|
|
1211
|
+
hitboxes.forEach((node, index) => {
|
|
1212
|
+
node.style.display = previousDisplays[index] || "";
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
|
1216
|
+
return { x: view.x, y: view.y, width: view.width, height: view.height };
|
|
1217
|
+
}
|
|
1218
|
+
const padding = 90;
|
|
1219
|
+
return {
|
|
1220
|
+
x: minX - padding,
|
|
1221
|
+
y: minY - padding,
|
|
1222
|
+
width: Math.max(1, maxX - minX + padding * 2),
|
|
1223
|
+
height: Math.max(1, maxY - minY + padding * 2),
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function exportCurrentCanvas(format) {
|
|
1228
|
+
const typed = activeTypedViewer();
|
|
1229
|
+
if (typed && typeof typed.exportImage === "function") {
|
|
1230
|
+
typed.exportImage(format);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const bounds = canvasContentBounds();
|
|
1234
|
+
const preferredScale = 2;
|
|
1235
|
+
const maxPixelSide = 16384;
|
|
1236
|
+
const maxPixelArea = 96000000;
|
|
1237
|
+
const boundedWidth = Math.max(1, bounds.width);
|
|
1238
|
+
const boundedHeight = Math.max(1, bounds.height);
|
|
1239
|
+
const scale = Math.max(
|
|
1240
|
+
0.1,
|
|
1241
|
+
Math.min(
|
|
1242
|
+
preferredScale,
|
|
1243
|
+
maxPixelSide / Math.max(boundedWidth, boundedHeight),
|
|
1244
|
+
Math.sqrt(maxPixelArea / (boundedWidth * boundedHeight)),
|
|
1245
|
+
),
|
|
1246
|
+
);
|
|
1247
|
+
const width = Math.max(1, Math.round(bounds.width * scale));
|
|
1248
|
+
const height = Math.max(1, Math.round(bounds.height * scale));
|
|
1249
|
+
const clone = svg.cloneNode(true);
|
|
1250
|
+
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
1251
|
+
clone.setAttribute("width", String(width));
|
|
1252
|
+
clone.setAttribute("height", String(height));
|
|
1253
|
+
clone.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`);
|
|
1254
|
+
clone.setAttribute("data-theme", document.documentElement.dataset.theme || "dark");
|
|
1255
|
+
clone.querySelectorAll(".edge-hit").forEach(node => node.remove());
|
|
1256
|
+
|
|
1257
|
+
const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
|
|
1258
|
+
style.textContent = document.querySelector("style")?.textContent || "";
|
|
1259
|
+
clone.prepend(style);
|
|
1260
|
+
const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
1261
|
+
background.setAttribute("x", String(bounds.x));
|
|
1262
|
+
background.setAttribute("y", String(bounds.y));
|
|
1263
|
+
background.setAttribute("width", String(bounds.width));
|
|
1264
|
+
background.setAttribute("height", String(bounds.height));
|
|
1265
|
+
background.setAttribute("fill", cssVar("--paper", "#ffffff"));
|
|
1266
|
+
clone.insertBefore(background, style.nextSibling);
|
|
1267
|
+
|
|
1268
|
+
const serialized = new XMLSerializer().serializeToString(clone);
|
|
1269
|
+
const svgBlob = new Blob([serialized], { type: "image/svg+xml;charset=utf-8" });
|
|
1270
|
+
const imageUrl = URL.createObjectURL(svgBlob);
|
|
1271
|
+
const image = new Image();
|
|
1272
|
+
image.onload = () => {
|
|
1273
|
+
const canvas = document.createElement("canvas");
|
|
1274
|
+
canvas.width = width;
|
|
1275
|
+
canvas.height = height;
|
|
1276
|
+
const ctx = canvas.getContext("2d");
|
|
1277
|
+
if (!ctx) {
|
|
1278
|
+
URL.revokeObjectURL(imageUrl);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
ctx.fillStyle = cssVar("--paper", "#ffffff");
|
|
1282
|
+
ctx.fillRect(0, 0, width, height);
|
|
1283
|
+
ctx.drawImage(image, 0, 0, width, height);
|
|
1284
|
+
URL.revokeObjectURL(imageUrl);
|
|
1285
|
+
const mime = format === "jpg" ? "image/jpeg" : "image/png";
|
|
1286
|
+
canvas.toBlob(blob => {
|
|
1287
|
+
if (!blob) return;
|
|
1288
|
+
const link = document.createElement("a");
|
|
1289
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1290
|
+
link.download = `codedebrief-flowchart-${stamp}.${format}`;
|
|
1291
|
+
link.href = URL.createObjectURL(blob);
|
|
1292
|
+
document.body.appendChild(link);
|
|
1293
|
+
link.click();
|
|
1294
|
+
link.remove();
|
|
1295
|
+
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
|
1296
|
+
}, mime, format === "jpg" ? 0.92 : undefined);
|
|
1297
|
+
};
|
|
1298
|
+
image.onerror = () => URL.revokeObjectURL(imageUrl);
|
|
1299
|
+
image.src = imageUrl;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function zoom(factor) {
|
|
1303
|
+
const typed = activeTypedViewer();
|
|
1304
|
+
if (typed && typeof typed.zoom === "function") {
|
|
1305
|
+
typed.zoom(factor);
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
const nextWidth = view.width * factor;
|
|
1309
|
+
const nextHeight = view.height * factor;
|
|
1310
|
+
view.x += (view.width - nextWidth) / 2;
|
|
1311
|
+
view.y += (view.height - nextHeight) / 2;
|
|
1312
|
+
view.width = nextWidth;
|
|
1313
|
+
view.height = nextHeight;
|
|
1314
|
+
updateViewBox();
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function fitView() {
|
|
1318
|
+
const typed = activeTypedViewer();
|
|
1319
|
+
if (typed && typeof typed.fitView === "function") {
|
|
1320
|
+
typed.fitView();
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
view = canvasContentBounds();
|
|
1324
|
+
updateViewBox();
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function expandView() {
|
|
1328
|
+
const typed = activeTypedViewer();
|
|
1329
|
+
if (typed && typeof typed.expandAll === "function") {
|
|
1330
|
+
typed.expandAll();
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
document.getElementById("resetView").addEventListener("click", () => {
|
|
1335
|
+
const typed = activeTypedViewer();
|
|
1336
|
+
if (typed && typeof typed.resetView === "function") {
|
|
1337
|
+
typed.resetView();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
if (codeDebrief.mode === "flow") {
|
|
1341
|
+
if (!activeFlow) return;
|
|
1342
|
+
manualPositions.delete(activeFlow.id); // discard hand-placed positions, re-layout
|
|
1343
|
+
renderFlow(activeFlow);
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
document.getElementById("expandView").addEventListener("click", expandView);
|
|
1347
|
+
document.getElementById("fitView").addEventListener("click", fitView);
|
|
1348
|
+
document.getElementById("zoomOut").addEventListener("click", () => zoom(1.22));
|
|
1349
|
+
document.getElementById("zoomIn").addEventListener("click", () => zoom(.82));
|
|
1350
|
+
if (exportPngButton) {
|
|
1351
|
+
exportPngButton.addEventListener("click", () => exportCurrentCanvas("png"));
|
|
1352
|
+
}
|
|
1353
|
+
if (exportJpgButton) {
|
|
1354
|
+
exportJpgButton.addEventListener("click", () => exportCurrentCanvas("jpg"));
|
|
1355
|
+
}
|
|
1356
|
+
if (menuButton) {
|
|
1357
|
+
menuButton.addEventListener("click", () => setLeftRailOpen(!leftRailOpen()));
|
|
1358
|
+
}
|
|
1359
|
+
if (detailButton) {
|
|
1360
|
+
detailButton.addEventListener("click", () => setRightRailOpen(!rightRailOpen()));
|
|
1361
|
+
}
|
|
1362
|
+
if (detailsClose) {
|
|
1363
|
+
detailsClose.addEventListener("click", () => setRightRailOpen(false));
|
|
1364
|
+
}
|
|
1365
|
+
document.addEventListener("keydown", event => {
|
|
1366
|
+
if (event.key !== "Escape" || eventTargetIsTextInput(event)) return;
|
|
1367
|
+
if (rightRailOpen()) {
|
|
1368
|
+
setRightRailOpen(false);
|
|
1369
|
+
event.stopImmediatePropagation();
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
if (leftRailOpen()) {
|
|
1373
|
+
setLeftRailOpen(false);
|
|
1374
|
+
event.stopImmediatePropagation();
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
svg.addEventListener("wheel", event => {
|
|
1379
|
+
event.preventDefault();
|
|
1380
|
+
zoom(event.deltaY > 0 ? 1.08 : .92);
|
|
1381
|
+
}, { passive: false });
|
|
1382
|
+
if (typedViewerHost) {
|
|
1383
|
+
typedViewerHost.addEventListener("wheel", event => {
|
|
1384
|
+
const typed = activeTypedViewer();
|
|
1385
|
+
if (!typed || typeof typed.zoom !== "function") return;
|
|
1386
|
+
event.preventDefault();
|
|
1387
|
+
typed.zoom(event.deltaY > 0 ? 1.08 : .92);
|
|
1388
|
+
}, { passive: false });
|
|
1389
|
+
}
|
|
1390
|
+
svg.addEventListener("pointerdown", activateDecisionEdgeRecord, true);
|
|
1391
|
+
svg.addEventListener("mousedown", activateDecisionEdgeRecord, true);
|
|
1392
|
+
svg.addEventListener("click", activateDecisionEdgeRecord, true);
|
|
1393
|
+
document.addEventListener("pointerdown", activateDecisionEdgeRecord, true);
|
|
1394
|
+
document.addEventListener("mousedown", activateDecisionEdgeRecord, true);
|
|
1395
|
+
document.addEventListener("click", activateDecisionEdgeRecord, true);
|
|
1396
|
+
svg.addEventListener("keydown", event => {
|
|
1397
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1398
|
+
const record = decisionEdgeRecordFromElement(event.target);
|
|
1399
|
+
if (!record) return;
|
|
1400
|
+
event.preventDefault();
|
|
1401
|
+
activateDecisionEdgeRecord(event);
|
|
1402
|
+
}, true);
|
|
1403
|
+
document.addEventListener("keydown", event => {
|
|
1404
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1405
|
+
const record = decisionEdgeRecordFromElement(event.target);
|
|
1406
|
+
if (!record) return;
|
|
1407
|
+
event.preventDefault();
|
|
1408
|
+
activateDecisionEdgeRecord(event);
|
|
1409
|
+
}, true);
|
|
1410
|
+
svg.addEventListener("pointerdown", event => {
|
|
1411
|
+
if (event.button !== 0) return;
|
|
1412
|
+
// Pan only from the empty canvas background. If the press lands on an interactive
|
|
1413
|
+
// node group (scope, flow, decision block all carry role="button"), do not start a pan or
|
|
1414
|
+
// capture the pointer, so the node's own click handler fires (expand/toggle).
|
|
1415
|
+
if (event.target.closest('[role="button"], .edge-hit, .edge-hit-segment, .edge-label-wrap')) return;
|
|
1416
|
+
drag = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y, moved: 0 };
|
|
1417
|
+
svg.classList.add("dragging");
|
|
1418
|
+
svg.setPointerCapture(event.pointerId);
|
|
1419
|
+
});
|
|
1420
|
+
svg.addEventListener("pointermove", event => {
|
|
1421
|
+
if (!drag) return;
|
|
1422
|
+
const scaleX = view.width / svg.clientWidth;
|
|
1423
|
+
const scaleY = view.height / svg.clientHeight;
|
|
1424
|
+
drag.moved = Math.max(
|
|
1425
|
+
drag.moved,
|
|
1426
|
+
Math.abs(event.clientX - drag.x) + Math.abs(event.clientY - drag.y)
|
|
1427
|
+
);
|
|
1428
|
+
view.x = drag.vx - (event.clientX - drag.x) * scaleX;
|
|
1429
|
+
view.y = drag.vy - (event.clientY - drag.y) * scaleY;
|
|
1430
|
+
updateViewBox();
|
|
1431
|
+
});
|
|
1432
|
+
svg.addEventListener("pointerup", () => {
|
|
1433
|
+
if (drag && drag.moved < 4) {
|
|
1434
|
+
if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
|
|
1435
|
+
clearHighlight();
|
|
1436
|
+
}
|
|
1437
|
+
drag = null;
|
|
1438
|
+
svg.classList.remove("dragging");
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
document.documentElement.dataset.theme = "dark";
|
|
1442
|
+
|
|
1443
|
+
// Expose flow/scope selection so the directory tree and details panel can drive the
|
|
1444
|
+
// official React chart without knowing about its implementation.
|
|
1445
|
+
codeDebrief.selectFlow = selectFlow;
|
|
1446
|
+
codeDebrief.selectScope = scope => {
|
|
1447
|
+
const typed = activeTypedViewer();
|
|
1448
|
+
setHeaderScope(scope);
|
|
1449
|
+
if (typed && typeof typed.selectScope === "function") {
|
|
1450
|
+
typed.selectScope(scope);
|
|
1451
|
+
} else {
|
|
1452
|
+
location.hash = "scope=" + encodeURIComponent(scope);
|
|
1453
|
+
codeDebrief.select({
|
|
1454
|
+
edgeId: null,
|
|
1455
|
+
endLine: null,
|
|
1456
|
+
flowId: null,
|
|
1457
|
+
line: null,
|
|
1458
|
+
nodeId: null,
|
|
1459
|
+
path: null,
|
|
1460
|
+
scope,
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
codeDebrief.resetGraph = () => {
|
|
1465
|
+
const typed = activeTypedViewer();
|
|
1466
|
+
if (typed && typeof typed.resetView === "function") typed.resetView();
|
|
1467
|
+
else {
|
|
1468
|
+
location.hash = "root";
|
|
1469
|
+
setHeaderRoot();
|
|
1470
|
+
codeDebrief.select({
|
|
1471
|
+
edgeId: null,
|
|
1472
|
+
endLine: null,
|
|
1473
|
+
flowId: null,
|
|
1474
|
+
line: null,
|
|
1475
|
+
nodeId: null,
|
|
1476
|
+
path: null,
|
|
1477
|
+
scope: null,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
codeDebrief.activeFlowId = () => activeFlow?.id || null;
|
|
1482
|
+
|
|
1483
|
+
// Viewport primitives retained for shared shell helpers.
|
|
1484
|
+
codeDebrief.renderFlow = renderFlow;
|
|
1485
|
+
codeDebrief.svg = svg;
|
|
1486
|
+
codeDebrief.openDetails = () => setRightRailOpen(true);
|
|
1487
|
+
codeDebrief.setCanvasLevel = setCanvasLevel;
|
|
1488
|
+
codeDebrief.setView = v => { view = v; updateViewBox(); };
|
|
1489
|
+
codeDebrief.updateViewBox = updateViewBox;
|
|
1490
|
+
codeDebrief.getView = () => view;
|
|
1491
|
+
// Shared decision-render helpers retained for source/export compatibility.
|
|
1492
|
+
codeDebrief.drawFlowGraph = drawFlowGraph;
|
|
1493
|
+
codeDebrief.flowDefs = flowDefs;
|
|
1494
|
+
codeDebrief.setCurrentRender = setCurrentRender;
|
|
1495
|
+
codeDebrief.inspectFlow = inspectFlow;
|
|
1496
|
+
codeDebrief.inspectNode = inspectNode;
|
|
1497
|
+
// Highlight surface panels.js reuses (one accent path, never duplicated): when a source
|
|
1498
|
+
// row resolves to a node, light up that node on the active decision graph exactly as a
|
|
1499
|
+
// direct block click would, without rebuilding the inspector.
|
|
1500
|
+
codeDebrief.highlightNode = highlightNode;
|
|
1501
|
+
codeDebrief.clearHighlight = clearHighlight;
|
|
1502
|
+
// Resolve a node object by (flowId, nodeId) so a source-line click can recover
|
|
1503
|
+
// the FlowNode without panels.js re-walking the model.
|
|
1504
|
+
codeDebrief.nodeById = (flowId, nodeId) => {
|
|
1505
|
+
const flow = byId.get(flowId);
|
|
1506
|
+
if (!flow || !nodeId) return null;
|
|
1507
|
+
return flow.nodes.find(n => n.id === nodeId) || null;
|
|
1508
|
+
};
|
|
1509
|
+
// Origin-relative bounds of a flow's decision layout. layoutFlow returns bounds over
|
|
1510
|
+
// node CENTERS only; inflate by the node half-extents so callers reserve the visual
|
|
1511
|
+
// footprint, not just center points.
|
|
1512
|
+
codeDebrief.measureFlow = (flow, opts) => {
|
|
1513
|
+
if (!flow || !flow.nodes || !flow.nodes.length) {
|
|
1514
|
+
return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0 };
|
|
1515
|
+
}
|
|
1516
|
+
const { bounds, nodes } = layoutFlow(flow, opts || {});
|
|
1517
|
+
if (!nodes.length) {
|
|
1518
|
+
return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0 };
|
|
1519
|
+
}
|
|
1520
|
+
const inflated = {
|
|
1521
|
+
minX: bounds.minX - FLOW_NODE_HALF_W,
|
|
1522
|
+
maxX: bounds.maxX + FLOW_NODE_HALF_W,
|
|
1523
|
+
minY: bounds.minY - FLOW_NODE_HALF_H,
|
|
1524
|
+
maxY: bounds.maxY + FLOW_NODE_HALF_H,
|
|
1525
|
+
};
|
|
1526
|
+
return {
|
|
1527
|
+
...inflated,
|
|
1528
|
+
width: inflated.maxX - inflated.minX,
|
|
1529
|
+
height: inflated.maxY - inflated.minY,
|
|
1530
|
+
};
|
|
1531
|
+
};
|
|
1532
|
+
// Drop a flow's hand-placed decision-node positions, so the canvas reset (0) restores
|
|
1533
|
+
// the automatic layout of an inline-expanded sub-graph just like it does full screen.
|
|
1534
|
+
codeDebrief.clearFlowPositions = id => {
|
|
1535
|
+
if (id == null) manualPositions.clear();
|
|
1536
|
+
else manualPositions.delete(id);
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
// Reconcile the CANVAS block highlight from the shared selection. A node selected on
|
|
1540
|
+
// ANY surface (a source line or tree row) lights up its block here, on whatever
|
|
1541
|
+
// decision graph is currently drawn -- the same single highlight path a direct block
|
|
1542
|
+
// click uses. Guarded by currentRender: when no decision graph is on screen (L0, or a
|
|
1543
|
+
// flow whose decisions are not expanded) there is no block to light, and that is fine
|
|
1544
|
+
// (the source/tree highlights still apply via panels.js / tree.js). This keeps
|
|
1545
|
+
// ONE accent path for the block instead of duplicating highlightNode in panels.js.
|
|
1546
|
+
codeDebrief.onSelection(sel => {
|
|
1547
|
+
if (!currentRender) return;
|
|
1548
|
+
if (sel.edgeId) {
|
|
1549
|
+
const record = currentRender.edgeRecords.find(item =>
|
|
1550
|
+
(item.edge.id || `${item.edge.source}->${item.edge.target}`) === sel.edgeId
|
|
1551
|
+
);
|
|
1552
|
+
if (record) highlightEdge(record);
|
|
1553
|
+
else clearHighlight();
|
|
1554
|
+
} else if (sel.nodeId && currentRender.nodeGroups.has(sel.nodeId)) {
|
|
1555
|
+
highlightNode(sel.nodeId);
|
|
1556
|
+
} else if (!sel.nodeId) {
|
|
1557
|
+
clearHighlight();
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// Keep the HTML shell in sync with the hash the React runtime owns. This deliberately
|
|
1562
|
+
// does not render canvas content; it only updates header/tree/source state.
|
|
1563
|
+
function syncShellFromHash() {
|
|
1564
|
+
const raw = location.hash.slice(1);
|
|
1565
|
+
const eq = raw.indexOf("=");
|
|
1566
|
+
const scopes = model.scopes || {};
|
|
1567
|
+
if (eq !== -1) {
|
|
1568
|
+
const key = raw.slice(0, eq);
|
|
1569
|
+
const value = safeDecodeHashValue(raw.slice(eq + 1));
|
|
1570
|
+
if (value == null) { setHeaderRoot(); return; }
|
|
1571
|
+
if (key === "flow" && byId.has(value)) {
|
|
1572
|
+
const flow = byId.get(value);
|
|
1573
|
+
setHeaderFlow(flow);
|
|
1574
|
+
codeDebrief.select(selectionForFlow(flow));
|
|
1575
|
+
if (codeDebrief.openDetails) codeDebrief.openDetails();
|
|
1576
|
+
if (window.CodeDebrief.onFlowSelected) window.CodeDebrief.onFlowSelected(flow);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (key === "scope" && Object.prototype.hasOwnProperty.call(scopes, value)) {
|
|
1580
|
+
setHeaderScope(value);
|
|
1581
|
+
codeDebrief.select({
|
|
1582
|
+
edgeId: null,
|
|
1583
|
+
endLine: null,
|
|
1584
|
+
flowId: null,
|
|
1585
|
+
line: null,
|
|
1586
|
+
nodeId: null,
|
|
1587
|
+
path: null,
|
|
1588
|
+
scope: value,
|
|
1589
|
+
});
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (key === "path" && value) {
|
|
1593
|
+
const scope = value.split("/").filter(Boolean)[0] || null;
|
|
1594
|
+
if (scope) setHeaderScope(scope);
|
|
1595
|
+
codeDebrief.select({
|
|
1596
|
+
edgeId: null,
|
|
1597
|
+
endLine: null,
|
|
1598
|
+
flowId: null,
|
|
1599
|
+
line: null,
|
|
1600
|
+
nodeId: null,
|
|
1601
|
+
path: value,
|
|
1602
|
+
scope,
|
|
1603
|
+
});
|
|
1604
|
+
if (codeDebrief.openDetails) codeDebrief.openDetails();
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
if (key === "edge") return;
|
|
1608
|
+
if (key === "node" && value === "codebase") {
|
|
1609
|
+
setHeaderRoot();
|
|
1610
|
+
codeDebrief.select({
|
|
1611
|
+
edgeId: null,
|
|
1612
|
+
endLine: null,
|
|
1613
|
+
flowId: null,
|
|
1614
|
+
line: null,
|
|
1615
|
+
nodeId: null,
|
|
1616
|
+
path: null,
|
|
1617
|
+
scope: null,
|
|
1618
|
+
});
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
} else if (raw) {
|
|
1622
|
+
const decoded = safeDecodeHashValue(raw);
|
|
1623
|
+
if (decoded == null) { setHeaderRoot(); return; }
|
|
1624
|
+
if (byId.has(decoded)) {
|
|
1625
|
+
const flow = byId.get(decoded);
|
|
1626
|
+
setHeaderFlow(flow);
|
|
1627
|
+
codeDebrief.select(selectionForFlow(flow));
|
|
1628
|
+
if (codeDebrief.openDetails) codeDebrief.openDetails();
|
|
1629
|
+
if (window.CodeDebrief.onFlowSelected) window.CodeDebrief.onFlowSelected(flow);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (decoded === "root") {
|
|
1633
|
+
setHeaderRoot();
|
|
1634
|
+
codeDebrief.select({
|
|
1635
|
+
edgeId: null,
|
|
1636
|
+
endLine: null,
|
|
1637
|
+
flowId: null,
|
|
1638
|
+
line: null,
|
|
1639
|
+
nodeId: null,
|
|
1640
|
+
path: null,
|
|
1641
|
+
scope: null,
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
if (!raw) setHeaderRoot();
|
|
1646
|
+
}
|
|
1647
|
+
codeDebrief.syncShellFromHash = syncShellFromHash;
|
|
1648
|
+
window.addEventListener("hashchange", syncShellFromHash);
|
|
1649
|
+
syncShellFromHash();
|