codebase-onboarder 0.6.0 → 0.7.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 (448) hide show
  1. package/README.md +174 -46
  2. package/bin/build-if-source.js +23 -0
  3. package/cli/commands.d.ts +26 -0
  4. package/cli/commands.js +836 -811
  5. package/cli/commands.js.map +1 -0
  6. package/cli/explorer/advanced.d.ts +14 -0
  7. package/cli/explorer/advanced.js +321 -329
  8. package/cli/explorer/advanced.js.map +1 -0
  9. package/cli/explorer/app.d.ts +1 -0
  10. package/cli/explorer/app.js +384 -313
  11. package/cli/explorer/app.js.map +1 -0
  12. package/cli/explorer/assistant.d.ts +9 -0
  13. package/cli/explorer/assistant.js +58 -0
  14. package/cli/explorer/assistant.js.map +1 -0
  15. package/cli/explorer/commands.d.ts +17 -0
  16. package/cli/explorer/commands.js +368 -302
  17. package/cli/explorer/commands.js.map +1 -0
  18. package/cli/explorer/featureViews.d.ts +124 -0
  19. package/cli/explorer/featureViews.js +179 -0
  20. package/cli/explorer/featureViews.js.map +1 -0
  21. package/cli/explorer/github.d.ts +21 -0
  22. package/cli/explorer/github.js +42 -44
  23. package/cli/explorer/github.js.map +1 -0
  24. package/cli/explorer/graphs.d.ts +7 -0
  25. package/cli/explorer/graphs.js +90 -95
  26. package/cli/explorer/graphs.js.map +1 -0
  27. package/cli/explorer/picker.d.ts +22 -0
  28. package/cli/explorer/picker.js +150 -0
  29. package/cli/explorer/picker.js.map +1 -0
  30. package/cli/explorer/report.d.ts +85 -0
  31. package/cli/explorer/report.js +60 -0
  32. package/cli/explorer/report.js.map +1 -0
  33. package/cli/explorer/session.d.ts +449 -0
  34. package/cli/explorer/session.js +143 -144
  35. package/cli/explorer/session.js.map +1 -0
  36. package/cli/explorer/views.d.ts +19 -0
  37. package/cli/explorer/views.js +465 -482
  38. package/cli/explorer/views.js.map +1 -0
  39. package/cli/explorer/wrap.d.ts +1 -0
  40. package/cli/explorer/wrap.js +28 -44
  41. package/cli/explorer/wrap.js.map +1 -0
  42. package/cli/main.d.ts +1 -0
  43. package/cli/main.js +214 -161
  44. package/cli/main.js.map +1 -0
  45. package/cli/prompt.d.ts +5 -0
  46. package/cli/prompt.js +84 -80
  47. package/cli/prompt.js.map +1 -0
  48. package/cli/scanOptions.d.ts +2 -0
  49. package/cli/scanOptions.js +11 -0
  50. package/cli/scanOptions.js.map +1 -0
  51. package/cli/ui.d.ts +33 -0
  52. package/cli/ui.js +35 -44
  53. package/cli/ui.js.map +1 -0
  54. package/cli/wizard.d.ts +187 -0
  55. package/cli/wizard.js +304 -284
  56. package/cli/wizard.js.map +1 -0
  57. package/package.json +15 -4
  58. package/public/app.d.ts +1 -0
  59. package/public/app.js +1057 -1009
  60. package/public/app.js.map +1 -0
  61. package/public/browser.d.ts +68 -0
  62. package/public/browser.js +2 -0
  63. package/public/browser.js.map +1 -0
  64. package/public/index.html +30 -13
  65. package/public/js/about.d.ts +6 -0
  66. package/public/js/about.js +152 -172
  67. package/public/js/about.js.map +1 -0
  68. package/public/js/aiDraft.d.ts +3 -0
  69. package/public/js/aiDraft.js +116 -124
  70. package/public/js/aiDraft.js.map +1 -0
  71. package/public/js/analysisPanel.d.ts +7 -0
  72. package/public/js/analysisPanel.js +166 -166
  73. package/public/js/analysisPanel.js.map +1 -0
  74. package/public/js/analysisReport.d.ts +38 -0
  75. package/public/js/analysisReport.js +105 -110
  76. package/public/js/analysisReport.js.map +1 -0
  77. package/public/js/api.d.ts +26 -0
  78. package/public/js/api.js +176 -167
  79. package/public/js/api.js.map +1 -0
  80. package/public/js/atlas.d.ts +6 -0
  81. package/public/js/atlas.js +50 -58
  82. package/public/js/atlas.js.map +1 -0
  83. package/public/js/blameView.d.ts +2 -0
  84. package/public/js/blameView.js +36 -37
  85. package/public/js/blameView.js.map +1 -0
  86. package/public/js/codeTab.d.ts +3 -0
  87. package/public/js/codeTab.js +175 -180
  88. package/public/js/codeTab.js.map +1 -0
  89. package/public/js/codeViewer.d.ts +5 -0
  90. package/public/js/codeViewer.js +162 -150
  91. package/public/js/codeViewer.js.map +1 -0
  92. package/public/js/components/FileChip.d.ts +9 -0
  93. package/public/js/components/FileChip.js +45 -49
  94. package/public/js/components/FileChip.js.map +1 -0
  95. package/public/js/components/MetricSparkline.d.ts +7 -0
  96. package/public/js/components/MetricSparkline.js +35 -44
  97. package/public/js/components/MetricSparkline.js.map +1 -0
  98. package/public/js/components/RiskBadge.d.ts +9 -0
  99. package/public/js/components/RiskBadge.js +55 -63
  100. package/public/js/components/RiskBadge.js.map +1 -0
  101. package/public/js/deepAnalysisView.d.ts +99 -0
  102. package/public/js/deepAnalysisView.js +282 -302
  103. package/public/js/deepAnalysisView.js.map +1 -0
  104. package/public/js/diagramPane.d.ts +8 -0
  105. package/public/js/diagramPane.js +133 -135
  106. package/public/js/diagramPane.js.map +1 -0
  107. package/public/js/diffView.d.ts +6 -0
  108. package/public/js/diffView.js +222 -209
  109. package/public/js/diffView.js.map +1 -0
  110. package/public/js/docsView.d.ts +2 -0
  111. package/public/js/docsView.js +441 -442
  112. package/public/js/docsView.js.map +1 -0
  113. package/public/js/dom.d.ts +189 -0
  114. package/public/js/dom.js +5 -0
  115. package/public/js/dom.js.map +1 -0
  116. package/public/js/fileSourceBrowser.d.ts +5 -0
  117. package/public/js/fileSourceBrowser.js +33 -41
  118. package/public/js/fileSourceBrowser.js.map +1 -0
  119. package/public/js/flameGraph.d.ts +5 -0
  120. package/public/js/flameGraph.js +62 -72
  121. package/public/js/flameGraph.js.map +1 -0
  122. package/public/js/forceGraph.d.ts +10 -0
  123. package/public/js/forceGraph.js +581 -556
  124. package/public/js/forceGraph.js.map +1 -0
  125. package/public/js/heatmap.d.ts +4 -0
  126. package/public/js/heatmap.js +142 -164
  127. package/public/js/heatmap.js.map +1 -0
  128. package/public/js/highlight.d.ts +2 -0
  129. package/public/js/highlight.js +116 -107
  130. package/public/js/highlight.js.map +1 -0
  131. package/public/js/html.d.ts +2 -0
  132. package/public/js/html.js +14 -44
  133. package/public/js/html.js.map +1 -0
  134. package/public/js/insightsView.d.ts +5 -0
  135. package/public/js/insightsView.js +93 -111
  136. package/public/js/insightsView.js.map +1 -0
  137. package/public/js/inspector.d.ts +37 -0
  138. package/public/js/inspector.js +416 -445
  139. package/public/js/inspector.js.map +1 -0
  140. package/public/js/llm.d.ts +113 -0
  141. package/public/js/llm.js +233 -272
  142. package/public/js/llm.js.map +1 -0
  143. package/public/js/mapView.d.ts +3 -0
  144. package/public/js/mapView.js +121 -120
  145. package/public/js/mapView.js.map +1 -0
  146. package/public/js/markdown.d.ts +3 -0
  147. package/public/js/markdown.js +84 -91
  148. package/public/js/markdown.js.map +1 -0
  149. package/public/js/mindmap.d.ts +32 -0
  150. package/public/js/mindmap.js +244 -259
  151. package/public/js/mindmap.js.map +1 -0
  152. package/public/js/mobilePanels.d.ts +5 -0
  153. package/public/js/mobilePanels.js +50 -0
  154. package/public/js/mobilePanels.js.map +1 -0
  155. package/public/js/repoFiles.d.ts +12 -0
  156. package/public/js/repoFiles.js +28 -27
  157. package/public/js/repoFiles.js.map +1 -0
  158. package/public/js/reviewView.d.ts +28 -0
  159. package/public/js/reviewView.js +256 -0
  160. package/public/js/reviewView.js.map +1 -0
  161. package/public/js/sbomView.d.ts +3 -0
  162. package/public/js/sbomView.js +59 -67
  163. package/public/js/sbomView.js.map +1 -0
  164. package/public/js/scanCache.d.ts +16 -0
  165. package/public/js/scanCache.js +67 -65
  166. package/public/js/scanCache.js.map +1 -0
  167. package/public/js/search.d.ts +7 -0
  168. package/public/js/search.js +868 -800
  169. package/public/js/search.js.map +1 -0
  170. package/public/js/searchLocal.d.ts +42 -0
  171. package/public/js/searchLocal.js +87 -0
  172. package/public/js/searchLocal.js.map +1 -0
  173. package/public/js/serverSettings.d.ts +5 -0
  174. package/public/js/serverSettings.js +153 -164
  175. package/public/js/serverSettings.js.map +1 -0
  176. package/public/js/state.d.ts +798 -0
  177. package/public/js/state.js +114 -122
  178. package/public/js/state.js.map +1 -0
  179. package/public/js/tour.d.ts +6 -0
  180. package/public/js/tour.js +5 -7
  181. package/public/js/tour.js.map +1 -0
  182. package/public/js/transitions.d.ts +2 -0
  183. package/public/js/transitions.js +9 -10
  184. package/public/js/transitions.js.map +1 -0
  185. package/public/js/tree.d.ts +12 -0
  186. package/public/js/tree.js +157 -154
  187. package/public/js/tree.js.map +1 -0
  188. package/public/js/workflowsView.d.ts +3 -0
  189. package/public/js/workflowsView.js +15 -22
  190. package/public/js/workflowsView.js.map +1 -0
  191. package/public/search.css +495 -0
  192. package/public/styles.css +163 -346
  193. package/public/workbench.css +166 -0
  194. package/server/apiAuth.d.ts +3 -0
  195. package/server/apiAuth.js +18 -20
  196. package/server/apiAuth.js.map +1 -0
  197. package/server/apiDiff.d.ts +2 -0
  198. package/server/apiDiff.js +20 -18
  199. package/server/apiDiff.js.map +1 -0
  200. package/server/apiDocs.d.ts +2 -0
  201. package/server/apiDocs.js +38 -40
  202. package/server/apiDocs.js.map +1 -0
  203. package/server/apiFile.d.ts +1 -0
  204. package/server/apiFile.js +14 -17
  205. package/server/apiFile.js.map +1 -0
  206. package/server/apiGitBlame.d.ts +2 -0
  207. package/server/apiGitBlame.js +50 -51
  208. package/server/apiGitBlame.js.map +1 -0
  209. package/server/apiMcp.d.ts +4 -0
  210. package/server/apiMcp.js +64 -66
  211. package/server/apiMcp.js.map +1 -0
  212. package/server/apiReview.d.ts +2 -0
  213. package/server/apiReview.js +45 -0
  214. package/server/apiReview.js.map +1 -0
  215. package/server/apiScan.d.ts +5 -0
  216. package/server/apiScan.js +90 -71
  217. package/server/apiScan.js.map +1 -0
  218. package/server/apiSearch.d.ts +33 -0
  219. package/server/apiSearch.js +184 -200
  220. package/server/apiSearch.js.map +1 -0
  221. package/server/apiSettings.d.ts +4 -0
  222. package/server/apiSettings.js +84 -85
  223. package/server/apiSettings.js.map +1 -0
  224. package/server/apiTools.d.ts +3 -0
  225. package/server/apiTools.js +55 -56
  226. package/server/apiTools.js.map +1 -0
  227. package/server/auth.d.ts +5 -0
  228. package/server/auth.js +54 -51
  229. package/server/auth.js.map +1 -0
  230. package/server/config.d.ts +42 -0
  231. package/server/config.js +189 -194
  232. package/server/config.js.map +1 -0
  233. package/server/daemon.d.ts +12 -0
  234. package/server/daemon.js +124 -114
  235. package/server/daemon.js.map +1 -0
  236. package/server/fileSourceNode.d.ts +2 -0
  237. package/server/fileSourceNode.js +34 -36
  238. package/server/fileSourceNode.js.map +1 -0
  239. package/server/gitClone.d.ts +9 -0
  240. package/server/gitClone.js +59 -61
  241. package/server/gitClone.js.map +1 -0
  242. package/server/gitDiff.d.ts +36 -0
  243. package/server/gitDiff.js +271 -169
  244. package/server/gitDiff.js.map +1 -0
  245. package/server/gitHistory.d.ts +12 -0
  246. package/server/gitHistory.js +80 -80
  247. package/server/gitHistory.js.map +1 -0
  248. package/server/gitRemote.d.ts +12 -0
  249. package/server/gitRemote.js +37 -38
  250. package/server/gitRemote.js.map +1 -0
  251. package/server/htmlText.d.ts +4 -0
  252. package/server/htmlText.js +16 -23
  253. package/server/htmlText.js.map +1 -0
  254. package/server/http.d.ts +5 -0
  255. package/server/http.js +27 -30
  256. package/server/http.js.map +1 -0
  257. package/server/httpGuards.d.ts +2 -0
  258. package/server/httpGuards.js +47 -43
  259. package/server/httpGuards.js.map +1 -0
  260. package/server/https.d.ts +34 -0
  261. package/server/https.js +121 -111
  262. package/server/https.js.map +1 -0
  263. package/server/index.d.ts +40 -0
  264. package/server/index.js +139 -147
  265. package/server/index.js.map +1 -0
  266. package/server/layout.d.ts +26 -0
  267. package/server/layout.js +59 -64
  268. package/server/layout.js.map +1 -0
  269. package/server/llmProxy.d.ts +3 -0
  270. package/server/llmProxy.js +137 -135
  271. package/server/llmProxy.js.map +1 -0
  272. package/server/logger.d.ts +38 -0
  273. package/server/logger.js +198 -211
  274. package/server/logger.js.map +1 -0
  275. package/server/mcp/analysis.d.ts +29 -0
  276. package/server/mcp/analysis.js +99 -120
  277. package/server/mcp/analysis.js.map +1 -0
  278. package/server/mcp/http.d.ts +9 -0
  279. package/server/mcp/http.js +128 -137
  280. package/server/mcp/http.js.map +1 -0
  281. package/server/mcp/runner.d.ts +68 -0
  282. package/server/mcp/runner.js +208 -218
  283. package/server/mcp/runner.js.map +1 -0
  284. package/server/mcp/server.d.ts +29 -0
  285. package/server/mcp/server.js +166 -179
  286. package/server/mcp/server.js.map +1 -0
  287. package/server/mcp/standalone.d.ts +1 -0
  288. package/server/mcp/standalone.js +8 -10
  289. package/server/mcp/standalone.js.map +1 -0
  290. package/server/mcp/tools.d.ts +2020 -0
  291. package/server/mcp/tools.js +523 -574
  292. package/server/mcp/tools.js.map +1 -0
  293. package/server/paths.d.ts +3 -0
  294. package/server/paths.js +17 -15
  295. package/server/paths.js.map +1 -0
  296. package/server/pidfile.d.ts +9 -0
  297. package/server/pidfile.js +43 -45
  298. package/server/pidfile.js.map +1 -0
  299. package/server/review.d.ts +5 -0
  300. package/server/review.js +35 -0
  301. package/server/review.js.map +1 -0
  302. package/server/router.d.ts +33 -0
  303. package/server/router.js +191 -197
  304. package/server/router.js.map +1 -0
  305. package/server/scanRequest.d.ts +3 -0
  306. package/server/scanRequest.js +36 -0
  307. package/server/scanRequest.js.map +1 -0
  308. package/server/searchIndex.d.ts +17 -0
  309. package/server/searchIndex.js +83 -80
  310. package/server/searchIndex.js.map +1 -0
  311. package/server/sessions.d.ts +14 -0
  312. package/server/sessions.js +62 -70
  313. package/server/sessions.js.map +1 -0
  314. package/server/startup.d.ts +118 -0
  315. package/server/startup.js +143 -148
  316. package/server/startup.js.map +1 -0
  317. package/server/static.d.ts +6 -0
  318. package/server/static.js +28 -31
  319. package/server/static.js.map +1 -0
  320. package/server/tools/install.d.ts +4 -0
  321. package/server/tools/install.js +176 -185
  322. package/server/tools/install.js.map +1 -0
  323. package/server/tools/parse.d.ts +8 -0
  324. package/server/tools/parse.js +112 -114
  325. package/server/tools/parse.js.map +1 -0
  326. package/server/tools/platform.d.ts +9 -0
  327. package/server/tools/platform.js +49 -47
  328. package/server/tools/platform.js.map +1 -0
  329. package/server/tools/registry.d.ts +137 -0
  330. package/server/tools/registry.js +173 -172
  331. package/server/tools/registry.js.map +1 -0
  332. package/server/tools/scan.d.ts +34 -0
  333. package/server/tools/scan.js +89 -98
  334. package/server/tools/scan.js.map +1 -0
  335. package/server/tools.d.ts +34 -0
  336. package/server/tools.js +94 -107
  337. package/server/tools.js.map +1 -0
  338. package/server/tunnel.d.ts +29 -0
  339. package/server/tunnel.js +61 -66
  340. package/server/tunnel.js.map +1 -0
  341. package/shared/analyzer/docs.d.ts +16 -0
  342. package/shared/analyzer/docs.js +99 -93
  343. package/shared/analyzer/docs.js.map +1 -0
  344. package/shared/analyzer/explainLocal.d.ts +4 -0
  345. package/shared/analyzer/explainLocal.js +122 -142
  346. package/shared/analyzer/explainLocal.js.map +1 -0
  347. package/shared/analyzer/github.d.ts +37 -0
  348. package/shared/analyzer/github.js +41 -43
  349. package/shared/analyzer/github.js.map +1 -0
  350. package/shared/analyzer/graph.d.ts +41 -0
  351. package/shared/analyzer/graph.js +393 -400
  352. package/shared/analyzer/graph.js.map +1 -0
  353. package/shared/analyzer/health.d.ts +33 -0
  354. package/shared/analyzer/health.js +197 -193
  355. package/shared/analyzer/health.js.map +1 -0
  356. package/shared/analyzer/history.d.ts +29 -0
  357. package/shared/analyzer/history.js +110 -111
  358. package/shared/analyzer/history.js.map +1 -0
  359. package/shared/analyzer/jsonConfig.d.ts +2 -0
  360. package/shared/analyzer/jsonConfig.js +61 -0
  361. package/shared/analyzer/jsonConfig.js.map +1 -0
  362. package/shared/analyzer/languages/csharp.d.ts +26 -0
  363. package/shared/analyzer/languages/csharp.js +83 -88
  364. package/shared/analyzer/languages/csharp.js.map +1 -0
  365. package/shared/analyzer/languages/generic.d.ts +23 -0
  366. package/shared/analyzer/languages/generic.js +94 -105
  367. package/shared/analyzer/languages/generic.js.map +1 -0
  368. package/shared/analyzer/languages/go.d.ts +24 -0
  369. package/shared/analyzer/languages/go.js +45 -54
  370. package/shared/analyzer/languages/go.js.map +1 -0
  371. package/shared/analyzer/languages/index.d.ts +6 -0
  372. package/shared/analyzer/languages/index.js +19 -20
  373. package/shared/analyzer/languages/index.js.map +1 -0
  374. package/shared/analyzer/languages/java.d.ts +20 -0
  375. package/shared/analyzer/languages/java.js +73 -78
  376. package/shared/analyzer/languages/java.js.map +1 -0
  377. package/shared/analyzer/languages/javascript.d.ts +12 -0
  378. package/shared/analyzer/languages/javascript.js +258 -209
  379. package/shared/analyzer/languages/javascript.js.map +1 -0
  380. package/shared/analyzer/languages/python.d.ts +23 -0
  381. package/shared/analyzer/languages/python.js +92 -100
  382. package/shared/analyzer/languages/python.js.map +1 -0
  383. package/shared/analyzer/languages/rust.d.ts +17 -0
  384. package/shared/analyzer/languages/rust.js +96 -98
  385. package/shared/analyzer/languages/rust.js.map +1 -0
  386. package/shared/analyzer/languages/typescript.d.ts +11 -0
  387. package/shared/analyzer/languages/typescript.js +117 -116
  388. package/shared/analyzer/languages/typescript.js.map +1 -0
  389. package/shared/analyzer/licenses.d.ts +18 -0
  390. package/shared/analyzer/licenses.js +130 -133
  391. package/shared/analyzer/licenses.js.map +1 -0
  392. package/shared/analyzer/metrics.d.ts +20 -0
  393. package/shared/analyzer/metrics.js +112 -104
  394. package/shared/analyzer/metrics.js.map +1 -0
  395. package/shared/analyzer/pathUtil.d.ts +10 -0
  396. package/shared/analyzer/pathUtil.js +29 -35
  397. package/shared/analyzer/pathUtil.js.map +1 -0
  398. package/shared/analyzer/patterns.d.ts +11 -0
  399. package/shared/analyzer/patterns.js +231 -253
  400. package/shared/analyzer/patterns.js.map +1 -0
  401. package/shared/analyzer/scan.d.ts +62 -0
  402. package/shared/analyzer/scan.js +451 -442
  403. package/shared/analyzer/scan.js.map +1 -0
  404. package/shared/analyzer/scanControl.d.ts +11 -0
  405. package/shared/analyzer/scanControl.js +62 -0
  406. package/shared/analyzer/scanControl.js.map +1 -0
  407. package/shared/analyzer/security.d.ts +18 -0
  408. package/shared/analyzer/security.js +159 -159
  409. package/shared/analyzer/security.js.map +1 -0
  410. package/shared/analyzer/services.d.ts +5 -0
  411. package/shared/analyzer/services.js +141 -138
  412. package/shared/analyzer/services.js.map +1 -0
  413. package/shared/analyzer/stack.d.ts +6 -0
  414. package/shared/analyzer/stack.js +134 -141
  415. package/shared/analyzer/stack.js.map +1 -0
  416. package/shared/analyzer/tour.d.ts +2 -0
  417. package/shared/analyzer/tour.js +45 -53
  418. package/shared/analyzer/tour.js.map +1 -0
  419. package/shared/analyzer/util.d.ts +8 -0
  420. package/shared/analyzer/util.js +130 -76
  421. package/shared/analyzer/util.js.map +1 -0
  422. package/shared/analyzer/workflows.d.ts +8 -0
  423. package/shared/analyzer/workflows.js +139 -134
  424. package/shared/analyzer/workflows.js.map +1 -0
  425. package/shared/contracts.d.ts +250 -0
  426. package/shared/contracts.js +2 -0
  427. package/shared/contracts.js.map +1 -0
  428. package/shared/diagram/aiFacts.d.ts +35 -0
  429. package/shared/diagram/aiFacts.js +102 -105
  430. package/shared/diagram/aiFacts.js.map +1 -0
  431. package/shared/diagram/aiMermaid.d.ts +5 -0
  432. package/shared/diagram/aiMermaid.js +46 -45
  433. package/shared/diagram/aiMermaid.js.map +1 -0
  434. package/shared/diagram/atlas.d.ts +16 -0
  435. package/shared/diagram/atlas.js +51 -60
  436. package/shared/diagram/atlas.js.map +1 -0
  437. package/shared/diagram/mermaid.d.ts +43 -0
  438. package/shared/diagram/mermaid.js +348 -370
  439. package/shared/diagram/mermaid.js.map +1 -0
  440. package/shared/review/contracts.d.ts +95 -0
  441. package/shared/review/contracts.js +2 -0
  442. package/shared/review/contracts.js.map +1 -0
  443. package/shared/review/review.d.ts +10 -0
  444. package/shared/review/review.js +184 -0
  445. package/shared/review/review.js.map +1 -0
  446. package/shared/search/query.d.ts +32 -0
  447. package/shared/search/query.js +395 -388
  448. package/shared/search/query.js.map +1 -0
