diffninja 0.1.0

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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/calltree.d.ts +47 -0
  4. package/dist/calltree.js +296 -0
  5. package/dist/cli.d.ts +57 -0
  6. package/dist/cli.js +340 -0
  7. package/dist/diff.d.ts +7 -0
  8. package/dist/diff.js +114 -0
  9. package/dist/extract.d.ts +26 -0
  10. package/dist/extract.js +152 -0
  11. package/dist/git.d.ts +40 -0
  12. package/dist/git.js +288 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +8 -0
  15. package/dist/infer.d.ts +21 -0
  16. package/dist/infer.js +189 -0
  17. package/dist/languages/bash.d.ts +2 -0
  18. package/dist/languages/bash.js +208 -0
  19. package/dist/languages/c.d.ts +2 -0
  20. package/dist/languages/c.js +218 -0
  21. package/dist/languages/call-syntax.d.ts +125 -0
  22. package/dist/languages/call-syntax.js +997 -0
  23. package/dist/languages/cpp.d.ts +2 -0
  24. package/dist/languages/cpp.js +321 -0
  25. package/dist/languages/csharp.d.ts +2 -0
  26. package/dist/languages/csharp.js +324 -0
  27. package/dist/languages/elixir.d.ts +2 -0
  28. package/dist/languages/elixir.js +331 -0
  29. package/dist/languages/go.d.ts +2 -0
  30. package/dist/languages/go.js +299 -0
  31. package/dist/languages/grammars.d.ts +50 -0
  32. package/dist/languages/grammars.js +351 -0
  33. package/dist/languages/haskell.d.ts +2 -0
  34. package/dist/languages/haskell.js +250 -0
  35. package/dist/languages/java.d.ts +2 -0
  36. package/dist/languages/java.js +351 -0
  37. package/dist/languages/javascript.d.ts +4 -0
  38. package/dist/languages/javascript.js +648 -0
  39. package/dist/languages/kotlin.d.ts +2 -0
  40. package/dist/languages/kotlin.js +368 -0
  41. package/dist/languages/lua.d.ts +2 -0
  42. package/dist/languages/lua.js +212 -0
  43. package/dist/languages/ocaml.d.ts +2 -0
  44. package/dist/languages/ocaml.js +291 -0
  45. package/dist/languages/perl.d.ts +2 -0
  46. package/dist/languages/perl.js +418 -0
  47. package/dist/languages/php.d.ts +2 -0
  48. package/dist/languages/php.js +397 -0
  49. package/dist/languages/python.d.ts +2 -0
  50. package/dist/languages/python.js +376 -0
  51. package/dist/languages/registry.d.ts +7 -0
  52. package/dist/languages/registry.js +69 -0
  53. package/dist/languages/ruby.d.ts +2 -0
  54. package/dist/languages/ruby.js +391 -0
  55. package/dist/languages/rust.d.ts +2 -0
  56. package/dist/languages/rust.js +261 -0
  57. package/dist/languages/scala.d.ts +2 -0
  58. package/dist/languages/scala.js +307 -0
  59. package/dist/languages/solidity.d.ts +2 -0
  60. package/dist/languages/solidity.js +240 -0
  61. package/dist/languages/swift.d.ts +2 -0
  62. package/dist/languages/swift.js +268 -0
  63. package/dist/languages/types.d.ts +36 -0
  64. package/dist/languages/types.js +74 -0
  65. package/dist/languages/typescript-contracts.d.ts +57 -0
  66. package/dist/languages/typescript-contracts.js +528 -0
  67. package/dist/languages/typescript-dispatch.d.ts +68 -0
  68. package/dist/languages/typescript-dispatch.js +710 -0
  69. package/dist/languages/typescript.d.ts +4 -0
  70. package/dist/languages/typescript.js +722 -0
  71. package/dist/languages/zig.d.ts +2 -0
  72. package/dist/languages/zig.js +243 -0
  73. package/dist/loc.d.ts +17 -0
  74. package/dist/loc.js +34 -0
  75. package/dist/reach.d.ts +17 -0
  76. package/dist/reach.js +65 -0
  77. package/dist/render.d.ts +18 -0
  78. package/dist/render.js +83 -0
  79. package/dist/review/brand.d.ts +8 -0
  80. package/dist/review/brand.js +25 -0
  81. package/dist/review/call-context.d.ts +27 -0
  82. package/dist/review/call-context.js +446 -0
  83. package/dist/review/call-flow-html.d.ts +32 -0
  84. package/dist/review/call-flow-html.js +1870 -0
  85. package/dist/review/call-flow-nav.d.ts +151 -0
  86. package/dist/review/call-flow-nav.js +317 -0
  87. package/dist/review/call-flow.d.ts +47 -0
  88. package/dist/review/call-flow.js +229 -0
  89. package/dist/review/change-facts.d.ts +69 -0
  90. package/dist/review/change-facts.js +729 -0
  91. package/dist/review/cli.d.ts +2 -0
  92. package/dist/review/cli.js +50 -0
  93. package/dist/review/connected-analysis.d.ts +100 -0
  94. package/dist/review/connected-analysis.js +163 -0
  95. package/dist/review/connected-html.d.ts +17 -0
  96. package/dist/review/connected-html.js +2853 -0
  97. package/dist/review/connected.d.ts +23 -0
  98. package/dist/review/connected.js +141 -0
  99. package/dist/review/escape-html.d.ts +2 -0
  100. package/dist/review/escape-html.js +9 -0
  101. package/dist/review/evidence-html.d.ts +21 -0
  102. package/dist/review/evidence-html.js +521 -0
  103. package/dist/review/evidence-syntax.d.ts +132 -0
  104. package/dist/review/evidence-syntax.js +478 -0
  105. package/dist/review/evidence-types.d.ts +62 -0
  106. package/dist/review/evidence-types.js +1 -0
  107. package/dist/review/evidence.d.ts +31 -0
  108. package/dist/review/evidence.js +1603 -0
  109. package/dist/review/file-role.d.ts +9 -0
  110. package/dist/review/file-role.js +29 -0
  111. package/dist/review/github.d.ts +204 -0
  112. package/dist/review/github.js +1245 -0
  113. package/dist/review/history.d.ts +101 -0
  114. package/dist/review/history.js +412 -0
  115. package/dist/review/html.d.ts +34 -0
  116. package/dist/review/html.js +1104 -0
  117. package/dist/review/input.d.ts +10 -0
  118. package/dist/review/input.js +113 -0
  119. package/dist/review/intent.d.ts +4 -0
  120. package/dist/review/intent.js +75 -0
  121. package/dist/review/mcp-cli.d.ts +2 -0
  122. package/dist/review/mcp-cli.js +25 -0
  123. package/dist/review/mcp.d.ts +12 -0
  124. package/dist/review/mcp.js +414 -0
  125. package/dist/review/module-resolution.d.ts +2 -0
  126. package/dist/review/module-resolution.js +86 -0
  127. package/dist/review/palette.d.ts +7 -0
  128. package/dist/review/palette.js +104 -0
  129. package/dist/review/pipeline.d.ts +77 -0
  130. package/dist/review/pipeline.js +227 -0
  131. package/dist/review/pr-input.d.ts +19 -0
  132. package/dist/review/pr-input.js +130 -0
  133. package/dist/review/questions.d.ts +201 -0
  134. package/dist/review/questions.js +174 -0
  135. package/dist/review/reference-check.d.ts +7 -0
  136. package/dist/review/reference-check.js +733 -0
  137. package/dist/review/report-pages.d.ts +109 -0
  138. package/dist/review/report-pages.js +328 -0
  139. package/dist/review/service.d.ts +23 -0
  140. package/dist/review/service.js +198 -0
  141. package/dist/review/setup.d.ts +112 -0
  142. package/dist/review/setup.js +549 -0
  143. package/dist/review/source.d.ts +26 -0
  144. package/dist/review/source.js +276 -0
  145. package/dist/review/toml.d.ts +38 -0
  146. package/dist/review/toml.js +565 -0
  147. package/dist/review/types.d.ts +179 -0
  148. package/dist/review/types.js +1 -0
  149. package/dist/run.d.ts +49 -0
  150. package/dist/run.js +311 -0
  151. package/dist/types.d.ts +366 -0
  152. package/dist/types.js +83 -0
  153. package/package.json +88 -0
  154. package/scripts/ensure-native-grammar.mjs +188 -0
