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,616 @@
|
|
|
1
|
+
|
|
2
|
+
// Directory tree for the left rail. Reads the nested {name, path, type, children,
|
|
3
|
+
// flow_ids} tree the payload builds (payload.tree) and the codeDebrief surface shell.js
|
|
4
|
+
// exposes (selectFlow / sortFlows / byId). Folders are collapsible rows; file leaves
|
|
5
|
+
// expand to list their flows, each clickable to select that flow in the canvas -
|
|
6
|
+
// exactly as the old flat flow list did.
|
|
7
|
+
//
|
|
8
|
+
// Accessibility: the container is a WAI-ARIA tree (role="tree"); every dir/file/flow
|
|
9
|
+
// row is a role="treeitem" with aria-level and (when expandable) aria-expanded, and
|
|
10
|
+
// each row's children sit in a role="group". Keyboard follows the tree pattern with a
|
|
11
|
+
// roving tabindex (exactly one row is tabbable at a time).
|
|
12
|
+
(function () {
|
|
13
|
+
const codeDebrief = window.CodeDebrief || {};
|
|
14
|
+
const model = codeDebrief.model || {};
|
|
15
|
+
const fullTree = model.tree;
|
|
16
|
+
const byId = codeDebrief.byId || new Map();
|
|
17
|
+
const treeEl = document.getElementById("tree");
|
|
18
|
+
const langFilterEl = document.getElementById("langFilter");
|
|
19
|
+
const searchEl = document.getElementById("globalSearch");
|
|
20
|
+
if (!treeEl || !fullTree) return;
|
|
21
|
+
|
|
22
|
+
const sortFlows = codeDebrief.sortFlows || (list => [...list]);
|
|
23
|
+
// The languages dropdown only appears for polyglot repos (>1 language), mirroring
|
|
24
|
+
// the old rail's visibility rule. "" means "All languages".
|
|
25
|
+
const languages = Array.isArray(model.languages) ? model.languages : [];
|
|
26
|
+
const scopeNames = new Set(Object.keys(model.scopes || {}));
|
|
27
|
+
let activeLang = "";
|
|
28
|
+
let activeQuery = "";
|
|
29
|
+
|
|
30
|
+
// Per-render lookup maps, rebuilt every time the tree is (re)rendered so a language
|
|
31
|
+
// change cleanly replaces them.
|
|
32
|
+
let dirRows = new Map(); // path -> dir <button>
|
|
33
|
+
let fileRows = new Map(); // path -> file <button>
|
|
34
|
+
let flowRows = new Map(); // flowId -> flow <button>
|
|
35
|
+
let childContainers = new Map(); // path -> children <div>
|
|
36
|
+
let lastActiveFlowId = codeDebrief.activeFlowId ? codeDebrief.activeFlowId() : null;
|
|
37
|
+
let suppressScopeFocus = false;
|
|
38
|
+
|
|
39
|
+
function svgFolderIcon(open) {
|
|
40
|
+
const ns = "http://www.w3.org/2000/svg";
|
|
41
|
+
const svg = document.createElementNS(ns, "svg");
|
|
42
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
43
|
+
svg.setAttribute("class", "tree-caret" + (open ? " open" : ""));
|
|
44
|
+
svg.setAttribute("aria-hidden", "true");
|
|
45
|
+
const path = document.createElementNS(ns, "path");
|
|
46
|
+
path.setAttribute("d", "M6 4l4 4-4 4");
|
|
47
|
+
svg.appendChild(path);
|
|
48
|
+
return svg;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeRow(className, depth) {
|
|
52
|
+
const row = document.createElement("button");
|
|
53
|
+
row.type = "button";
|
|
54
|
+
row.className = className;
|
|
55
|
+
row.style.setProperty("--depth", String(depth));
|
|
56
|
+
row.setAttribute("role", "treeitem");
|
|
57
|
+
row.setAttribute("aria-level", String(depth + 1));
|
|
58
|
+
// Roving tabindex: every row starts untabbable; one is promoted after render.
|
|
59
|
+
row.tabIndex = -1;
|
|
60
|
+
return row;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function flowRole(flow) {
|
|
64
|
+
return flow.is_entrypoint ? "ENTRY" : "INTERNAL";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function flowRoleClass(flow) {
|
|
68
|
+
return flow.is_entrypoint ? "entry" : "subflow";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function flowKindLabel(flow) {
|
|
72
|
+
const kind = String(flow.entry_kind || "flow").replace(/[_-]+/g, " ").trim();
|
|
73
|
+
if (!kind) return "Flow";
|
|
74
|
+
return kind
|
|
75
|
+
.split(/\s+/)
|
|
76
|
+
.map((part, index) =>
|
|
77
|
+
index === 0
|
|
78
|
+
? part.slice(0, 1).toUpperCase() + part.slice(1).toLowerCase()
|
|
79
|
+
: part.toLowerCase()
|
|
80
|
+
)
|
|
81
|
+
.join(" ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function flowDisplayName(flow) {
|
|
85
|
+
const name = String(flow.name || flow.symbol || flow.id || "flow");
|
|
86
|
+
const kind = String(flow.entry_kind || "").toLowerCase();
|
|
87
|
+
const httpMethod = name.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/i);
|
|
88
|
+
if (kind.includes("route") && httpMethod) {
|
|
89
|
+
return `${httpMethod[1].toUpperCase()} route`;
|
|
90
|
+
}
|
|
91
|
+
if (isIdentifierLike(name) && /[A-Z_]/.test(name)) {
|
|
92
|
+
return humanizeIdentifier(name);
|
|
93
|
+
}
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isIdentifierLike(value) {
|
|
98
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function humanizeIdentifier(value) {
|
|
102
|
+
const words = value
|
|
103
|
+
.replace(/^[_$]+|[_$]+$/g, "")
|
|
104
|
+
.replace(/[_$]+/g, " ")
|
|
105
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
106
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
107
|
+
.trim()
|
|
108
|
+
.split(/\s+/)
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
if (!words.length) return value;
|
|
111
|
+
return words
|
|
112
|
+
.map((word, index) => {
|
|
113
|
+
if (/^[A-Z0-9]{2,}$/.test(word)) return word;
|
|
114
|
+
const lower = word.toLowerCase();
|
|
115
|
+
return index === 0 ? lower.slice(0, 1).toUpperCase() + lower.slice(1) : lower;
|
|
116
|
+
})
|
|
117
|
+
.join(" ");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function flowSourceLabel(flow) {
|
|
121
|
+
const location = flow.location || {};
|
|
122
|
+
const path = location.path ? String(location.path) : "";
|
|
123
|
+
if (!path) return "";
|
|
124
|
+
const line = location.start_line ? `:${location.start_line}` : "";
|
|
125
|
+
const parts = path.split("/").filter(Boolean);
|
|
126
|
+
const shortPath = parts.length > 2 ? parts.slice(-2).join("/") : path;
|
|
127
|
+
return `${shortPath}${line}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function treePathLabel(node) {
|
|
131
|
+
if (!node || node.type !== "file") return node ? node.name : "";
|
|
132
|
+
const segments = String(node.path || "").split("/").filter(Boolean);
|
|
133
|
+
if (segments.length <= 2) return segments.join("/") || node.name;
|
|
134
|
+
const generic = /^(index|route|page|layout|handler|main)\.[^.]+$/i.test(node.name);
|
|
135
|
+
return generic ? segments.slice(-2).join("/") : node.name;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Resolve a file node's flow ids to flows, pruned to the active language.
|
|
139
|
+
function flowsForFile(file) {
|
|
140
|
+
const flows = (file.flow_ids || []).map(id => byId.get(id)).filter(Boolean);
|
|
141
|
+
const byLanguage = activeLang ? flows.filter(f => f.language === activeLang) : flows;
|
|
142
|
+
const visible = activeQuery ? byLanguage.filter(flowMatchesQuery) : byLanguage;
|
|
143
|
+
return sortFlows(visible);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function flowMatchesQuery(flow) {
|
|
147
|
+
if (!activeQuery) return true;
|
|
148
|
+
const scope = flow.metadata && Array.isArray(flow.metadata.scope)
|
|
149
|
+
? flow.metadata.scope.join(" ")
|
|
150
|
+
: "";
|
|
151
|
+
const haystack = [
|
|
152
|
+
flow.name,
|
|
153
|
+
flow.symbol,
|
|
154
|
+
flow.language,
|
|
155
|
+
flow.framework,
|
|
156
|
+
flow.entry_kind,
|
|
157
|
+
scope,
|
|
158
|
+
flow.location && flow.location.path,
|
|
159
|
+
].join(" ").toLowerCase();
|
|
160
|
+
return activeQuery.split(/\s+/).every(term => haystack.includes(term));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Whether a node has at least one flow that survives the active-language filter.
|
|
164
|
+
// A dir survives if any descendant file does.
|
|
165
|
+
function nodeHasVisibleFlows(node) {
|
|
166
|
+
if (node.type === "file") return flowsForFile(node).length > 0;
|
|
167
|
+
return (node.children || []).some(nodeHasVisibleFlows);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Render a file's flows as indented rows beneath it.
|
|
171
|
+
function renderFlows(file, container, depth) {
|
|
172
|
+
const flows = flowsForFile(file);
|
|
173
|
+
flows.forEach(flow => {
|
|
174
|
+
const row = makeRow("tree-flow", depth);
|
|
175
|
+
row.setAttribute("data-flow-id", flow.id);
|
|
176
|
+
row.setAttribute("data-path", file.path);
|
|
177
|
+
const title = document.createElement("span");
|
|
178
|
+
title.className = "tree-flow-title";
|
|
179
|
+
const name = document.createElement("strong");
|
|
180
|
+
name.textContent = flowDisplayName(flow);
|
|
181
|
+
const source = document.createElement("span");
|
|
182
|
+
source.className = "tree-flow-source";
|
|
183
|
+
source.textContent = flowSourceLabel(flow);
|
|
184
|
+
if (source.textContent) title.append(name, source);
|
|
185
|
+
else title.appendChild(name);
|
|
186
|
+
const fullSource = flow.location?.path
|
|
187
|
+
? `${flow.location.path}${flow.location.start_line ? `:${flow.location.start_line}` : ""}`
|
|
188
|
+
: "";
|
|
189
|
+
row.title = [
|
|
190
|
+
flow.name || flow.id,
|
|
191
|
+
flowKindLabel(flow),
|
|
192
|
+
flow.language,
|
|
193
|
+
fullSource,
|
|
194
|
+
].filter(Boolean).join(" · ");
|
|
195
|
+
row.setAttribute(
|
|
196
|
+
"aria-label",
|
|
197
|
+
`Open ${flowDisplayName(flow)} in the progressive flowchart`
|
|
198
|
+
);
|
|
199
|
+
const badges = document.createElement("span");
|
|
200
|
+
badges.className = "tree-flow-badges";
|
|
201
|
+
const role = document.createElement("span");
|
|
202
|
+
role.className = "tree-flow-badge role-" + flowRoleClass(flow);
|
|
203
|
+
role.textContent = flowRole(flow);
|
|
204
|
+
const kind = document.createElement("span");
|
|
205
|
+
kind.className = "tree-flow-badge";
|
|
206
|
+
kind.textContent = flowKindLabel(flow);
|
|
207
|
+
badges.append(role, kind);
|
|
208
|
+
row.append(title, badges);
|
|
209
|
+
row.addEventListener("click", () => {
|
|
210
|
+
if (codeDebrief.selectFlow) codeDebrief.selectFlow(flow.id);
|
|
211
|
+
});
|
|
212
|
+
flowRows.set(flow.id, row);
|
|
213
|
+
container.appendChild(row);
|
|
214
|
+
});
|
|
215
|
+
if (!flows.length) {
|
|
216
|
+
const empty = makeRow("tree-flow tree-empty", depth);
|
|
217
|
+
empty.disabled = true;
|
|
218
|
+
empty.textContent = "No flows";
|
|
219
|
+
container.appendChild(empty);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderNode(node, parent, depth) {
|
|
224
|
+
// Skip subtrees with nothing to show under the active language filter.
|
|
225
|
+
if (!nodeHasVisibleFlows(node)) return;
|
|
226
|
+
|
|
227
|
+
const isDir = node.type === "dir";
|
|
228
|
+
const row = makeRow(isDir ? "tree-dir" : "tree-file", depth);
|
|
229
|
+
row.setAttribute("data-path", node.path);
|
|
230
|
+
row.setAttribute("aria-expanded", "false");
|
|
231
|
+
const caret = svgFolderIcon(false);
|
|
232
|
+
const label = document.createElement("span");
|
|
233
|
+
label.className = "tree-label";
|
|
234
|
+
label.textContent = treePathLabel(node);
|
|
235
|
+
row.title = node.type === "dir"
|
|
236
|
+
? `Expand ${node.path || node.name} and focus it on the flowchart`
|
|
237
|
+
: `Expand ${node.path || node.name} and show its flows`;
|
|
238
|
+
row.setAttribute("aria-label", row.title);
|
|
239
|
+
if (!isDir && label.textContent !== node.path) label.title = node.path;
|
|
240
|
+
row.append(caret, label);
|
|
241
|
+
|
|
242
|
+
if (!isDir) {
|
|
243
|
+
const count = flowsForFile(node).length;
|
|
244
|
+
if (count) {
|
|
245
|
+
const badge = document.createElement("span");
|
|
246
|
+
badge.className = "tree-count";
|
|
247
|
+
badge.textContent = String(count);
|
|
248
|
+
row.appendChild(badge);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const children = document.createElement("div");
|
|
253
|
+
children.className = "tree-children";
|
|
254
|
+
children.setAttribute("role", "group");
|
|
255
|
+
children.hidden = true;
|
|
256
|
+
childContainers.set(node.path, children);
|
|
257
|
+
|
|
258
|
+
row.addEventListener("click", () => toggle(node, row, children, depth));
|
|
259
|
+
if (isDir) dirRows.set(node.path, row);
|
|
260
|
+
else fileRows.set(node.path, row);
|
|
261
|
+
parent.append(row, children);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Lazily build a node's children the first time it is opened, then toggle.
|
|
265
|
+
function toggle(node, row, children, depth) {
|
|
266
|
+
const open = children.hidden;
|
|
267
|
+
if (open && !children.dataset.built) {
|
|
268
|
+
if (node.type === "dir") {
|
|
269
|
+
node.children.forEach(child => renderNode(child, children, depth + 1));
|
|
270
|
+
} else {
|
|
271
|
+
renderFlows(node, children, depth + 1);
|
|
272
|
+
}
|
|
273
|
+
children.dataset.built = "1";
|
|
274
|
+
}
|
|
275
|
+
children.hidden = !open;
|
|
276
|
+
row.setAttribute("aria-expanded", String(open));
|
|
277
|
+
const caret = row.querySelector(".tree-caret");
|
|
278
|
+
if (caret) caret.classList.toggle("open", open);
|
|
279
|
+
// Opening any tree path focuses the same area on the canvas. Top-level scopes use
|
|
280
|
+
// the scope route; nested folders/files use the path route so the canvas can
|
|
281
|
+
// highlight that sub-area while keeping the global map visible.
|
|
282
|
+
if (open && !suppressScopeFocus) {
|
|
283
|
+
if (codeDebrief.focusPath) {
|
|
284
|
+
codeDebrief.focusPath(node.path);
|
|
285
|
+
} else if (node.type === "dir" && scopeNames.has(node.path) && codeDebrief.focusScope) {
|
|
286
|
+
codeDebrief.focusScope(node.path);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function cssEscape(value) {
|
|
292
|
+
return window.CSS && CSS.escape ? CSS.escape(value) : value.replace(/["\\]/g, "\\$&");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The dir/file row for a path (never a flow row, which shares its file's path).
|
|
296
|
+
function structureRow(path) {
|
|
297
|
+
return treeEl.querySelector(
|
|
298
|
+
`.tree-dir[data-path="${cssEscape(path)}"], .tree-file[data-path="${cssEscape(path)}"]`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Open every ancestor folder + the file of a path so its flow row is visible.
|
|
303
|
+
function revealPath(path) {
|
|
304
|
+
const segments = path.split("/");
|
|
305
|
+
let prefix = "";
|
|
306
|
+
segments.forEach(segment => {
|
|
307
|
+
prefix = prefix ? `${prefix}/${segment}` : segment;
|
|
308
|
+
const children = childContainers.get(prefix);
|
|
309
|
+
const row = structureRow(prefix);
|
|
310
|
+
if (children && row && children.hidden) {
|
|
311
|
+
suppressScopeFocus = true;
|
|
312
|
+
try {
|
|
313
|
+
row.click();
|
|
314
|
+
} finally {
|
|
315
|
+
suppressScopeFocus = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function highlightActive(flowId) {
|
|
322
|
+
flowRows.forEach((row, id) => row.classList.toggle("active", id === flowId));
|
|
323
|
+
dirRows.forEach(row => row.classList.remove("active-folder"));
|
|
324
|
+
fileRows.forEach(row => row.classList.remove("active-file", "active-parent"));
|
|
325
|
+
const flow = byId.get(flowId);
|
|
326
|
+
if (flow) {
|
|
327
|
+
const fileRow = fileRows.get(flow.location.path);
|
|
328
|
+
if (fileRow) fileRow.classList.add("active-parent");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function highlightPath(path) {
|
|
333
|
+
flowRows.forEach(row => row.classList.remove("active"));
|
|
334
|
+
dirRows.forEach(row => row.classList.remove("active-folder"));
|
|
335
|
+
fileRows.forEach(row => row.classList.remove("active-file", "active-parent"));
|
|
336
|
+
if (!path) return;
|
|
337
|
+
revealPath(path);
|
|
338
|
+
const row = structureRow(path);
|
|
339
|
+
if (row) {
|
|
340
|
+
row.classList.toggle("active-folder", row.classList.contains("tree-dir"));
|
|
341
|
+
row.classList.toggle("active-file", row.classList.contains("tree-file"));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function setLanguageFilterAvailability(sel) {
|
|
346
|
+
if (!langFilterEl || languages.length <= 1) return;
|
|
347
|
+
const locked = !!(sel && sel.flowId);
|
|
348
|
+
langFilterEl.disabled = locked;
|
|
349
|
+
langFilterEl.title = locked
|
|
350
|
+
? "Return to the codebase or scope level to change language"
|
|
351
|
+
: "";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function clearCanvasSelectionForLanguageFilter() {
|
|
355
|
+
const sel = codeDebrief.selection || {};
|
|
356
|
+
if (!(sel.flowId || sel.nodeId || sel.path || sel.scope)) return;
|
|
357
|
+
const scope = sel.scope || (sel.path ? sel.path.split("/").filter(Boolean)[0] : null);
|
|
358
|
+
lastActiveFlowId = null;
|
|
359
|
+
highlightActive(null);
|
|
360
|
+
if (scope && codeDebrief.selectScope) {
|
|
361
|
+
codeDebrief.selectScope(scope);
|
|
362
|
+
} else if (codeDebrief.resetGraph) {
|
|
363
|
+
codeDebrief.resetGraph();
|
|
364
|
+
} else if (codeDebrief.select) {
|
|
365
|
+
codeDebrief.select({ scope: null, path: null, flowId: null, nodeId: null });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function resetLanguageFilterForFlow(flow) {
|
|
370
|
+
if (!langFilterEl || !flow || !activeLang || flow.language === activeLang) return;
|
|
371
|
+
activeLang = "";
|
|
372
|
+
langFilterEl.value = "";
|
|
373
|
+
render();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- Roving tabindex + keyboard navigation (WAI-ARIA tree pattern) --------------
|
|
377
|
+
|
|
378
|
+
// Every currently rendered + visible (no hidden ancestor) row, in DOM order.
|
|
379
|
+
function visibleRows() {
|
|
380
|
+
return [...treeEl.querySelectorAll(".tree-dir, .tree-file, .tree-flow")].filter(
|
|
381
|
+
row => row.offsetParent !== null
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Promote exactly one row to tabindex=0; the rest are -1.
|
|
386
|
+
function setRovingTarget(row) {
|
|
387
|
+
treeEl.querySelectorAll('[role="treeitem"]').forEach(r => {
|
|
388
|
+
r.tabIndex = r === row ? 0 : -1;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// After any (re)render or expand/collapse, keep a single tabbable row. Prefer the
|
|
393
|
+
// active flow's row, else the active file, else the first visible row.
|
|
394
|
+
function refreshRovingTarget() {
|
|
395
|
+
const rows = visibleRows();
|
|
396
|
+
if (!rows.length) return;
|
|
397
|
+
let target =
|
|
398
|
+
(lastActiveFlowId && flowRows.get(lastActiveFlowId)) ||
|
|
399
|
+
treeEl.querySelector(".tree-dir.active-folder") ||
|
|
400
|
+
treeEl.querySelector(".tree-file.active-file") ||
|
|
401
|
+
treeEl.querySelector(".tree-file.active-parent");
|
|
402
|
+
if (!target || target.offsetParent === null) target = rows[0];
|
|
403
|
+
setRovingTarget(target);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isExpandable(row) {
|
|
407
|
+
return row.hasAttribute("aria-expanded") && !row.classList.contains("tree-empty");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isExpanded(row) {
|
|
411
|
+
return row.getAttribute("aria-expanded") === "true";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// The children container a dir/file row controls (its next sibling).
|
|
415
|
+
function rowChildren(row) {
|
|
416
|
+
const next = row.nextElementSibling;
|
|
417
|
+
return next && next.classList.contains("tree-children") ? next : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// The parent treeitem row of a given row, or null at the top level.
|
|
421
|
+
function parentRow(row) {
|
|
422
|
+
const group = row.parentElement;
|
|
423
|
+
if (!group || !group.classList.contains("tree-children")) return null;
|
|
424
|
+
const prev = group.previousElementSibling;
|
|
425
|
+
return prev && prev.getAttribute("role") === "treeitem" ? prev : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function focusRow(row) {
|
|
429
|
+
if (!row) return;
|
|
430
|
+
setRovingTarget(row);
|
|
431
|
+
row.focus();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function moveFocus(current, delta) {
|
|
435
|
+
const rows = visibleRows();
|
|
436
|
+
const i = rows.indexOf(current);
|
|
437
|
+
if (i === -1) return;
|
|
438
|
+
const next = rows[i + delta];
|
|
439
|
+
if (next) focusRow(next);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function onKeydown(event) {
|
|
443
|
+
const row = event.target.closest('[role="treeitem"]');
|
|
444
|
+
if (!row || !treeEl.contains(row)) return;
|
|
445
|
+
switch (event.key) {
|
|
446
|
+
case "ArrowDown":
|
|
447
|
+
event.preventDefault();
|
|
448
|
+
moveFocus(row, 1);
|
|
449
|
+
break;
|
|
450
|
+
case "ArrowUp":
|
|
451
|
+
event.preventDefault();
|
|
452
|
+
moveFocus(row, -1);
|
|
453
|
+
break;
|
|
454
|
+
case "ArrowRight": {
|
|
455
|
+
event.preventDefault();
|
|
456
|
+
if (isExpandable(row)) {
|
|
457
|
+
if (!isExpanded(row)) {
|
|
458
|
+
row.click(); // expand
|
|
459
|
+
} else {
|
|
460
|
+
const children = rowChildren(row);
|
|
461
|
+
const first = children && children.querySelector('[role="treeitem"]');
|
|
462
|
+
if (first) focusRow(first);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
case "ArrowLeft": {
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
if (isExpandable(row) && isExpanded(row)) {
|
|
470
|
+
row.click(); // collapse
|
|
471
|
+
} else {
|
|
472
|
+
const parent = parentRow(row);
|
|
473
|
+
if (parent) focusRow(parent);
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case "Home": {
|
|
478
|
+
event.preventDefault();
|
|
479
|
+
const rows = visibleRows();
|
|
480
|
+
if (rows.length) focusRow(rows[0]);
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
case "End": {
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
const rows = visibleRows();
|
|
486
|
+
if (rows.length) focusRow(rows[rows.length - 1]);
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case "Enter":
|
|
490
|
+
case " ":
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
row.click();
|
|
493
|
+
break;
|
|
494
|
+
default:
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
treeEl.addEventListener("keydown", onKeydown);
|
|
500
|
+
// A pointer click on a row makes it the roving target, so the next Tab in / arrow
|
|
501
|
+
// key starts from where the user clicked. Capture phase so this runs regardless of
|
|
502
|
+
// the row's own click handler (which expands/collapses or selects a flow).
|
|
503
|
+
treeEl.addEventListener(
|
|
504
|
+
"click",
|
|
505
|
+
event => {
|
|
506
|
+
const row = event.target.closest('[role="treeitem"]');
|
|
507
|
+
if (row) setRovingTarget(row);
|
|
508
|
+
},
|
|
509
|
+
true
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
// --- (Re)render -----------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
function render() {
|
|
515
|
+
treeEl.replaceChildren();
|
|
516
|
+
dirRows = new Map();
|
|
517
|
+
fileRows = new Map();
|
|
518
|
+
flowRows = new Map();
|
|
519
|
+
childContainers = new Map();
|
|
520
|
+
(fullTree.children || []).forEach(child => renderNode(child, treeEl, 0));
|
|
521
|
+
if (!treeEl.children.length) {
|
|
522
|
+
const empty = document.createElement("div");
|
|
523
|
+
empty.className = "tree-empty-state";
|
|
524
|
+
empty.textContent = "No matching flows";
|
|
525
|
+
treeEl.appendChild(empty);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Re-reveal + re-highlight whatever flow is active so a language switch keeps the
|
|
529
|
+
// canvas selection reflected when the active flow survives the filter.
|
|
530
|
+
const activeId = lastActiveFlowId;
|
|
531
|
+
if (activeId) {
|
|
532
|
+
const flow = byId.get(activeId);
|
|
533
|
+
if (flow && (!activeLang || flow.language === activeLang)) {
|
|
534
|
+
revealPath(flow.location.path);
|
|
535
|
+
highlightActive(activeId);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
refreshRovingTarget();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// --- Language filter ------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
function setupLanguageFilter() {
|
|
544
|
+
if (!langFilterEl) return;
|
|
545
|
+
// Only offer the control for polyglot repos (mirror the old visibility rule).
|
|
546
|
+
if (languages.length <= 1) {
|
|
547
|
+
langFilterEl.style.display = "none";
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
langFilterEl.replaceChildren();
|
|
551
|
+
const all = document.createElement("option");
|
|
552
|
+
all.value = "";
|
|
553
|
+
all.textContent = "All languages";
|
|
554
|
+
langFilterEl.appendChild(all);
|
|
555
|
+
languages.forEach(lang => {
|
|
556
|
+
const option = document.createElement("option");
|
|
557
|
+
option.value = lang;
|
|
558
|
+
option.textContent = lang;
|
|
559
|
+
langFilterEl.appendChild(option);
|
|
560
|
+
});
|
|
561
|
+
langFilterEl.style.display = "";
|
|
562
|
+
langFilterEl.addEventListener("change", () => {
|
|
563
|
+
activeLang = langFilterEl.value;
|
|
564
|
+
render();
|
|
565
|
+
clearCanvasSelectionForLanguageFilter();
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function setupSearch() {
|
|
570
|
+
if (!searchEl) return;
|
|
571
|
+
searchEl.addEventListener("input", () => {
|
|
572
|
+
activeQuery = searchEl.value.trim().toLowerCase();
|
|
573
|
+
render();
|
|
574
|
+
clearCanvasSelectionForLanguageFilter();
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
setupSearch();
|
|
579
|
+
setupLanguageFilter();
|
|
580
|
+
render();
|
|
581
|
+
|
|
582
|
+
codeDebrief.onFlowSelected = function (flow) {
|
|
583
|
+
if (!flow) return;
|
|
584
|
+
resetLanguageFilterForFlow(flow);
|
|
585
|
+
lastActiveFlowId = flow.id;
|
|
586
|
+
revealPath(flow.location.path);
|
|
587
|
+
highlightActive(flow.id);
|
|
588
|
+
refreshRovingTarget();
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// Bidirectional highlight: a selection made on ANY surface (a canvas block, a source
|
|
592
|
+
// line, or the tree itself) reveals + highlights the owning file/flow row here, in the one
|
|
593
|
+
// shared accent. Block clicks publish a flowId without going through onFlowSelected,
|
|
594
|
+
// so subscribe to the store directly. revealPath + highlightActive are the same calls
|
|
595
|
+
// onFlowSelected uses, so the tree's accent never drifts from the rest of the app.
|
|
596
|
+
if (codeDebrief.onSelection) {
|
|
597
|
+
codeDebrief.onSelection(function (sel) {
|
|
598
|
+
setLanguageFilterAvailability(sel);
|
|
599
|
+
const flowId = sel.flowId;
|
|
600
|
+
if (!flowId) {
|
|
601
|
+
lastActiveFlowId = null;
|
|
602
|
+
highlightPath(sel.path);
|
|
603
|
+
refreshRovingTarget();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (flowId === lastActiveFlowId) return; // already reflected.
|
|
607
|
+
const flow = byId.get(flowId);
|
|
608
|
+
if (!flow) return;
|
|
609
|
+
resetLanguageFilterForFlow(flow);
|
|
610
|
+
lastActiveFlowId = flowId;
|
|
611
|
+
revealPath(flow.location.path);
|
|
612
|
+
highlightActive(flowId);
|
|
613
|
+
refreshRovingTarget();
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
})();
|