package/public/app.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { abortable, throwIfAborted } from '/shared/analyzer/scanControl.js';
2
+ import { eventElement } from '/js/dom.js';
1
3
  // Onboarder — bootstrap, routing, and wiring.
2
4
  //
3
5
  // Two ways a repo arrives here:
@@ -13,7 +15,6 @@
13
15
  // codeTab, about, aiDraft — and is handed its host element and a set of callbacks
14
16
  // here. The callbacks are the reason the module graph stays acyclic: a feature
15
17
  // module never imports back into app.js.
16
-
17
18
  import { scanRepo } from '/shared/analyzer/scan.js';
18
19
  import { computeFacts, scanIndex } from '/shared/analyzer/graph.js';
19
20
  import { analyzeStack } from '/shared/analyzer/stack.js';
@@ -21,17 +22,11 @@ import { detectManifest } from '/shared/analyzer/services.js';
21
22
  import { computeLayers, detectPatterns, couplingMatrix } from '/shared/analyzer/patterns.js';
22
23
  import { explainOverview } from '/shared/analyzer/explainLocal.js';
23
24
  import { dirOf, pathFilter } from '/shared/analyzer/pathUtil.js';
24
- import {
25
- overviewDiagram, folderDiagram, fileDetailDiagram, servicesDiagram, emptyDiagram,
26
- layersDiagram, healthDiagram, securityDiagram, historyDiagram, setDiagramTheme, diagramThemeBlock,
27
- } from '/shared/diagram/mermaid.js';
25
+ import { overviewDiagram, folderDiagram, fileDetailDiagram, servicesDiagram, emptyDiagram, layersDiagram, healthDiagram, securityDiagram, historyDiagram, setDiagramTheme, diagramThemeBlock, } from '/shared/diagram/mermaid.js';
28
26
  import { analyzeHealth } from '/shared/analyzer/health.js';
29
27
  import { summarizeSecurity } from '/shared/analyzer/security.js';
30
28
  import { escapeHtml } from '/js/html.js';
31
- import {
32
- scanOnServer, cleanupClone, streamExplain,
33
- fetchMcpStatus, startMcpServer, stopMcpServer, fetchMcpCommand,
34
- } from '/js/api.js';
29
+ import { scanOnServer, cleanupClone, streamExplain, fetchMcpStatus, startMcpServer, stopMcpServer, fetchMcpCommand, } from '/js/api.js';
35
30
  import { canPickFolder, pickDirectory, browserFileSource } from '/js/fileSourceBrowser.js';
36
31
  import { renderInto, wireNodeClicks, makePanzoom, downloadSvg } from '/js/diagramPane.js';
37
32
  import { setViewerTheme } from '/js/codeViewer.js';
@@ -54,265 +49,313 @@ import { initSearch } from '/js/search.js';
54
49
  import { withTransition } from '/js/transitions.js';
55
50
  import { renderInsights } from '/js/insightsView.js';
56
51
  import { createServerDrawer } from '/js/serverSettings.js';
57
- import { renderDiffView } from '/js/diffView.js';
52
+ import { renderDiffView, stopDiff } from '/js/diffView.js';
53
+ import { renderReviewView, stopReview } from '/js/reviewView.js';
58
54
  import { renderWorkflows } from '/js/workflowsView.js';
59
55
  import { renderSbom } from '/js/sbomView.js';
60
- import {
61
- state as appState, isExplorerView, loadRepo, unloadRepo,
62
- focusFile, focusFolder, clearFocus as clearFocusState, inspectorSubject, aiViewKey,
63
- } from '/js/state.js';
64
-
56
+ import { initMobilePanels } from '/js/mobilePanels.js';
57
+ import { state as appState, isExplorerView, loadRepo, unloadRepo, focusFile, focusFolder, clearFocus as clearFocusState, inspectorSubject, aiViewKey, } from '/js/state.js';
65
58
  const $ = (id) => document.getElementById(id);
66
-
67
59
  const dom = {};
68
60
  [
69
- 'landing', 'landingError', 'landingStatus', 'pathForm', 'pathInput', 'gitForm', 'gitInput',
70
- 'pickBtn', 'demoBtn', 'explorer', 'viewTabs', 'repoChip', 'newRepoBtn', 'settingsBtn',
71
- 'landingAbout', 'creditsHide', 'creditsRemove', 'creditsBar', 'creditsShow', 'creditsBarRemove',
72
- 'treeFilter', 'fileTree', 'crumbs', 'copyMermaidBtn', 'svgBtn', 'fitBtn',
73
- 'canvas', 'canvasContent', 'canvasEmpty', 'stageFoot', 'themeBtn', 'aiDrawBtn', 'docView', 'aboutView', 'codeView',
74
- 'mmToggleBtn', 'stageFilters', 'modeSwitch', 'nodeFilter', 'testsToggle', 'depthSelect',
75
- 'tourbar', 'tourTitle', 'tourCount', 'tourPrev', 'tourNext', 'tourExit',
76
- 'drawerScrim', 'settingsDrawer', 'drawerClose', 'setBaseUrl', 'setApiKey', 'setModel',
77
- 'saveSettings', 'testSettings', 'settingsStatus', 'toast', 'brandNote', 'askBtn', 'askInput',
78
- 'graphView', 'graphCanvas', 'graphControls', 'graphReset', 'graphZoomIn', 'graphZoomOut', 'graphFit', 'graphSearchInput', 'graphTooltip',
79
- 'heatmapView', 'heatmapCanvas', 'heatmapTooltip', 'blameGutter', 'codeTabContainer',
80
- 'insightsView', 'diffView', 'workflowsView', 'sbomView', 'searchTriggerBtn', 'inspSecurityTools', 'analysisView',
81
- 'mcpBtn', 'mcpBtnLabel', 'mcpDot', 'mcpDrawer', 'mcpScrim', 'mcpClose', 'mcpPanelDot', 'mcpPanelState',
82
- 'mcpStart', 'mcpStop', 'mcpStatus', 'mcpConfig', 'mcpCommandInput', 'mcpCopy', 'mcpTabJson', 'mcpTabToml',
83
- 'mcpConfigBlock', 'mcpToolsHint', 'serverBtn',
61
+ 'landing', 'landingError', 'landingStatus', 'cancelScanBtn', 'scanProgress', 'pathForm', 'pathInput', 'gitForm', 'gitInput',
62
+ 'pickBtn', 'demoBtn', 'explorer', 'viewTabs', 'repoChip', 'newRepoBtn', 'settingsBtn',
63
+ 'landingAbout', 'creditsHide', 'creditsRemove', 'creditsBar', 'creditsShow', 'creditsBarRemove',
64
+ 'treeFilter', 'fileTree', 'crumbs', 'copyMermaidBtn', 'svgBtn', 'fitBtn',
65
+ 'canvas', 'canvasContent', 'canvasEmpty', 'stageFoot', 'themeBtn', 'aiDrawBtn', 'docView', 'aboutView', 'codeView',
66
+ 'mmToggleBtn', 'stageFilters', 'modeSwitch', 'nodeFilter', 'testsToggle', 'depthSelect',
67
+ 'tourbar', 'tourTitle', 'tourCount', 'tourPrev', 'tourNext', 'tourExit',
68
+ 'drawerScrim', 'settingsDrawer', 'drawerClose', 'setBaseUrl', 'setApiKey', 'setModel',
69
+ 'saveSettings', 'testSettings', 'settingsStatus', 'toast', 'brandNote', 'askBtn', 'askInput',
70
+ 'graphView', 'graphCanvas', 'graphControls', 'graphReset', 'graphZoomIn', 'graphZoomOut', 'graphFit', 'graphSearchInput', 'graphTooltip',
71
+ 'heatmapView', 'heatmapCanvas', 'heatmapTooltip', 'blameGutter', 'codeTabContainer',
72
+ 'insightsView', 'diffView', 'reviewView', 'workflowsView', 'sbomView', 'searchTriggerBtn', 'inspSecurityTools', 'analysisView',
73
+ 'mcpBtn', 'mcpBtnLabel', 'mcpDot', 'mcpDrawer', 'mcpScrim', 'mcpClose', 'mcpPanelDot', 'mcpPanelState',
74
+ 'mcpStart', 'mcpStop', 'mcpStatus', 'mcpConfig', 'mcpCommandInput', 'mcpCopy', 'mcpTabJson', 'mcpTabToml',
75
+ 'mcpConfigBlock', 'mcpToolsHint', 'serverBtn',
84
76
  ].forEach((id) => (dom[id] = $(id)));
85
-
86
77
  let forceGraphCtl = null;
78
+ let graphScan = null;
79
+ let graphHealth = null;
87
80
  let heatmapCtl = null;
88
81
  let searchCtl = null;
89
-
90
82
  const state = appState;
91
-
92
83
  const panzoom = makePanzoom(dom.canvas, dom.canvasContent);
93
-
84
+ const mobilePanels = initMobilePanels();
94
85
  // ---------------------------------------------------------------- landing --