@@ -0,0 +1,1870 @@
1
+ import { CALL_FLOW_MAX_CHILDREN, CALL_FLOW_MAX_CROSS_FILE_CHILDREN, CALL_FLOW_MAX_DEPTH, CALL_FLOW_MAX_NODES, CALL_FLOW_MAX_ROOTS, } from "./call-flow.js";
2
+ import { CALL_FLOW_DEFAULT_DEPTH, CALL_FLOW_DEPTHS, CALL_FLOW_MODES, CALL_FLOW_NAV_SOURCE, } from "./call-flow-nav.js";
3
+ import { escapeHtml } from "./escape-html.js";
4
+ /**
5
+ * Call-flow navigation for a review report: Tree, Graph and Sequence views of
6
+ * the syntactic call trees that touch each changed file.
7
+ *
8
+ * Everything is server-rendered. Tree mode is live markup, so calls, call
9
+ * sites and each resolved definition are readable with JavaScript disabled; the
10
+ * Graph and Sequence renderings of the same calls ship inside templates and are
11
+ * instantiated the first time their mode is opened, which costs no live nodes
12
+ * until then, and the page states that plainly where it matters. Tree nodes fold
13
+ * with native `<details>`, and the resolved definition of a call —
14
+ * line-numbered and escaped — sits in its own `<details>` under the call. The
15
+ * appended script switches modes, keeps an explicit trail of the functions the
16
+ * reviewer visited, limits the graph to a depth below the focused call, and
17
+ * repeats one node's server-rendered source in a single panel; it reads every
18
+ * label from the escaped DOM and never receives report text.
19
+ *
20
+ * Node identity is an occurrence path (`0-2-1`), never a key or a name, so
21
+ * duplicate and hostile keys stay distinct, and trail entries carry the file
22
+ * section as well, so the same path in two files stays two entries. Status
23
+ * reaches the reviewer as a glyph plus color for a call that was added,
24
+ * removed, or contains a change, and plain dimmed text for an unchanged call.
25
+ */
26
+ export function renderCallFlows(report) {
27
+ const files = collectFiles(report.callFlows, report.items);
28
+ if (files.length === 0) {
29
+ return `<p class="cf-note cf-absence">${escapeHtml(AVAILABILITY_NOTE[report.callFlowAvailability])}</p>`;
30
+ }
31
+ return [
32
+ '<div class="cf">',
33
+ renderSummary(files, new Set(report.items.map(item => item.file)).size),
34
+ renderControls(),
35
+ renderJump(files),
36
+ '<nav class="cf-crumbs" id="cf-crumbs" aria-label="Visited call trail" hidden></nav>',
37
+ // One panel the script fills with the focused call's definition; without the
38
+ // script each call keeps its own server-rendered source disclosure instead.
39
+ '<details class="cf-src-panel" id="cf-src-panel" hidden>',
40
+ '<summary class="cf-src-panel-sum">Source details</summary>',
41
+ '<div class="cf-src-panel-body" id="cf-src-panel-body"></div>',
42
+ "</details>",
43
+ '<div class="cf-files">',
44
+ files.map((view, index) => renderFile(view, index + 1)).join("\n"),
45
+ "</div>",
46
+ "</div>",
47
+ ].join("\n");
48
+ }
49
+ /** Order a reviewer reads in: most severe changed file first, then report order. */
50
+ const SEVERITY_RANK = {
51
+ attention: 0,
52
+ uncertain: 1,
53
+ low: 2,
54
+ passed: 3,
55
+ };
56
+ const SEVERITY_WORD = {
57
+ attention: "Attention",
58
+ uncertain: "Uncertain",
59
+ low: "Low",
60
+ passed: "Passed",
61
+ };
62
+ const STATUS_WORD = {
63
+ same: "unchanged",
64
+ added: "added",
65
+ removed: "removed",
66
+ changed: "changed",
67
+ };
68
+ const STATUS_MARK = {
69
+ same: "",
70
+ added: "+",
71
+ removed: "−",
72
+ changed: "~",
73
+ };
74
+ const MODE_LABEL = {
75
+ tree: "Tree",
76
+ graph: "Diagram",
77
+ sequence: "Sequence",
78
+ };
79
+ const MODES = CALL_FLOW_MODES;
80
+ const AVAILABILITY_NOTE = {
81
+ available: "No call tree reaches a changed file in this range. This is not evidence of safety.",
82
+ "needs-git-range": "Call paths need a git range. This input was a patch, so no call trees were built.",
83
+ "no-changes": "No call path reaches a changed file. This is not evidence of safety.",
84
+ failed: "Call-flow analysis failed, so no call trees were built. The diff review is unaffected.",
85
+ };
86
+ /** Call paths drawn per file in Sequence mode before the omitted-path note. */
87
+ const SEQUENCE_LIMIT = 10;
88
+ /** What the serializer keeps per changed file; the UI states the same bounds. */
89
+ const BOUNDS_TEXT = `roots ≤${CALL_FLOW_MAX_ROOTS}, depth ≤${CALL_FLOW_MAX_DEPTH} edges, ` +
90
+ `≤${CALL_FLOW_MAX_CHILDREN} calls per node (up to ${CALL_FLOW_MAX_CROSS_FILE_CHILDREN} more when the callee is defined in another file), ` +
91
+ `${CALL_FLOW_MAX_NODES} nodes per file`;
92
+ const GRAPH_NODE_HEIGHT = 40;
93
+ /** Extra box height for the one-line description the backend may attach. */
94
+ const GRAPH_DESC_HEIGHT = 13;
95
+ const GRAPH_LEVEL_GAP = 32;
96
+ const GRAPH_COLUMN_GAP = 20;
97
+ const GRAPH_PAD = 8;
98
+ /** Advance width of the 12px / 10px monospace label faces used in the graph. */
99
+ const GRAPH_CHAR = 7.4;
100
+ const GRAPH_LOC_CHAR = 6.2;
101
+ const GRAPH_MIN_WIDTH = 132;
102
+ const GRAPH_MAX_WIDTH = 300;
103
+ /** Most severe hunk per file, and the earliest hunk of that severity. */
104
+ function hunkRanks(items) {
105
+ const ranks = new Map();
106
+ for (const [index, item] of items.entries()) {
107
+ const status = item.status;
108
+ const seen = ranks.get(item.file);
109
+ if (seen === undefined || SEVERITY_RANK[status] < SEVERITY_RANK[seen.status]) {
110
+ ranks.set(item.file, { status, rank: index + 1 });
111
+ }
112
+ }
113
+ return ranks;
114
+ }
115
+ /** One diagram section per changed file with structured call paths. */
116
+ function collectFiles(callFlows, items) {
117
+ const ranks = hunkRanks(items);
118
+ const views = [];
119
+ const seen = new Set();
120
+ for (const entry of callFlows) {
121
+ const { file, trees } = entry;
122
+ const hunk = ranks.get(file);
123
+ if (!hunk || trees.length === 0 || seen.has(file))
124
+ continue;
125
+ seen.add(file);
126
+ views.push({
127
+ file,
128
+ status: hunk.status,
129
+ rank: hunk.rank,
130
+ trees,
131
+ truncated: entry.truncated === true,
132
+ });
133
+ }
134
+ // Most severe file first; ties keep report order.
135
+ views.sort(compareViews);
136
+ return views;
137
+ }
138
+ function compareViews(a, b) {
139
+ return SEVERITY_RANK[a.status] - SEVERITY_RANK[b.status] || a.rank - b.rank;
140
+ }
141
+ function countNodes(trees) {
142
+ let total = 0;
143
+ for (const tree of trees) {
144
+ total += 1;
145
+ total += countNodes(tree.children);
146
+ }
147
+ return total;
148
+ }
149
+ function plural(count, word) {
150
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
151
+ }
152
+ function renderSummary(files, totalFiles) {
153
+ const calls = files.reduce((total, view) => total + countNodes(view.trees), 0);
154
+ const coverage = `${plural(calls, "call")} across ${files.length} of ${totalFiles} changed files`;
155
+ return [
156
+ '<div class="cf-head">',
157
+ `<p class="cf-prov" title="${escapeHtml(`Bounds per file: ${BOUNDS_TEXT}. Source is the complete resolved definition at the reviewed revision. Dynamic calls may be absent.`)}">Syntactic calls · depth ≤4 · + added · − removed · ~ changed</p>`,
158
+ `<p class="cf-note">${escapeHtml(coverage)}</p>`,
159
+ "</div>",
160
+ ].join("\n");
161
+ }
162
+ /**
163
+ * Mode anchors work without JavaScript (each jumps to its section); the
164
+ * script marks the active one with aria-current once all modes are switchable.
165
+ *
166
+ * The switch is script-only, because only the script can instantiate the graph
167
+ * and sequence sections: markup for them ships inside templates, so without the
168
+ * script those anchors would point at nothing. A reader without the script gets
169
+ * Tree, which is complete, plus the note `renderModesNote` renders.
170
+ * The depth control exists only with the script and only bounds the graph: it
171
+ * counts call levels below the focused call, and the focus itself resets it.
172
+ */
173
+ function renderControls() {
174
+ // The diagram is not a tab: it needs room to pan and zoom, so a button opens
175
+ // it across the whole screen and closing it returns to the tab it left.
176
+ const links = MODES.filter((mode) => mode !== "graph").map((mode) => `<a class="cf-mode-link enhanced" data-cf-mode="${mode}" href="#cf-f1-${mode}">${MODE_LABEL[mode]}</a>`).join("");
177
+ const depths = CALL_FLOW_DEPTHS.map((depth) => `<button type="button" class="cf-depth-btn" data-cf-depth="${depth}"` +
178
+ ` aria-pressed="${depth === CALL_FLOW_DEFAULT_DEPTH ? "true" : "false"}"` +
179
+ ` title="Show ${depth === "all" ? "every serialized call" : `${depth} call ${depth === 1 ? "level" : "levels"}`} below the focus">` +
180
+ `${depth === "all" ? "All" : depth}</button>`).join("");
181
+ return [
182
+ '<div class="cf-controls">',
183
+ `<div class="cf-modes" role="group" aria-label="Call flow mode">${links}</div>`,
184
+ '<button type="button" class="cf-diagram-btn enhanced" data-cf-diagram aria-label="Open the call diagram across the window">Diagram</button>',
185
+ '<p class="cf-diagram-hint" data-cf-diagram-hint hidden>Scroll or drag to move. Hold Ctrl or \u2318 and scroll to zoom.</p>',
186
+ '<button type="button" class="cf-diagram-close" data-cf-diagram-close hidden>Close diagram</button>',
187
+ '<div class="cf-depth enhanced" role="group" aria-label="Diagram depth below the focused call">',
188
+ '<span class="cf-depth-label">Depth</span>',
189
+ depths,
190
+ "</div>",
191
+ "</div>",
192
+ ].join("\n");
193
+ }
194
+ /** Severity dot, screen-reader word and call count, shared by nav and header. */
195
+ function fileFacts(view) {
196
+ return {
197
+ dot: `<span class="dot dot-${view.status}" aria-hidden="true"></span>`,
198
+ tone: `<span class="sr">${escapeHtml(SEVERITY_WORD[view.status])}</span>`,
199
+ count: plural(countNodes(view.trees), "call"),
200
+ };
201
+ }
202
+ function renderJump(files) {
203
+ const links = files
204
+ .map((view, index) => {
205
+ const { dot, tone, count } = fileFacts(view);
206
+ return [
207
+ '<li><a class="cf-jump-link" href="#cf-f',
208
+ `${index + 1}">`,
209
+ dot,
210
+ `<span class="cf-jump-path mono">${escapeHtml(view.file)}</span>`,
211
+ `<span class="cf-jump-count mono">${escapeHtml(count)}</span>`,
212
+ tone,
213
+ "</a></li>",
214
+ ].join("");
215
+ })
216
+ .join("\n");
217
+ return [
218
+ '<nav class="cf-jump" aria-label="Call flow files">',
219
+ `<ul>${links}</ul>`,
220
+ "</nav>",
221
+ ].join("\n");
222
+ }
223
+ /** One file section: severity, path, call count and its diff link. */
224
+ function renderFile(view, at) {
225
+ const { dot, tone, count } = fileFacts(view);
226
+ const status = view.status;
227
+ const diff = `<a class="cf-diff-link" data-open-hunk href="#item-${view.rank}">View diff #${view.rank}</a>`;
228
+ return [
229
+ `<details class="cf-file cf-file-${status}" id="cf-f${at}" data-cf-file="${at}"${at === 1 ? " open" : ""}>`,
230
+ '<summary class="cf-file-head">',
231
+ dot,
232
+ `<span class="cf-file-path mono">${escapeHtml(view.file)}</span>`,
233
+ tone,
234
+ `<span class="cf-file-count mono">${escapeHtml(count)}</span>`,
235
+ diff,
236
+ "</summary>",
237
+ '<div class="cf-file-body">',
238
+ view.truncated
239
+ ? `<p class="cf-bounds">Bounds reached for this file: ${escapeHtml(BOUNDS_TEXT)}. Calls cut at a bound are omitted.</p>`
240
+ : "",
241
+ modesNote(),
242
+ renderModes(view, at),
243
+ "</div>",
244
+ "</details>",
245
+ ]
246
+ .filter((part) => part !== "")
247
+ .join("\n");
248
+ }
249
+ /**
250
+ * The three views of the same trees, in the order a reviewer reads them: Tree is
251
+ * rendered eagerly because it is the default mode and the one a reader without
252
+ * the script keeps; Graph and Sequence are alternate renderings of the same
253
+ * calls, so each is shipped inside a `<template>` and instantiated the first
254
+ * time its mode is opened. A template's content is inert markup: it costs the
255
+ * same bytes but adds no live nodes to the page, which is what keeps the DOM of a
256
+ * 50-file pull request small enough to load quickly.
257
+ */
258
+ function renderModes(view, at) {
259
+ return [
260
+ renderTreeMode(view, at),
261
+ lazyMode("graph", at, renderGraphMode(view, at)),
262
+ lazyMode("sequence", at, renderSequenceMode(view, at)),
263
+ ].join("\n");
264
+ }
265
+ /**
266
+ * What a reader without the page script gets. Only Tree is live markup, so this
267
+ * says which renderings are missing instead of letting them look absent, and the
268
+ * diff — every changed line, including hunks no evaluation covers — is complete
269
+ * in both cases.
270
+ */
271
+ function modesNote() {
272
+ return (`<noscript class="cf-noscript"><p class="cf-note">` +
273
+ `The diagram and Sequence are alternate renderings of these same calls, drawn by the page script, ` +
274
+ `so they are not shown here. Tree lists every call with its source location and each call's ` +
275
+ `resolved definition opens on its own. The diff holds every changed line.` +
276
+ `</p></noscript>`);
277
+ }
278
+ function modeSection(mode, at, body) {
279
+ return [
280
+ `<section class="cf-mode cf-mode-${mode}" id="cf-f${at}-${mode}" data-cf-mode-body="${mode}">`,
281
+ `<h3 class="cf-mode-head">${MODE_LABEL[mode]}</h3>`,
282
+ body,
283
+ "</section>",
284
+ ].join("\n");
285
+ }
286
+ /** A mode section held back until the script first shows that mode. */
287
+ function lazyMode(mode, at, section) {
288
+ return `<template data-cf-lazy-mode="${mode}" data-cf-file="${at}">${section}</template>`;
289
+ }
290
+ function renderTreeMode(view, at) {
291
+ const trees = view.trees
292
+ .map((tree, root) => renderTreeNode(tree, [root], at, view.file))
293
+ .join("\n");
294
+ return modeSection("tree", at, `<ul class="cf-tree">${trees}</ul>`);
295
+ }
296
+ /** The resolved definition of one call. It is a native disclosure, so the actual
297
+ * source is readable without the script, and the script clones this body into
298
+ * its single panel instead of sending report text through the DOM builder. */
299
+ function renderSource(node, path, at) {
300
+ const source = node.source;
301
+ if (!source) {
302
+ return `<details class="cf-source" id="cf-f${at}-src-${path}">` +
303
+ '<summary class="cf-src-sum">Source unavailable</summary>' +
304
+ '<div class="cf-src-body"><p class="cf-src-note">No resolved definition source for this call.</p></div></details>';
305
+ }
306
+ const location = sourceLocationText(source);
307
+ const lines = source.text.split("\n");
308
+ if (lines.length > 1 && lines[lines.length - 1] === "")
309
+ lines.pop();
310
+ const body = lines.every((line) => line.trim() === "")
311
+ ? '<p class="cf-src-note">No definition text was captured for this call.</p>'
312
+ : [
313
+ '<pre class="cf-src-code">',
314
+ lines
315
+ .map((line, index) => '<span class="cf-src-line">' +
316
+ `<span class="cf-src-no" aria-hidden="true">${source.line + index}</span>` +
317
+ `${escapeHtml(line)}</span>`)
318
+ .join(""),
319
+ "</pre>",
320
+ ].join("");
321
+ const ref = source.ref === "" ? "" : ` · resolved from ${source.ref}`;
322
+ return [
323
+ `<details class="cf-source" id="cf-f${at}-src-${path}">`,
324
+ `<summary class="cf-src-sum mono">${escapeHtml(location)}</summary>`,
325
+ '<div class="cf-src-body">',
326
+ `<p class="cf-src-ref mono">${escapeHtml(`Definition in ${location}${ref}`)}</p>`,
327
+ body,
328
+ "</div>",
329
+ "</details>",
330
+ ].join("");
331
+ }
332
+ /** Identity and the two files a node is known by: the resolved definition file
333
+ * when the backend resolved one, and the call-site file the node carries. */
334
+ function nodeAttrs(node, path, at) {
335
+ const source = node.source;
336
+ return [
337
+ ` id="cf-f${at}-t-${path}"`,
338
+ ` data-cf-file="${at}"`,
339
+ ` data-cf-path="${path}"`,
340
+ source ? ` data-cf-srcfile="${escapeHtml(source.file)}"` : "",
341
+ node.file === undefined ? "" : ` data-cf-nodefile="${escapeHtml(node.file)}"`,
342
+ ].join("");
343
+ }
344
+ function renderTreeNode(node, path, at, changedFile) {
345
+ const id = path.join("-");
346
+ const status = node.status;
347
+ const inFile = node.file === changedFile;
348
+ const loc = locationText(node);
349
+ // The label is the zoom target. It stays a plain span without JavaScript (a
350
+ // dead button would be worse) and the script upgrades it into a real button.
351
+ const zoom = `<span class="cf-label mono" data-cf-zoom data-cf-file="${at}" data-cf-path="${id}"` +
352
+ ` title="Zoom into ${escapeHtml(node.label)}">${escapeHtml(node.label)}</span>`;
353
+ const row = [
354
+ statusBadge(status, "cf-badge"),
355
+ zoom,
356
+ loc === ""
357
+ ? '<span class="cf-loc cf-loc-none">no source location</span>'
358
+ : `<span class="cf-loc mono">${escapeHtml(loc)}</span>`,
359
+ node.description ? `<span class="cf-desc">${escapeHtml(node.description)}</span>` : "",
360
+ // An anchor, not a button: it reaches the disclosure below without the script.
361
+ `<a class="cf-src-link" href="#cf-f${at}-src-${id}" data-cf-source` +
362
+ ` data-cf-file="${at}" data-cf-path="${id}">${node.source ? "source" : "details"}</a>`,
363
+ ]
364
+ .filter((part) => part !== "")
365
+ .join("");
366
+ const classes = ["cf-node", `cf-st-${status}`];
367
+ if (inFile)
368
+ classes.push("cf-infile");
369
+ const kids = node.children;
370
+ const source = renderSource(node, id, at);
371
+ if (kids.length === 0) {
372
+ return `<li class="${classes.join(" ")} cf-leaf"${nodeAttrs(node, id, at)}><span class="cf-row">${row}</span>${source}</li>`;
373
+ }
374
+ const children = kids
375
+ .map((child, childAt) => renderTreeNode(child, path.concat([childAt]), at, changedFile))
376
+ .join("\n");
377
+ return [
378
+ `<li class="${classes.join(" ")}"${nodeAttrs(node, id, at)}>`,
379
+ '<details class="cf-fold" open>',
380
+ `<summary class="cf-row">${row}</summary>`,
381
+ `<ul class="cf-children">${children}</ul>`,
382
+ "</details>",
383
+ source,
384
+ ]
385
+ .filter((part) => part !== "")
386
+ .join("");
387
+ }
388
+ /** The mark is drawn only for a call that changed; `same` keeps its label dim. */
389
+ function statusBadge(status, extra) {
390
+ const mark = STATUS_MARK[status];
391
+ return [
392
+ mark === "" ? "" : `<span class="${extra} ${extra}-${status}" aria-hidden="true">${mark}</span>`,
393
+ `<span class="sr">${STATUS_WORD[status]}</span>`,
394
+ ].join("");
395
+ }
396
+ /** `file:line` or `file:line-endLine` for the resolved definition. */
397
+ function sourceLocationText(source) {
398
+ const end = source.endLine;
399
+ return `${source.file}:${end > source.line ? `${source.line}-${end}` : `${source.line}`}`;
400
+ }
401
+ /** `file:line` when both are known, `file:line-endLine` for a call that spans
402
+ * lines, the file alone when the line is not known. */
403
+ function locationText(node) {
404
+ const file = node.file ?? "";
405
+ const line = node.line ?? null;
406
+ const end = node.endLine ?? null;
407
+ if (line === null)
408
+ return file;
409
+ const span = end !== null && end > line ? `${line}-${end}` : `${line}`;
410
+ return file === "" ? `line ${span}` : `${file}:${span}`;
411
+ }
412
+ function renderGraphMode(view, at) {
413
+ const figures = view.trees
414
+ .map((tree, root) => {
415
+ const layout = layoutTree(tree, [root]);
416
+ const edges = layout.edges
417
+ .map((edge, index) => renderGraphEdge(edge, index + 1, layout.edges.length, at))
418
+ .join("");
419
+ const boxes = layout.boxes
420
+ .map((box) => renderGraphNode(box, at, view.file))
421
+ .join("");
422
+ return [
423
+ `<div class="cf-graph-frame" data-cf-graph="${at}:${root}">`,
424
+ '<div class="cf-camera-tools enhanced" role="group" aria-label="Diagram framing">',
425
+ '<button type="button" data-cf-camera="out" aria-label="Zoom out">−</button>',
426
+ '<output class="cf-scale" aria-label="Diagram scale">100%</output>',
427
+ '<button type="button" data-cf-camera="in" aria-label="Zoom in">+</button>',
428
+ '<button type="button" data-cf-camera="overview" title="Fit all retained calls. Labels may be small.">Overview</button>',
429
+ '<button type="button" data-cf-camera="readable" title="Readable size at the selected function">Readable</button>',
430
+ '<span class="cf-note">Drag / swipe to pan</span>',
431
+ '</div>',
432
+ '<div class="cf-svg-wrap">',
433
+ `<svg class="cf-svg" width="${layout.width}" height="${layout.height}" viewBox="0 0 ${layout.width} ${layout.height}" role="group" aria-label="${escapeHtml(`Call graph for ${tree.label}`)}">`,
434
+ edges,
435
+ boxes,
436
+ "</svg></div></div>",
437
+ ].join("");
438
+ })
439
+ .join("\n");
440
+ return modeSection("graph", at, `${figures}<p class="cf-note cf-edge-note">Boxes select and show source. + and numbered edges focus a branch. Numbers identify static calls, not execution order.</p>`);
441
+ }
442
+ /**
443
+ * Hand layout: one row per call depth, boxes packed left to right and centred
444
+ * per row, elbow edges from parent bottom to child top. A row is as tall as its
445
+ * tallest box, so a description line on one call never overlaps the next row.
446
+ * The enhanced viewport frames these coordinates without shrinking text.
447
+ * Without JavaScript the native-size diagram scrolls inside its container.
448
+ */
449
+ function layoutTree(root, rootPath) {
450
+ const levels = [];
451
+ const boxes = [];
452
+ const edges = [];
453
+ const walk = (node, path, parent) => {
454
+ const loc = locationText(node);
455
+ const description = node.description ?? "";
456
+ const labelChars = Math.max(1, node.label.length + (node.status === "same" ? 0 : 2));
457
+ const width = Math.ceil(Math.min(GRAPH_MAX_WIDTH, Math.max(GRAPH_MIN_WIDTH, 48 +
458
+ Math.max(labelChars * GRAPH_CHAR, loc.length * GRAPH_LOC_CHAR, description.length * GRAPH_LOC_CHAR))));
459
+ const box = {
460
+ node,
461
+ path,
462
+ x: 0,
463
+ y: 0,
464
+ width,
465
+ height: description === "" ? GRAPH_NODE_HEIGHT : GRAPH_NODE_HEIGHT + GRAPH_DESC_HEIGHT,
466
+ labelChars: Math.floor((width - 48) / GRAPH_CHAR),
467
+ locChars: Math.floor((width - 24) / GRAPH_LOC_CHAR),
468
+ descChars: Math.floor((width - 24) / GRAPH_LOC_CHAR),
469
+ };
470
+ const depth = path.length - 1;
471
+ const level = levels[depth];
472
+ if (level === undefined)
473
+ levels[depth] = [box];
474
+ else
475
+ level.push(box);
476
+ boxes.push(box);
477
+ if (parent !== null)
478
+ edges.push({ from: parent, to: box });
479
+ node.children.forEach((child, childAt) => walk(child, path.concat([childAt]), box));
480
+ };
481
+ walk(root, rootPath, null);
482
+ const levelWidths = levels.map((level) => level.reduce((total, box) => total + box.width, 0) +
483
+ GRAPH_COLUMN_GAP * Math.max(0, level.length - 1));
484
+ const levelHeights = levels.map((level) => Math.max(...level.map((box) => box.height)));
485
+ const widest = Math.max(...levelWidths, GRAPH_MIN_WIDTH) + GRAPH_PAD * 2;
486
+ let top = GRAPH_PAD;
487
+ levels.forEach((level, depth) => {
488
+ let x = GRAPH_PAD + Math.max(0, (widest - GRAPH_PAD * 2 - levelWidths[depth]) / 2);
489
+ const y = top;
490
+ top += levelHeights[depth] + GRAPH_LEVEL_GAP;
491
+ for (const box of level) {
492
+ box.x = Math.round(x);
493
+ box.y = y;
494
+ x += box.width + GRAPH_COLUMN_GAP;
495
+ }
496
+ });
497
+ const height = GRAPH_PAD * 2 +
498
+ levelHeights.reduce((total, level) => total + level, 0) +
499
+ Math.max(0, levels.length - 1) * GRAPH_LEVEL_GAP;
500
+ return { boxes, edges, width: Math.round(widest), height: Math.round(height) };
501
+ }
502
+ /** Elbow edge plus the number of that call in the serialized diagram. The
503
+ * number is a link, because it zooms into the callee the edge points at. */
504
+ function renderGraphEdge(edge, number, total, at) {
505
+ const { from, to } = edge;
506
+ const id = to.path.join("-");
507
+ const x1 = Math.round(from.x + from.width / 2);
508
+ const y1 = Math.round(from.y + from.height);
509
+ const x2 = Math.round(to.x + to.width / 2);
510
+ const y2 = Math.round(to.y);
511
+ const mid = Math.round(y1 + (y2 - y1) / 2);
512
+ const title = `${from.node.label} → ${to.node.label} · call ${number} of ${total} in serialized order`;
513
+ return [
514
+ `<path class="cf-edge" data-cf-edge-child="${id}"`,
515
+ ` d="M${x1} ${y1} V${mid} H${x2} V${y2}"></path>`,
516
+ `<a class="cf-edge-num" data-cf-edge="${number}" href="#cf-f${at}-t-${id}" data-cf-zoom data-cf-file="${at}" data-cf-path="${id}">`,
517
+ `<title>${escapeHtml(title)}</title>`,
518
+ `<circle cx="${x2}" cy="${mid}" r="9"></circle>`,
519
+ `<text class="cf-edge-num-text" x="${x2}" y="${mid + 4}" text-anchor="middle">${number}</text>`,
520
+ "</a>",
521
+ ].join("");
522
+ }
523
+ function renderGraphNode(box, at, changedFile) {
524
+ const node = box.node;
525
+ const path = box.path.join("-");
526
+ const status = node.status;
527
+ const loc = locationText(node);
528
+ const source = node.source;
529
+ const description = node.description ?? "";
530
+ const desc = description === "" ? "" : clip(description, box.descChars);
531
+ const inFile = node.file === changedFile;
532
+ const action = `href="#cf-f${at}-src-${path}" data-cf-source`;
533
+ const title = [
534
+ node.label,
535
+ loc === "" ? "no source location" : loc,
536
+ STATUS_WORD[status],
537
+ description,
538
+ source ? `definition in ${sourceLocationText(source)}` : "no resolved definition source",
539
+ ]
540
+ .filter((part) => part !== "")
541
+ .join(" · ");
542
+ const mark = STATUS_MARK[status];
543
+ const label = clip(mark ? `${mark} ${node.label}` : node.label, box.labelChars);
544
+ return [
545
+ `<a class="cf-gnode cf-st-${status}${inFile ? " cf-infile" : ""}" ${action} data-cf-file="${at}" data-cf-path="${path}">`,
546
+ `<title>${escapeHtml(title)}</title>`,
547
+ `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="7"></rect>`,
548
+ `<text class="cf-glabel" x="${box.x + 12}" y="${loc === "" && desc === "" ? box.y + 25 : box.y + 17}">${escapeHtml(label)}</text>`,
549
+ loc === ""
550
+ ? ""
551
+ : `<text class="cf-gloc" x="${box.x + 12}" y="${box.y + 31}">${escapeHtml(clip(loc, box.locChars))}</text>`,
552
+ desc === ""
553
+ ? ""
554
+ : `<text class="cf-gdesc" x="${box.x + 12}" y="${loc === "" ? box.y + 31 : box.y + 44}">${escapeHtml(desc)}</text>`,
555
+ "</a>",
556
+ `<a class="cf-gzoom" href="#cf-f${at}-t-${path}" data-cf-zoom data-cf-file="${at}" data-cf-path="${path}" aria-label="Zoom into ${escapeHtml(node.label)}">`,
557
+ `<circle cx="${box.x + box.width - 13}" cy="${box.y + 13}" r="10"></circle>`,
558
+ `<text x="${box.x + box.width - 13}" y="${box.y + 17}" text-anchor="middle">+</text>`,
559
+ "</a>",
560
+ ]
561
+ .filter((part) => part !== "")
562
+ .join("");
563
+ }
564
+ /** The graph truncates visually; the full label stays in the node title. */
565
+ function clip(text, maxChars) {
566
+ if (maxChars < 2 || text.length <= maxChars)
567
+ return text;
568
+ return `${text.slice(0, maxChars - 1)}…`;
569
+ }
570
+ function renderSequenceMode(view, at) {
571
+ const paths = [];
572
+ view.trees.forEach((tree, root) => collectPaths(tree, [root], [], paths));
573
+ const shown = Math.min(paths.length, SEQUENCE_LIMIT);
574
+ const note = `<p class="cf-note cf-omitted"${paths.length <= SEQUENCE_LIMIT ? " hidden" : ""}>Showing ${shown} of ${paths.length} call paths; focus a branch to narrow.</p>`;
575
+ return modeSection("sequence", at, `<p class="cf-note">Static call paths, not execution order.</p><ol class="cf-paths" data-cf-path-limit="${SEQUENCE_LIMIT}">${paths.map((chain) => renderPath(chain, at)).join("\n")}</ol>${note}`);
576
+ }
577
+ function collectPaths(node, path, chain, out) {
578
+ const next = chain.concat([{ node, path }]);
579
+ const kids = node.children;
580
+ if (kids.length === 0) {
581
+ out.push(next);
582
+ return;
583
+ }
584
+ kids.forEach((child, childAt) => collectPaths(child, path.concat([childAt]), next, out));
585
+ }
586
+ function renderPath(chain, at) {
587
+ const leaf = chain[chain.length - 1];
588
+ const chips = chain
589
+ .map((step) => renderChip(step, at))
590
+ .join('<span class="cf-arrow" aria-hidden="true">→</span>');
591
+ return `<li class="cf-path" data-cf-file="${at}" data-cf-path="${leaf.path.join("-")}">${chips}</li>`;
592
+ }
593
+ /** A chip is the step's zoom target plus, when the backend resolved it, its own
594
+ * source link: without the script both are plain anchors that reach the tree. */
595
+ function renderChip(step, at) {
596
+ const path = step.path.join("-");
597
+ const node = step.node;
598
+ const status = node.status;
599
+ const loc = locationText(node);
600
+ const title = loc === "" ? "" : ` title="${escapeHtml(loc)}"`;
601
+ return [
602
+ `<span class="cf-chip cf-st-${status}"${title}>`,
603
+ `<a class="cf-chip-zoom" href="#cf-f${at}-t-${path}" data-cf-zoom data-cf-file="${at}" data-cf-path="${path}">`,
604
+ statusBadge(status, "cf-chip-badge"),
605
+ `<span class="cf-chip-label mono">${escapeHtml(node.label)}</span>`,
606
+ loc === "" ? "" : `<span class="cf-chip-loc mono">${escapeHtml(loc)}</span>`,
607
+ "</a>",
608
+ node.description ? `<span class="cf-desc">${escapeHtml(node.description)}</span>` : "",
609
+ `<a class="cf-src-link" href="#cf-f${at}-src-${path}" data-cf-source` +
610
+ ` data-cf-file="${at}" data-cf-path="${path}">${node.source ? "source" : "details"}</a>`,
611
+ "</span>",
612
+ ]
613
+ .filter((part) => part !== "")
614
+ .join("");
615
+ }
616
+ export const CALL_FLOW_STYLES = `
617
+ .cf {
618
+ display: flex; flex-direction: column; gap: 12px; margin-top: 18px;
619
+ --cf-add: #1a7f37; --cf-add-soft: #dafbe1;
620
+ --cf-del: #cf222e; --cf-del-soft: #ffebe9;
621
+ --cf-chg: #9a6700; --cf-chg-soft: #fff8c5;
622
+ --cf-quiet: var(--ink-soft);
623
+ }
624
+ @media (prefers-color-scheme: dark) {
625
+ .cf {
626
+ --cf-add: #3fb950; --cf-add-soft: rgba(46, 160, 67, 0.15);
627
+ --cf-del: #f85149; --cf-del-soft: rgba(248, 81, 73, 0.12);
628
+ --cf-chg: #d29922; --cf-chg-soft: rgba(187, 128, 9, 0.15);
629
+ }
630
+ }
631
+ /* Mode sections, folded files and the breadcrumb are toggled with the hidden
632
+ attribute, so the module carries that rule itself instead of relying on the page. */
633
+ .cf [hidden] { display: none !important; }
634
+ .cf-head { display: flex; flex-direction: column; gap: 4px; }
635
+ .cf-prov { font-size: 12px; color: var(--ink-soft); }
636
+ .cf-note { font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }
637
+ .cf-absence { padding: 16px 0; }
638
+ .cf-bounds {
639
+ margin: 10px 0 0;
640
+ padding: 2px 0 2px 10px;
641
+ border-left: 2px solid var(--cf-chg);
642
+ color: var(--ink-soft);
643
+ font-size: 12px;
644
+ overflow-wrap: anywhere;
645
+ }
646
+ .cf-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
647
+ .cf-ready .cf-controls { position: sticky; top: var(--toolbar-height, 0px); z-index: 4; background: var(--bg); padding: 8px 0; }
648
+ .cf, .cf-files, .cf-file-body, .cf-mode { min-width: 0; }
649
+ .cf-modes {
650
+ display: inline-flex;
651
+ align-items: stretch;
652
+ border: 1px solid var(--line);
653
+ border-radius: 6px;
654
+ overflow: hidden;
655
+ background: var(--panel);
656
+ }
657
+ .cf-mode-link {
658
+ display: inline-flex;
659
+ align-items: center;
660
+ padding: 5px 14px;
661
+ font-size: 12px;
662
+ color: var(--ink-soft);
663
+ text-decoration: none;
664
+ border-right: 1px solid var(--line);
665
+ }
666
+ .cf-mode-link:last-child { border-right: 0; }
667
+ .cf-mode-link:hover, .cf-mode-link:focus-visible { color: var(--ink); background: var(--sunken); }
668
+ .js .cf-mode-link[aria-current] { color: var(--ink); background: var(--sunken); font-weight: 600; }
669
+ .cf-jump ul {
670
+ display: flex;
671
+ flex-wrap: wrap;
672
+ gap: 8px;
673
+ list-style: none;
674
+ margin: 0;
675
+ padding: 0;
676
+ max-height: 22vh;
677
+ overflow-y: auto;
678
+ }
679
+ .cf-jump-link {
680
+ display: inline-flex;
681
+ align-items: center;
682
+ gap: 8px;
683
+ max-width: 100%;
684
+ min-width: 0;
685
+ padding: 4px 10px 4px 9px;
686
+ border: 1px solid var(--line);
687
+ border-radius: 6px;
688
+ background: var(--panel);
689
+ color: var(--ink);
690
+ font-size: 12px;
691
+ text-decoration: none;
692
+ }
693
+ .cf-jump-link:hover, .cf-jump-link:focus-visible { border-color: var(--cursor); }
694
+ .cf-jump-path { min-width: 0; max-width: 32ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
695
+ .cf-jump-count { color: var(--ink-soft); white-space: nowrap; }
696
+ .cf-crumbs {
697
+ display: flex;
698
+ align-items: center;
699
+ flex-wrap: nowrap;
700
+ gap: 6px;
701
+ font-size: 12.5px;
702
+ padding-bottom: 3px;
703
+ overflow-x: auto;
704
+ overscroll-behavior-x: contain;
705
+ }
706
+ .cf-crumb {
707
+ display: inline-flex;
708
+ align-items: center;
709
+ gap: 6px;
710
+ flex: 0 0 auto;
711
+ max-width: 34ch;
712
+ font: inherit;
713
+ font-size: 12px;
714
+ padding: 3px 10px;
715
+ border: 1px solid var(--line-strong);
716
+ border-radius: 999px;
717
+ background: var(--panel);
718
+ color: var(--ink);
719
+ cursor: pointer;
720
+ white-space: nowrap;
721
+ }
722
+ .cf-crumb[aria-current] { border-color: var(--cursor); font-weight: 600; }
723
+ .cf-crumb-label { overflow: hidden; text-overflow: ellipsis; }
724
+ .cf-crumb-file {
725
+ flex: 0 0 auto;
726
+ max-width: 22ch;
727
+ overflow: hidden;
728
+ text-overflow: ellipsis;
729
+ font-size: 11px;
730
+ color: var(--ink-soft);
731
+ }
732
+ .cf-sep { flex: 0 0 auto; color: var(--ink-soft); }
733
+ .cf-files { display: flex; flex-direction: column; gap: 12px; }
734
+ .cf-file {
735
+ background: var(--panel);
736
+ border: 1px solid var(--line);
737
+ border-left: 3px solid var(--line-strong);
738
+ border-radius: 6px;
739
+ overflow: hidden;
740
+ }
741
+ .cf-file { --cf-color: var(--ink-soft); --cf-fill: var(--sunken); }
742
+ .cf-file-attention { border-left-color: var(--alarm); --cf-color: var(--alarm); --cf-fill: var(--alarm-bg); }
743
+ .cf-file-uncertain { border-left-color: var(--warn); --cf-color: var(--warn); --cf-fill: var(--warn-bg); }
744
+ .cf-file-low { border-left-color: var(--line-strong); }
745
+ .cf-file-passed { border-left-color: var(--teal); --cf-color: var(--teal); --cf-fill: var(--teal-bg); }
746
+ .cf-file-head {
747
+ display: flex;
748
+ align-items: center;
749
+ flex-wrap: wrap;
750
+ gap: 10px;
751
+ padding: 8px 12px;
752
+ background: var(--sunken);
753
+ cursor: pointer;
754
+ list-style: none;
755
+ }
756
+ .cf-file-head::-webkit-details-marker { display: none; }
757
+ .cf-file-head::after {
758
+ content: "▸";
759
+ margin-left: auto;
760
+ font-size: 11px;
761
+ color: var(--ink-soft);
762
+ transition: transform 0.15s ease;
763
+ }
764
+ .cf-file[open] > .cf-file-head::after { transform: rotate(90deg); }
765
+ .cf-file-head:focus-visible { outline: 2px solid var(--cursor); outline-offset: -2px; }
766
+ .cf-file-path { flex: 1 1 260px; min-width: 0; font-size: 12.5px; font-weight: 600; overflow-wrap: anywhere; }
767
+ .cf-file-count { font-size: 12px; color: var(--ink-soft); white-space: nowrap; }
768
+ .cf-diff-link { font-size: 12px; white-space: nowrap; }
769
+ .cf-file-body { border-top: 1px solid var(--line); padding: 0 12px 12px; }
770
+ .cf-mode { padding-top: 12px; }
771
+ .cf-noscript { display: block; }
772
+ .cf-noscript .cf-note { margin: 6px 0 0; }
773
+ .cf-mode + .cf-mode { margin-top: 12px; border-top: 1px dashed var(--line); }
774
+ .cf-mode-head {
775
+ margin: 0 0 8px;
776
+ font-size: 11.5px;
777
+ font-weight: 700;
778
+ letter-spacing: 0.08em;
779
+ text-transform: uppercase;
780
+ color: var(--ink-soft);
781
+ }
782
+ .cf-ready .cf-mode-head { display: none; }
783
+ .cf-ready .cf-mode + .cf-mode { margin-top: 0; border-top: 0; }
784
+ .cf-tree, .cf-children { list-style: none; margin: 0; padding: 0; }
785
+ .cf-children { margin-left: 15px; padding-left: 10px; border-left: 1px dashed var(--line); }
786
+ .cf-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0 8px; padding: 2px 6px; border-radius: 6px; min-width: 0; }
787
+ .cf-row:hover { background: var(--sunken); }
788
+ .cf-fold > .cf-row { cursor: pointer; list-style: none; }
789
+ .cf-fold > .cf-row::-webkit-details-marker { display: none; }
790
+ .cf-fold > .cf-row::before { content: "▸"; font-size: 10px; color: var(--ink-soft); transition: transform 0.15s ease; }
791
+ .cf-fold[open] > .cf-row::before { transform: rotate(90deg); }
792
+ .cf-badge, .cf-chip-badge {
793
+ display: inline-flex;
794
+ align-items: center;
795
+ justify-content: center;
796
+ flex: 0 0 auto;
797
+ min-width: 16px;
798
+ height: 16px;
799
+ border: 1px solid transparent;
800
+ border-radius: 4px;
801
+ font-family: var(--mono);
802
+ font-size: 11px;
803
+ font-weight: 700;
804
+ }
805
+ .cf-badge, .cf-chip-badge { color: var(--cf-node-color); background: var(--cf-node-fill); }
806
+ .cf-label {
807
+ font: 400 12.5px/1.5 var(--mono);
808
+ padding: 0 2px;
809
+ border: 1px solid transparent;
810
+ border-radius: 4px;
811
+ background: none;
812
+ text-align: left;
813
+ /* Capped so a long call never pushes its row onto a second line; the
814
+ location takes what is left and truncates first. */
815
+ flex: 0 1 auto;
816
+ min-width: 0;
817
+ max-width: 62%;
818
+ white-space: nowrap;
819
+ overflow: hidden;
820
+ text-overflow: ellipsis;
821
+ }
822
+ .js .cf-label { cursor: pointer; }
823
+ .js .cf-label:hover, .cf-label:focus-visible { text-decoration: underline; }
824
+ .cf-label:focus-visible { outline: 2px solid var(--cursor); outline-offset: 1px; }
825
+ .cf-loc { flex: 1 1 0; min-width: 0; font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
826
+ .cf-loc-none { font-style: italic; }
827
+ .cf-node, .cf-gnode, .cf-chip { --cf-node-color: var(--cf-chg); --cf-node-fill: var(--cf-chg-soft); --cf-node-weight: 600; --cf-node-decoration: none; }
828
+ .cf-st-added { --cf-node-color: var(--cf-add); --cf-node-fill: var(--cf-add-soft); }
829
+ .cf-st-removed { --cf-node-color: var(--cf-del); --cf-node-fill: var(--cf-del-soft); --cf-node-decoration: line-through; }
830
+ .cf-node.cf-st-same, .cf-gnode.cf-st-same, .cf-chip.cf-st-same { --cf-node-color: var(--ink-soft); --cf-node-fill: var(--sunken); --cf-node-weight: 400; }
831
+ .cf-label { color: var(--cf-node-color); font-weight: var(--cf-node-weight); text-decoration: var(--cf-node-decoration); }
832
+ .cf-node.cf-ancestor > .cf-fold > .cf-row { display: none; }
833
+ .cf-node.cf-ancestor > .cf-fold > .cf-children { margin: 0; padding: 0; border: 0; }
834
+ .cf-svg-wrap {
835
+ max-width: 100%;
836
+ overflow: auto;
837
+ border: 1px solid var(--line);
838
+ border-radius: 6px;
839
+ background: var(--sunken);
840
+ overscroll-behavior: contain;
841
+ }
842
+ .cf-svg { display: block; max-width: none; }
843
+ .cf-ready .cf-svg-wrap { height: clamp(220px, 42vh, 400px); overflow: hidden; touch-action: none; cursor: grab; }
844
+ .cf-diagram-btn, .cf-diagram-close {
845
+ display: inline-flex; align-items: center; gap: 6px; height: 29px; padding: 0 12px;
846
+ border: 1px solid var(--line); border-radius: 6px; background: var(--panel);
847
+ font: 500 12px/1 var(--sans, inherit); color: var(--ink); cursor: pointer;
848
+ }
849
+ .cf-diagram-btn { color: var(--cursor); border-color: var(--cursor); }
850
+ .cf-diagram-btn::after { content: "\\2197"; font-size: 12px; }
851
+ .cf-diagram-btn:hover, .cf-diagram-close:hover { background: var(--sunken); }
852
+ .cf-diagram-close { margin-left: auto; }
853
+ .cf-diagram-hint { margin: 0 0 0 auto; font-size: 12px; color: var(--ink-soft); }
854
+ .cf-diagram-hint + .cf-diagram-close { margin-left: 12px; }
855
+ .cf.cf-diagram-open .cf-head { display: none; }
856
+ /* The diagram view fills the browser window, and the graph gets nearly all of its height. */
857
+ .cf.cf-diagram-open {
858
+ position: fixed; inset: 0; z-index: 50; margin: 0; padding: 12px 20px 20px;
859
+ background: var(--bg); overflow: auto; overscroll-behavior: contain;
860
+ }
861
+ .cf.cf-diagram-open .cf-controls { position: sticky; top: -12px; z-index: 5; background: var(--bg); padding: 10px 0; }
862
+ .cf.cf-diagram-open .cf-modes, .cf.cf-diagram-open .cf-diagram-btn { display: none !important; }
863
+ .cf.cf-diagram-open { padding-left: 292px; }
864
+ .cf.cf-diagram-open .cf-svg-wrap { height: calc(100vh - 150px); min-height: 320px; }
865
+ /* One canvas at a time: the entry point picked in the list, in its own file. */
866
+ .cf.cf-diagram-open .cf-jump, .cf.cf-diagram-open .cf-bounds, .cf.cf-diagram-open .cf-omitted { display: none !important; }
867
+ .cf.cf-diagram-open .cf-file:not(.cf-file-current) { display: none; }
868
+ .cf.cf-diagram-open .cf-graph-frame:not(.cf-frame-current) { display: none; }
869
+ .cf.cf-diagram-open .cf-file-body { padding-bottom: 0; }
870
+ .cf-diagram-nav {
871
+ position: fixed; top: 0; bottom: 0; left: 0; width: 276px; overflow-y: auto; z-index: 6;
872
+ padding: 16px 12px; background: var(--sunken); border-right: 1px solid var(--line);
873
+ }
874
+ .cf-diagram-nav-head { margin: 0 0 10px; font-size: 13px; font-weight: 600; color: var(--ink); }
875
+ .cf-diagram-nav-file {
876
+ margin: 14px 0 4px; font: 600 12px var(--mono); color: var(--ink-soft);
877
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
878
+ }
879
+ .cf-diagram-nav ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
880
+ .cf-diagram-nav button {
881
+ display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%;
882
+ padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: var(--ink); text-align: left; cursor: pointer;
883
+ }
884
+ .cf-diagram-nav button:hover { background: var(--panel); }
885
+ .cf-diagram-nav button[aria-current] { background: var(--panel); box-shadow: inset 3px 0 0 var(--cursor); }
886
+ .cf-diagram-nav-name { max-width: 100%; font: 600 12px/1.35 var(--mono); overflow-wrap: anywhere; }
887
+ .cf-diagram-nav-count { max-width: 100%; font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
888
+ @media (max-width: 760px) {
889
+ .cf.cf-diagram-open { padding-left: 12px; padding-top: 12px; }
890
+ .cf-diagram-nav { position: static; width: auto; max-height: 30vh; margin: 0 0 8px; border: 1px solid var(--line); border-radius: 6px; }
891
+ }
892
+ .cf-ready .cf-svg-wrap:focus-visible { outline: 2px solid var(--cursor); outline-offset: 2px; }
893
+ .cf-ready .cf-svg-wrap.cf-dragging { cursor: grabbing; user-select: none; }
894
+ .cf-ready .cf-svg { width: 100%; height: 100%; }
895
+ .cf-graph-frame + .cf-graph-frame { margin-top: 14px; }
896
+ .cf-camera-tools { align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; }
897
+ .cf-camera-tools button { min-height: 28px; padding: 0 10px; font-size: 12px; }
898
+ .cf-scale { min-width: 4ch; text-align: center; font: 11px var(--mono); color: var(--ink-soft); }
899
+ .cf-omitted { display: none; }
900
+ .cf-ready .cf-omitted { display: block; }
901
+ .cf-node.cf-selected > .cf-row, .cf-node.cf-selected > .cf-fold > .cf-row,
902
+ .cf-chip.cf-selected { outline: 2px solid var(--cursor); outline-offset: 1px; border-radius: 5px; }
903
+ .cf-edge { fill: none; stroke: var(--line-strong); stroke-width: 1.5; }
904
+ .cf-gnode rect { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; }
905
+ .cf-gnode:focus-visible { outline: none; }
906
+ .cf-gzoom circle { fill: var(--panel); stroke: var(--line-strong); }
907
+ .cf-gzoom text { fill: var(--ink); font: 14px var(--mono); }
908
+ .cf-gzoom:hover circle, .cf-gzoom:focus-visible circle { stroke: var(--cursor); stroke-width: 2; }
909
+ .cf-gzoom:focus-visible { outline: none; }
910
+ .cf-glabel { font-family: var(--mono); font-size: 12px; fill: var(--ink); }
911
+ .cf-gloc { font-family: var(--mono); font-size: 10px; fill: var(--ink-soft); }
912
+ .cf-gnode.cf-st-same rect { stroke: var(--line); fill: transparent; }
913
+ .cf-gnode.cf-st-same .cf-glabel { fill: var(--ink-soft); }
914
+ .cf-gnode:not(.cf-st-same) rect { stroke: var(--cf-node-color); fill: var(--panel); }
915
+ .cf-gnode:not(.cf-st-same) .cf-glabel { fill: var(--cf-node-color); font-weight: 600; }
916
+ .cf-gnode:hover rect, .cf-gnode:focus-visible rect { stroke: var(--cursor); stroke-width: 2.5; }
917
+ .cf-gnode.cf-selected rect { stroke: var(--cursor); stroke-width: 3; }
918
+ .cf-paths { display: flex; flex-direction: column; gap: 6px; list-style: none; margin: 0; padding: 0; }
919
+ .cf-path {
920
+ display: flex;
921
+ align-items: center;
922
+ flex-wrap: wrap;
923
+ gap: 4px 6px;
924
+ padding: 6px 8px;
925
+ border: 1px solid var(--line);
926
+ border-radius: 6px;
927
+ background: var(--panel);
928
+ }
929
+ .cf-chip {
930
+ display: inline-flex;
931
+ align-items: center;
932
+ gap: 6px;
933
+ max-width: 100%;
934
+ min-width: 0;
935
+ padding: 2px 8px 2px 4px;
936
+ border: 1px solid var(--line);
937
+ border-left: 3px solid var(--cf-node-color);
938
+ border-radius: 6px;
939
+ background: var(--panel);
940
+ color: var(--ink);
941
+ }
942
+ .cf-chip:hover, .cf-chip:focus-within { border-color: var(--cursor); }
943
+ .cf-chip-zoom {
944
+ display: inline-flex;
945
+ align-items: center;
946
+ gap: 6px;
947
+ max-width: 100%;
948
+ min-width: 0;
949
+ color: inherit;
950
+ text-decoration: none;
951
+ }
952
+ .cf-chip-label { min-width: 0; max-width: 36ch; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
953
+ .cf-chip-loc { min-width: 0; max-width: 28ch; font-size: 10.5px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
954
+ .cf-chip.cf-st-same .cf-chip-label { color: var(--ink-soft); }
955
+ .cf-chip:not(.cf-st-same) .cf-chip-label { color: var(--cf-node-color); font-weight: 600; }
956
+ .cf-chip .cf-desc { max-width: 28ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
957
+ .cf-arrow { color: var(--ink-soft); font-size: 12px; }
958
+ /* One description line per call, only when the backend attached one. */
959
+ .cf-desc { font-size: 11.5px; color: var(--ink-soft); font-style: italic; overflow-wrap: anywhere; }
960
+ .cf-row > .cf-desc { flex: 1 0 100%; order: 5; padding-left: 26px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
961
+ .cf-row > .cf-src-link { order: 4; }
962
+ .cf-depth { align-items: center; gap: 4px; }
963
+ .cf-depth-label { font-size: 12px; color: var(--ink-soft); }
964
+ .cf-depth-btn { padding: 3px 9px; font-size: 12px; }
965
+ .cf-depth-btn[aria-pressed="true"] { border-color: var(--cursor); color: var(--cursor); font-weight: 600; }
966
+ /* Numbered, clickable call order on the graph edges. */
967
+ .cf-edge-num circle { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; }
968
+ .cf-edge-num text { font-family: var(--mono); font-size: 10.5px; fill: var(--ink-soft); }
969
+ .cf-edge-num:hover circle, .cf-edge-num:focus-visible circle { stroke: var(--cursor); stroke-width: 2; }
970
+ .cf-edge-num:hover text, .cf-edge-num:focus-visible text { fill: var(--cursor); font-weight: 700; }
971
+ .cf-edge-num:focus-visible { outline: none; }
972
+ .cf-gdesc { font-family: var(--mono); font-size: 10px; font-style: italic; fill: var(--ink-soft); }
973
+ /* Resolved definition of one call: a native disclosure without the script, and
974
+ the body the script clones into its single shared panel. */
975
+ .cf-source { margin: 3px 0 3px 24px; }
976
+ .cf-ready .cf-source { display: none; }
977
+ .cf-src-sum { width: fit-content; cursor: pointer; list-style: none; font-size: 11.5px; color: var(--ink-soft); }
978
+ .cf-src-sum::-webkit-details-marker { display: none; }
979
+ .cf-src-sum::before { content: "▸"; margin-right: 6px; font-size: 10px; transition: transform 0.15s ease; display: inline-block; }
980
+ .cf-source[open] > .cf-src-sum::before { transform: rotate(90deg); }
981
+ .cf-src-sum:hover, .cf-src-sum:focus-visible { color: var(--cursor); }
982
+ .cf-src-body { padding: 6px 0 4px; }
983
+ .cf-src-ref { margin: 0 0 5px; font-size: 11.5px; color: var(--ink-soft); overflow-wrap: anywhere; }
984
+ .cf-src-code {
985
+ margin: 0;
986
+ padding: 6px 9px;
987
+ border: 1px solid var(--line);
988
+ border-radius: 6px;
989
+ background: var(--sunken);
990
+ font-family: var(--mono);
991
+ font-size: 11.5px;
992
+ line-height: 1.5;
993
+ overflow-x: auto;
994
+ }
995
+ .cf-src-line { display: block; white-space: pre; }
996
+ .cf-src-no {
997
+ display: inline-block;
998
+ width: 4ch;
999
+ margin-right: 10px;
1000
+ text-align: right;
1001
+ color: var(--ink-soft);
1002
+ user-select: none;
1003
+ }
1004
+ .cf-src-note { margin: 0; font-size: 11.5px; color: var(--ink-soft); font-style: italic; }
1005
+ .cf-src-panel {
1006
+ padding: 8px 12px 10px;
1007
+ border: 1px solid var(--line-strong);
1008
+ border-radius: 6px;
1009
+ background: var(--panel);
1010
+ }
1011
+ .cf-src-panel-sum { width: fit-content; cursor: pointer; font-size: 12.5px; font-weight: 600; }
1012
+ .cf-src-panel-body { padding-top: 6px; }
1013
+ .cf-src-panel-body { max-height: 40vh; overflow: auto; }
1014
+ .cf-src-head-line { margin: 0 0 6px; font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }
1015
+ .cf-src-missing { margin: 0; }
1016
+ .cf-src-link {
1017
+ flex: 0 0 auto;
1018
+ font-size: 11px;
1019
+ color: var(--ink-soft);
1020
+ text-decoration: none;
1021
+ white-space: nowrap;
1022
+ padding: 0 6px;
1023
+ border: 1px solid var(--line);
1024
+ border-radius: 999px;
1025
+ }
1026
+ .cf-src-link:hover { text-decoration: none; }
1027
+ .cf-src-link:hover, .cf-src-link:focus-visible { color: var(--cursor); }
1028
+ @media (max-width: 680px) {
1029
+ .cf-jump ul { max-height: 38vh; }
1030
+ .cf-jump-link { width: 100%; }
1031
+ .cf-jump-path { max-width: none; flex: 1 1 auto; }
1032
+ .cf-file-body { padding: 0 11px 12px; }
1033
+ .cf-crumb { max-width: 24ch; }
1034
+ .cf-crumb-file { max-width: 12ch; }
1035
+ .cf-source { margin-left: 10px; }
1036
+ .cf-src-no { width: 3ch; margin-right: 7px; }
1037
+ .cf-chip, .cf-chip-zoom { min-width: 0; flex-wrap: wrap; }
1038
+ .cf-chip-loc { overflow-wrap: anywhere; }
1039
+ }
1040
+ `;
1041
+ /**
1042
+ * Progressive enhancement for the rendered call flow. Nothing here is
1043
+ * interpolated: the visited trail, mode, depth and the source text all come from
1044
+ * attributes and text already escaped in the DOM, and the navigation state
1045
+ * machine is the module the unit tests call (see CALL_FLOW_NAV_SOURCE).
1046
+ */
1047
+ export const CALL_FLOW_SCRIPT = `
1048
+ (function () {
1049
+ 'use strict';
1050
+ ${CALL_FLOW_NAV_SOURCE}
1051
+ var view = document.getElementById('view-call-flow');
1052
+ if (!view) return;
1053
+ var host = view.querySelector('.cf');
1054
+ if (!host) return;
1055
+ var files = Array.prototype.slice.call(view.querySelectorAll('.cf-file'));
1056
+ var modeLinks = Array.prototype.slice.call(view.querySelectorAll('[data-cf-mode]'));
1057
+ var depthButtons = Array.prototype.slice.call(view.querySelectorAll('[data-cf-depth]'));
1058
+ var crumbNav = document.getElementById('cf-crumbs');
1059
+ var sourcePanel = document.getElementById('cf-src-panel');
1060
+ var sourceBody = document.getElementById('cf-src-panel-body');
1061
+ var state = cfNav.createState();
1062
+ var graphViews = new WeakMap();
1063
+ var graphSelections = new WeakMap();
1064
+ var drag = null;
1065
+ var suppressClick = false;
1066
+
1067
+ function rectOf(rect) {
1068
+ return {
1069
+ x: Number(rect.getAttribute('x')), y: Number(rect.getAttribute('y')),
1070
+ width: Number(rect.getAttribute('width')), height: Number(rect.getAttribute('height')),
1071
+ };
1072
+ }
1073
+
1074
+ function paintCamera(graph, camera) {
1075
+ var info = graphViews.get(graph);
1076
+ // frameGraph replaces info after resize/depth changes, forcing a fresh paint.
1077
+ var previous = info.camera;
1078
+ if (previous && previous.x === camera.x && previous.y === camera.y && previous.scale === camera.scale) return;
1079
+ info.camera = camera;
1080
+ state = cfNav.setCamera(state, info.key, camera);
1081
+ graph.setAttribute('viewBox', [camera.x, camera.y, info.size.width / camera.scale, info.size.height / camera.scale].join(' '));
1082
+ // Panning does not change the zoom readout or its button bounds.
1083
+ if (previous && previous.scale === camera.scale) return;
1084
+ var frame = graph.closest('.cf-graph-frame');
1085
+ frame.querySelector('.cf-scale').textContent = Math.round(camera.scale * 100) + '%';
1086
+ frame.querySelector('[data-cf-camera="out"]').disabled = camera.scale <= 1;
1087
+ frame.querySelector('[data-cf-camera="in"]').disabled = camera.scale >= 2.5;
1088
+ }
1089
+
1090
+ function frameGraph(graph) {
1091
+ var wrap = graph.parentElement;
1092
+ if (state.mode !== 'graph' || !wrap.clientWidth || !wrap.clientHeight || !graph.getClientRects().length) return;
1093
+ var boxes = graph.querySelectorAll('a.cf-gnode:not([hidden]) rect');
1094
+ if (!boxes.length) return;
1095
+ var left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
1096
+ for (var i = 0; i < boxes.length; i++) {
1097
+ var box = rectOf(boxes[i]);
1098
+ left = Math.min(left, box.x); top = Math.min(top, box.y);
1099
+ right = Math.max(right, box.x + box.width); bottom = Math.max(bottom, box.y + box.height);
1100
+ }
1101
+ var selected = graph.querySelector('a.cf-gnode.cf-selected:not([hidden]) rect');
1102
+ var bounds = { x: left - 16, y: top - 16, width: right - left + 32, height: bottom - top + 32 };
1103
+ var size = { width: wrap.clientWidth, height: wrap.clientHeight };
1104
+ var target = rectOf(selected || boxes[0]);
1105
+ var branch = state.branch;
1106
+ var key = graph.closest('.cf-graph-frame').getAttribute('data-cf-graph') + '/' +
1107
+ (branch ? cfNav.key(branch.file, branch.path) : '') + '/' + state.depth;
1108
+ graphViews.set(graph, { key: key, bounds: bounds, size: size, target: target });
1109
+ var camera = state.cameras[key];
1110
+ camera = camera ? cfNav.constrain(camera, bounds, size) : cfNav.frame(bounds, size, target);
1111
+ var selection = selected ? selected.parentElement.getAttribute('data-cf-path') : '';
1112
+ // Only a new cross-view selection can move an existing camera. Returning to
1113
+ // the same selection preserves even a deliberately panned-away viewport.
1114
+ if (selected && graphSelections.has(graph) && graphSelections.get(graph) !== selection &&
1115
+ (target.x < camera.x || target.y < camera.y ||
1116
+ target.x + target.width > camera.x + size.width / camera.scale ||
1117
+ target.y + target.height > camera.y + size.height / camera.scale)) {
1118
+ camera = cfNav.frame(bounds, size, target);
1119
+ }
1120
+ graphSelections.set(graph, selection);
1121
+ paintCamera(graph, camera);
1122
+ }
1123
+
1124
+ function frameGraphs() {
1125
+ if (state.mode !== 'graph') return;
1126
+ var graphs = view.querySelectorAll('.cf-svg:not([hidden])');
1127
+ for (var i = 0; i < graphs.length; i++) frameGraph(graphs[i]);
1128
+ }
1129
+
1130
+ function cameraAction(graph, action) {
1131
+ var info = graphViews.get(graph);
1132
+ if (!info) return;
1133
+ var camera = state.cameras[info.key];
1134
+ if (action === 'readable' || action === 'overview') {
1135
+ camera = cfNav.frame(info.bounds, info.size, info.target, action === 'overview');
1136
+ } else {
1137
+ camera = cfNav.zoom(camera, action === 'in' ? 1.25 : 0.8,
1138
+ { x: info.size.width / 2, y: info.size.height / 2 }, info.bounds, info.size);
1139
+ }
1140
+ paintCamera(graph, camera);
1141
+ }
1142
+
1143
+ function fileAt(index) {
1144
+ for (var i = 0; i < files.length; i++) {
1145
+ if (files[i].getAttribute('data-cf-file') === String(index)) return files[i];
1146
+ }
1147
+ return null;
1148
+ }
1149
+
1150
+ function each(list, at) {
1151
+ for (var i = 0; i < list.length; i++) {
1152
+ if (list[i].getAttribute('data-cf-path') === at) return list[i];
1153
+ }
1154
+ return null;
1155
+ }
1156
+
1157
+ function nodeAt(index, path) {
1158
+ var file = fileAt(index);
1159
+ return file === null ? null : each(file.querySelectorAll('li.cf-node'), path);
1160
+ }
1161
+
1162
+ // Where the focus is right now: no entry means the whole file list is shown.
1163
+ function focusAt() {
1164
+ var entry = cfNav.current(state);
1165
+ return {
1166
+ file: entry === null ? null : entry.file,
1167
+ path: entry === null ? '' : entry.path,
1168
+ };
1169
+ }
1170
+
1171
+ // A tree label is inert markup until the page has scripts. Upgrade it into a
1172
+ // real button so zoom is clickable and reachable by keyboard.
1173
+ function upgradeLabels() {
1174
+ var labels = Array.prototype.slice.call(view.querySelectorAll('span.cf-label[data-cf-zoom]'));
1175
+ for (var i = 0; i < labels.length; i++) {
1176
+ var span = labels[i];
1177
+ var button = document.createElement('button');
1178
+ button.type = 'button';
1179
+ button.className = span.className;
1180
+ button.textContent = span.textContent;
1181
+ button.setAttribute('data-cf-zoom', '');
1182
+ button.setAttribute('data-cf-file', span.getAttribute('data-cf-file'));
1183
+ button.setAttribute('data-cf-path', span.getAttribute('data-cf-path'));
1184
+ button.setAttribute('aria-label', 'Zoom into ' + span.textContent);
1185
+ var title = span.getAttribute('title');
1186
+ if (title) button.setAttribute('title', title);
1187
+ span.parentNode.replaceChild(button, span);
1188
+ }
1189
+ }
1190
+
1191
+ function applyMode() {
1192
+ for (var i = 0; i < files.length; i++) {
1193
+ var sections = files[i].querySelectorAll('[data-cf-mode-body]');
1194
+ for (var j = 0; j < sections.length; j++) {
1195
+ sections[j].hidden = sections[j].getAttribute('data-cf-mode-body') !== state.mode;
1196
+ }
1197
+ }
1198
+ var focus = focusAt();
1199
+ for (var k = 0; k < modeLinks.length; k++) {
1200
+ var name = modeLinks[k].getAttribute('data-cf-mode');
1201
+ if (name === state.mode) modeLinks[k].setAttribute('aria-current', 'true');
1202
+ else modeLinks[k].removeAttribute('aria-current');
1203
+ if (focus.file !== null) modeLinks[k].setAttribute('href', '#cf-f' + focus.file + '-' + name);
1204
+ }
1205
+ for (var d = 0; d < depthButtons.length; d++) {
1206
+ var pressed = String(depthButtons[d].getAttribute('data-cf-depth')) === String(state.depth);
1207
+ depthButtons[d].setAttribute('aria-pressed', pressed ? 'true' : 'false');
1208
+ }
1209
+ var depthControl = host.querySelector('.cf-depth');
1210
+ if (depthControl) depthControl.hidden = state.mode !== 'graph';
1211
+ }
1212
+
1213
+ function applyFocus() {
1214
+ var selected = focusAt();
1215
+ var branch = state.branch;
1216
+ var focus = { file: branch === null ? null : branch.file, path: branch === null ? '' : branch.path };
1217
+ var depth = state.depth;
1218
+ for (var i = 0; i < files.length; i++) {
1219
+ var file = files[i];
1220
+ var mine = focus.file === null || file.getAttribute('data-cf-file') === String(focus.file);
1221
+ file.hidden = focus.file !== null && !mine;
1222
+ var nodes = file.querySelectorAll('li.cf-node');
1223
+ for (var n = 0; n < nodes.length; n++) {
1224
+ var path = nodes[n].getAttribute('data-cf-path');
1225
+ nodes[n].hidden = !mine || !cfNav.visibleNode(path, focus.path, 'all');
1226
+ nodes[n].classList.toggle('cf-selected', Number(file.getAttribute('data-cf-file')) === selected.file && path === selected.path);
1227
+ nodes[n].classList.toggle(
1228
+ 'cf-ancestor',
1229
+ mine && focus.path !== '' && path !== focus.path && cfNav.onChain(path, focus.path),
1230
+ );
1231
+ }
1232
+ var gNodes = file.querySelectorAll('a.cf-gnode, a.cf-gzoom');
1233
+ for (var g = 0; g < gNodes.length; g++) {
1234
+ var gPath = gNodes[g].getAttribute('data-cf-path');
1235
+ gNodes[g].toggleAttribute('hidden', !mine || !cfNav.under(gPath, focus.path) || !cfNav.visibleNode(gPath, focus.path, depth));
1236
+ gNodes[g].classList.toggle('cf-selected', Number(file.getAttribute('data-cf-file')) === selected.file && gPath === selected.path);
1237
+ }
1238
+ var edges = file.querySelectorAll('path.cf-edge');
1239
+ for (var e = 0; e < edges.length; e++) {
1240
+ var child = edges[e].getAttribute('data-cf-edge-child');
1241
+ edges[e].toggleAttribute('hidden', !mine || !cfNav.visibleCall(child, focus.path, depth));
1242
+ }
1243
+ var numbers = file.querySelectorAll('a.cf-edge-num');
1244
+ for (var c = 0; c < numbers.length; c++) {
1245
+ var target = numbers[c].getAttribute('data-cf-path');
1246
+ numbers[c].toggleAttribute('hidden', !mine || !cfNav.visibleCall(target, focus.path, depth));
1247
+ }
1248
+ var graphs = file.querySelectorAll('.cf-svg');
1249
+ for (var s = 0; s < graphs.length; s++) {
1250
+ var graph = graphs[s];
1251
+ var shown = graph.querySelector('a.cf-gnode:not([hidden])');
1252
+ graph.toggleAttribute('hidden', !shown);
1253
+ graph.closest('.cf-graph-frame').hidden = !shown;
1254
+ if (shown) frameGraph(graph);
1255
+ }
1256
+ var paths = file.querySelectorAll('.cf-path');
1257
+ var pathList = file.querySelector('.cf-paths');
1258
+ var limit = pathList ? Number(pathList.getAttribute('data-cf-path-limit')) : 0;
1259
+ var matching = [];
1260
+ var preferred = -1;
1261
+ for (var p = 0; p < paths.length; p++) {
1262
+ var leaf = paths[p].getAttribute('data-cf-path');
1263
+ paths[p].hidden = true;
1264
+ if (mine && cfNav.under(leaf, focus.path)) {
1265
+ if (preferred < 0 && Number(file.getAttribute('data-cf-file')) === selected.file && cfNav.under(leaf, selected.path)) preferred = matching.length;
1266
+ matching.push(paths[p]);
1267
+ }
1268
+ }
1269
+ var matches = matching.length;
1270
+ for (var m = 0; m < Math.min(matches, limit); m++) {
1271
+ matching[m === limit - 1 && preferred >= limit ? preferred : m].hidden = false;
1272
+ }
1273
+ var chips = file.querySelectorAll('.cf-chip');
1274
+ for (var h = 0; h < chips.length; h++) {
1275
+ var link = chips[h].querySelector('[data-cf-path]');
1276
+ chips[h].classList.toggle('cf-selected', Number(file.getAttribute('data-cf-file')) === selected.file && link.getAttribute('data-cf-path') === selected.path);
1277
+ }
1278
+ var omitted = file.querySelector('.cf-omitted');
1279
+ if (omitted) {
1280
+ omitted.hidden = matches <= limit;
1281
+ omitted.textContent = 'Showing ' + Math.min(matches, limit) + ' of ' + matches + ' call paths; focus a branch to narrow.';
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ function labelAt(index, path) {
1287
+ var node = nodeAt(index, path);
1288
+ var label = node === null ? null : node.querySelector('.cf-label');
1289
+ return label === null ? '' : label.textContent;
1290
+ }
1291
+
1292
+ function fileLabel(index) {
1293
+ var file = fileAt(index);
1294
+ var path = file === null ? null : file.querySelector('.cf-file-path');
1295
+ return path === null ? '' : path.textContent;
1296
+ }
1297
+
1298
+ // Receiver file transitions: the resolved definition file when the backend
1299
+ // found one, the call-site file next, then the section the call is drawn in.
1300
+ function receiverFile(index, path) {
1301
+ var node = nodeAt(index, path);
1302
+ if (node !== null) {
1303
+ var source = node.getAttribute('data-cf-srcfile');
1304
+ if (source) return source;
1305
+ var callsite = node.getAttribute('data-cf-nodefile');
1306
+ if (callsite) return callsite;
1307
+ }
1308
+ return fileLabel(index);
1309
+ }
1310
+
1311
+ function sep() {
1312
+ var span = document.createElement('span');
1313
+ span.className = 'cf-sep';
1314
+ span.setAttribute('aria-hidden', 'true');
1315
+ span.textContent = '/';
1316
+ return span;
1317
+ }
1318
+
1319
+ function crumb(text, index) {
1320
+ var button = document.createElement('button');
1321
+ button.type = 'button';
1322
+ button.className = 'cf-crumb';
1323
+ if (index < 0) button.setAttribute('data-cf-root', '');
1324
+ else button.setAttribute('data-cf-crumb', String(index));
1325
+ var label = document.createElement('span');
1326
+ label.className = 'cf-crumb-label';
1327
+ label.textContent = text;
1328
+ button.appendChild(label);
1329
+ return button;
1330
+ }
1331
+
1332
+ // The trail is the history of focused calls, so going back is clicking a tab:
1333
+ // every visit after it is dropped. A tab shows the file only where the
1334
+ // receiver file changes, which is what makes a cross-file step visible.
1335
+ function renderCrumbs() {
1336
+ if (!crumbNav) return;
1337
+ while (crumbNav.firstChild) crumbNav.removeChild(crumbNav.firstChild);
1338
+ var trail = state.trail;
1339
+ if (trail.length === 0) {
1340
+ crumbNav.hidden = true;
1341
+ return;
1342
+ }
1343
+ crumbNav.appendChild(crumb('All files', -1));
1344
+ var previous = '';
1345
+ for (var i = 0; i < trail.length; i++) {
1346
+ var entry = trail[i];
1347
+ var label = labelAt(entry.file, entry.path) || entry.path;
1348
+ var file = receiverFile(entry.file, entry.path);
1349
+ crumbNav.appendChild(sep());
1350
+ var tab = crumb(label, i);
1351
+ tab.setAttribute('title', file === '' ? label : label + ' · ' + file);
1352
+ if (i === trail.length - 1) tab.setAttribute('aria-current', 'true');
1353
+ if (file !== '' && (i === 0 || file !== previous)) {
1354
+ var tag = document.createElement('span');
1355
+ tag.className = 'cf-crumb-file mono';
1356
+ tag.textContent = file;
1357
+ tab.appendChild(tag);
1358
+ }
1359
+ previous = file;
1360
+ crumbNav.appendChild(tab);
1361
+ }
1362
+ crumbNav.hidden = false;
1363
+ }
1364
+
1365
+ function note(text) {
1366
+ var paragraph = document.createElement('p');
1367
+ paragraph.className = 'cf-note cf-src-missing';
1368
+ paragraph.textContent = text;
1369
+ return paragraph;
1370
+ }
1371
+
1372
+ // The single panel repeats the focused call's definition, cloned from the
1373
+ // disclosure the renderer escaped and line-numbered. No report text is parsed
1374
+ // or interpreted here; a call without a resolved definition says so.
1375
+ function showSource(index, path) {
1376
+ if (!sourcePanel || !sourceBody) return;
1377
+ var disclosure = document.getElementById('cf-f' + index + '-src-' + path);
1378
+ var body = disclosure === null ? null : disclosure.querySelector('.cf-src-body');
1379
+ while (sourceBody.firstChild) sourceBody.removeChild(sourceBody.firstChild);
1380
+ var head = document.createElement('p');
1381
+ head.className = 'cf-src-head-line mono';
1382
+ var where = receiverFile(index, path);
1383
+ head.textContent = labelAt(index, path) + (where === '' ? '' : ' · ' + where);
1384
+ sourceBody.appendChild(head);
1385
+ if (body) sourceBody.appendChild(body.cloneNode(true));
1386
+ else sourceBody.appendChild(note('No resolved definition source for this call. The call site above is all the report has.'));
1387
+ sourcePanel.hidden = false;
1388
+ sourcePanel.open = true;
1389
+ if (sourcePanel.scrollIntoView) sourcePanel.scrollIntoView({ block: 'nearest' });
1390
+ }
1391
+
1392
+ function hideSource() {
1393
+ if (!sourcePanel || sourcePanel.hidden) return;
1394
+ sourcePanel.hidden = true;
1395
+ if (sourceBody) {
1396
+ while (sourceBody.firstChild) sourceBody.removeChild(sourceBody.firstChild);
1397
+ }
1398
+ }
1399
+
1400
+ function reveal() {
1401
+ var focus = focusAt();
1402
+ if (focus.file === null) return;
1403
+ var file = fileAt(focus.file);
1404
+ if (!file) return;
1405
+ file.open = true;
1406
+ var section = file.querySelector('[data-cf-mode-body="' + state.mode + '"]');
1407
+ if (!section) return;
1408
+ var targets = section.querySelectorAll(state.mode === 'tree' ? 'li.cf-node' : state.mode === 'graph' ? 'a.cf-gnode' : '.cf-path:not([hidden]) .cf-chip-zoom');
1409
+ var target = each(targets, focus.path);
1410
+ if (!target) return;
1411
+ var fold = state.mode === 'tree' ? target.querySelector('.cf-fold') : null;
1412
+ if (fold) fold.open = true;
1413
+ var step = target;
1414
+ while (step && step !== view) {
1415
+ if (step.tagName === 'DETAILS') step.open = true;
1416
+ step = step.parentElement;
1417
+ }
1418
+ if (state.mode === 'tree') target = target.querySelector('.cf-label');
1419
+ if (state.mode === 'graph') {
1420
+ frameGraphs();
1421
+ target = target.closest('.cf-graph-frame');
1422
+ }
1423
+ if (target && target.scrollIntoView) target.scrollIntoView({ block: 'center', inline: 'nearest' });
1424
+ if (target && target.focus && state.mode !== 'graph') target.focus({ preventScroll: true });
1425
+ }
1426
+
1427
+ function refresh(scrollCrumbs) {
1428
+ applyMode();
1429
+ applyFocus();
1430
+ renderCrumbs();
1431
+ if (scrollCrumbs && crumbNav && !crumbNav.hidden && crumbNav.scrollIntoView) {
1432
+ crumbNav.scrollIntoView({ block: 'nearest' });
1433
+ }
1434
+ }
1435
+
1436
+ // Every path that changes the focus keeps an open source panel in step with
1437
+ // it, so the panel never describes a call the trail has left behind.
1438
+ function settle() {
1439
+ reveal();
1440
+ var focus = focusAt();
1441
+ if (sourcePanel && !sourcePanel.hidden && focus.file !== null) showSource(focus.file, focus.path);
1442
+ }
1443
+
1444
+ function inspectSource(index, path) {
1445
+ state = cfNav.inspect(state, index, path);
1446
+ // Inspection must not move the canvas underneath the pointer.
1447
+ var graph = fileAt(index).querySelector('.cf-gnode[data-cf-path="' + path + '"]');
1448
+ if (graph && state.mode === 'graph') graphSelections.set(graph.closest('svg'), path);
1449
+ refresh(false);
1450
+ showSource(index, path);
1451
+ }
1452
+
1453
+ function focusOn(index, path) {
1454
+ var next = cfNav.visit(state, index, path);
1455
+ if (next === state) return;
1456
+ state = next;
1457
+ refresh(true);
1458
+ settle();
1459
+ }
1460
+
1461
+ function goBack(index) {
1462
+ var next = cfNav.truncate(state, index);
1463
+ if (next !== state) {
1464
+ state = next;
1465
+ refresh(true);
1466
+ }
1467
+ settle();
1468
+ }
1469
+
1470
+ function clearFocus(scrollBack) {
1471
+ if (state.trail.length === 0) return;
1472
+ state = cfNav.clear(state);
1473
+ refresh(false);
1474
+ hideSource();
1475
+ if (scrollBack) host.scrollIntoView({ block: 'start' });
1476
+ }
1477
+
1478
+ // Graph and Sequence ship inside templates: their markup costs no live nodes
1479
+ // until the mode is first opened. Instantiating is one-way and in place, so
1480
+ // the mode sections keep their document order and their ids.
1481
+ function activateMode(mode) {
1482
+ var pending = host.querySelectorAll('template[data-cf-lazy-mode]');
1483
+ var added = [];
1484
+ for (var i = 0; i < pending.length; i++) {
1485
+ if (pending[i].getAttribute('data-cf-lazy-mode') !== mode) continue;
1486
+ var parent = pending[i].parentNode;
1487
+ var fragment = pending[i].content;
1488
+ var nodes = Array.prototype.slice.call(fragment.children);
1489
+ parent.replaceChild(fragment, pending[i]);
1490
+ for (var n = 0; n < nodes.length; n++) added.push(nodes[n]);
1491
+ }
1492
+ if (!added.length) return;
1493
+ observeWraps(added);
1494
+ }
1495
+
1496
+ function observeWraps(nodes) {
1497
+ var found = [];
1498
+ for (var i = 0; i < nodes.length; i++) {
1499
+ if (!nodes[i].querySelectorAll) continue;
1500
+ var wraps = nodes[i].querySelectorAll('.cf-svg-wrap');
1501
+ for (var j = 0; j < wraps.length; j++) found.push(wraps[j]);
1502
+ }
1503
+ for (var w = 0; w < found.length; w++) {
1504
+ found[w].tabIndex = 0;
1505
+ found[w].setAttribute('role', 'region');
1506
+ found[w].setAttribute('aria-label', 'Pannable call graph. Arrow keys pan, plus and minus zoom, Home restores readable size.');
1507
+ if (graphObserver) graphObserver.observe(found[w]);
1508
+ }
1509
+ }
1510
+
1511
+ var graphObserver = null;
1512
+ if (typeof ResizeObserver === 'undefined') window.addEventListener('resize', frameGraphs);
1513
+ else graphObserver = new ResizeObserver(frameGraphs);
1514
+ observeWraps([host]);
1515
+
1516
+ function setMode(next) {
1517
+ var change = cfNav.setMode(state, next);
1518
+ if (change === state) return;
1519
+ var depthChanged = change.depth !== state.depth;
1520
+ state = change;
1521
+ activateMode(state.mode);
1522
+ applyMode();
1523
+ if (depthChanged) applyFocus();
1524
+ else frameGraphs();
1525
+ reveal();
1526
+ }
1527
+
1528
+ // Mode to return to when the diagram view closes; null while it is closed.
1529
+ var diagramReturn = null;
1530
+ var diagramClose = host.querySelector('[data-cf-diagram-close]');
1531
+
1532
+ // Inside the pull request page's drawer, ask the page to widen the drawer to
1533
+ // the whole window too, so the diagram gets all of it.
1534
+ function tellEmbedder(open) {
1535
+ if (window.parent !== window) window.parent.postMessage({ type: 'diffninja-diagram', open: open }, window.location.origin);
1536
+ }
1537
+
1538
+ // The diagram view shows one entry point's diagram at a time, picked from
1539
+ // a list of every entry point per file, instead of stacking every canvas.
1540
+ var diagramNav = null;
1541
+ var diagramCurrent = null;
1542
+
1543
+ function frameName(frame) {
1544
+ var node = frame.querySelector('a.cf-gnode title');
1545
+ var text = node ? node.textContent : '';
1546
+ return text.split(' \u00b7 ')[0] || 'Entry point';
1547
+ }
1548
+
1549
+ function showFrame(frame) {
1550
+ diagramCurrent = frame;
1551
+ var all = host.querySelectorAll('.cf-graph-frame');
1552
+ for (var i = 0; i < all.length; i++) all[i].classList.toggle('cf-frame-current', all[i] === frame);
1553
+ for (var f = 0; f < files.length; f++) {
1554
+ var mine = files[f].contains(frame);
1555
+ files[f].classList.toggle('cf-file-current', mine);
1556
+ if (mine) files[f].open = true;
1557
+ }
1558
+ if (diagramNav) {
1559
+ var items = diagramNav.querySelectorAll('[data-cf-frame]');
1560
+ for (var n = 0; n < items.length; n++) {
1561
+ var on = items[n].getAttribute('data-cf-frame') === frame.getAttribute('data-cf-graph');
1562
+ if (on) items[n].setAttribute('aria-current', 'true');
1563
+ else items[n].removeAttribute('aria-current');
1564
+ }
1565
+ }
1566
+ // Open on the whole entry point when that still leaves its text readable.
1567
+ window.requestAnimationFrame(function () {
1568
+ frameGraphs();
1569
+ var graph = frame.querySelector('svg'), info = graphViews.get(graph);
1570
+ if (!info) return;
1571
+ var whole = cfNav.frame(info.bounds, info.size, info.target, true);
1572
+ if (whole.scale >= 0.55) paintCamera(graph, whole);
1573
+ });
1574
+ }
1575
+
1576
+ function buildDiagramNav() {
1577
+ var nav = document.createElement('nav');
1578
+ nav.className = 'cf-diagram-nav';
1579
+ nav.setAttribute('aria-label', 'Entry points');
1580
+ var heading = document.createElement('p');
1581
+ heading.className = 'cf-diagram-nav-head';
1582
+ heading.textContent = 'Entry points';
1583
+ nav.appendChild(heading);
1584
+ for (var f = 0; f < files.length; f++) {
1585
+ var frames = files[f].querySelectorAll('.cf-graph-frame');
1586
+ if (!frames.length) continue;
1587
+ var path = files[f].querySelector('.cf-file-path');
1588
+ var group = document.createElement('p');
1589
+ group.className = 'cf-diagram-nav-file';
1590
+ var full = path ? path.textContent : '';
1591
+ group.textContent = full.slice(full.lastIndexOf('/') + 1);
1592
+ group.title = full;
1593
+ nav.appendChild(group);
1594
+ var list = document.createElement('ul');
1595
+ for (var i = 0; i < frames.length; i++) {
1596
+ var item = document.createElement('li');
1597
+ var button = document.createElement('button');
1598
+ button.type = 'button';
1599
+ button.setAttribute('data-cf-frame', frames[i].getAttribute('data-cf-graph'));
1600
+ // "Class.method(args)" reads as the method, with its class beneath: entry
1601
+ // points of one class would otherwise all start the same way.
1602
+ var label = frameName(frames[i]);
1603
+ var paren = label.indexOf('(');
1604
+ var dot = label.lastIndexOf('.', paren < 0 ? label.length : paren);
1605
+ var name = document.createElement('span');
1606
+ name.className = 'cf-diagram-nav-name';
1607
+ name.textContent = dot > 0 && label.slice(0, 4) !== 'new ' ? label.slice(dot + 1) : label;
1608
+ button.title = label;
1609
+ var count = document.createElement('span');
1610
+ count.className = 'cf-diagram-nav-count';
1611
+ var calls = frames[i].querySelectorAll('a.cf-gnode').length;
1612
+ count.textContent = (dot > 0 && label.slice(0, 4) !== 'new ' ? label.slice(0, dot) + ', ' : '') + calls + (calls === 1 ? ' call' : ' calls');
1613
+ button.appendChild(name);
1614
+ button.appendChild(count);
1615
+ item.appendChild(button);
1616
+ list.appendChild(item);
1617
+ }
1618
+ nav.appendChild(list);
1619
+ }
1620
+ host.insertBefore(nav, host.firstChild);
1621
+ return nav;
1622
+ }
1623
+
1624
+ function openDiagram() {
1625
+ if (diagramReturn !== null) return;
1626
+ diagramReturn = state.mode === 'graph' ? 'tree' : state.mode;
1627
+ host.classList.add('cf-diagram-open');
1628
+ tellEmbedder(true);
1629
+ if (diagramClose) diagramClose.hidden = false;
1630
+ var hint = host.querySelector('[data-cf-diagram-hint]');
1631
+ if (hint) hint.hidden = false;
1632
+ setMode('graph');
1633
+ diagramNav = buildDiagramNav();
1634
+ var first = null;
1635
+ for (var f = 0; f < files.length && first === null; f++) {
1636
+ if (!files[f].hidden) first = files[f].querySelector('.cf-graph-frame');
1637
+ }
1638
+ if (first) showFrame(first);
1639
+ window.requestAnimationFrame(frameGraphs);
1640
+ if (diagramClose) diagramClose.focus({ preventScroll: true });
1641
+ }
1642
+
1643
+ function closeDiagram() {
1644
+ if (diagramReturn === null) return;
1645
+ var back = diagramReturn;
1646
+ diagramReturn = null;
1647
+ host.classList.remove('cf-diagram-open');
1648
+ tellEmbedder(false);
1649
+ if (diagramNav) diagramNav.remove();
1650
+ diagramNav = null;
1651
+ diagramCurrent = null;
1652
+ var marked = host.querySelectorAll('.cf-frame-current, .cf-file-current');
1653
+ for (var m = 0; m < marked.length; m++) marked[m].classList.remove('cf-frame-current', 'cf-file-current');
1654
+ if (diagramClose) diagramClose.hidden = true;
1655
+ var hintOff = host.querySelector('[data-cf-diagram-hint]');
1656
+ if (hintOff) hintOff.hidden = true;
1657
+ setMode(back);
1658
+ var opener = host.querySelector('[data-cf-diagram]');
1659
+ if (opener) opener.focus({ preventScroll: true });
1660
+ }
1661
+
1662
+ function setDepth(value) {
1663
+ var change = cfNav.setDepth(state, value);
1664
+ if (change === state) return;
1665
+ state = change;
1666
+ refresh(false);
1667
+ }
1668
+
1669
+ document.addEventListener('click', function (event) {
1670
+ if (!event.target.closest) return;
1671
+ var cameraButton = event.target.closest('[data-cf-camera]');
1672
+ if (cameraButton) {
1673
+ cameraAction(cameraButton.closest('.cf-graph-frame').querySelector('svg'), cameraButton.getAttribute('data-cf-camera'));
1674
+ return;
1675
+ }
1676
+ var crumbButton = event.target.closest('.cf-crumb');
1677
+ if (crumbButton) {
1678
+ event.preventDefault();
1679
+ if (crumbButton.hasAttribute('data-cf-root')) clearFocus(false);
1680
+ else goBack(Number(crumbButton.getAttribute('data-cf-crumb')));
1681
+ return;
1682
+ }
1683
+ var depth = event.target.closest('[data-cf-depth]');
1684
+ if (depth) {
1685
+ event.preventDefault();
1686
+ setDepth(depth.getAttribute('data-cf-depth'));
1687
+ return;
1688
+ }
1689
+ var source = event.target.closest('[data-cf-source]');
1690
+ if (source) {
1691
+ event.preventDefault();
1692
+ inspectSource(Number(source.getAttribute('data-cf-file')), source.getAttribute('data-cf-path'));
1693
+ return;
1694
+ }
1695
+ var zoom = event.target.closest('[data-cf-zoom]');
1696
+ if (zoom) {
1697
+ event.preventDefault();
1698
+ focusOn(Number(zoom.getAttribute('data-cf-file')), zoom.getAttribute('data-cf-path'));
1699
+ return;
1700
+ }
1701
+ if (event.target.closest('[data-cf-diagram]')) {
1702
+ event.preventDefault();
1703
+ openDiagram();
1704
+ return;
1705
+ }
1706
+ var pick = event.target.closest('[data-cf-frame]');
1707
+ if (pick && diagramNav && diagramNav.contains(pick)) {
1708
+ event.preventDefault();
1709
+ var chosen = host.querySelector('.cf-graph-frame[data-cf-graph="' + CSS.escape(pick.getAttribute('data-cf-frame')) + '"]');
1710
+ if (chosen) showFrame(chosen);
1711
+ return;
1712
+ }
1713
+ if (event.target.closest('[data-cf-diagram-close]')) {
1714
+ event.preventDefault();
1715
+ closeDiagram();
1716
+ return;
1717
+ }
1718
+ var modeLink = event.target.closest('[data-cf-mode]');
1719
+ if (modeLink) {
1720
+ event.preventDefault();
1721
+ setMode(modeLink.getAttribute('data-cf-mode'));
1722
+ return;
1723
+ }
1724
+ var jump = event.target.closest('.cf-jump-link');
1725
+ if (jump) {
1726
+ hideSource();
1727
+ if (state.trail.length) clearFocus(false);
1728
+ var target = document.getElementById(jump.hash.slice(1));
1729
+ if (target) target.open = true;
1730
+ }
1731
+ });
1732
+
1733
+ document.addEventListener('keydown', function (event) {
1734
+ if (event.key === 'Escape' && diagramReturn !== null && !event.defaultPrevented) {
1735
+ event.preventDefault();
1736
+ closeDiagram();
1737
+ return;
1738
+ }
1739
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
1740
+ if (view.hidden) return;
1741
+ if (!event.target.closest) return;
1742
+ var canvas = event.target.closest('.cf-svg-wrap');
1743
+ if (canvas) {
1744
+ var graph = canvas.querySelector('svg'), info = graphViews.get(graph);
1745
+ var moves = { ArrowLeft: [60, 0], ArrowRight: [-60, 0], ArrowUp: [0, 60], ArrowDown: [0, -60] };
1746
+ if (info && moves[event.key]) {
1747
+ event.preventDefault();
1748
+ paintCamera(graph, cfNav.pan(state.cameras[info.key], moves[event.key][0], moves[event.key][1], info.bounds, info.size));
1749
+ return;
1750
+ }
1751
+ if (event.key === '+' || event.key === '=' || event.key === '-') {
1752
+ event.preventDefault();
1753
+ cameraAction(graph, event.key === '-' ? 'out' : 'in');
1754
+ return;
1755
+ }
1756
+ if (event.key === 'Home') {
1757
+ event.preventDefault();
1758
+ cameraAction(graph, 'readable');
1759
+ return;
1760
+ }
1761
+ }
1762
+ var link = event.target.closest('a[data-cf-zoom], a[data-cf-source]');
1763
+ if (link && event.key === 'Enter') {
1764
+ // A focused SVG link gets no default activation in every engine; inside
1765
+ // the report it means "zoom into this node" or "show this source", so do
1766
+ // that instead of the bare anchor jump the href would perform.
1767
+ event.preventDefault();
1768
+ var index = Number(link.getAttribute('data-cf-file')), path = link.getAttribute('data-cf-path');
1769
+ if (link.hasAttribute('data-cf-source')) inspectSource(index, path);
1770
+ else focusOn(index, path);
1771
+ return;
1772
+ }
1773
+ if (event.key === 'Escape' && sourcePanel && !sourcePanel.hidden) {
1774
+ event.preventDefault();
1775
+ hideSource();
1776
+ return;
1777
+ }
1778
+ if (event.key === 'Escape' && state.trail.length) {
1779
+ event.preventDefault();
1780
+ clearFocus(true);
1781
+ }
1782
+ });
1783
+
1784
+ view.addEventListener('pointerdown', function (event) {
1785
+ var wrap = event.target.closest('.cf-svg-wrap');
1786
+ if (!wrap || event.button !== 0 || !event.isPrimary) return;
1787
+ suppressClick = false;
1788
+ drag = { wrap: wrap, id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
1789
+ });
1790
+ view.addEventListener('pointermove', function (event) {
1791
+ if (!drag || drag.id !== event.pointerId) return;
1792
+ var dx = event.clientX - drag.x, dy = event.clientY - drag.y;
1793
+ if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 5) return;
1794
+ drag.moved = true;
1795
+ drag.wrap.setPointerCapture(event.pointerId);
1796
+ drag.wrap.classList.add('cf-dragging');
1797
+ var graph = drag.wrap.querySelector('svg'), info = graphViews.get(graph);
1798
+ if (info) paintCamera(graph, cfNav.pan(state.cameras[info.key], dx, dy, info.bounds, info.size));
1799
+ drag.x = event.clientX; drag.y = event.clientY;
1800
+ });
1801
+ function finishDrag(event) {
1802
+ if (!drag || drag.id !== event.pointerId) return;
1803
+ suppressClick = drag.moved && event.type === 'pointerup';
1804
+ drag.wrap.classList.remove('cf-dragging');
1805
+ if (drag.wrap.hasPointerCapture(event.pointerId)) drag.wrap.releasePointerCapture(event.pointerId);
1806
+ drag = null;
1807
+ }
1808
+ // In the diagram view the wheel moves the diagram, as in a map: scroll
1809
+ // pans, Ctrl or Cmd with the wheel (a trackpad pinch) zooms at the pointer.
1810
+ // Inline, the wheel keeps scrolling the page.
1811
+ view.addEventListener('wheel', function (event) {
1812
+ if (!host.classList.contains('cf-diagram-open')) return;
1813
+ var wrap = event.target.closest('.cf-svg-wrap');
1814
+ if (!wrap) return;
1815
+ var graph = wrap.querySelector('svg'), info = graphViews.get(graph);
1816
+ if (!info) return;
1817
+ event.preventDefault();
1818
+ var unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? info.size.height : 1;
1819
+ var camera = state.cameras[info.key];
1820
+ if (event.ctrlKey || event.metaKey) {
1821
+ var box = wrap.getBoundingClientRect();
1822
+ var factor = Math.exp(-event.deltaY * unit * 0.0025);
1823
+ paintCamera(graph, cfNav.zoom(camera, factor, { x: event.clientX - box.left, y: event.clientY - box.top }, info.bounds, info.size));
1824
+ } else {
1825
+ var dx = event.shiftKey && !event.deltaX ? event.deltaY : event.deltaX;
1826
+ var dy = event.shiftKey && !event.deltaX ? 0 : event.deltaY;
1827
+ paintCamera(graph, cfNav.pan(camera, -dx * unit, -dy * unit, info.bounds, info.size));
1828
+ }
1829
+ }, { passive: false });
1830
+ view.addEventListener('pointerup', finishDrag);
1831
+ view.addEventListener('pointercancel', finishDrag);
1832
+ view.addEventListener('click', function (event) {
1833
+ if (!suppressClick) return;
1834
+ suppressClick = false;
1835
+ if (!event.target.closest('.cf-svg-wrap')) return;
1836
+ event.preventDefault();
1837
+ event.stopPropagation();
1838
+ }, true);
1839
+
1840
+ view.addEventListener('focusin', function (event) {
1841
+ var link = event.target.closest('.cf-gnode, .cf-gzoom, .cf-edge-num');
1842
+ if (!link || !link.matches(':focus-visible')) return;
1843
+ var graph = link.closest('svg'), info = graphViews.get(graph);
1844
+ var node = each(graph.querySelectorAll('.cf-gnode'), link.getAttribute('data-cf-path'));
1845
+ if (!info || !node) return;
1846
+ var target = rectOf(node.querySelector('rect'));
1847
+ var camera = state.cameras[info.key];
1848
+ if (target.x < camera.x || target.y < camera.y || target.x + target.width > camera.x + info.size.width / camera.scale ||
1849
+ target.y + target.height > camera.y + info.size.height / camera.scale) {
1850
+ paintCamera(graph, cfNav.frame(info.bounds, info.size, target));
1851
+ }
1852
+ });
1853
+ view.addEventListener('toggle', frameGraphs, true);
1854
+
1855
+ upgradeLabels();
1856
+ host.classList.add('cf-ready');
1857
+ // A deep link to one mode opens on that mode, which is also where its lazy
1858
+ // template is instantiated.
1859
+ var deep = /#cf-f\\d+-([a-z]+)$/.exec(location.hash);
1860
+ if (deep) {
1861
+ for (var dm = 0; dm < modeLinks.length; dm++) {
1862
+ if (modeLinks[dm].getAttribute('data-cf-mode') !== deep[1]) continue;
1863
+ state = cfNav.setMode(state, deep[1]);
1864
+ activateMode(state.mode);
1865
+ break;
1866
+ }
1867
+ }
1868
+ refresh(false);
1869
+ }());
1870
+ `;