95
-
86
+ let activeScan = null;
96
87
  function landingBusy(message) {
97
- dom.landingError.hidden = true;
98
- dom.landingStatus.textContent = message;
99
- dom.landingStatus.hidden = false;
100
- for (const btn of dom.landing.querySelectorAll('button')) btn.disabled = true;
88
+ dom.landingError.hidden = true;
89
+ dom.landing.setAttribute('aria-busy', 'true');
90
+ dom.landingStatus.textContent = message;
91
+ dom.landingStatus.hidden = false;
92
+ for (const btn of dom.landing.querySelectorAll('button'))
93
+ btn.disabled = true;
94
+ dom.cancelScanBtn.hidden = false;
95
+ dom.cancelScanBtn.disabled = false;
96
+ dom.scanProgress.hidden = false;
97
+ dom.scanProgress.removeAttribute('value');
101
98
  }
102
-
103
99
  function landingIdle(error) {
104
- dom.landingStatus.hidden = true;
105
- for (const btn of dom.landing.querySelectorAll('button')) btn.disabled = false;
106
- if (error) {
107
- dom.landingError.textContent = error;
108
- dom.landingError.hidden = false;
109
- }
100
+ dom.landingStatus.hidden = true;
101
+ dom.cancelScanBtn.hidden = true;
102
+ dom.scanProgress.hidden = true;
103
+ dom.landing.removeAttribute('aria-busy');
104
+ for (const btn of dom.landing.querySelectorAll('button'))
105
+ btn.disabled = false;
106
+ dom.pickBtn.disabled = !canPickFolder();
107
+ if (error) {
108
+ dom.landingError.textContent = error;
109
+ dom.landingError.hidden = false;
110
+ }
111
+ }
112
+ function beginScan(message) {
113
+ activeScan?.abort();
114
+ const controller = new AbortController();
115
+ activeScan = controller;
116
+ landingBusy(message);
117
+ return controller;
110
118
  }
111
-
119
+ function updateScanProgress(progress) {
120
+ if (progress.phase === 'walk')
121
+ dom.landingStatus.textContent = `Walking the file tree… ${progress.found} files`;
122
+ if (progress.phase === 'parse' || progress.phase === 'resolve') {
123
+ dom.landingStatus.textContent = `${progress.phase === 'parse' ? 'Reading code' : 'Tracing imports'}… ${progress.done} / ${progress.total} files`;
124
+ dom.scanProgress.max = Math.max(1, progress.total);
125
+ dom.scanProgress.value = progress.done;
126
+ }
127
+ }
128
+ dom.cancelScanBtn.addEventListener('click', () => {
129
+ activeScan?.abort();
130
+ activeScan = null;
131
+ landingIdle();
132
+ dom.landingStatus.hidden = false;
133
+ dom.landingStatus.textContent = 'Scan cancelled. Choose a repository when you’re ready.';
134
+ });
112
135
  async function loadFromServer(payload, busyText) {
113
- landingBusy(busyText);
114
- try {
115
- const result = await scanOnServer(payload);
116
- enterExplorer(result, {});
117
- } catch (err) {
118
- landingIdle(err.message);
119
- }
136
+ const controller = beginScan(busyText);
137
+ try {
138
+ const result = await scanOnServer(payload, { signal: controller.signal });
139
+ if (activeScan !== controller)
140
+ return;
141
+ enterExplorer(result, {});
142
+ }
143
+ catch (err) {
144
+ if (activeScan === controller)
145
+ landingIdle(err.message);
146
+ }
147
+ finally {
148
+ if (activeScan === controller)
149
+ activeScan = null;
150
+ }
120
151
  }
121
-
122
152
  dom.pathForm.addEventListener('submit', (event) => {
123
- event.preventDefault();
124
- const path = dom.pathInput.value.trim();
125
- if (!path) return landingIdle('Give me a path first — something like ~/work/that-repo.');
126
- loadFromServer({ path }, 'Walking the file tree…');
153
+ event.preventDefault();
154
+ const path = dom.pathInput.value.trim();
155
+ if (!path)
156
+ return landingIdle('Give me a path first — something like ~/work/that-repo.');
157
+ loadFromServer({ path }, 'Walking the file tree…');
127
158
  });
128
-
129
159
  dom.gitForm.addEventListener('submit', (event) => {
130
- event.preventDefault();
131
- const gitUrl = dom.gitInput.value.trim();
132
- if (!gitUrl) return landingIdle('Paste a git URL first.');
133
- loadFromServer({ gitUrl }, 'Cloning, then scanning. Big repos take a minute…');
160
+ event.preventDefault();
161
+ const gitUrl = dom.gitInput.value.trim();
162
+ if (!gitUrl)
163
+ return landingIdle('Paste a git URL first.');
164
+ loadFromServer({ gitUrl }, 'Cloning, then scanning. Big repos take a minute…');
134
165
  });
135
-
136
166
  dom.demoBtn.addEventListener('click', () => {
137
- loadFromServer({ demo: true }, 'Reading our own source…');
167
+ loadFromServer({ demo: true }, 'Reading our own source…');
138
168
  });
139
-
140
169
  // The landing credits card can be tucked away or permanently removed; the choice sticks.
141
170
  const CREDITS_KEY = 'onboarder.credits';
142
171
  function applyCreditsVisibility() {
143
- const status = localStorage.getItem(CREDITS_KEY);
144
- if (status === 'removed') {
145
- dom.landingAbout?.remove();
146
- dom.creditsBar?.remove();
147
- return;
148
- }
149
- const hidden = status === 'hidden';
150
- if (dom.landingAbout) dom.landingAbout.hidden = hidden;
151
- if (dom.creditsBar) dom.creditsBar.hidden = !hidden;
172
+ const status = localStorage.getItem(CREDITS_KEY);
173
+ if (status === 'removed') {
174
+ dom.landingAbout?.remove();
175
+ dom.creditsBar?.remove();
176
+ return;
177
+ }
178
+ const hidden = status === 'hidden';
179
+ if (dom.landingAbout)
180
+ dom.landingAbout.hidden = hidden;
181
+ if (dom.creditsBar)
182
+ dom.creditsBar.hidden = !hidden;
152
183
  }
153
-
154
184
  function removeCreditsPermanently() {
155
- localStorage.setItem(CREDITS_KEY, 'removed');
156
- applyCreditsVisibility();
185
+ localStorage.setItem(CREDITS_KEY, 'removed');
186
+ applyCreditsVisibility();
157
187
  }
158
-
159
188
  dom.creditsHide?.addEventListener('click', () => {
160
- localStorage.setItem(CREDITS_KEY, 'hidden');
161
- applyCreditsVisibility();
189
+ localStorage.setItem(CREDITS_KEY, 'hidden');
190
+ applyCreditsVisibility();
162
191
  });
163
192
  dom.creditsShow?.addEventListener('click', () => {
164
- localStorage.setItem(CREDITS_KEY, 'shown');
165
- applyCreditsVisibility();
193
+ localStorage.setItem(CREDITS_KEY, 'shown');
194
+ applyCreditsVisibility();
166
195
  });
167
196
  dom.creditsRemove?.addEventListener('click', removeCreditsPermanently);
168
197
  dom.creditsBarRemove?.addEventListener('click', removeCreditsPermanently);
169
-
170
198
  applyCreditsVisibility();
171
-
172
199
  dom.pickBtn.addEventListener('click', async () => {
173
- if (!canPickFolder()) {
174
- return landingIdle('This browser can’t pick folders. Chrome or Edge can; the other two options work anywhere.');
175
- }
176
- let handle;
177
- try {
178
- handle = await pickDirectory();
179
- } catch {
180
- return; // the picker was dismissed — no news is good news
181
- }
182
- landingBusy('Walking the file tree…');
183
- try {
184
- const source = browserFileSource(handle);
185
- const scan = await scanRepo(source, {
186
- onProgress: (p) => {
187
- if (p.phase === 'walk') dom.landingStatus.textContent = `Walking the file tree… ${p.found} files`;
188
- if (p.phase === 'parse') dom.landingStatus.textContent = `Reading code… ${p.done} files`;
189
- },
190
- });
191
- dom.landingStatus.textContent = 'Counting connections…';
192
- const manifest = await detectManifest(source);
193
- const facts = computeFacts(scan, manifest);
194
- enterExplorer({ scan, facts, manifest }, { browserSource: source });
195
- } catch (err) {
196
- landingIdle(err.message);
197
- }
200
+ if (!canPickFolder()) {
201
+ return landingIdle('This browser can’t pick folders. Chrome or Edge can; the other two options work anywhere.');
202
+ }
203
+ let handle;
204
+ try {
205
+ handle = await pickDirectory();
206
+ }
207
+ catch {
208
+ return; // the picker was dismissed — no news is good news
209
+ }
210
+ const controller = beginScan('Walking the file tree…');
211
+ try {
212
+ const source = browserFileSource(handle);
213
+ const scan = await scanRepo(source, {
214
+ signal: controller.signal,
215
+ onProgress: updateScanProgress,
216
+ });
217
+ dom.landingStatus.textContent = 'Counting connections…';
218
+ const manifest = await abortable(detectManifest(source), controller.signal);
219
+ throwIfAborted(controller.signal);
220
+ if (activeScan !== controller)
221
+ return;
222
+ const facts = computeFacts(scan, manifest);
223
+ enterExplorer({ scan, facts, manifest }, { browserSource: source });
224
+ }
225
+ catch (err) {
226
+ if (activeScan === controller)
227
+ landingIdle(err.message);
228
+ }
229
+ finally {
230
+ if (activeScan === controller)
231
+ activeScan = null;
232
+ }
198
233
  });
199
-
200
234
  if (!canPickFolder()) {
201
- dom.pickBtn.disabled = true;
202
- dom.pickBtn.title = 'Folder picking needs Chrome or Edge.';
235
+ dom.pickBtn.disabled = true;
236
+ dom.pickBtn.title = 'Folder picking needs Chrome or Edge.';
203
237
  }
204
-
205
238
  // -------------------------------------------------------------- explorer --
206
-
207
239
  function enterExplorer(payload, opts) {
208
- loadRepo(payload, {
209
- browserSource: opts.browserSource || null,
210
- history: payload.history || null, // computed server-side; null for browser picks
211
- });
212
- state.stack = analyzeStack(state.manifest, state.scan.stats.languages);
213
-
214
- resetOpenState();
215
- state.treeData = buildTree(state.scan.allFiles);
216
- state.patterns = buildPatterns(state.scan, state.facts, state.manifest);
217
- state.health = analyzeHealth(state.scan, state.facts);
218
- state.security = summarizeSecurity(state.scan);
219
- dom.nodeFilter.value = '';
220
- dom.testsToggle.classList.add('is-on');
221
- dom.depthSelect.value = '0';
222
-
223
- if (searchCtl) {
224
- searchCtl.updateFiles(state.scan.files, state.scanId);
225
- searchCtl.hide();
226
- } else {
227
- searchCtl = initSearch(state.scan.files, { getScanId: () => state.scanId });
228
- }
229
-
230
- // Compute everything first, then flip the UI over — a failure here should
231
- // land on the landing page with the error visible, not a half-dead explorer.
232
- landingIdle();
233
- dom.landing.hidden = true;
234
- dom.explorer.hidden = false;
235
- dom.viewTabs.hidden = false;
236
- dom.repoChip.hidden = false;
237
- dom.newRepoBtn.hidden = false;
238
- dom.searchTriggerBtn.hidden = false;
239
- dom.repoChip.innerHTML =
240
- `<span class="repo-name">${escapeHtml(state.scan.name)}</span>` +
241
- `<span class="repo-sub">${escapeHtml(repoSubLabel())}</span>`;
242
- dom.repoChip.title = state.scan.root;
243
- dom.brandNote.textContent = state.scan.stats.filesParsed + ' files · ' + state.scan.stats.edgeCount + ' connections';
244
-
245
- renderSidebar();
246
- setView('map');
240
+ loadRepo(payload, {
241
+ browserSource: opts.browserSource || null,
242
+ history: payload.history || null, // computed server-side; null for browser picks
243
+ });
244
+ state.stack = analyzeStack(state.manifest, state.scan.stats.languages);
245
+ resetOpenState();
246
+ state.treeData = buildTree(state.scan.allFiles);
247
+ state.patterns = buildPatterns(state.scan, state.facts, state.manifest);
248
+ state.health = analyzeHealth(state.scan, state.facts);
249
+ state.security = summarizeSecurity(state.scan);
250
+ dom.nodeFilter.value = '';
251
+ dom.testsToggle.classList.add('is-on');
252
+ dom.depthSelect.value = '0';
253
+ if (searchCtl) {
254
+ searchCtl.updateFiles(state.scan.files, state.scanId);
255
+ searchCtl.hide();
256
+ }
257
+ else {
258
+ searchCtl = initSearch(state.scan.files, { getScanId: () => state.scanId });
259
+ }
260
+ // Compute everything first, then flip the UI over — a failure here should
261
+ // land on the landing page with the error visible, not a half-dead explorer.
262
+ landingIdle();
263
+ dom.landing.hidden = true;
264
+ dom.explorer.hidden = false;
265
+ dom.viewTabs.hidden = false;
266
+ dom.repoChip.hidden = false;
267
+ dom.newRepoBtn.hidden = false;
268
+ dom.searchTriggerBtn.hidden = false;
269
+ dom.repoChip.innerHTML =
270
+ `<span class="repo-name">${escapeHtml(state.scan.name)}</span>` +
271
+ `<span class="repo-sub">${escapeHtml(repoSubLabel())}</span>`;
272
+ dom.repoChip.title = state.scan.root;
273
+ dom.brandNote.textContent = state.scan.stats.filesParsed + ' files · ' + state.scan.stats.edgeCount + ' connections';
274
+ renderSidebar();
275
+ setView(state.scanId ? 'review' : 'map');
247
276
  }
248
-
249
277
  // The big name is the repo's own; the small line says where it actually lives.
250
278
  function repoSubLabel() {
251
- if (state.scan.tempId) return state.scan.tempId + ' · temp clone';
252
- if (state.browserSource) return 'picked in the browser';
253
- return state.scan.root;
279
+ if (state.scan.tempId)
280
+ return state.scan.tempId + ' · temp clone';
281
+ if (state.browserSource)
282
+ return 'picked in the browser';
283
+ return state.scan.root;
254
284
  }
255
-
256
285
  function buildPatterns(scan, facts, manifest) {
257
- const layersInfo = computeLayers(scan, facts);
258
- return {
259
- layersInfo,
260
- findings: detectPatterns(scan, facts, manifest, layersInfo),
261
- coupling: couplingMatrix(scan, facts),
262
- };
286
+ const layersInfo = computeLayers(scan, facts);
287
+ return {
288
+ layersInfo,
289
+ findings: detectPatterns(scan, facts, manifest, layersInfo),
290
+ coupling: couplingMatrix(scan, facts),
291
+ };
263
292
  }
264
-
265
293
  dom.newRepoBtn.addEventListener('click', () => {
266
- if (state.cloneId) cleanupClone(state.cloneId);
267
- // The graph and heatmap views attach `window` resize listeners when
268
- // they initialize. The view tab is unmounted by hiding the container,
269
- // not by removing the listener, so without this call every new repo
270
- // would attach another listener to the same global and the page would
271
- // fan out work on every resize. Destroy tears them down before the
272
- // next `init*` rebuilds them for a new repo.
273
- if (forceGraphCtl) { forceGraphCtl.destroy(); forceGraphCtl = null; }
274
- if (heatmapCtl) { heatmapCtl.destroy(); heatmapCtl = null; }
275
- unloadRepo();
276
- dom.explorer.hidden = true;
277
- dom.tourbar.hidden = true;
278
- dom.viewTabs.hidden = true;
279
- dom.repoChip.hidden = true;
280
- dom.newRepoBtn.hidden = true;
281
- dom.brandNote.textContent = 'a map for any codebase';
282
- dom.landing.hidden = false;
283
- landingIdle();
294
+ stopReview();
295
+ stopDiff();
296
+ mobilePanels.close();
297
+ if (state.cloneId)
298
+ cleanupClone(state.cloneId);
299
+ // The graph and heatmap views attach `window` resize listeners when
300
+ // they initialize. The view tab is unmounted by hiding the container,
301
+ // not by removing the listener, so without this call every new repo
302
+ // would attach another listener to the same global and the page would
303
+ // fan out work on every resize. Destroy tears them down before the
304
+ // next `init*` rebuilds them for a new repo.
305
+ if (forceGraphCtl) {
306
+ forceGraphCtl.destroy();
307
+ forceGraphCtl = null;
308
+ }
309
+ if (heatmapCtl) {
310
+ heatmapCtl.destroy();
311
+ heatmapCtl = null;
312
+ }
313
+ graphScan = null;
314
+ graphHealth = null;
315
+ unloadRepo();
316
+ dom.explorer.hidden = true;
317
+ dom.tourbar.hidden = true;
318
+ dom.viewTabs.hidden = true;
319
+ dom.repoChip.hidden = true;
320
+ dom.newRepoBtn.hidden = true;
321
+ dom.brandNote.textContent = 'a map for any codebase';
322
+ dom.landing.hidden = false;
323
+ landingIdle();
284
324
  });
285
-
286
325
  function renderSidebar() {
287
- renderTree(dom.fileTree, state.treeData, {
288
- facts: state.facts,
289
- filter: dom.treeFilter.value,
290
- selected: state.selected,
291
- parsedPaths: state.scan.files.map((f) => f.path),
292
- onFile: openFile,
293
- onFolder: (folder) => {
294
- focusFolder(folder);
295
- state.folder = folder;
296
- state.detail = null;
297
- if (state.view === 'files') renderCanvas();
298
- syncInspector();
299
- },
300
- });
326
+ renderTree(dom.fileTree, state.treeData, {
327
+ facts: state.facts,
328
+ filter: dom.treeFilter.value,
329
+ selected: state.selected,
330
+ parsedPaths: state.scan.files.map((f) => f.path),
331
+ onFile: openFile,
332
+ onFolder: (folder) => {
333
+ focusFolder(folder);
334
+ state.folder = folder;
335
+ state.detail = null;
336
+ if (state.view === 'files')
337
+ renderCanvas();
338
+ syncInspector();
339
+ },
340
+ });
301
341
  }
302
-
303
- dom.treeFilter.addEventListener('input', renderSidebar);
304
-
342
+ let sidebarFrame = null;
343
+ dom.treeFilter.addEventListener('input', () => {
344
+ if (sidebarFrame !== null)
345
+ cancelAnimationFrame(sidebarFrame);
346
+ sidebarFrame = requestAnimationFrame(() => { sidebarFrame = null; if (state.scan)
347
+ renderSidebar(); });
348
+ });
305
349
  // ------------------------------------------------------------------ views --
306
-
307
350
  dom.viewTabs.addEventListener('click', (event) => {
308
- const tab = event.target.closest('.view-tab');
309
- if (!tab) return;
310
- withTransition(() => {
311
- if (tab.dataset.view === 'explorer') return setView(state.lastExplorer || 'map');
312
- setView(tab.dataset.view);
313
- });
351
+ const tab = eventElement(event)?.closest('.view-tab');
352
+ if (!tab)
353
+ return;
354
+ const view = tab.dataset.view === 'explorer' ? state.lastExplorer || 'map' : tab.dataset.view;
355
+ if (state.view === view)
356
+ return;
357
+ withTransition(() => setView(view));
314
358
  });
315
-
316
359
  // The inspector's single source of truth. Whatever is focused — a file, a
317
360
  // folder — is shown in every tab; with nothing focused, each view shows its
318
361
  // own default panel. Every selection path funnels here, so the panel is
@@ -320,432 +363,467 @@ dom.viewTabs.addEventListener('click', (event) => {
320
363
  // comes from `inspectorSubject` in state.js, which is pure and tested; this
321
364
  // function only does the showing.
322
365
  function syncInspector() {
323
- const subject = inspectorSubject(state);
324
- const opts = { ...state, backLabel: subject.backLabel };
325
- switch (subject.kind) {
326
- case 'file': return inspector.showFile(subject.path, opts);
327
- case 'folder': return inspector.showFolder(subject.path, opts);
328
- case 'patterns': return inspector.showPatterns(state);
329
- case 'health': return inspector.showHealth(state);
330
- case 'security': return inspector.showSecurity(state);
331
- case 'history': return inspector.showHistory(state);
332
- default: return inspector.showOverview(state);
333
- }
366
+ const subject = inspectorSubject(state);
367
+ const opts = { ...state, backLabel: subject.backLabel };
368
+ switch (subject.kind) {
369
+ case 'file': return inspector.showFile(subject.path, opts);
370
+ case 'folder': return inspector.showFolder(subject.path, opts);
371
+ case 'patterns': return inspector.showPatterns(state);
372
+ case 'health': return inspector.showHealth(state);
373
+ case 'security': return inspector.showSecurity(state);
374
+ case 'history': return inspector.showHistory(state);
375
+ default: return inspector.showOverview(state);
376
+ }
334
377
  }
335
-
336
378
  function clearFocus() {
337
- clearFocusState();
338
- syncInspector();
379
+ clearFocusState();
380
+ syncInspector();
339
381
  }
340
-
341
382
  function setView(view) {
342
- state.view = view;
343
- if (isExplorerView(view)) state.lastExplorer = view;
344
- for (const tab of dom.viewTabs.querySelectorAll('.view-tab')) {
345
- tab.classList.toggle(
346
- 'is-active',
347
- tab.dataset.view === view || (tab.dataset.view === 'explorer' && isExplorerView(view))
348
- );
349
- }
350
- for (const chip of dom.modeSwitch.querySelectorAll('.mode-chip')) {
351
- chip.classList.toggle('is-active', chip.dataset.mode === view);
352
- }
353
- dom.tourbar.hidden = view !== 'tour';
354
-
355
- if (view === 'tour') {
356
- state.tour.stops = buildTourStops(state.scan, state.facts);
357
- state.tour.idx = 0;
358
- state.selected = null; // the tour drives its own focus
359
- }
360
-
361
- renderCanvas();
362
-
363
- if (view === 'tour') showTourStop();
364
- else syncInspector();
383
+ if (view !== 'review')
384
+ stopReview();
385
+ if (view !== 'diff')
386
+ stopDiff();
387
+ dom.explorer.classList.toggle('review-workspace', view === 'review');
388
+ state.view = view;
389
+ if (isExplorerView(view))
390
+ state.lastExplorer = view;
391
+ for (const tab of dom.viewTabs.querySelectorAll('.view-tab')) {
392
+ tab.classList.toggle('is-active', tab.dataset.view === view || (tab.dataset.view === 'explorer' && isExplorerView(view)));
393
+ }
394
+ for (const chip of dom.modeSwitch.querySelectorAll('.mode-chip')) {
395
+ chip.classList.toggle('is-active', chip.dataset.mode === view);
396
+ }
397
+ dom.tourbar.hidden = view !== 'tour';
398
+ if (view === 'tour') {
399
+ state.tour.stops = buildTourStops(state.scan, state.facts);
400
+ state.tour.idx = 0;
401
+ state.selected = null; // the tour drives its own focus
402
+ }
403
+ for (const tab of dom.viewTabs.querySelectorAll('.view-tab'))
404
+ tab.setAttribute('aria-current', tab.classList.contains('is-active') ? 'page' : 'false');
405
+ renderCanvas();
406
+ if (view === 'tour')
407
+ showTourStop();
408
+ else
409
+ syncInspector();
365
410
  }
366
-
367
411
  function openFile(path) {
368
- focusFile(path);
369
- state.code.path = path; // the Code tab follows the current file
370
- if (state.view !== 'files' && state.view !== 'tour' && state.view !== 'code') {
371
- state.folder = dirOf(path);
372
- setView('files');
373
- return;
374
- }
375
- if (state.view === 'files') {
376
- state.folder = dirOf(path);
377
- state.detail = path;
378
- renderCanvas();
379
- }
380
- if (state.view === 'code') renderCode();
381
- syncInspector();
382
- renderSidebar();
412
+ focusFile(path);
413
+ state.code.path = path; // the Code tab follows the current file
414
+ if (state.view !== 'files' && state.view !== 'tour' && state.view !== 'code') {
415
+ state.folder = dirOf(path);
416
+ setView('files');
417
+ return;
418
+ }
419
+ if (state.view === 'files') {
420
+ state.folder = dirOf(path);
421
+ state.detail = path;
422
+ renderCanvas();
423
+ }
424
+ if (state.view === 'code')
425
+ renderCode();
426
+ syncInspector();
427
+ renderSidebar();
383
428
  }
384
-
385
429
  // The Code tab, optionally at a line. Search results for symbols and content
386
430
  // hits come through here: the scan already knows where a symbol was declared and
387
431
  // where a phrase matched, so opening the file at line 1 would throw away the
388
432
  // only part of the answer the person did not already know.
389
433
  function openFileInCode(path, line) {
390
- focusFile(path);
391
- state.code.path = path;
392
- if (line) revealLineInCode(line);
393
- setView('code');
434
+ focusFile(path);
435
+ state.code.path = path;
436
+ if (line)
437
+ revealLineInCode(line);
438
+ setView('code');
394
439
  }
395
-
396
440
  function openDeepDive(path) {
397
- focusFile(path);
398
- state.folder = dirOf(path);
399
- state.detail = path;
400
- setView('files');
441
+ focusFile(path);
442
+ state.folder = dirOf(path);
443
+ state.detail = path;
444
+ setView('files');
401
445
  }
402
-
403
446
  function onNodeClick(payload) {
404
- if (payload.kind === 'folder') {
405
- state.folder = payload.path;
406
- state.detail = null;
407
- focusFolder(payload.path);
408
- setView('files');
409
- } else if (payload.kind === 'file') {
410
- openFile(payload.path);
411
- }
447
+ if (payload.kind === 'folder') {
448
+ state.folder = payload.path;
449
+ state.detail = null;
450
+ focusFolder(payload.path);
451
+ setView('files');
452
+ }
453
+ else if (payload.kind === 'file') {
454
+ openFile(payload.path);
455
+ }
412
456
  }
413
-
414
457
  inspector.onInspectorNavigate(openFile);
415
-
416
458
  document.addEventListener('search-select', (event) => {
417
- const detail = event.detail || {};
418
- if (!detail.path) return;
419
- // The palette says where a result belongs. A file's home is the graph; a
420
- // symbol or a content hit belongs in the Code tab, at its line.
421
- if (detail.target === 'code') return openFileInCode(detail.path, detail.line);
422
- return openFile(detail.path);
459
+ const detail = event.detail || {};
460
+ if (!detail.path)
461
+ return;
462
+ // The palette says where a result belongs. A file's home is the graph; a
463
+ // symbol or a content hit belongs in the Code tab, at its line.
464
+ if (detail.target === 'code')
465
+ return openFileInCode(detail.path, detail.line);
466
+ return openFile(detail.path);
423
467
  });
424
-
425
468
  document.addEventListener('file-select', (event) => {
426
- if (event.detail?.path) openFile(event.detail.path);
469
+ if (event.detail?.path)
470
+ openFile(event.detail.path);
427
471
  });
428
-
429
472
  document.addEventListener('graph-node-click', (event) => {
430
- if (event.detail?.path) openFile(event.detail.path);
473
+ if (event.detail?.path)
474
+ openFile(event.detail.path);
431
475
  });
432
-
433
476
  document.addEventListener('heatmap-node-click', (event) => {
434
- if (event.detail?.path) openFile(event.detail.path);
477
+ if (event.detail?.path)
478
+ openFile(event.detail.path);
435
479
  });
436
-
437
480
  async function renderCanvas() {
438
- const isDocs = state.view === 'docs';
439
- const isAbout = state.view === 'about';
440
- const isCode = state.view === 'code';
441
- const isGraph = state.view === 'graph';
442
- const isHeatmap = state.view === 'heatmap';
443
- const isInsights = state.view === 'insights';
444
- const isDiff = state.view === 'diff';
445
- const isWorkflows = state.view === 'workflows';
446
- const isSbom = state.view === 'sbom';
447
- const isAnalysis = state.view === 'analysis';
448
- const isPage = isDocs || isAbout || isCode || isGraph || isHeatmap || isInsights || isDiff || isWorkflows || isSbom || isAnalysis;
449
- const isAtlasList = state.view === 'atlas' && !state.atlasOpen;
450
-
451
- dom.canvas.hidden = isPage;
452
- dom.docView.hidden = !isDocs;
453
- dom.aboutView.hidden = !isAbout;
454
- dom.codeView.hidden = !isCode;
455
- if (dom.codeTabContainer) dom.codeTabContainer.hidden = !isCode;
456
- if (dom.graphView) dom.graphView.hidden = !isGraph;
457
- if (dom.heatmapView) dom.heatmapView.hidden = !isHeatmap;
458
- if (dom.insightsView) dom.insightsView.hidden = !isInsights;
459
- if (dom.diffView) dom.diffView.hidden = !isDiff;
460
- if (dom.workflowsView) dom.workflowsView.hidden = !isWorkflows;
461
- if (dom.sbomView) dom.sbomView.hidden = !isSbom;
462
- if (dom.analysisView) dom.analysisView.hidden = !isAnalysis;
463
-
464
- dom.copyMermaidBtn.hidden = isPage || isAtlasList;
465
- dom.svgBtn.hidden = isPage;
466
- dom.fitBtn.hidden = isPage || isAtlasList;
467
- dom.stageFilters.hidden = isPage;
468
- dom.mmToggleBtn.hidden = isPage || state.view !== 'map';
469
- if (!isAtlasList) dom.canvasContent.classList.remove('atlas-mode');
470
-
471
- if (isPage) {
472
- dom.aiDrawBtn.hidden = true;
473
- if (isDocs) renderDocs();
474
- else if (isCode) renderCode();
475
- else if (isAbout) renderAbout();
476
- else if (isInsights) renderInsights(dom.insightsView, state);
477
- else if (isDiff) renderDiffView(dom.diffView, state);
478
- else if (isWorkflows) renderWorkflows(dom.workflowsView, state);
479
- else if (isSbom) renderSbom(dom.sbomView, state);
480
- else if (isAnalysis) renderDeepAnalysis(dom.analysisView, state);
481
-
482
- if (isGraph) {
483
- if (!forceGraphCtl) forceGraphCtl = initForceGraph(dom.graphCanvas);
484
- const edges = state.scan.edges || [];
485
- const facts = state.facts || {};
486
- const healthMap = new Map((state.health?.perFile || []).map(h => [h.path, h]));
487
- const nodes = state.scan.files.map(f => {
488
- const h = healthMap.get(f.path);
489
- return {
490
- path: f.path,
491
- fanIn: facts.fanIn?.[f.path] || 0,
492
- fanOut: facts.fanOut?.[f.path] || 0,
493
- community: facts.communities?.[f.path] || 0,
494
- risk: h?.risk || 0,
495
- complexity: h?.complexity || f.cognitive || 0,
496
- lang: f.lang || 'unknown',
497
- };
498
- });
499
- forceGraphCtl.update(nodes, edges, facts.communities || {});
500
- }
501
-
502
- if (isHeatmap) {
503
- const heatFiles = state.scan.files.map(f => ({
504
- path: f.path,
505
- risk: state.health?.perFile?.find(x => x.path === f.path)?.risk || 0,
506
- loc: f.size
507
- }));
508
- if (!heatmapCtl) heatmapCtl = initHeatmap(dom.heatmapCanvas, heatFiles);
509
- else heatmapCtl.update(heatFiles);
510
- }
511
-
512
- renderCrumbs();
513
- renderFoot();
514
- return;
515
- }
516
-
517
- const { scan, facts, manifest } = state;
518
- const key = aiViewKey();
519
- updateAiBtn(key);
520
-
521
- // Tree-map furniture only belongs to the Map; SVG export can't take cells.
522
- dom.svgBtn.hidden = isPage || isAtlasList || state.view === 'map';
523
- const filterable = ['map', 'files', 'patterns'].includes(state.view);
524
- dom.nodeFilter.hidden = !filterable && state.view !== 'atlas';
525
- dom.testsToggle.hidden = !filterable && state.view !== 'atlas';
526
- dom.depthSelect.hidden = state.view !== 'map';
527
- if (state.view === 'map') {
528
- // Copy Mermaid still hands over the classic folder overview.
529
- state.currentDiagram = { source: overviewDiagram(scan, facts).source, nodes: {} };
530
- renderMap(true);
531
- renderCrumbs();
532
- renderFoot();
533
- return;
534
- }
535
-
536
- if (isAtlasList) {
537
- dom.aiDrawBtn.hidden = true;
538
- dom.canvasEmpty.hidden = true; // the list is its own content; no placeholder
539
- renderAtlasList();
540
- renderCrumbs();
541
- renderFoot();
542
- return;
543
- }
544
-
545
- // An active AI draft replaces the static diagram for its exact view.
546
- if (state.aiActiveKey === key && state.aiDiagrams[key]) {
547
- const ai = state.aiDiagrams[key];
548
- const source = diagramThemeBlock() + '\n' + ai.body; // theme follows the toggle
481
+ const isDocs = state.view === 'docs';
482
+ const isAbout = state.view === 'about';
483
+ const isCode = state.view === 'code';
484
+ const isGraph = state.view === 'graph';
485
+ const isHeatmap = state.view === 'heatmap';
486
+ const isInsights = state.view === 'insights';
487
+ const isDiff = state.view === 'diff';
488
+ const isReview = state.view === 'review';
489
+ const isWorkflows = state.view === 'workflows';
490
+ const isSbom = state.view === 'sbom';
491
+ const isAnalysis = state.view === 'analysis';
492
+ const isPage = isDocs || isAbout || isCode || isGraph || isHeatmap || isInsights || isDiff || isReview || isWorkflows || isSbom || isAnalysis;
493
+ forceGraphCtl?.setActive(isGraph && !searchCtl?.isShowing());
494
+ const isAtlasList = state.view === 'atlas' && !state.atlasOpen;
495
+ dom.canvas.hidden = isPage;
496
+ dom.docView.hidden = !isDocs;
497
+ dom.aboutView.hidden = !isAbout;
498
+ dom.codeView.hidden = !isCode;
499
+ if (dom.codeTabContainer)
500
+ dom.codeTabContainer.hidden = !isCode;
501
+ if (dom.graphView)
502
+ dom.graphView.hidden = !isGraph;
503
+ if (dom.heatmapView)
504
+ dom.heatmapView.hidden = !isHeatmap;
505
+ if (dom.insightsView)
506
+ dom.insightsView.hidden = !isInsights;
507
+ if (dom.diffView)
508
+ dom.diffView.hidden = !isDiff;
509
+ if (dom.reviewView)
510
+ dom.reviewView.hidden = !isReview;
511
+ if (dom.workflowsView)
512
+ dom.workflowsView.hidden = !isWorkflows;
513
+ if (dom.sbomView)
514
+ dom.sbomView.hidden = !isSbom;
515
+ if (dom.analysisView)
516
+ dom.analysisView.hidden = !isAnalysis;
517
+ dom.copyMermaidBtn.hidden = isPage || isAtlasList;
518
+ dom.svgBtn.hidden = isPage;
519
+ dom.fitBtn.hidden = isPage || isAtlasList;
520
+ dom.stageFilters.hidden = isPage;
521
+ dom.mmToggleBtn.hidden = isPage || state.view !== 'map';
522
+ if (!isAtlasList)
523
+ dom.canvasContent.classList.remove('atlas-mode');
524
+ if (isPage) {
525
+ dom.aiDrawBtn.hidden = true;
526
+ if (isDocs)
527
+ renderDocs();
528
+ else if (isCode)
529
+ renderCode();
530
+ else if (isAbout)
531
+ renderAbout();
532
+ else if (isInsights)
533
+ renderInsights(dom.insightsView, state);
534
+ else if (isDiff)
535
+ renderDiffView(dom.diffView, state);
536
+ else if (isReview)
537
+ renderReviewView(dom.reviewView, state);
538
+ else if (isWorkflows)
539
+ renderWorkflows(dom.workflowsView, state);
540
+ else if (isSbom)
541
+ renderSbom(dom.sbomView, state);
542
+ else if (isAnalysis)
543
+ renderDeepAnalysis(dom.analysisView, state);
544
+ if (isGraph) {
545
+ if (!forceGraphCtl)
546
+ forceGraphCtl = initForceGraph(dom.graphCanvas);
547
+ forceGraphCtl.setActive(!searchCtl?.isShowing());
548
+ if (graphScan !== state.scan || graphHealth !== state.health) {
549
+ const edges = state.scan.edges || [];
550
+ const facts = state.facts || {};
551
+ const healthMap = new Map((state.health?.perFile || []).map(h => [h.path, h]));
552
+ const nodes = state.scan.files.map(f => {
553
+ const h = healthMap.get(f.path);
554
+ return {
555
+ path: f.path,
556
+ fanIn: facts.fanIn?.[f.path] || 0,
557
+ fanOut: facts.fanOut?.[f.path] || 0,
558
+ community: facts.communities?.[f.path] || 0,
559
+ risk: h?.risk || 0,
560
+ complexity: h?.complexity || f.cognitive || 0,
561
+ lang: f.lang || 'unknown',
562
+ };
563
+ });
564
+ forceGraphCtl.update(nodes, edges, facts.communities || {});
565
+ graphScan = state.scan;
566
+ graphHealth = state.health;
567
+ }
568
+ forceGraphCtl.resize();
569
+ }
570
+ if (isHeatmap) {
571
+ const healthByPath = new Map((state.health?.perFile || []).map(f => [f.path, f]));
572
+ const heatFiles = state.scan.files.map(f => ({
573
+ path: f.path,
574
+ risk: healthByPath.get(f.path)?.risk || 0,
575
+ loc: f.size
576
+ }));
577
+ if (!heatmapCtl)
578
+ heatmapCtl = initHeatmap(dom.heatmapCanvas, heatFiles);
579
+ else
580
+ heatmapCtl.update(heatFiles);
581
+ }
582
+ renderCrumbs();
583
+ renderFoot();
584
+ return;
585
+ }
586
+ const { scan, facts, manifest } = state;
587
+ const key = aiViewKey();
588
+ updateAiBtn(key);
589
+ // Tree-map furniture only belongs to the Map; SVG export can't take cells.
590
+ dom.svgBtn.hidden = isPage || isAtlasList || state.view === 'map';
591
+ const filterable = ['map', 'files', 'patterns'].includes(state.view);
592
+ dom.nodeFilter.hidden = !filterable && state.view !== 'atlas';
593
+ dom.testsToggle.hidden = !filterable && state.view !== 'atlas';
594
+ dom.depthSelect.hidden = state.view !== 'map';
595
+ if (state.view === 'map') {
596
+ // Copy Mermaid still hands over the classic folder overview.
597
+ state.currentDiagram = { source: overviewDiagram(scan, facts).source, nodes: {} };
598
+ renderMap(true);
599
+ renderCrumbs();
600
+ renderFoot();
601
+ return;
602
+ }
603
+ if (isAtlasList) {
604
+ dom.aiDrawBtn.hidden = true;
605
+ dom.canvasEmpty.hidden = true; // the list is its own content; no placeholder
606
+ renderAtlasList();
607
+ renderCrumbs();
608
+ renderFoot();
609
+ return;
610
+ }
611
+ // An active AI draft replaces the static diagram for its exact view.
612
+ if (state.aiActiveKey === key && state.aiDiagrams[key]) {
613
+ const ai = state.aiDiagrams[key];
614
+ const source = diagramThemeBlock() + '\n' + ai.body; // theme follows the toggle
615
+ dom.canvasEmpty.hidden = true;
616
+ try {
617
+ await renderInto(dom.canvasContent, source);
618
+ wireAIClicks();
619
+ requestAnimationFrame(() => panzoom.fit());
620
+ }
621
+ catch (err) {
622
+ console.error(err);
623
+ delete state.aiDiagrams[key];
624
+ state.aiActiveKey = null;
625
+ toast('That AI sketch would not draw — back to the static map.');
626
+ renderCanvas();
627
+ return;
628
+ }
629
+ state.currentDiagram = { source, nodes: {} };
630
+ renderCrumbs();
631
+ renderFoot(ai.caption);
632
+ return;
633
+ }
634
+ // Honest empty states: better than a sparse, confusing diagram.
635
+ const nothing = nothingToShow();
636
+ if (nothing) {
637
+ dom.canvasContent.innerHTML = '';
638
+ dom.canvasEmpty.textContent = nothing;
639
+ dom.canvasEmpty.hidden = false;
640
+ state.currentDiagram = { source: '', nodes: {} };
641
+ renderCrumbs();
642
+ renderFoot();
643
+ return;
644
+ }
645
+ let d;
646
+ if (state.view === 'patterns') {
647
+ d = layersDiagram(scan, facts, state.patterns.layersInfo, { include: pathFilter(state.filters) });
648
+ }
649
+ else if (state.view === 'health') {
650
+ d = healthDiagram(scan, facts, state.health, { include: pathFilter(state.filters) });
651
+ }
652
+ else if (state.view === 'security') {
653
+ d = securityDiagram(scan, facts, state.security, { include: pathFilter(state.filters) });
654
+ }
655
+ else if (state.view === 'history') {
656
+ d = historyDiagram(scan, facts, state.history);
657
+ }
658
+ else if (state.view === 'services') {
659
+ d = servicesDiagram(scan, manifest);
660
+ }
661
+ else if (state.view === 'tour') {
662
+ const stop = state.tour.stops[state.tour.idx];
663
+ d = stop ? fileDetailDiagram(scan, facts, stop.path) : emptyDiagram('No tour stops could be picked from this repo.');
664
+ }
665
+ else if (state.view === 'atlas' && state.atlasOpen) {
666
+ d = openCardDiagram();
667
+ }
668
+ else {
669
+ d = state.detail
670
+ ? fileDetailDiagram(scan, facts, state.detail)
671
+ : folderDiagram(scan, facts, state.folder, { include: pathFilter(state.filters) });
672
+ }
673
+ state.currentDiagram = d;
549
674
  dom.canvasEmpty.hidden = true;
550
675
  try {
551
- await renderInto(dom.canvasContent, source);
552
- wireAIClicks();
553
- requestAnimationFrame(() => panzoom.fit());
554
- } catch (err) {
555
- console.error(err);
556
- delete state.aiDiagrams[key];
557
- state.aiActiveKey = null;
558
- toast('That AI sketch would not draw — back to the static map.');
559
- renderCanvas();
560
- return;
561
- }
562
- state.currentDiagram = { source, nodes: {} };
563
- renderCrumbs();
564
- renderFoot(ai.caption);
565
- return;
566
- }
567
-
568
- // Honest empty states: better than a sparse, confusing diagram.
569
- const nothing = nothingToShow();
570
- if (nothing) {
571
- dom.canvasContent.innerHTML = '';
572
- dom.canvasEmpty.textContent = nothing;
573
- dom.canvasEmpty.hidden = false;
574
- state.currentDiagram = { source: '', nodes: {} };
676
+ await renderInto(dom.canvasContent, d.source);
677
+ wireNodeClicks(dom.canvasContent, d.nodes, onNodeClick);
678
+ requestAnimationFrame(() => panzoom.fit());
679
+ }
680
+ catch (err) {
681
+ console.error(err);
682
+ dom.canvasEmpty.textContent = 'That diagram would not draw: ' + err.message;
683
+ dom.canvasEmpty.hidden = false;
684
+ }
575
685
  renderCrumbs();
576
686
  renderFoot();
577
- return;
578
- }
579
-
580
- let d;
581
- if (state.view === 'patterns') {
582
- d = layersDiagram(scan, facts, state.patterns.layersInfo, { include: pathFilter(state.filters) });
583
- } else if (state.view === 'health') {
584
- d = healthDiagram(scan, facts, state.health, { include: pathFilter(state.filters) });
585
- } else if (state.view === 'security') {
586
- d = securityDiagram(scan, facts, state.security, { include: pathFilter(state.filters) });
587
- } else if (state.view === 'history') {
588
- d = historyDiagram(scan, facts, state.history);
589
- } else if (state.view === 'services') {
590
- d = servicesDiagram(scan, manifest);
591
- } else if (state.view === 'tour') {
592
- const stop = state.tour.stops[state.tour.idx];
593
- d = stop ? fileDetailDiagram(scan, facts, stop.path) : emptyDiagram('No tour stops could be picked from this repo.');
594
- } else if (state.view === 'atlas' && state.atlasOpen) {
595
- d = openCardDiagram();
596
- } else {
597
- d = state.detail
598
- ? fileDetailDiagram(scan, facts, state.detail)
599
- : folderDiagram(scan, facts, state.folder, { include: pathFilter(state.filters) });
600
- }
601
-
602
- state.currentDiagram = d;
603
- dom.canvasEmpty.hidden = true;
604
- try {
605
- await renderInto(dom.canvasContent, d.source);
606
- wireNodeClicks(dom.canvasContent, d.nodes, onNodeClick);
607
- requestAnimationFrame(() => panzoom.fit());
608
- } catch (err) {
609
- console.error(err);
610
- dom.canvasEmpty.textContent = 'That diagram would not draw: ' + err.message;
611
- dom.canvasEmpty.hidden = false;
612
- }
613
- renderCrumbs();
614
- renderFoot();
615
687
  }
616
-
617
688
  // Returns a message when the current view has nothing worth drawing, else
618
689
  // null. The inspector still has plenty to say in these cases — this is only
619
690
  // about the canvas.
620
691
  function nothingToShow() {
621
- const { scan, facts } = state;
622
- const parsed = scan.stats.filesParsed;
623
- const edges = scan.stats.edgeCount;
624
-
625
- if (state.view === 'patterns') {
626
- if (!parsed) return 'No code files parsed, so there are no patterns to read.';
627
- if (!edges) return 'No import connections anywhere — a shape needs edges, and there are none.';
628
- }
629
-
630
- if (state.view === 'files') {
631
- if (state.detail) {
632
- const f = scanIndex(scan).fileAt(state.detail);
633
- if (!f) return 'That file is not in the scan.';
634
- const fin = facts.fanIn[state.detail] || 0;
635
- const fout = facts.fanOut[state.detail] || 0;
636
- if (!fin && !fout) {
637
- return f.functions.length
638
- ? `${f.name} stands alone — nothing imports it and it imports nothing.`
639
- : `${f.name} stands alone — nothing imports it, it imports nothing, and no functions to chart.`;
640
- }
641
- } else {
642
- const here = scanIndex(scan).filesIn(state.folder);
643
- if (!here.length) return 'Nothing parsed in this folder.';
644
- const localSet = new Set(here.map((x) => x.path));
645
- const touched = scan.edges.some((e) => localSet.has(e.from) || localSet.has(e.to));
646
- if (!touched) return 'The files here import nothing we can see, and nothing imports them.';
647
- }
648
- }
649
-
650
- if (state.view === 'services') {
651
- if (!state.manifest?.services?.length) {
652
- return 'No services detected — no docker-compose, Procfile, or workspace setup we recognize.';
653
- }
654
- }
655
-
656
- return null;
692
+ const { scan, facts } = state;
693
+ const parsed = scan.stats.filesParsed;
694
+ const edges = scan.stats.edgeCount;
695
+ if (state.view === 'patterns') {
696
+ if (!parsed)
697
+ return 'No code files parsed, so there are no patterns to read.';
698
+ if (!edges)
699
+ return 'No import connections anywhere — a shape needs edges, and there are none.';
700
+ }
701
+ if (state.view === 'files') {
702
+ if (state.detail) {
703
+ const f = scanIndex(scan).fileAt(state.detail);
704
+ if (!f)
705
+ return 'That file is not in the scan.';
706
+ const fin = facts.fanIn[state.detail] || 0;
707
+ const fout = facts.fanOut[state.detail] || 0;
708
+ if (!fin && !fout) {
709
+ return f.functions.length
710
+ ? `${f.name} stands alone — nothing imports it and it imports nothing.`
711
+ : `${f.name} stands alone — nothing imports it, it imports nothing, and no functions to chart.`;
712
+ }
713
+ }
714
+ else {
715
+ const here = scanIndex(scan).filesIn(state.folder);
716
+ if (!here.length)
717
+ return 'Nothing parsed in this folder.';
718
+ const localSet = new Set(here.map((x) => x.path));
719
+ const touched = scan.edges.some((e) => localSet.has(e.from) || localSet.has(e.to));
720
+ if (!touched)
721
+ return 'The files here import nothing we can see, and nothing imports them.';
722
+ }
723
+ }
724
+ if (state.view === 'services') {
725
+ if (!state.manifest?.services?.length) {
726
+ return 'No services detected — no docker-compose, Procfile, or workspace setup we recognize.';
727
+ }
728
+ }
729
+ return null;
657
730
  }
658
-
659
731
  // ------------------------------------------------------- filters & routing --
660
-
661
732
  // Typing re-renders, so it is debounced; the toggles are single clicks and are
662
733
  // not. The Map redraws in place, everything else goes through the canvas router.
663
734
  let filterTimer = null;
664
735
  function applyFilters() {
665
- if (state.view === 'map') renderMap(false);
666
- else renderCanvas();
736
+ if (state.view === 'map')
737
+ renderMap(false);
738
+ else
739
+ renderCanvas();
667
740
  }
668
741
  function applyFiltersSoon() {
669
- clearTimeout(filterTimer);
670
- filterTimer = setTimeout(applyFilters, 160);
742
+ clearTimeout(filterTimer);
743
+ filterTimer = setTimeout(applyFilters, 160);
671
744
  }
672
-
673
745
  dom.modeSwitch.addEventListener('click', (event) => {
674
- const chip = event.target.closest('.mode-chip');
675
- if (chip) setView(chip.dataset.mode);
746
+ const chip = eventElement(event)?.closest('.mode-chip');
747
+ if (chip)
748
+ setView(chip.dataset.mode);
676
749
  });
677
750
  dom.nodeFilter.addEventListener('input', () => {
678
- state.filters.text = dom.nodeFilter.value;
679
- applyFiltersSoon();
751
+ state.filters.text = dom.nodeFilter.value;
752
+ applyFiltersSoon();
680
753
  });
681
754
  dom.testsToggle.addEventListener('click', () => {
682
- state.filters.showTests = !state.filters.showTests;
683
- dom.testsToggle.classList.toggle('is-on', state.filters.showTests);
684
- applyFilters();
755
+ state.filters.showTests = !state.filters.showTests;
756
+ dom.testsToggle.classList.toggle('is-on', state.filters.showTests);
757
+ applyFilters();
685
758
  });
686
759
  dom.depthSelect.addEventListener('change', () => {
687
- state.filters.depth = Number(dom.depthSelect.value);
688
- if (state.filters.depth > 0) state.mm.expanded = expandedToDepth(state.treeData, state.filters.depth);
689
- if (state.view === 'map') renderMap(true);
760
+ state.filters.depth = Number(dom.depthSelect.value);
761
+ if (state.filters.depth > 0)
762
+ state.mm.expanded = expandedToDepth(state.treeData, state.filters.depth);
763
+ if (state.view === 'map')
764
+ renderMap(true);
690
765
  });
691
-
692
766
  // ---------------------------------------------------------- crumbs & foot --
693
-
694
767
  function renderCrumbs() {
695
- const parts = [];
696
- parts.push(`<button data-go="map">${escapeHtml(state.scan.name)}</button>`);
697
-
698
- if (state.view === 'files' || state.view === 'tour') {
699
- const folder = state.view === 'tour' ? dirOf(state.tour.stops[state.tour.idx]?.path || '') : state.folder;
700
- if (folder) {
701
- const segs = folder.split('/');
702
- segs.forEach((seg, i) => {
703
- parts.push(`<button data-folder="${escapeHtml(segs.slice(0, i + 1).join('/'))}">${escapeHtml(seg)}</button>`);
704
- });
705
- }
706
- const detail = state.view === 'tour' ? state.tour.stops[state.tour.idx]?.path : state.detail;
707
- if (detail) parts.push(`<span>${escapeHtml(detail.split('/').pop())}</span>`);
708
- } else if (state.view === 'services') {
709
- parts.push('<span>services</span>');
710
- } else if (state.view === 'atlas') {
711
- parts.push('<button data-atlas="list">atlas</button>');
712
- if (state.atlasOpen) {
713
- const t = state.atlasOpen.title;
714
- parts.push(`<span>${escapeHtml(t.length > 34 ? '…' + t.slice(-33) : t)}</span>`);
715
- }
716
- } else if (state.view === 'about') {
717
- parts.push('<span>about</span>');
718
- } else if (state.view === 'docs') {
719
- parts.push('<span>docs</span>');
720
- } else if (state.view === 'patterns') {
721
- parts.push('<span>patterns</span>');
722
- } else if (state.view === 'history') {
723
- parts.push('<span>history</span>');
724
- }
725
-
726
- dom.crumbs.innerHTML = parts.join('<span class="sep">/</span>');
727
- for (const b of dom.crumbs.querySelectorAll('button')) {
728
- b.addEventListener('click', () => {
729
- if (b.dataset.atlas) {
730
- state.atlasOpen = null;
731
- renderCanvas();
732
- return;
733
- }
734
- if (b.dataset.go === 'map') return setView('map');
735
- state.folder = b.dataset.folder || '';
736
- state.detail = null;
737
- setView('files');
738
- });
739
- }
768
+ const parts = [];
769
+ parts.push(`<button data-go="map">${escapeHtml(state.scan.name)}</button>`);
770
+ if (state.view === 'files' || state.view === 'tour') {
771
+ const folder = state.view === 'tour' ? dirOf(state.tour.stops[state.tour.idx]?.path || '') : state.folder;
772
+ if (folder) {
773
+ const segs = folder.split('/');
774
+ segs.forEach((seg, i) => {
775
+ parts.push(`<button data-folder="${escapeHtml(segs.slice(0, i + 1).join('/'))}">${escapeHtml(seg)}</button>`);
776
+ });
777
+ }
778
+ const detail = state.view === 'tour' ? state.tour.stops[state.tour.idx]?.path : state.detail;
779
+ if (detail)
780
+ parts.push(`<span>${escapeHtml(detail.split('/').pop())}</span>`);
781
+ }
782
+ else if (state.view === 'services') {
783
+ parts.push('<span>services</span>');
784
+ }
785
+ else if (state.view === 'atlas') {
786
+ parts.push('<button data-atlas="list">atlas</button>');
787
+ if (state.atlasOpen) {
788
+ const t = state.atlasOpen.title;
789
+ parts.push(`<span>${escapeHtml(t.length > 34 ? '…' + t.slice(-33) : t)}</span>`);
790
+ }
791
+ }
792
+ else if (state.view === 'about') {
793
+ parts.push('<span>about</span>');
794
+ }
795
+ else if (state.view === 'docs') {
796
+ parts.push('<span>docs</span>');
797
+ }
798
+ else if (state.view === 'patterns') {
799
+ parts.push('<span>patterns</span>');
800
+ }
801
+ else if (state.view === 'history') {
802
+ parts.push('<span>history</span>');
803
+ }
804
+ dom.crumbs.innerHTML = parts.join('<span class="sep">/</span>');
805
+ for (const b of dom.crumbs.querySelectorAll('button')) {
806
+ b.addEventListener('click', () => {
807
+ if (b.dataset.atlas) {
808
+ state.atlasOpen = null;
809
+ renderCanvas();
810
+ return;
811
+ }
812
+ if (b.dataset.go === 'map')
813
+ return setView('map');
814
+ state.folder = b.dataset.folder || '';
815
+ state.detail = null;
816
+ setView('files');
817
+ });
818
+ }
740
819
  }
741
-
742
820
  function renderFoot(aiCaption) {
743
- const s = state.scan.stats;
744
- const f = state.facts;
745
- const langList = Object.entries(s.languages).map(([k, v]) => `${v} ${k}`).join(', ');
746
- dom.stageFoot.innerHTML = [
747
- aiCaption ? `<span class="ai-cap" title="${escapeHtml(aiCaption)}">“${escapeHtml(aiCaption)}”</span>` : '',
748
- `<span class="foot-stats">
821
+ const s = state.scan.stats;
822
+ const f = state.facts;
823
+ const langList = Object.entries(s.languages).map(([k, v]) => `${v} ${k}`).join(', ');
824
+ dom.stageFoot.innerHTML = [
825
+ aiCaption ? `<span class="ai-cap" title="${escapeHtml(aiCaption)}">“${escapeHtml(aiCaption)}”</span>` : '',
826
+ `<span class="foot-stats">
749
827
  <b>${s.filesParsed}</b> files ·
750
828
  <b>${s.edgeCount}</b> connections ·
751
829
  <b>${f.entries.length}</b> entries ·
@@ -753,281 +831,258 @@ function renderFoot(aiCaption) {
753
831
  <b>${f.orphans.length}</b> unconnected
754
832
  ${f.cycles.length ? ` · <b class="warn">${f.cycles.length}</b> circular` : ''}
755
833
  </span>`,
756
- `<span class="foot-right">
834
+ `<span class="foot-right">
757
835
  ${langList ? `<span class="langs" title="${escapeHtml(langList)}">${escapeHtml(langList)}</span>` : ''}
758
836
  <span>scanned in <b>${s.tookMs}</b> ms</span>
759
837
  </span>`,
760
- ].filter(Boolean).join('');
838
+ ].filter(Boolean).join('');
761
839
  }
762
-
763
840
  // ------------------------------------------------------------------- tour --
764
-
765
841
  function showTourStop() {
766
- const { stops, idx } = state.tour;
767
- const stop = stops[idx];
768
- if (!stop) {
769
- dom.tourTitle.textContent = 'No stops could be picked from this repo.';
770
- dom.tourCount.textContent = '0 / 0';
771
- inspector.showOverview(state);
772
- return;
773
- }
774
- dom.tourTitle.textContent = stop.path;
775
- dom.tourTitle.title = stop.why;
776
- dom.tourCount.textContent = describeStop(stop, idx, stops.length).count;
777
- focusFile(stop.path);
778
- syncInspector();
779
- renderSidebar();
842
+ const { stops, idx } = state.tour;
843
+ const stop = stops[idx];
844
+ if (!stop) {
845
+ dom.tourTitle.textContent = 'No stops could be picked from this repo.';
846
+ dom.tourCount.textContent = '0 / 0';
847
+ inspector.showOverview(state);
848
+ return;
849
+ }
850
+ dom.tourTitle.textContent = stop.path;
851
+ dom.tourTitle.title = stop.why;
852
+ dom.tourCount.textContent = describeStop(stop, idx, stops.length).count;
853
+ focusFile(stop.path);
854
+ syncInspector();
855
+ renderSidebar();
780
856
  }
781
-
782
857
  dom.tourPrev.addEventListener('click', () => {
783
- if (state.tour.idx > 0) {
784
- state.tour.idx--;
785
- renderCanvas();
786
- showTourStop();
787
- }
858
+ if (state.tour.idx > 0) {
859
+ state.tour.idx--;
860
+ renderCanvas();
861
+ showTourStop();
862
+ }
788
863
  });
789
864
  dom.tourNext.addEventListener('click', () => {
790
- if (state.tour.idx < state.tour.stops.length - 1) {
791
- state.tour.idx++;
792
- renderCanvas();
793
- showTourStop();
794
- }
865
+ if (state.tour.idx < state.tour.stops.length - 1) {
866
+ state.tour.idx++;
867
+ renderCanvas();
868
+ showTourStop();
869
+ }
795
870
  });
796
871
  dom.tourExit.addEventListener('click', () => setView('map'));
797
-
798
872
  // ---------------------------------------------------------- stage actions --
799
-
800
873
  dom.copyMermaidBtn.addEventListener('click', async () => {
801
- try {
802
- await navigator.clipboard.writeText(state.currentDiagram.source);
803
- toast('Mermaid source copied.');
804
- } catch {
805
- toast('The clipboard said no.');
806
- }
874
+ try {
875
+ await navigator.clipboard.writeText(state.currentDiagram.source);
876
+ toast('Mermaid source copied.');
877
+ }
878
+ catch {
879
+ toast('The clipboard said no.');
880
+ }
807
881
  });
808
-
809
882
  dom.svgBtn.addEventListener('click', () => {
810
- const name = (state.scan?.name || 'codebase') + '-' + state.view + '.svg';
811
- if (downloadSvg(dom.canvasContent, name)) toast('SVG saved.');
883
+ const name = (state.scan?.name || 'codebase') + '-' + state.view + '.svg';
884
+ if (downloadSvg(dom.canvasContent, name))
885
+ toast('SVG saved.');
812
886
  });
813
-
814
887
  dom.fitBtn.addEventListener('click', () => panzoom.fit());
815
- dom.searchTriggerBtn.addEventListener('click', () => searchCtl?.show());
816
- dom.graphSearchInput?.addEventListener('input', (e) => forceGraphCtl?.search(e.target.value));
888
+ dom.searchTriggerBtn.addEventListener('click', () => searchCtl?.show(dom.searchTriggerBtn));
889
+ document.addEventListener('search-visibility', () => forceGraphCtl?.setActive(state.view === 'graph' && !searchCtl?.isShowing()));
890
+ dom.graphSearchInput?.addEventListener('input', (e) => forceGraphCtl?.search(e.currentTarget.value));
817
891
  dom.graphFit?.addEventListener('click', () => forceGraphCtl?.fit());
818
-
819
892
  // ------------------------------------------------------------- AI explain --
820
-
821
893
  inspector.initInspector({
822
- onAskAI: askAI,
823
- onClearFocus: clearFocus,
824
- // The inspector's "Code" button means the Code tab, not the Explorer —
825
- // openFile would drop the person back on the map they came from.
826
- onOpenCode: openFileInCode,
827
- onDeepDive: openDeepDive,
828
- onToast: toast,
894
+ onAskAI: askAI,
895
+ onClearFocus: clearFocus,
896
+ // The inspector's "Code" button means the Code tab, not the Explorer —
897
+ // openFile would drop the person back on the map they came from.
898
+ onOpenCode: openFileInCode,
899
+ onDeepDive: openDeepDive,
900
+ onToast: toast,
829
901
  });
830
-
831
902
  // The About view owns its own rendering; it borrows these three from the shell.
832
903
  initAbout({
833
- host: dom.aboutView,
834
- onToast: toast,
835
- onOpenSettings: openSettings,
836
- repoOverview,
904
+ host: dom.aboutView,
905
+ onToast: toast,
906
+ onOpenSettings: openSettings,
907
+ repoOverview,
837
908
  });
838
-
839
909
  initDocs({
840
- host: dom.docView,
841
- onToast: toast,
842
- onOpenSettings: openSettings,
843
- onOpenFile: openFile,
844
- repoOverview,
910
+ host: dom.docView,
911
+ onToast: toast,
912
+ onOpenSettings: openSettings,
913
+ onOpenFile: openFile,
914
+ repoOverview,
845
915
  });
846
-
847
916
  initCodeTab({
848
- host: dom.codeView,
849
- blameGutter: dom.blameGutter,
850
- onSyncInspector: syncInspector,
851
- onRenderSidebar: renderSidebar,
852
- onOpenFile: openFile,
853
- onDeepDive: openDeepDive,
854
- onToast: toast,
855
- getTheme,
917
+ host: dom.codeView,
918
+ blameGutter: dom.blameGutter,
919
+ onSyncInspector: syncInspector,
920
+ onRenderSidebar: renderSidebar,
921
+ onOpenFile: openFile,
922
+ onDeepDive: openDeepDive,
923
+ onToast: toast,
924
+ getTheme,
856
925
  });
857
-
858
926
  // The deep-analysis panel lives in the security view's inspector. It runs the
859
927
  // external engines through the server, then merges what they find back into the
860
928
  // scan so the grade and the finding list update to include them.
861
929
  initAnalysisPanel({
862
- host: dom.inspSecurityTools,
863
- onToast: toast,
864
- onOpenFile: openFileInCode,
930
+ host: dom.inspSecurityTools,
931
+ onToast: toast,
932
+ onOpenFile: openFileInCode,
865
933
  });
866
-
867
934
  // The full-page Deep Analysis view. Same store, same run, more room: the whole
868
935
  // report with file links and an AI explainer.
869
936
  initDeepAnalysis({
870
- host: dom.analysisView,
871
- onToast: toast,
872
- onOpenFile: openFileInCode,
873
- onOpenSettings: openSettings,
874
- repoOverview,
937
+ host: dom.analysisView,
938
+ onToast: toast,
939
+ onOpenFile: openFileInCode,
940
+ onOpenSettings: openSettings,
941
+ repoOverview,
875
942
  });
876
-
877
943
  // A run started from either surface changes the scan's findings, so whatever
878
944
  // panel is on screen has to be told. The merge itself happens in the store.
879
945
  subscribeAnalysis(() => {
880
- if (state.scan) syncInspector();
946
+ if (state.scan)
947
+ syncInspector();
881
948
  });
882
-
883
949
  initAiDraft({
884
- host: dom.aiDrawBtn,
885
- statusEl: dom.canvasEmpty, // the placeholder doubles as the progress line
886
- nodeHost: dom.canvasContent,
887
- onRender: renderCanvas,
888
- onOpenFile: openFile,
889
- onOpenSettings: openSettings,
890
- onToast: toast,
950
+ host: dom.aiDrawBtn,
951
+ statusEl: dom.canvasEmpty, // the placeholder doubles as the progress line
952
+ nodeHost: dom.canvasContent,
953
+ onRender: renderCanvas,
954
+ onOpenFile: openFile,
955
+ onOpenSettings: openSettings,
956
+ onToast: toast,
891
957
  });
892
-
893
958
  initAtlas({
894
- host: dom.canvasContent,
895
- canvas: dom.canvas,
896
- onSyncInspector: syncInspector,
897
- onRender: renderCanvas,
898
- onHome: () => panzoom.home(),
959
+ host: dom.canvasContent,
960
+ canvas: dom.canvas,
961
+ onSyncInspector: syncInspector,
962
+ onRender: renderCanvas,
963
+ onHome: () => panzoom.home(),
899
964
  });
900
-
901
965
  initMap({
902
- host: dom.canvasContent,
903
- toggleBtn: dom.mmToggleBtn,
904
- onSyncInspector: syncInspector,
905
- onRenderSidebar: renderSidebar,
906
- onOpenFile: openFile,
907
- onOpenFolder: (folder) => {
908
- state.folder = folder;
909
- state.detail = null;
910
- setView('files');
911
- },
912
- onToast: toast,
913
- onFit: () => requestAnimationFrame(() => panzoom.fit()),
966
+ host: dom.canvasContent,
967
+ toggleBtn: dom.mmToggleBtn,
968
+ onSyncInspector: syncInspector,
969
+ onRenderSidebar: renderSidebar,
970
+ onOpenFile: openFile,
971
+ onOpenFolder: (folder) => {
972
+ state.folder = folder;
973
+ state.detail = null;
974
+ setView('files');
975
+ },
976
+ onToast: toast,
977
+ onFit: () => requestAnimationFrame(() => panzoom.fit()),
914
978
  });
915
-
916
979
  function repoOverview() {
917
- return explainOverview(state.scan, state.facts, state.manifest).replace(/[*`]/g, '');
980
+ return explainOverview(state.scan, state.facts, state.manifest).replace(/[*`]/g, '');
918
981
  }
919
-
920
982
  async function askAI() {
921
- if (!llm.isConfigured()) {
922
- openSettings();
923
- toast('Add an endpoint and model first.');
924
- return;
925
- }
926
- if (!state.selected) return;
927
-
928
- const settings = llm.getSettings();
929
- const ctx = await gatherFileContext(state.selected);
930
- const messages = llm.fileMessages({
931
- repoName: state.scan.name,
932
- overview: repoOverview(),
933
- ...ctx,
934
- });
935
-
936
- const writer = inspector.explainStreamWriter();
937
- try {
938
- // Reasoning models (like the bundled Nemotron default) think before they
939
- // write, so the budget needs room for both the thinking and the answer.
940
- for await (const delta of streamExplain({ ...settings, messages, maxTokens: 1600 })) {
941
- writer.push(delta);
942
- }
943
- writer.finish(`Written by ${settings.model}, from the real source and the import graph.`);
944
- } catch (err) {
945
- writer.fail(err.message + ' Check the API key drawer, top right.');
946
- }
983
+ if (!llm.isConfigured()) {
984
+ openSettings();
985
+ toast('Add an endpoint and model first.');
986
+ return;
987
+ }
988
+ if (!state.selected)
989
+ return;
990
+ const settings = llm.getSettings();
991
+ const ctx = await gatherFileContext(state.selected);
992
+ const messages = llm.fileMessages({
993
+ repoName: state.scan.name,
994
+ overview: repoOverview(),
995
+ ...ctx,
996
+ });
997
+ const writer = inspector.explainStreamWriter();
998
+ try {
999
+ // Reasoning models (like the bundled Nemotron default) think before they
1000
+ // write, so the budget needs room for both the thinking and the answer.
1001
+ for await (const delta of streamExplain({ ...settings, messages, maxTokens: 1600 })) {
1002
+ writer.push(delta);
1003
+ }
1004
+ writer.finish(`Written by ${settings.model}, from the real source and the import graph.`);
1005
+ }
1006
+ catch (err) {
1007
+ writer.fail(err.message + ' Check the API key drawer, top right.');
1008
+ }
947
1009
  }
948
-
949
1010
  // Free-form questions. The selected file's context rides along when there is
950
1011
  // one; otherwise the model reasons from the repo overview alone.
951
1012
  async function askCustom() {
952
- const question = dom.askInput.value.trim();
953
- if (!question) return toast('Type a question first.');
954
- if (!state.scan) return;
955
- if (!llm.isConfigured()) {
956
- openSettings();
957
- toast('Add an endpoint and model first.');
958
- return;
959
- }
960
-
961
- const settings = llm.getSettings();
962
- const file = state.selected ? await gatherFileContext(state.selected) : null;
963
- const messages = llm.questionMessages({
964
- repoName: state.scan.name,
965
- overview: repoOverview(),
966
- question,
967
- file,
968
- });
969
-
970
- dom.askBtn.disabled = true;
971
- dom.askBtn.textContent = '…';
972
- const writer = inspector.explainStreamWriter(`<p class="ask-q">“${escapeHtml(question)}”</p>`);
973
- try {
974
- for await (const delta of streamExplain({ ...settings, messages, maxTokens: 1600 })) {
975
- writer.push(delta);
976
- }
977
- writer.finish(`Answered by ${settings.model}.`);
978
- } catch (err) {
979
- writer.fail(err.message + ' Check the API key drawer, top right.');
980
- } finally {
981
- dom.askBtn.disabled = false;
982
- dom.askBtn.textContent = 'Ask';
983
- }
1013
+ const question = dom.askInput.value.trim();
1014
+ if (!question)
1015
+ return toast('Type a question first.');
1016
+ if (!state.scan)
1017
+ return;
1018
+ if (!llm.isConfigured()) {
1019
+ openSettings();
1020
+ toast('Add an endpoint and model first.');
1021
+ return;
1022
+ }
1023
+ const settings = llm.getSettings();
1024
+ const file = state.selected ? await gatherFileContext(state.selected) : null;
1025
+ const messages = llm.questionMessages({
1026
+ repoName: state.scan.name,
1027
+ overview: repoOverview(),
1028
+ question,
1029
+ file,
1030
+ });
1031
+ dom.askBtn.disabled = true;
1032
+ dom.askBtn.textContent = '…';
1033
+ const writer = inspector.explainStreamWriter(`<p class="ask-q">“${escapeHtml(question)}”</p>`);
1034
+ try {
1035
+ for await (const delta of streamExplain({ ...settings, messages, maxTokens: 1600 })) {
1036
+ writer.push(delta);
1037
+ }
1038
+ writer.finish(`Answered by ${settings.model}.`);
1039
+ }
1040
+ catch (err) {
1041
+ writer.fail(err.message + ' Check the API key drawer, top right.');
1042
+ }
1043
+ finally {
1044
+ dom.askBtn.disabled = false;
1045
+ dom.askBtn.textContent = 'Ask';
1046
+ }
984
1047
  }
985
-
986
1048
  dom.askBtn.addEventListener('click', askCustom);
987
1049
  dom.askInput.addEventListener('keydown', (event) => {
988
- if (event.key === 'Enter') askCustom();
1050
+ if (event.key === 'Enter')
1051
+ askCustom();
989
1052
  });
990
-
991
1053
  // ---------------------------------------------------------- settings bits --
992
-
993
1054
  function openSettings() {
994
- // Same reason the MCP drawer closes settings: one scrim at a time.
995
- if (!dom.mcpDrawer.hidden) closeMcp();
996
- serverDrawer.close();
997
- const s = llm.getSettings();
998
- dom.setBaseUrl.value = s.baseUrl;
999
- dom.setApiKey.value = s.apiKey;
1000
- dom.setModel.value = s.model;
1001
- dom.settingsStatus.textContent = '';
1002
- dom.settingsDrawer.hidden = false;
1003
- dom.drawerScrim.hidden = false;
1055
+ // Same reason the MCP drawer closes settings: one scrim at a time.
1056
+ if (!dom.mcpDrawer.hidden)
1057
+ closeMcp();
1058
+ serverDrawer.close();
1059
+ const s = llm.getSettings();
1060
+ dom.setBaseUrl.value = s.baseUrl;
1061
+ dom.setApiKey.value = s.apiKey;
1062
+ dom.setModel.value = s.model;
1063
+ dom.settingsStatus.textContent = '';
1064
+ dom.settingsDrawer.hidden = false;
1065
+ dom.drawerScrim.hidden = false;
1004
1066
  }
1005
-
1006
1067
  function closeSettings() {
1007
- dom.settingsDrawer.hidden = true;
1008
- dom.drawerScrim.hidden = true;
1068
+ dom.settingsDrawer.hidden = true;
1069
+ dom.drawerScrim.hidden = true;
1009
1070
  }
1010
-
1011
1071
  dom.settingsBtn.addEventListener('click', openSettings);
1012
1072
  dom.drawerClose.addEventListener('click', closeSettings);
1013
-
1014
1073
  // ------------------------------------------------------------- server bits --
1015
-
1016
1074
  // The third drawer. Its content and round-trips live in js/serverSettings.js;
1017
1075
  // what stays here is the choreography it shares with the other two drawers —
1018
1076
  // one scrim at a time, Escape to dismiss.
1019
1077
  const serverDrawer = createServerDrawer({ toast });
1020
-
1021
1078
  function openServer() {
1022
- closeSettings();
1023
- if (!dom.mcpDrawer.hidden) closeMcp();
1024
- serverDrawer.open();
1079
+ closeSettings();
1080
+ if (!dom.mcpDrawer.hidden)
1081
+ closeMcp();
1082
+ serverDrawer.open();
1025
1083
  }
1026
-
1027
1084
  dom.serverBtn.addEventListener('click', () => (serverDrawer.isOpen() ? serverDrawer.close() : openServer()));
1028
-
1029
1085
  // ------------------------------------------------------------------- mcp --
1030
-
1031
1086
  // The topbar button, the drawer, and the polling behind them.
1032
1087
  //
1033
1088
  // The shape is deliberately the same as the settings drawer above — one scrim,
@@ -1036,304 +1091,297 @@ dom.serverBtn.addEventListener('click', () => (serverDrawer.isOpen() ? serverDra
1036
1091
  // a live status, two actions, and a config block, so it polls. Polling continues
1037
1092
  // while closed, slowly, because the dot on the button has to stay truthful when
1038
1093
  // the drawer is not there to explain it.
1039
-
1040
1094
  let mcpPoll = null;
1041
1095
  let mcpBusy = false;
1042
1096
  let mcpCommand = null; // fetched once; the command does not change while it runs
1043
-
1044
1097
  // The dot's three states, and the copy that goes with each. `starting` is
1045
1098
  // separate from `running` because the handshake takes a beat, and claiming
1046
1099
  // success before it is known would be a lie the person acts on.
1047
1100
  function mcpDotClass(state) {
1048
- if (state === 'running') return 'is-running';
1049
- if (state === 'starting' || state === 'stopping') return 'is-starting';
1050
- if (state === 'error' || state === 'unavailable') return 'is-error';
1051
- return '';
1101
+ if (state === 'running')
1102
+ return 'is-running';
1103
+ if (state === 'starting' || state === 'stopping')
1104
+ return 'is-starting';
1105
+ if (state === 'error' || state === 'unavailable')
1106
+ return 'is-error';
1107
+ return '';
1052
1108
  }
1053
-
1054
1109
  function mcpStateLabel(data) {
1055
- switch (data.state) {
1056
- case 'running': return `Running · ${data.toolCount} tools`;
1057
- case 'starting': return 'Starting…';
1058
- case 'stopping': return 'Stopping…';
1059
- case 'error': return 'Failed to start';
1060
- case 'unavailable': return 'Not available';
1061
- default: return 'Stopped';
1062
- }
1110
+ switch (data.state) {
1111
+ case 'running': return `Running · ${data.toolCount} tools`;
1112
+ case 'starting': return 'Starting…';
1113
+ case 'stopping': return 'Stopping…';
1114
+ case 'error': return 'Failed to start';
1115
+ case 'unavailable': return 'Not available';
1116
+ default: return 'Stopped';
1117
+ }
1063
1118
  }
1064
-
1065
1119
  function formatUptime(ms) {
1066
- if (!ms && ms !== 0) return '0s';
1067
- const s = Math.floor(ms / 1000);
1068
- if (s < 60) return `${s}s`;
1069
- const m = Math.floor(s / 60);
1070
- if (m < 60) return `${m}m ${s % 60}s`;
1071
- return `${Math.floor(m / 60)}h ${m % 60}m`;
1120
+ if (!ms && ms !== 0)
1121
+ return '0s';
1122
+ const s = Math.floor(ms / 1000);
1123
+ if (s < 60)
1124
+ return `${s}s`;
1125
+ const m = Math.floor(s / 60);
1126
+ if (m < 60)
1127
+ return `${m}m ${s % 60}s`;
1128
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
1072
1129
  }
1073
-
1074
1130
  function renderMcpStatus(data) {
1075
- if (!data) {
1076
- // No server answered. The button is still there — it always is — but it says
1077
- // so rather than showing a confident "stopped" for a process we cannot see.
1078
- dom.mcpDot.className = 'mcp-dot is-error';
1079
- dom.mcpBtnLabel.textContent = 'MCP';
1080
- dom.mcpPanelDot.className = 'mcp-dot mcp-dot-lg is-error';
1081
- dom.mcpPanelState.textContent = 'The server did not answer.';
1082
- dom.mcpStart.disabled = true;
1083
- dom.mcpStop.disabled = true;
1084
- dom.mcpStatus.textContent = 'Is the Onboarder server running?';
1085
- return;
1086
- }
1087
-
1088
- const cls = mcpDotClass(data.state);
1089
- dom.mcpDot.className = 'mcp-dot ' + cls;
1090
- dom.mcpPanelDot.className = 'mcp-dot mcp-dot-lg ' + cls;
1091
- dom.mcpBtnLabel.textContent = data.running ? 'MCP on' : 'MCP';
1092
- dom.mcpBtn.title = data.running
1093
- ? `MCP server running · ${data.toolCount} tools · pid ${data.pid}`
1094
- : 'Start the MCP server so AI agents can read this repository';
1095
- dom.mcpPanelState.textContent = mcpStateLabel(data);
1096
-
1097
- // Both buttons stay visible and swap roles through `disabled`, so the panel
1098
- // does not reflow as the state changes underneath it.
1099
- const busy = data.state === 'starting' || data.state === 'stopping';
1100
- dom.mcpStart.disabled = busy || data.state === 'unavailable' || data.running;
1101
- dom.mcpStop.disabled = busy || data.state === 'unavailable' || !data.running;
1102
-
1103
- const bits = [];
1104
- if (data.running) {
1105
- bits.push(`pid ${data.pid}`);
1106
- bits.push(`up ${formatUptime(data.uptimeMs)}`);
1107
- }
1108
- if (data.lastError) bits.push(data.lastError);
1109
- dom.mcpStatus.textContent = bits.join(' · ');
1110
-
1111
- // The config only appears once there is a server to configure against. Shown
1112
- // earlier, it would be a command for a process that is not running.
1113
- dom.mcpConfig.hidden = !data.running;
1114
- if (data.running) {
1115
- dom.mcpToolsHint.textContent = `${data.toolCount} tools: ${(data.tools || []).join(', ')}`;
1116
- }
1131
+ if (!data) {
1132
+ // No server answered. The button is still there — it always is — but it says
1133
+ // so rather than showing a confident "stopped" for a process we cannot see.
1134
+ dom.mcpDot.className = 'mcp-dot is-error';
1135
+ dom.mcpBtnLabel.textContent = 'MCP';
1136
+ dom.mcpPanelDot.className = 'mcp-dot mcp-dot-lg is-error';
1137
+ dom.mcpPanelState.textContent = 'The server did not answer.';
1138
+ dom.mcpStart.disabled = true;
1139
+ dom.mcpStop.disabled = true;
1140
+ dom.mcpStatus.textContent = 'Is the Onboarder server running?';
1141
+ return;
1142
+ }
1143
+ const cls = mcpDotClass(data.state);
1144
+ dom.mcpDot.className = 'mcp-dot ' + cls;
1145
+ dom.mcpPanelDot.className = 'mcp-dot mcp-dot-lg ' + cls;
1146
+ dom.mcpBtnLabel.textContent = data.running ? 'MCP on' : 'MCP';
1147
+ dom.mcpBtn.title = data.running
1148
+ ? `MCP server running · ${data.toolCount} tools · pid ${data.pid}`
1149
+ : 'Start the MCP server so AI agents can read this repository';
1150
+ dom.mcpPanelState.textContent = mcpStateLabel(data);
1151
+ // Both buttons stay visible and swap roles through `disabled`, so the panel
1152
+ // does not reflow as the state changes underneath it.
1153
+ const busy = data.state === 'starting' || data.state === 'stopping';
1154
+ dom.mcpStart.disabled = busy || data.state === 'unavailable' || data.running;
1155
+ dom.mcpStop.disabled = busy || data.state === 'unavailable' || !data.running;
1156
+ const bits = [];
1157
+ if (data.running) {
1158
+ bits.push(`pid ${data.pid}`);
1159
+ bits.push(`up ${formatUptime(data.uptimeMs)}`);
1160
+ }
1161
+ if (data.lastError)
1162
+ bits.push(data.lastError);
1163
+ dom.mcpStatus.textContent = bits.join(' · ');
1164
+ // The config only appears once there is a server to configure against. Shown
1165
+ // earlier, it would be a command for a process that is not running.
1166
+ dom.mcpConfig.hidden = !data.running;
1167
+ if (data.running) {
1168
+ dom.mcpToolsHint.textContent = `${data.toolCount} tools: ${(data.tools || []).join(', ')}`;
1169
+ }
1117
1170
  }
1118
-
1119
1171
  function scheduleMcpPoll(ms) {
1120
- clearTimeout(mcpPoll);
1121
- mcpPoll = setTimeout(pollMcp, ms);
1172
+ clearTimeout(mcpPoll);
1173
+ mcpPoll = setTimeout(pollMcp, ms);
1122
1174
  }
1123
-
1124
1175
  async function pollMcp() {
1125
- renderMcpStatus(await fetchMcpStatus());
1126
- // Closed: a slow heartbeat, so the dot is right when the person comes back
1127
- // without the tab asking the server anything it does not need to answer.
1128
- scheduleMcpPoll(dom.mcpDrawer.hidden ? 8000 : 2500);
1176
+ renderMcpStatus(await fetchMcpStatus());
1177
+ // Closed: a slow heartbeat, so the dot is right when the person comes back
1178
+ // without the tab asking the server anything it does not need to answer.
1179
+ scheduleMcpPoll(dom.mcpDrawer.hidden ? 8000 : 2500);
1129
1180
  }
1130
-
1131
1181
  async function loadMcpCommand() {
1132
- if (mcpCommand) return mcpCommand;
1133
- mcpCommand = await fetchMcpCommand();
1134
- if (mcpCommand) {
1135
- dom.mcpCommandInput.value = mcpCommand.command;
1136
- renderMcpConfigBlock();
1137
- }
1138
- return mcpCommand;
1182
+ if (mcpCommand)
1183
+ return mcpCommand;
1184
+ mcpCommand = await fetchMcpCommand();
1185
+ if (mcpCommand) {
1186
+ dom.mcpCommandInput.value = mcpCommand.command;
1187
+ renderMcpConfigBlock();
1188
+ }
1189
+ return mcpCommand;
1139
1190
  }
1140
-
1141
1191
  async function openMcp() {
1142
- // Two drawers over one page would stack their scrims, and the top one would win
1143
- // every click. Closing settings first keeps the invariant the Escape handler
1144
- // assumes, and matches what the person was doing — switching panels.
1145
- closeSettings();
1146
- serverDrawer.close();
1147
- dom.mcpDrawer.hidden = false;
1148
- dom.mcpScrim.hidden = false;
1149
- await pollMcp();
1150
- await loadMcpCommand();
1151
- scheduleMcpPoll(2500);
1192
+ // Two drawers over one page would stack their scrims, and the top one would win
1193
+ // every click. Closing settings first keeps the invariant the Escape handler
1194
+ // assumes, and matches what the person was doing — switching panels.
1195
+ closeSettings();
1196
+ serverDrawer.close();
1197
+ dom.mcpDrawer.hidden = false;
1198
+ dom.mcpScrim.hidden = false;
1199
+ await pollMcp();
1200
+ await loadMcpCommand();
1201
+ scheduleMcpPoll(2500);
1152
1202
  }
1153
-
1154
1203
  function closeMcp() {
1155
- dom.mcpDrawer.hidden = true;
1156
- dom.mcpScrim.hidden = true;
1157
- scheduleMcpPoll(8000);
1204
+ dom.mcpDrawer.hidden = true;
1205
+ dom.mcpScrim.hidden = true;
1206
+ scheduleMcpPoll(8000);
1158
1207
  }
1159
-
1160
1208
  // The two config shapes a client is most likely to want: JSON for the harnesses
1161
1209
  // that read a config file, TOML for the ones that do not. Both are built from
1162
1210
  // what the server reported, so neither can drift from the command that actually
1163
1211
  // starts it.
1164
1212
  function renderMcpConfigBlock() {
1165
- const ex = mcpCommand?.examples?.[0];
1166
- if (!ex) return;
1167
- const command = shortCommand(ex.command);
1168
- const json = { mcpServers: { onboarder: { command, args: ex.args } } };
1169
- const toml = [
1170
- '[mcp_servers.onboarder]',
1171
- `command = ${JSON.stringify(command)}`,
1172
- `args = [${ex.args.map((a) => JSON.stringify(a)).join(', ')}]`,
1173
- ].join('\n');
1174
- dom.mcpConfigBlock.textContent = dom.mcpTabToml.classList.contains('is-on')
1175
- ? toml
1176
- : JSON.stringify(json, null, 2);
1213
+ const ex = mcpCommand?.examples?.[0];
1214
+ if (!ex)
1215
+ return;
1216
+ const command = shortCommand(ex.command);
1217
+ const json = { mcpServers: { onboarder: { command, args: ex.args } } };
1218
+ const toml = [
1219
+ '[mcp_servers.onboarder]',
1220
+ `command = ${JSON.stringify(command)}`,
1221
+ `args = [${ex.args.map((a) => JSON.stringify(a)).join(', ')}]`,
1222
+ ].join('\n');
1223
+ dom.mcpConfigBlock.textContent = dom.mcpTabToml.classList.contains('is-on')
1224
+ ? toml
1225
+ : JSON.stringify(json, null, 2);
1177
1226
  }
1178
-
1179
1227
  // The server reports its own `process.execPath` so the config works even where
1180
1228
  // the harness's PATH has no node. But an absolute path is noise in a snippet
1181
1229
  // people read, so the common case is shown short and the input above keeps the
1182
1230
  // exact one.
1183
1231
  function shortCommand(cmd) {
1184
- return typeof cmd === 'string' && cmd.endsWith('/node') ? 'node' : cmd;
1232
+ return typeof cmd === 'string' && cmd.endsWith('/node') ? 'node' : cmd;
1185
1233
  }
1186
-
1187
1234
  function pickMcpTab(toml) {
1188
- dom.mcpTabJson.classList.toggle('is-on', !toml);
1189
- dom.mcpTabToml.classList.toggle('is-on', toml);
1190
- dom.mcpTabJson.setAttribute('aria-selected', String(!toml));
1191
- dom.mcpTabToml.setAttribute('aria-selected', String(toml));
1192
- renderMcpConfigBlock();
1235
+ dom.mcpTabJson.classList.toggle('is-on', !toml);
1236
+ dom.mcpTabToml.classList.toggle('is-on', toml);
1237
+ dom.mcpTabJson.setAttribute('aria-selected', String(!toml));
1238
+ dom.mcpTabToml.setAttribute('aria-selected', String(toml));
1239
+ renderMcpConfigBlock();
1193
1240
  }
1194
-
1195
1241
  dom.mcpTabJson.addEventListener('click', () => pickMcpTab(false));
1196
1242
  dom.mcpTabToml.addEventListener('click', () => pickMcpTab(true));
1197
1243
  dom.mcpBtn.addEventListener('click', () => (dom.mcpDrawer.hidden ? openMcp() : closeMcp()));
1198
1244
  dom.mcpClose.addEventListener('click', closeMcp);
1199
1245
  dom.mcpScrim.addEventListener('click', closeMcp);
1200
-
1201
1246
  dom.mcpCopy.addEventListener('click', async () => {
1202
- try {
1203
- await navigator.clipboard.writeText(dom.mcpConfigBlock.textContent);
1204
- toast('Config copied');
1205
- } catch {
1206
- // Clipboard access can be refused outright; selecting the text is the one
1207
- // fallback that always works, so the person can copy it themselves.
1208
- dom.mcpCommandInput.select();
1209
- }
1247
+ try {
1248
+ await navigator.clipboard.writeText(dom.mcpConfigBlock.textContent);
1249
+ toast('Config copied');
1250
+ }
1251
+ catch {
1252
+ // Clipboard access can be refused outright; selecting the text is the one
1253
+ // fallback that always works, so the person can copy it themselves.
1254
+ dom.mcpCommandInput.select();
1255
+ }
1210
1256
  });
1211
-
1212
1257
  async function setMcpState(action) {
1213
- if (mcpBusy) return;
1214
- mcpBusy = true;
1215
- // Optimistic: the dot goes amber immediately rather than after a round-trip
1216
- // during which the button would still read "stopped" and invite a second click.
1217
- renderMcpStatus({ state: action === 'start' ? 'starting' : 'stopping', running: action === 'stop', toolCount: 0, tools: [] });
1218
- try {
1219
- const data = action === 'start' ? await startMcpServer() : await stopMcpServer();
1220
- renderMcpStatus(data);
1221
- if (action === 'start') await loadMcpCommand();
1222
- } catch (err) {
1223
- renderMcpStatus({ state: 'error', running: action === 'stop', lastError: err.message, tools: [] });
1224
- toast(err.message);
1225
- } finally {
1226
- mcpBusy = false;
1227
- scheduleMcpPoll(1500);
1228
- }
1258
+ if (mcpBusy)
1259
+ return;
1260
+ mcpBusy = true;
1261
+ // Optimistic: the dot goes amber immediately rather than after a round-trip
1262
+ // during which the button would still read "stopped" and invite a second click.
1263
+ renderMcpStatus({ state: action === 'start' ? 'starting' : 'stopping', running: action === 'stop', toolCount: 0, tools: [] });
1264
+ try {
1265
+ const data = action === 'start' ? await startMcpServer() : await stopMcpServer();
1266
+ renderMcpStatus(data);
1267
+ if (action === 'start')
1268
+ await loadMcpCommand();
1269
+ }
1270
+ catch (err) {
1271
+ renderMcpStatus({ state: 'error', running: action === 'stop', lastError: err.message, tools: [] });
1272
+ toast(err.message);
1273
+ }
1274
+ finally {
1275
+ mcpBusy = false;
1276
+ scheduleMcpPoll(1500);
1277
+ }
1229
1278
  }
1230
-
1231
1279
  dom.mcpStart.addEventListener('click', () => setMcpState('start'));
1232
1280
  dom.mcpStop.addEventListener('click', () => setMcpState('stop'));
1233
-
1234
1281
  // One heartbeat at load. This first fetch is also what decides whether the button
1235
1282
  // already reads "MCP on" for a server that was left running from before.
1236
1283
  scheduleMcpPoll(0);
1237
-
1238
-
1239
1284
  dom.drawerScrim.addEventListener('click', closeSettings);
1240
-
1241
1285
  dom.saveSettings.addEventListener('click', () => {
1242
- llm.saveSettings({ baseUrl: dom.setBaseUrl.value, apiKey: dom.setApiKey.value, model: dom.setModel.value });
1243
- dom.settingsStatus.className = 'drawer-status ok';
1244
- dom.settingsStatus.textContent = 'Saved in this browser.';
1286
+ llm.saveSettings({ baseUrl: dom.setBaseUrl.value, apiKey: dom.setApiKey.value, model: dom.setModel.value });
1287
+ dom.settingsStatus.className = 'drawer-status ok';
1288
+ dom.settingsStatus.textContent = 'Saved in this browser.';
1245
1289
  });
1246
-
1247
1290
  dom.testSettings.addEventListener('click', async () => {
1248
- const settings = {
1249
- baseUrl: dom.setBaseUrl.value.trim().replace(/\/+$/, ''),
1250
- apiKey: dom.setApiKey.value,
1251
- model: dom.setModel.value.trim(),
1252
- };
1253
- if (!settings.baseUrl || !settings.model) {
1254
- dom.settingsStatus.className = 'drawer-status err';
1255
- dom.settingsStatus.textContent = 'Base URL and model are both needed.';
1256
- return;
1257
- }
1258
- dom.settingsStatus.className = 'drawer-status';
1259
- dom.settingsStatus.textContent = 'Asking the endpoint…';
1260
- try {
1261
- let heard = '';
1262
- for await (const delta of streamExplain({
1263
- ...settings,
1264
- maxTokens: 64,
1265
- messages: [{ role: 'user', content: 'Reply with the single word: ready' }],
1266
- })) {
1267
- heard += delta;
1291
+ const settings = {
1292
+ baseUrl: dom.setBaseUrl.value.trim().replace(/\/+$/, ''),
1293
+ apiKey: dom.setApiKey.value,
1294
+ model: dom.setModel.value.trim(),
1295
+ };
1296
+ if (!settings.baseUrl || !settings.model) {
1297
+ dom.settingsStatus.className = 'drawer-status err';
1298
+ dom.settingsStatus.textContent = 'Base URL and model are both needed.';
1299
+ return;
1300
+ }
1301
+ dom.settingsStatus.className = 'drawer-status';
1302
+ dom.settingsStatus.textContent = 'Asking the endpoint…';
1303
+ try {
1304
+ let heard = '';
1305
+ for await (const delta of streamExplain({
1306
+ ...settings,
1307
+ maxTokens: 64,
1308
+ messages: [{ role: 'user', content: 'Reply with the single word: ready' }],
1309
+ })) {
1310
+ heard += delta;
1311
+ }
1312
+ dom.settingsStatus.className = 'drawer-status ok';
1313
+ dom.settingsStatus.textContent = heard
1314
+ ? `Connected — ${settings.model} answered.`
1315
+ : `Connected. It answered with silence, but that is a thinking model spending its whole token budget on thought — explanations use a bigger one.`;
1316
+ }
1317
+ catch (err) {
1318
+ dom.settingsStatus.className = 'drawer-status err';
1319
+ dom.settingsStatus.textContent = err.message;
1268
1320
  }
1269
- dom.settingsStatus.className = 'drawer-status ok';
1270
- dom.settingsStatus.textContent = heard
1271
- ? `Connected — ${settings.model} answered.`
1272
- : `Connected. It answered with silence, but that is a thinking model spending its whole token budget on thought — explanations use a bigger one.`;
1273
- } catch (err) {
1274
- dom.settingsStatus.className = 'drawer-status err';
1275
- dom.settingsStatus.textContent = err.message;
1276
- }
1277
1321
  });
1278
-
1279
1322
  // ------------------------------------------------------------------- misc --
1280
-
1281
1323
  let toastTimer = null;
1282
1324
  function toast(message) {
1283
- dom.toast.textContent = message;
1284
- dom.toast.hidden = false;
1285
- clearTimeout(toastTimer);
1286
- toastTimer = setTimeout(() => (dom.toast.hidden = true), 2600);
1325
+ dom.toast.textContent = message;
1326
+ dom.toast.hidden = false;
1327
+ clearTimeout(toastTimer);
1328
+ toastTimer = setTimeout(() => (dom.toast.hidden = true), 2600);
1287
1329
  }
1288
-
1289
1330
  document.addEventListener('keydown', (event) => {
1290
- if (event.target.matches('input, textarea, select')) return;
1291
- // Escape closes whichever drawer is open. The three are kept mutually
1292
- // exclusive when they open, so at most one is ever showing — but checking
1293
- // each here means the behavior does not depend on that invariant holding.
1294
- if (event.key === 'Escape') {
1295
- if (!dom.mcpDrawer.hidden) return closeMcp();
1296
- if (serverDrawer.isOpen()) return serverDrawer.close();
1297
- return closeSettings();
1298
- }
1299
- if (!state.scan) return;
1300
- if (event.key === '1') setView(state.lastExplorer || 'map');
1301
- if (event.key === '2') setView('code');
1302
- if (event.key === '3') setView('docs');
1303
- if (event.key === '4') setView('about');
1304
- if (event.key === '/') {
1305
- event.preventDefault();
1306
- dom.treeFilter.focus();
1307
- }
1308
- if (state.view === 'tour') {
1309
- if (event.key === ']') dom.tourNext.click();
1310
- if (event.key === '[') dom.tourPrev.click();
1311
- }
1331
+ if (eventElement(event)?.matches('input, textarea, select'))
1332
+ return;
1333
+ // Escape closes whichever drawer is open. The three are kept mutually
1334
+ // exclusive when they open, so at most one is ever showing — but checking
1335
+ // each here means the behavior does not depend on that invariant holding.
1336
+ if (event.key === 'Escape') {
1337
+ if (!dom.mcpDrawer.hidden)
1338
+ return closeMcp();
1339
+ if (serverDrawer.isOpen())
1340
+ return serverDrawer.close();
1341
+ return closeSettings();
1342
+ }
1343
+ if (!state.scan)
1344
+ return;
1345
+ if (event.key === '1')
1346
+ setView(state.lastExplorer || 'map');
1347
+ if (event.key === '2')
1348
+ setView('code');
1349
+ if (event.key === '3')
1350
+ setView('docs');
1351
+ if (event.key === '4')
1352
+ setView('about');
1353
+ if (event.key === '/') {
1354
+ event.preventDefault();
1355
+ dom.treeFilter.focus();
1356
+ }
1357
+ if (state.view === 'tour') {
1358
+ if (event.key === ']')
1359
+ dom.tourNext.click();
1360
+ if (event.key === '[')
1361
+ dom.tourPrev.click();
1362
+ }
1312
1363
  });
1313
-
1314
1364
  // ------------------------------------------------------------------ theme --
1315
-
1316
1365
  const THEME_KEY = 'onboarder.theme';
1317
-
1318
1366
  function getTheme() {
1319
- const saved = localStorage.getItem(THEME_KEY);
1320
- if (saved === 'dark' || saved === 'light') return saved;
1321
- return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1367
+ const saved = localStorage.getItem(THEME_KEY);
1368
+ if (saved === 'dark' || saved === 'light')
1369
+ return saved;
1370
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1322
1371
  }
1323
-
1324
1372
  function applyTheme(theme) {
1325
- document.documentElement.dataset.theme = theme;
1326
- setDiagramTheme(theme);
1327
- setViewerTheme(theme);
1328
- dom.themeBtn.textContent = theme === 'dark' ? 'Light' : 'Dark';
1329
- dom.themeBtn.title = theme === 'dark' ? 'Switch to the light theme' : 'Switch to the dark theme';
1373
+ document.documentElement.dataset.theme = theme;
1374
+ setDiagramTheme(theme);
1375
+ setViewerTheme(theme);
1376
+ dom.themeBtn.textContent = theme === 'dark' ? 'Light' : 'Dark';
1377
+ dom.themeBtn.title = theme === 'dark' ? 'Switch to the light theme' : 'Switch to the dark theme';
1330
1378
  }
1331
-
1332
1379
  dom.themeBtn.addEventListener('click', () => {
1333
- const next = getTheme() === 'dark' ? 'light' : 'dark';
1334
- localStorage.setItem(THEME_KEY, next);
1335
- applyTheme(next);
1336
- if (state.scan) renderCanvas(); // diagrams carry their own palette — redraw
1380
+ const next = getTheme() === 'dark' ? 'light' : 'dark';
1381
+ localStorage.setItem(THEME_KEY, next);
1382
+ applyTheme(next);
1383
+ if (state.scan && (isExplorerView(state.view) || state.view === 'graph' || state.view === 'heatmap'))
1384
+ renderCanvas(); // Only canvas/diagram views need a theme redraw.
1337
1385
  });
1338
-
1339
1386
  applyTheme(getTheme());
1387
+ //# sourceMappingURL=app.js.map