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
@@ -24,145 +24,142 @@
24
24
  //
25
25
  // All DOM work happens inside `initSearch`. The module is importable in Node,
26
26
  // which is the rule that keeps it testable — see Agent.md.
27
-
28
27
  import { escapeHtml } from './html.js';
29
- import {
30
- highlightRanges, matchesPathFilters, parseQuery, queryIsEmpty,
31
- removeClause, scorePath, scoreSymbol,
32
- } from '/shared/search/query.js';
33
-
28
+ import { eventElement } from './dom.js';
29
+ import { buildLocalSearchIndex, searchLocalIndex } from './searchLocal.js';
30
+ import { highlightRanges, matchesPathFilters, parseQuery, queryIsEmpty, removeClause, } from '/shared/search/query.js';
34
31
  // Remembering what you searched for is a courtesy, never a hard dependency.
35
32
  const RECENTS_KEY = 'onboarder:recent-searches';
36
33
  const MAX_RECENTS = 8;
37
-
38
34
  // Rows drawn per group. The *counts* stay honest — the header says how many
39
35
  // there are — but the palette is for jumping somewhere, not for scrolling a
40
36
  // list of four hundred files.
41
37
  const PREVIEW_LIMIT = 40;
42
38
  const CODE_LIMIT = 40;
43
-
44
39
  // Long enough that a fast typist makes one request instead of nine, short
45
40
  // enough that a slow one never notices.
46
41
  const DEBOUNCE_MS = 140;
47
-
48
42
  const EXAMPLES = [
49
- { query: 'ext:js createServer', hint: 'a function, in one language' },
50
- { query: 'is:test', hint: 'tests only' },
51
- { query: 'path:server -vendor', hint: 'somewhere, minus a folder' },
52
- { query: '"exact phrase"', hint: 'words in that order' },
53
- { query: '/create.*Router/', hint: 'a regular expression' },
54
- { query: 'kind:class', hint: 'declarations of one kind' },
43
+ { query: 'ext:js createServer', hint: 'a function, in one language' },
44
+ { query: 'is:test', hint: 'tests only' },
45
+ { query: 'path:server -vendor', hint: 'somewhere, minus a folder' },
46
+ { query: '"exact phrase"', hint: 'words in that order' },
47
+ { query: '/create.*Router/', hint: 'a regular expression' },
48
+ { query: 'kind:class', hint: 'declarations of one kind' },
55
49
  ];
56
-
57
50
  // localStorage throws in private windows, and in browser settings that forbid
58
51
  // it. A search box that refused to open because it could not remember your last
59
52
  // query would be an absurd way to fail, so every access is guarded.
60
53
  function readRecents() {
61
- try {
62
- const raw = localStorage.getItem(RECENTS_KEY);
63
- const list = raw ? JSON.parse(raw) : [];
64
- if (!Array.isArray(list)) return [];
65
- return list.filter((entry) => typeof entry === 'string' && entry.trim()).slice(0, MAX_RECENTS);
66
- } catch {
67
- return [];
68
- }
54
+ try {
55
+ const raw = localStorage.getItem(RECENTS_KEY);
56
+ const list = raw ? JSON.parse(raw) : [];
57
+ if (!Array.isArray(list))
58
+ return [];
59
+ return list.filter((entry) => typeof entry === 'string' && entry.trim()).slice(0, MAX_RECENTS);
60
+ }
61
+ catch {
62
+ return [];
63
+ }
69
64
  }
70
-
71
65
  function writeRecents(list) {
72
- try {
73
- localStorage.setItem(RECENTS_KEY, JSON.stringify(list.slice(0, MAX_RECENTS)));
74
- } catch {
75
- /* remembering is optional */
76
- }
66
+ try {
67
+ localStorage.setItem(RECENTS_KEY, JSON.stringify(list.slice(0, MAX_RECENTS)));
68
+ }
69
+ catch {
70
+ /* remembering is optional */
71
+ }
77
72
  }
78
-
79
73
  function extensionOf(path) {
80
- const name = path.split('/').pop() || '';
81
- const dot = name.lastIndexOf('.');
82
- if (dot <= 0) return 'FILE';
83
- return name.slice(dot + 1).toUpperCase();
74
+ const name = path.split('/').pop() || '';
75
+ const dot = name.lastIndexOf('.');
76
+ if (dot <= 0)
77
+ return 'FILE';
78
+ return name.slice(dot + 1).toUpperCase();
84
79
  }
85
-
86
80
  // The human word for each clause the parser found, used on the chips row.
87
81
  const CLAUSE_LABELS = {
88
- ext: 'ext', path: 'path', kind: 'kind', is: 'is',
89
- neg: 'not', phrase: 'phrase', regex: 'regex',
82
+ ext: 'ext', path: 'path', kind: 'kind', is: 'is',
83
+ neg: 'not', phrase: 'phrase', regex: 'regex',
90
84
  };
91
-
92
85
  // One palette is live at a time, and one Cmd+K listener serves them all — it is
93
86
  // installed once, on the first `initSearch`, and routes to the newest controller.
94
87
  let keyListenerAttached = false;
95
88
  let activeInstance = null;
96
-
97
89
  export function initSearch(files = [], options) {
98
- const opts = options || {};
99
- let fileList = Array.isArray(files) ? files : [];
100
- let scanIdProvider = opts.getScanId || (() => opts.scanId || null);
101
-
102
- let isShowing = false;
103
- let overlay = null;
104
- let input = null;
105
- let modeBar = null;
106
- let chipsBar = null;
107
- let resultsList = null;
108
- let statusBar = null;
109
-
110
- let activeMode = 'all'; // 'all' | 'files' | 'symbols' | 'code'
111
- let caseSensitive = false;
112
- let selectedIndex = 0;
113
- let groups = []; // what is rendered, in order
114
- let flat = []; // `groups` flattened — what the arrow keys walk
115
- let localFiles = [];
116
- let localSymbols = [];
117
- let localCounts = { files: 0, symbols: 0 };
118
- let code = null; // the last /api/search payload
119
- let codeLoading = false;
120
- let codeError = null;
121
- let parseError = null;
122
- let requestSeq = 0; // every request gets a number; only the newest paints
123
- let inFlight = null; // the AbortController for the request above
124
- let debounceTimer = null;
125
- let recents = [];
126
- let lastQuery = '';
127
-
128
- function getPath(file) {
129
- if (!file) return '';
130
- return typeof file === 'string' ? file : file.path || '';
131
- }
132
-
133
- // The shell is built once and then reused for the session. It goes *into* the
134
- // `#searchOverlay` element the page already has rather than replacing it, so
135
- // the markup in `index.html` stays the single source of position and z-index.
136
- function buildShell() {
137
- overlay = document.getElementById('searchOverlay');
138
- if (!overlay) {
139
- overlay = document.createElement('div');
140
- overlay.id = 'searchOverlay';
141
- overlay.className = 'search-overlay';
142
- document.body.appendChild(overlay);
143
- }
144
-
145
- if (overlay.querySelector('.search-modal')) {
146
- input = overlay.querySelector('.search-input');
147
- modeBar = overlay.querySelector('.search-modes');
148
- chipsBar = overlay.querySelector('.search-chips');
149
- resultsList = overlay.querySelector('.search-results');
150
- statusBar = overlay.querySelector('.search-status');
151
- return;
152
- }
153
-
154
- overlay.hidden = true;
155
- overlay.setAttribute('role', 'dialog');
156
- overlay.setAttribute('aria-modal', 'true');
157
- overlay.setAttribute('aria-label', 'Search this codebase');
158
- overlay.innerHTML = `<div class="search-modal">
90
+ activeInstance?.destroy();
91
+ const opts = options || {};
92
+ let fileList = Array.isArray(files) ? files : [];
93
+ let localIndex = buildLocalSearchIndex(fileList);
94
+ const localCache = new Map();
95
+ const listeners = new AbortController();
96
+ let localFrame = null;
97
+ let focusBefore = null;
98
+ let selectedRow = null;
99
+ const background = new Map();
100
+ let scanIdProvider = opts.getScanId || (() => opts.scanId || null);
101
+ let isShowing = false;
102
+ let overlay = null;
103
+ let input = null;
104
+ let modeBar = null;
105
+ let chipsBar = null;
106
+ let chipMarkup = '';
107
+ let resultsList = null;
108
+ let statusBar = null;
109
+ let activeMode = 'all'; // 'all' | 'files' | 'symbols' | 'code'
110
+ let caseSensitive = false;
111
+ let selectedIndex = 0;
112
+ let groups = []; // what is rendered, in order
113
+ let flat = []; // `groups` flattened — what the arrow keys walk
114
+ let localFiles = [];
115
+ let localSymbols = [];
116
+ let localCounts = { files: 0, symbols: 0 };
117
+ let code = null; // the last /api/search payload
118
+ let codeLoading = false;
119
+ let codeError = null;
120
+ let parseError = null;
121
+ let requestSeq = 0; // every request gets a number; only the newest paints
122
+ let inFlight = null; // the AbortController for the request above
123
+ let debounceTimer = null;
124
+ let recents = [];
125
+ let lastQuery = '';
126
+ function getPath(file) {
127
+ if (!file)
128
+ return '';
129
+ return typeof file === 'string' ? file : file.path || '';
130
+ }
131
+ // The shell is built once and then reused for the session. It goes *into* the
132
+ // `#searchOverlay` element the page already has rather than replacing it, so
133
+ // the markup in `index.html` stays the single source of position and z-index.
134
+ function buildShell() {
135
+ overlay = document.getElementById('searchOverlay');
136
+ if (!overlay) {
137
+ overlay = document.createElement('div');
138
+ overlay.id = 'searchOverlay';
139
+ overlay.className = 'search-overlay';
140
+ document.body.appendChild(overlay);
141
+ }
142
+ if (overlay.querySelector('.search-modal')) {
143
+ input = overlay.querySelector('.search-input');
144
+ modeBar = overlay.querySelector('.search-modes');
145
+ chipsBar = overlay.querySelector('.search-chips');
146
+ resultsList = overlay.querySelector('.search-results');
147
+ statusBar = overlay.querySelector('.search-status');
148
+ return;
149
+ }
150
+ overlay.hidden = true;
151
+ overlay.setAttribute('role', 'dialog');
152
+ overlay.setAttribute('aria-modal', 'true');
153
+ overlay.setAttribute('aria-label', 'Search this codebase');
154
+ overlay.innerHTML = `<div class="search-modal">
159
155
  <div class="search-header">
160
156
  <div class="search-inputrow">
161
157
  <span class="search-icon" aria-hidden="true"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></span>
162
158
  <input class="search-input" type="text" role="combobox" aria-expanded="true"
163
159
  aria-controls="searchResultList" aria-autocomplete="list" autocomplete="off"
164
- spellcheck="false" placeholder="Search files, symbols and contents\u2026">
165
- <button class="search-toggle" data-toggle="case" title="Match case" aria-pressed="false">Aa</button>
160
+ aria-label="Search files, symbols and contents" spellcheck="false" placeholder="Search files, symbols and contents\u2026">
161
+ <button class="search-toggle" data-toggle="case" title="Match case" aria-label="Match case" aria-pressed="false">Aa</button>
162
+ <button class="search-close" type="button" aria-label="Close search" title="Close search">×</button>
166
163
  </div>
167
164
  <div class="search-modes" role="tablist" aria-label="Result kinds"></div>
168
165
  <div class="search-chips"></div>
@@ -170,705 +167,776 @@ export function initSearch(files = [], options) {
170
167
  <div class="search-results" id="searchResultList" role="listbox" aria-label="Search results"></div>
171
168
  <div class="search-status"></div>
172
169
  </div>`;
173
-
174
- input = overlay.querySelector('.search-input');
175
- modeBar = overlay.querySelector('.search-modes');
176
- chipsBar = overlay.querySelector('.search-chips');
177
- resultsList = overlay.querySelector('.search-results');
178
- statusBar = overlay.querySelector('.search-status');
179
-
180
- input.addEventListener('input', () => {
181
- selectedIndex = 0;
182
- runSearch();
183
- });
184
- input.addEventListener('keydown', onInputKeyDown);
185
- input.addEventListener('focus', () => {
186
- if (!input.value.trim()) runSearch();
187
- });
188
-
189
- // One delegated listener for the whole palette. Every row in it is rebuilt
190
- // on each keystroke, so per-row listeners would be churn; the shape of the
191
- // markup is the only contract this needs.
192
- overlay.addEventListener('click', (event) => {
193
- if (event.target === overlay) return hide();
194
-
195
- const mode = event.target.closest('.search-mode-chip');
196
- if (mode) return setMode(mode.dataset.mode);
197
-
198
- const toggle = event.target.closest('.search-toggle');
199
- if (toggle) return toggleCase();
200
-
201
- const remove = event.target.closest('.search-chip-remove');
202
- if (remove) return removeChip(Number(remove.dataset.clause));
203
-
204
- const row = event.target.closest('.search-result');
205
- if (row) {
206
- const index = Number(row.dataset.index);
207
- if (Number.isFinite(index) && flat[index]) select(flat[index]);
208
- }
209
- return undefined;
210
- });
211
-
212
- overlay.addEventListener('mouseover', (event) => {
213
- const row = event.target.closest('.search-result');
214
- if (!row) return;
215
- const index = Number(row.dataset.index);
216
- if (!Number.isFinite(index) || index === selectedIndex) return;
217
- selectedIndex = index;
218
- paintSelection();
219
- });
220
- }
221
-
222
- // ---- the pipeline -------------------------------------------------------
223
-
224
- // Functions and classes carry a line number, which is what lets a symbol row
225
- // open the file at the right place. An export record does not, so a symbol
226
- // that is only known as an export opens at the top rather than at a guess.
227
- // Declarations win over export records for the same name — otherwise every
228
- // exported function would appear twice, once with a line and once without.
229
- function symbolsOf(file) {
230
- const byName = new Map();
231
- const put = (bare, kind, line, suffix) => {
232
- if (!bare) return;
233
- const existing = byName.get(bare);
234
- if (existing && (existing.line || !line)) return;
235
- byName.set(bare, { name: suffix ? bare + suffix : bare, symbol: bare, kind, line });
236
- };
237
- for (const cls of file.classes || []) put(cls && cls.name, (cls && cls.kind) || 'class', cls && cls.line, '');
238
- for (const fn of file.functions || []) put(fn && fn.name, (fn && fn.kind) || 'function', fn && fn.line, '()');
239
- for (const exp of file.exports || []) put(exp && exp.name, 'export ' + ((exp && exp.kind) || ''), undefined, '');
240
- return [...byName.values()];
241
- }
242
-
243
- function rank(list) {
244
- return list.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
245
- }
246
-
247
- // Everything the browser can answer without a network hop: the file list and
248
- // the symbols the scan already extracted. This is what makes the palette feel
249
- // immediate — the arrows move over real results before any request is made.
250
- function collectLocal(parsed) {
251
- const files = [];
252
- const symbols = [];
253
- if (queryIsEmpty(parsed)) return { files, symbols };
254
-
255
- for (const file of fileList) {
256
- const path = getPath(file);
257
- if (!path) continue;
258
- if (!matchesPathFilters(parsed, path)) continue;
259
-
260
- const pathScore = scorePath(parsed, path);
261
- if (pathScore > 0) files.push({ type: 'file', path, score: pathScore });
262
-
263
- if (typeof file !== 'object') continue;
264
- for (const symbol of symbolsOf(file)) {
265
- const score = scoreSymbol(parsed, symbol.name, symbol.kind);
266
- if (score > 0) symbols.push({ type: 'symbol', path, score, ...symbol });
267
- }
268
- }
269
-
270
- return { files: rank(files), symbols: rank(symbols) };
271
- }
272
-
273
- // Is a content search worth a round trip? In a single-kind view, only that
274
- // kind matters. In the combined view a filter-only query — `is:test`, say —
275
- // is already answered by the file list, so asking the server would be a
276
- // request whose answer we would throw away.
277
- function wantsCode(parsed) {
278
- if (queryIsEmpty(parsed) || !scanIdProvider()) return false;
279
- if (activeMode === 'files' || activeMode === 'symbols') return false;
280
- if (activeMode === 'code') return true;
281
- return parsed.terms.some((term) => term.length >= 2)
282
- || parsed.phrases.length > 0 || parsed.regex !== null;
283
- }
284
-
285
- function scheduleCodeSearch(raw) {
286
- clearTimeout(debounceTimer);
287
- codeLoading = true;
288
- debounceTimer = setTimeout(() => {
289
- sendCodeSearch(raw);
290
- }, DEBOUNCE_MS);
291
- }
292
-
293
- // The sequencing is the point. `requestSeq` is bumped by every keystroke *and*
294
- // by every send, and a response is only allowed to paint if its number is
295
- // still the current one. Without that, a slow answer to a short query could
296
- // land after a fast answer to a longer one, and the palette would show results
297
- // for something you had already stopped typing — the bug that made fast
298
- // typing untrustworthy.
299
- async function sendCodeSearch(raw) {
300
- const scanId = scanIdProvider();
301
- if (!scanId) {
302
- codeLoading = false;
303
- render(currentParsed);
304
- return;
305
- }
306
-
307
- const seq = ++requestSeq;
308
- const controller = new AbortController();
309
- inFlight = controller;
310
-
311
- try {
312
- const res = await fetch('/api/search', {
313
- method: 'POST',
314
- headers: { 'Content-Type': 'application/json' },
315
- signal: controller.signal,
316
- body: JSON.stringify({ scanId, query: raw, limit: CODE_LIMIT, caseSensitive }),
317
- });
318
- if (seq !== requestSeq) return;
319
-
320
- const data = await res.json().catch(() => null);
321
- if (seq !== requestSeq) return;
322
-
323
- if (!data) {
170
+ input = overlay.querySelector('.search-input');
171
+ modeBar = overlay.querySelector('.search-modes');
172
+ chipsBar = overlay.querySelector('.search-chips');
173
+ resultsList = overlay.querySelector('.search-results');
174
+ statusBar = overlay.querySelector('.search-status');
175
+ input.addEventListener('input', (event) => {
176
+ if (event.isComposing)
177
+ return;
178
+ selectedIndex = 0;
179
+ scheduleLocalSearch();
180
+ }, { signal: listeners.signal });
181
+ input.addEventListener('compositionend', () => {
182
+ selectedIndex = 0;
183
+ scheduleLocalSearch();
184
+ }, { signal: listeners.signal });
185
+ input.addEventListener('keydown', onInputKeyDown, { signal: listeners.signal });
186
+ overlay.addEventListener('keydown', onDialogKeyDown, { signal: listeners.signal });
187
+ // One delegated listener for the whole palette. Every row in it is rebuilt
188
+ // on each keystroke, so per-row listeners would be churn; the shape of the
189
+ // markup is the only contract this needs.
190
+ overlay.addEventListener('click', (event) => {
191
+ if (event.target === overlay)
192
+ return hide();
193
+ const target = eventElement(event);
194
+ if (!target)
195
+ return;
196
+ if (target.closest('.search-close'))
197
+ return hide();
198
+ const mode = eventElement(event)?.closest('.search-mode-chip');
199
+ if (mode)
200
+ return setMode(mode.dataset.mode);
201
+ const toggle = eventElement(event)?.closest('.search-toggle');
202
+ if (toggle)
203
+ return toggleCase();
204
+ const remove = eventElement(event)?.closest('.search-chip-remove');
205
+ if (remove)
206
+ return removeChip(Number(remove.dataset.clause));
207
+ const row = eventElement(event)?.closest('.search-result');
208
+ if (row) {
209
+ const index = Number(row.dataset.index);
210
+ if (Number.isFinite(index) && flat[index])
211
+ select(flat[index]);
212
+ }
213
+ return undefined;
214
+ }, { signal: listeners.signal });
215
+ overlay.addEventListener('mouseover', (event) => {
216
+ const row = eventElement(event)?.closest('.search-result');
217
+ if (!row)
218
+ return;
219
+ const index = Number(row.dataset.index);
220
+ if (!Number.isFinite(index) || index === selectedIndex)
221
+ return;
222
+ selectedIndex = index;
223
+ paintSelection(false);
224
+ }, { signal: listeners.signal });
225
+ }
226
+ // ---- the pipeline -------------------------------------------------------
227
+ // The symbol index belongs to the scan; repeated queries reuse their bounded
228
+ // previews rather than extracting declarations and sorting every match again.
229
+ function collectLocal(parsed) {
230
+ const key = `${caseSensitive}:${parsed.raw}`;
231
+ const cached = localCache.get(key);
232
+ if (cached)
233
+ return cached;
234
+ const result = searchLocalIndex(localIndex, parsed, PREVIEW_LIMIT);
235
+ if (localCache.size >= 12)
236
+ localCache.delete(localCache.keys().next().value);
237
+ localCache.set(key, result);
238
+ return result;
239
+ }
240
+ // Coalesce keys in the same frame and invalidate wire results immediately.
241
+ function scheduleLocalSearch() {
242
+ requestSeq++;
243
+ inFlight?.abort();
244
+ inFlight = null;
245
+ clearTimeout(debounceTimer);
246
+ if (localFrame !== null)
247
+ cancelAnimationFrame(localFrame);
248
+ localFrame = requestAnimationFrame(() => { localFrame = null; if (isShowing)
249
+ runSearch(); });
250
+ }
251
+ // Is a content search worth a round trip? In a single-kind view, only that
252
+ // kind matters. In the combined view a filter-only query — `is:test`, say —
253
+ // is already answered by the file list, so asking the server would be a
254
+ // request whose answer we would throw away.
255
+ function wantsCode(parsed) {
256
+ if (queryIsEmpty(parsed) || !scanIdProvider())
257
+ return false;
258
+ if (activeMode === 'files' || activeMode === 'symbols')
259
+ return false;
260
+ if (activeMode === 'code')
261
+ return true;
262
+ return parsed.terms.some((term) => term.length >= 2)
263
+ || parsed.phrases.length > 0 || parsed.regex !== null;
264
+ }
265
+ function scheduleCodeSearch(raw) {
266
+ clearTimeout(debounceTimer);
267
+ codeLoading = true;
268
+ debounceTimer = setTimeout(() => {
269
+ sendCodeSearch(raw);
270
+ }, DEBOUNCE_MS);
271
+ }
272
+ // The sequencing is the point. `requestSeq` is bumped by every keystroke *and*
273
+ // by every send, and a response is only allowed to paint if its number is
274
+ // still the current one. Without that, a slow answer to a short query could
275
+ // land after a fast answer to a longer one, and the palette would show results
276
+ // for something you had already stopped typing — the bug that made fast
277
+ // typing untrustworthy.
278
+ async function sendCodeSearch(raw) {
279
+ const scanId = scanIdProvider();
280
+ if (!scanId) {
281
+ codeLoading = false;
282
+ render(currentParsed);
283
+ return;
284
+ }
285
+ const seq = ++requestSeq;
286
+ const controller = new AbortController();
287
+ inFlight = controller;
288
+ try {
289
+ const res = await fetch('/api/search', {
290
+ method: 'POST',
291
+ headers: { 'Content-Type': 'application/json' },
292
+ signal: controller.signal,
293
+ body: JSON.stringify({ scanId, query: raw, limit: CODE_LIMIT, caseSensitive }),
294
+ });
295
+ if (seq !== requestSeq)
296
+ return;
297
+ const data = await res.json().catch(() => null);
298
+ if (seq !== requestSeq)
299
+ return;
300
+ if (!res.ok)
301
+ throw new Error(data?.error || `Search failed (${res.status}).`);
302
+ if (!data) {
303
+ code = null;
304
+ codeError = 'The search endpoint returned nothing readable.';
305
+ return;
306
+ }
307
+ code = data;
308
+ codeError = data.error || null;
309
+ }
310
+ catch (err) {
311
+ // An aborted request is not a failure; it is the newer query arriving.
312
+ if (seq !== requestSeq || (err && err.name === 'AbortError'))
313
+ return;
314
+ code = null;
315
+ codeError = 'Could not search file contents: ' + ((err && err.message) || err);
316
+ }
317
+ finally {
318
+ if (seq === requestSeq) {
319
+ codeLoading = false;
320
+ inFlight = null;
321
+ render(currentParsed);
322
+ }
323
+ }
324
+ }
325
+ // The whole keystroke-to-pixels path. Local work first, so results are on
326
+ // screen before the request for the slower half has even been sent.
327
+ function runSearch() {
328
+ if (localFrame !== null) {
329
+ cancelAnimationFrame(localFrame);
330
+ localFrame = null;
331
+ }
332
+ const raw = input ? input.value.trim() : '';
333
+ lastQuery = raw;
334
+ const parsed = parsedQuery(raw);
335
+ currentParsed = parsed;
336
+ parseError = parsed.error;
337
+ const local = collectLocal(parsed);
338
+ localFiles = local.files;
339
+ localSymbols = local.symbols;
340
+ localCounts = local.counts;
341
+ // Whatever was asked before this keystroke is stale now, on the wire or not.
342
+ requestSeq++;
343
+ if (inFlight) {
344
+ inFlight.abort();
345
+ inFlight = null;
346
+ }
347
+ clearTimeout(debounceTimer);
324
348
  code = null;
325
- codeError = 'The search endpoint returned nothing readable.';
326
- return;
327
- }
328
- code = data;
329
- codeError = data.error || null;
330
- } catch (err) {
331
- // An aborted request is not a failure; it is the newer query arriving.
332
- if (seq !== requestSeq || (err && err.name === 'AbortError')) return;
333
- code = null;
334
- codeError = 'Could not search file contents: ' + ((err && err.message) || err);
335
- } finally {
336
- if (seq === requestSeq) {
349
+ codeError = null;
337
350
  codeLoading = false;
351
+ if (!parseError && wantsCode(parsed))
352
+ scheduleCodeSearch(raw);
353
+ render(parsed);
354
+ }
355
+ // ---- what to show -------------------------------------------------------
356
+ function flatten(list) {
357
+ const out = [];
358
+ for (const group of list) {
359
+ for (const item of group.items)
360
+ out.push(item);
361
+ }
362
+ return out;
363
+ }
364
+ // A content hit on the line a symbol row already names is the same
365
+ // destination twice, and the symbol row is the more useful label ("createRouter
366
+ // in server/router.js, line 90" beats "a line that contains createRouter").
367
+ function codeMatches() {
368
+ if (!code || !Array.isArray(code.results))
369
+ return [];
370
+ const claimed = new Set(localSymbols.map((sym) => `${sym.path}:${sym.line || ''}`));
371
+ return code.results
372
+ .filter((hit) => hit && hit.path && !claimed.has(`${hit.path}:${hit.line || ''}`))
373
+ .map((hit) => ({
374
+ type: 'code',
375
+ path: hit.path,
376
+ line: hit.line,
377
+ snippet: hit.snippet || '',
378
+ score: hit.score || 0,
379
+ }));
380
+ }
381
+ function buildGroups(parsed) {
382
+ const list = [];
383
+ if (queryIsEmpty(parsed)) {
384
+ // Nothing typed: offer what was typed before, then the way in, then the
385
+ // syntax — which is the only place the advanced grammar is discoverable.
386
+ if (recents.length) {
387
+ list.push({
388
+ key: 'recents',
389
+ label: 'Recent searches',
390
+ items: recents.map((query, i) => ({ type: 'recent', query, score: MAX_RECENTS - i })),
391
+ });
392
+ }
393
+ if (fileList.length) {
394
+ list.push({
395
+ key: 'start',
396
+ label: 'Start somewhere',
397
+ items: fileList.slice(0, 8).map((file) => ({ type: 'file', path: getPath(file), score: 1 })),
398
+ });
399
+ }
400
+ list.push({
401
+ key: 'examples',
402
+ label: 'Search syntax',
403
+ items: EXAMPLES.map((example) => ({
404
+ type: 'example', query: example.query, hint: example.hint, score: 1,
405
+ })),
406
+ });
407
+ groups = list;
408
+ flat = flatten(list);
409
+ return;
410
+ }
411
+ const showFiles = activeMode === 'all' || activeMode === 'files';
412
+ const showSymbols = activeMode === 'all' || activeMode === 'symbols';
413
+ const showCode = activeMode === 'all' || activeMode === 'code';
414
+ if (showFiles && localFiles.length) {
415
+ list.push({ key: 'files', label: 'Files', total: localCounts.files, items: localFiles.slice(0, PREVIEW_LIMIT) });
416
+ }
417
+ if (showSymbols && localSymbols.length) {
418
+ list.push({ key: 'symbols', label: 'Symbols', total: localCounts.symbols, items: localSymbols.slice(0, PREVIEW_LIMIT) });
419
+ }
420
+ if (showCode) {
421
+ const hits = codeMatches();
422
+ if (hits.length) {
423
+ list.push({
424
+ key: 'code',
425
+ label: 'Code matches',
426
+ total: (code && code.total) || hits.length,
427
+ items: hits.slice(0, PREVIEW_LIMIT),
428
+ });
429
+ }
430
+ }
431
+ groups = list;
432
+ flat = flatten(list);
433
+ }
434
+ // ---- painting -----------------------------------------------------------
435
+ // Every span the query matched, not just the first. The old highlighter
436
+ // stopped at one occurrence, which made a two-word query look like it had
437
+ // only matched half of itself.
438
+ function highlightAll(text, parsed) {
439
+ const value = String(text ?? '');
440
+ const ranges = highlightRanges(value, parsed);
441
+ if (!ranges.length)
442
+ return escapeHtml(value);
443
+ let out = '';
444
+ let at = 0;
445
+ for (const [start, end] of ranges) {
446
+ if (start < at)
447
+ continue; // merged already, but never double-wrap
448
+ out += escapeHtml(value.slice(at, start));
449
+ out += `<mark class="search-match">${escapeHtml(value.slice(start, end))}</mark>`;
450
+ at = end;
451
+ }
452
+ return out + escapeHtml(value.slice(at));
453
+ }
454
+ function render(parsed) {
455
+ if (!isShowing || !overlay || !resultsList)
456
+ return;
457
+ buildGroups(parsed);
458
+ renderModes(parsed);
459
+ renderChips(parsed);
460
+ renderRows(parsed);
461
+ renderStatus(parsed);
462
+ }
463
+ function renderModes(parsed) {
464
+ if (!modeBar)
465
+ return;
466
+ const empty = queryIsEmpty(parsed);
467
+ const localSum = localCounts.files + localCounts.symbols;
468
+ const codeTotal = (code && code.total) || 0;
469
+ const codeText = codeLoading ? '\u2026' : (code ? String(codeTotal) : '');
470
+ const allText = codeLoading ? `${localSum}+` : String(localSum + codeTotal);
471
+ const chips = [
472
+ ['all', 'All', allText],
473
+ ['files', 'Files', String(localCounts.files)],
474
+ ['symbols', 'Symbols', String(localCounts.symbols)],
475
+ ['code', 'Code', codeText],
476
+ ];
477
+ if (!modeBar.children.length)
478
+ modeBar.innerHTML = chips.map(([mode, label]) => `<button class="search-mode-chip" data-mode="${mode}" role="tab"><span>${label}</span><span class="search-mode-count"></span></button>`).join('');
479
+ for (const [mode, , count] of chips) {
480
+ const button = modeBar.querySelector(`[data-mode="${mode}"]`);
481
+ const active = mode === activeMode;
482
+ button.classList.toggle('is-active', active);
483
+ button.setAttribute('aria-selected', String(active));
484
+ button.tabIndex = active ? 0 : -1;
485
+ const badge = button.querySelector('.search-mode-count');
486
+ badge.hidden = empty || count === '';
487
+ badge.textContent = count;
488
+ }
489
+ const toggle = overlay.querySelector('.search-toggle');
490
+ toggle.classList.toggle('is-active', caseSensitive);
491
+ toggle.setAttribute('aria-pressed', String(caseSensitive));
492
+ }
493
+ function renderChips(parsed) {
494
+ if (!chipsBar)
495
+ return;
496
+ // Terms are deliberately not chipped: they are already legible in the input,
497
+ // and a chip per word would double the width of the row to say what you can
498
+ // see. Filters, phrases and regexes are the decisions worth showing — and
499
+ // worth being able to take back with one click. The case toggle is not a
500
+ // clause, so it lives beside the input rather than here; without a chip of
501
+ // its own, an empty query leaves this row hidden.
502
+ const parts = queryIsEmpty(parsed)
503
+ ? []
504
+ : parsed.clauses
505
+ .map((clause, index) => ({ clause, index }))
506
+ .filter((entry) => entry.clause.kind !== 'term')
507
+ .map(({ clause, index }) => {
508
+ const kind = CLAUSE_LABELS[clause.kind] || clause.kind;
509
+ return `<span class="search-chip"><span class="search-chip-kind">${escapeHtml(kind)}</span>`
510
+ + `${escapeHtml(clause.raw)}<button class="search-chip-remove" data-clause="${index}" `
511
+ + `title="Remove this filter" aria-label="Remove this filter">\u00d7</button></span>`;
512
+ });
513
+ const markup = parts.join('');
514
+ // A content response should not replace a filter button that has focus.
515
+ if (markup !== chipMarkup) {
516
+ chipsBar.innerHTML = markup;
517
+ chipMarkup = markup;
518
+ }
519
+ chipsBar.hidden = !parts.length;
520
+ }
521
+ function rowHtml(item, index, parsed) {
522
+ const on = index === selectedIndex;
523
+ const attrs = `class="search-result is-${item.type}${on ? ' is-selected' : ''}" `
524
+ + `role="option" aria-selected="${on}" id="search-option-${index}" data-index="${index}"`;
525
+ if (item.type === 'recent') {
526
+ return `<div ${attrs}><div class="search-item-main">`
527
+ + `<span class="search-ext-badge is-recent">\u21ba</span>`
528
+ + `<span class="search-title"><span class="search-name">${escapeHtml(item.query)}</span>`
529
+ + `<span class="search-dir">recent search</span></span></div></div>`;
530
+ }
531
+ if (item.type === 'example') {
532
+ return `<div ${attrs}><div class="search-item-main">`
533
+ + `<span class="search-ext-badge is-syntax">/</span>`
534
+ + `<span class="search-title"><span class="search-name">${escapeHtml(item.query)}</span>`
535
+ + `<span class="search-dir">${escapeHtml(item.hint || '')}</span></span></div></div>`;
536
+ }
537
+ const path = item.path;
538
+ const name = path.split('/').pop();
539
+ const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
540
+ if (item.type === 'symbol') {
541
+ return `<div ${attrs}><div class="search-item-main">`
542
+ + `<span class="search-ext-badge is-symbol">SYM</span>`
543
+ + `<span class="search-title"><span class="search-name">${highlightAll(item.name, parsed)}</span>`
544
+ + `<span class="search-dir">${escapeHtml(path)}${item.line ? ' \u00b7 L' + item.line : ''}</span></span>`
545
+ + `<span class="search-type-tag is-sym">${escapeHtml(item.kind || 'symbol')}</span>`
546
+ + `</div></div>`;
547
+ }
548
+ if (item.type === 'code') {
549
+ const snippet = item.snippet
550
+ ? `<div class="search-snippet"><code>${highlightAll(item.snippet, parsed)}</code></div>`
551
+ : '';
552
+ return `<div ${attrs}><div class="search-item-main">`
553
+ + `<span class="search-ext-badge is-code">CODE</span>`
554
+ + `<span class="search-title"><span class="search-name">${escapeHtml(name)}</span>`
555
+ + `<span class="search-dir">${escapeHtml(path)}${item.line ? ' \u00b7 Line ' + item.line : ''}</span></span>`
556
+ + `<span class="search-type-tag is-code">match</span>`
557
+ + `</div>${snippet}</div>`;
558
+ }
559
+ return `<div ${attrs}><div class="search-item-main">`
560
+ + `<span class="search-ext-badge">${escapeHtml(extensionOf(path))}</span>`
561
+ + `<span class="search-title"><span class="search-name">${highlightAll(name, parsed)}</span>`
562
+ + `<span class="search-dir">${escapeHtml(dir)}</span></span>`
563
+ + `<span class="search-type-tag">file</span></div></div>`;
564
+ }
565
+ function renderRows(parsed) {
566
+ if (!resultsList)
567
+ return;
568
+ selectedRow = null;
569
+ input?.removeAttribute('aria-activedescendant');
570
+ if (!groups.length) {
571
+ resultsList.innerHTML = emptyState(parsed);
572
+ return;
573
+ }
574
+ if (selectedIndex >= flat.length)
575
+ selectedIndex = Math.max(0, flat.length - 1);
576
+ let index = -1;
577
+ resultsList.innerHTML = groups.map((group) => {
578
+ const rows = group.items.map((item) => {
579
+ index++;
580
+ return rowHtml(item, index, parsed);
581
+ }).join('');
582
+ const count = group.total === undefined ? group.items.length : group.total;
583
+ return `<div class="search-group" role="group" aria-label="${escapeHtml(group.label)}">`
584
+ + `<div class="search-group-head"><span>${escapeHtml(group.label)}</span>`
585
+ + `<span class="search-group-count">${escapeHtml(String(count))}</span></div>`
586
+ + `${rows}</div>`;
587
+ }).join('');
588
+ selectedRow = resultsList.querySelector(`[data-index="${selectedIndex}"]`);
589
+ if (selectedRow)
590
+ input.setAttribute('aria-activedescendant', selectedRow.id);
591
+ }
592
+ function emptyState(parsed) {
593
+ if (parseError) {
594
+ return '<div class="search-empty"><strong>That pattern will not compile.</strong>'
595
+ + `<div class="search-empty-hint">${escapeHtml(parseError)}</div></div>`;
596
+ }
597
+ if (codeLoading) {
598
+ return '<div class="search-empty"><span class="search-spinner"></span> Searching file contents\u2026</div>';
599
+ }
600
+ if (queryIsEmpty(parsed)) {
601
+ return '<div class="search-empty">Type to search this codebase.</div>';
602
+ }
603
+ return `<div class="search-empty">Nothing matched <code>${escapeHtml(parsed.raw.trim())}</code>.`
604
+ + '<div class="search-empty-hint">Try fewer words, or drop a filter — <code>ext:</code>, '
605
+ + '<code>path:</code> and <code>-word</code> all narrow the same list, and <kbd>Alt + ←/→</kbd> '
606
+ + 'switches what kind of result you are looking at.</div></div>';
607
+ }
608
+ function renderStatus(parsed) {
609
+ if (!statusBar)
610
+ return;
611
+ const bits = [];
612
+ if (parseError) {
613
+ bits.push(`<span class="search-err">${escapeHtml(parseError)}</span>`);
614
+ }
615
+ else if (codeError) {
616
+ bits.push(`<span class="search-err">${escapeHtml(codeError)}</span>`);
617
+ }
618
+ else if (queryIsEmpty(parsed)) {
619
+ bits.push('Type to search. <kbd>Tab</kbd> moves focus, <kbd>Esc</kbd> clears or closes.');
620
+ }
621
+ else {
622
+ const total = localCounts.files + localCounts.symbols + ((code && code.total) || 0);
623
+ bits.push(`${total} match${total === 1 ? '' : 'es'}`);
624
+ if (codeLoading)
625
+ bits.push('<span class="search-spinner"></span> searching contents');
626
+ if (code) {
627
+ bits.push(`${code.indexed || 0} files indexed`);
628
+ if (code.capped)
629
+ bits.push(`showing the top ${code.results.length}`);
630
+ // Saying that the filters are enforced is the honest counterpart to the
631
+ // forgiving ranking a plain word search still gets.
632
+ if (code.advanced)
633
+ bits.push('filters enforced');
634
+ }
635
+ const skipped = code && code.stats && code.stats.skipped;
636
+ if (skipped) {
637
+ const count = Object.keys(skipped).reduce((sum, key) => sum + (skipped[key] || 0), 0);
638
+ if (count)
639
+ bits.push(`${count} not indexed`);
640
+ }
641
+ }
642
+ statusBar.innerHTML = `<div class="search-status-line">${bits.join('<span class="search-sep">\u00b7</span>')}</div>`
643
+ + '<div class="search-keys"><span><kbd>\u2191</kbd><kbd>\u2193</kbd> move</span>'
644
+ + '<span><kbd>\u21b5</kbd> open</span><span><kbd>\u2318\u21b5</kbd> code</span>'
645
+ + '<span><kbd>alt</kbd><kbd>←/→</kbd> kind</span><span><kbd>esc</kbd> close</span></div>';
646
+ }
647
+ // ---- interaction --------------------------------------------------------
648
+ let currentParsed = parseQuery('');
649
+ function parsedQuery(raw) {
650
+ return parseQuery(raw, { caseSensitive });
651
+ }
652
+ function moveSelection(delta) {
653
+ if (!flat.length)
654
+ return;
655
+ selectedIndex = Math.min(Math.max(selectedIndex + delta, 0), flat.length - 1);
656
+ paintSelection();
657
+ }
658
+ function paintSelection(scroll = true) {
659
+ if (!resultsList)
660
+ return;
661
+ const next = resultsList.querySelector(`[data-index="${selectedIndex}"]`);
662
+ if (selectedRow && selectedRow !== next) {
663
+ selectedRow.classList.remove('is-selected');
664
+ selectedRow.setAttribute('aria-selected', 'false');
665
+ }
666
+ selectedRow = next;
667
+ if (!next) {
668
+ input.removeAttribute('aria-activedescendant');
669
+ return;
670
+ }
671
+ next.classList.add('is-selected');
672
+ next.setAttribute('aria-selected', 'true');
673
+ input.setAttribute('aria-activedescendant', next.id);
674
+ if (scroll) {
675
+ const row = next.getBoundingClientRect();
676
+ const list = resultsList.getBoundingClientRect();
677
+ if (row.bottom > list.bottom)
678
+ resultsList.scrollTop += row.bottom - list.bottom;
679
+ else if (row.top < list.top)
680
+ resultsList.scrollTop -= list.top - row.top;
681
+ }
682
+ }
683
+ function onDialogKeyDown(event) {
684
+ if (event.key === 'Escape' && event.target !== input) {
685
+ event.preventDefault();
686
+ event.stopPropagation();
687
+ hide();
688
+ return;
689
+ }
690
+ const tab = eventElement(event)?.closest('.search-mode-chip');
691
+ if (tab && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
692
+ event.preventDefault();
693
+ event.stopPropagation();
694
+ const tabs = [...modeBar.querySelectorAll('.search-mode-chip')];
695
+ const at = tabs.indexOf(tab);
696
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1
697
+ : (at + (event.key === 'ArrowLeft' ? -1 : 1) + tabs.length) % tabs.length;
698
+ setMode(tabs[next].dataset.mode);
699
+ tabs[next].focus({ preventScroll: true });
700
+ return;
701
+ }
702
+ // Tab follows the controls and wraps inside the modal.
703
+ if (event.key !== 'Tab')
704
+ return;
705
+ const focusable = [...overlay.querySelectorAll('input, button')].filter((el) => !el.hidden && el.getClientRects().length);
706
+ const at = focusable.indexOf(document.activeElement);
707
+ event.preventDefault();
708
+ event.stopPropagation();
709
+ focusable[(at + (event.shiftKey ? -1 : 1) + focusable.length) % focusable.length]?.focus({ preventScroll: true });
710
+ }
711
+ function onInputKeyDown(event) {
712
+ const key = event.key;
713
+ if (event.isComposing)
714
+ return;
715
+ if (localFrame !== null && ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter'].includes(key))
716
+ runSearch();
717
+ if (key === 'Tab')
718
+ return;
719
+ if (event.altKey && (key === 'ArrowLeft' || key === 'ArrowRight')) {
720
+ event.preventDefault();
721
+ event.stopPropagation();
722
+ return cycleMode(key === 'ArrowLeft' ? -1 : 1);
723
+ }
724
+ // The palette is modal, so the keys it handles stop here instead of reaching
725
+ // the window listener — otherwise one Escape would clear the query and close
726
+ // the palette in the same keystroke.
727
+ if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Tab' || key === 'Home'
728
+ || key === 'End' || key === 'Enter' || key === 'Escape') {
729
+ event.stopPropagation();
730
+ }
731
+ if (key === 'ArrowDown') {
732
+ event.preventDefault();
733
+ return moveSelection(1);
734
+ }
735
+ if (key === 'ArrowUp') {
736
+ event.preventDefault();
737
+ return moveSelection(-1);
738
+ }
739
+ if (key === 'Home') {
740
+ event.preventDefault();
741
+ selectedIndex = 0;
742
+ return paintSelection();
743
+ }
744
+ if (key === 'End') {
745
+ event.preventDefault();
746
+ selectedIndex = Math.max(0, flat.length - 1);
747
+ return paintSelection();
748
+ }
749
+ if (key === 'Enter') {
750
+ event.preventDefault();
751
+ const item = flat[selectedIndex];
752
+ if (item)
753
+ select(item, { forceCode: event.metaKey || event.ctrlKey });
754
+ return undefined;
755
+ }
756
+ if (key === 'Escape') {
757
+ event.preventDefault();
758
+ // One Escape clears the query, a second closes the palette. Losing a long
759
+ // query to a stray keypress is worse than one more keystroke to close.
760
+ if (input && input.value) {
761
+ input.value = '';
762
+ selectedIndex = 0;
763
+ return runSearch();
764
+ }
765
+ return hide();
766
+ }
767
+ if (key === 'Backspace' && input && !input.value) {
768
+ // Backspace on an empty input takes back the last filter, the way a token
769
+ // editor does. There is nothing else for the key to mean here.
770
+ const removable = currentParsed.clauses.filter((clause) => clause.kind !== 'term');
771
+ if (removable.length) {
772
+ event.preventDefault();
773
+ return removeChip(currentParsed.clauses.indexOf(removable[removable.length - 1]));
774
+ }
775
+ }
776
+ return undefined;
777
+ }
778
+ function setMode(mode) {
779
+ if (!mode || mode === activeMode)
780
+ return;
781
+ activeMode = mode;
782
+ selectedIndex = 0;
783
+ runSearch();
784
+ if (input)
785
+ input.focus();
786
+ }
787
+ function cycleMode(delta) {
788
+ const order = ['all', 'files', 'symbols', 'code'];
789
+ const at = order.indexOf(activeMode);
790
+ setMode(order[(at + delta + order.length) % order.length]);
791
+ }
792
+ function toggleCase() {
793
+ caseSensitive = !caseSensitive;
794
+ selectedIndex = 0;
795
+ runSearch();
796
+ if (input)
797
+ input.focus();
798
+ }
799
+ function useQuery(query) {
800
+ if (!input)
801
+ return;
802
+ input.value = query;
803
+ selectedIndex = 0;
804
+ runSearch();
805
+ input.focus();
806
+ }
807
+ function removeChip(index) {
808
+ if (!input)
809
+ return;
810
+ const clause = currentParsed.clauses[index];
811
+ if (!clause)
812
+ return;
813
+ input.value = removeClause(input.value, clause);
814
+ selectedIndex = 0;
815
+ runSearch();
816
+ input.focus();
817
+ }
818
+ function rememberSearch(query) {
819
+ const value = (query || '').trim();
820
+ if (!value)
821
+ return;
822
+ const list = recents.filter((entry) => entry !== value);
823
+ list.unshift(value);
824
+ recents = list.slice(0, MAX_RECENTS);
825
+ writeRecents(recents);
826
+ }
827
+ // Where a result takes you. A content match belongs in the Code tab at the
828
+ // line it matched, and a symbol belongs there too, at the line it was
829
+ // declared — both are facts the scan already knows, so neither should dump
830
+ // you at line 1. A file's natural home is the graph. Cmd+Enter overrides all
831
+ // of it for when the graph is not what you meant.
832
+ function select(item, flags) {
833
+ if (!item)
834
+ return;
835
+ if (item.type === 'example' || item.type === 'recent')
836
+ return useQuery(item.query);
837
+ rememberSearch(lastQuery);
838
+ const toCode = !!((flags && flags.forceCode)
839
+ || item.type === 'symbol' || item.type === 'code');
840
+ hide(false);
841
+ document.dispatchEvent(new CustomEvent('search-select', {
842
+ detail: { path: item.path, line: item.line, target: toCode ? 'code' : 'files' },
843
+ }));
844
+ return undefined;
845
+ }
846
+ function show(opener) {
847
+ if (isShowing)
848
+ return;
849
+ buildShell();
850
+ // Pointer clicks do not focus buttons in WebKit; remember the explicit trigger.
851
+ focusBefore = opener || (document.activeElement instanceof HTMLElement ? document.activeElement : null);
852
+ recents = readRecents();
853
+ selectedIndex = 0;
854
+ for (const el of document.querySelectorAll('.topbar, #landing, #explorer')) {
855
+ background.set(el, el.inert);
856
+ el.inert = true;
857
+ }
858
+ overlay.hidden = false;
859
+ isShowing = true;
860
+ if (input)
861
+ input.value = '';
862
+ runSearch();
863
+ input?.focus({ preventScroll: true });
864
+ document.dispatchEvent(new CustomEvent('search-visibility', { detail: { visible: true } }));
865
+ }
866
+ function hide(restoreFocus = true) {
867
+ const wasShowing = isShowing;
868
+ requestSeq++;
869
+ inFlight?.abort();
338
870
  inFlight = null;
339
- render(currentParsed);
340
- }
341
- }
342
- }
343
-
344
- // The whole keystroke-to-pixels path. Local work first, so results are on
345
- // screen before the request for the slower half has even been sent.
346
- function runSearch() {
347
- const raw = input ? input.value.trim() : '';
348
- lastQuery = raw;
349
- const parsed = parsedQuery(raw);
350
- currentParsed = parsed;
351
- parseError = parsed.error;
352
-
353
- const local = collectLocal(parsed);
354
- localFiles = local.files;
355
- localSymbols = local.symbols;
356
- localCounts = { files: local.files.length, symbols: local.symbols.length };
357
- recents = readRecents();
358
-
359
- // Whatever was asked before this keystroke is stale now, on the wire or not.
360
- requestSeq++;
361
- if (inFlight) {
362
- inFlight.abort();
363
- inFlight = null;
364
- }
365
- clearTimeout(debounceTimer);
366
- code = null;
367
- codeError = null;
368
- codeLoading = false;
369
-
370
- if (!parseError && wantsCode(parsed)) scheduleCodeSearch(raw);
371
- render(parsed);
372
- }
373
-
374
- // ---- what to show -------------------------------------------------------
375
-
376
- function flatten(list) {
377
- const out = [];
378
- for (const group of list) {
379
- for (const item of group.items) out.push(item);
380
- }
381
- return out;
382
- }
383
-
384
- // A content hit on the line a symbol row already names is the same
385
- // destination twice, and the symbol row is the more useful label ("createRouter
386
- // in server/router.js, line 90" beats "a line that contains createRouter").
387
- function codeMatches() {
388
- if (!code || !Array.isArray(code.results)) return [];
389
- const claimed = new Set(localSymbols.map((sym) => `${sym.path}:${sym.line || ''}`));
390
- return code.results
391
- .filter((hit) => hit && hit.path && !claimed.has(`${hit.path}:${hit.line || ''}`))
392
- .map((hit) => ({
393
- type: 'code',
394
- path: hit.path,
395
- line: hit.line,
396
- snippet: hit.snippet || '',
397
- score: hit.score || 0,
398
- }));
399
- }
400
-
401
- function buildGroups(parsed) {
402
- const list = [];
403
-
404
- if (queryIsEmpty(parsed)) {
405
- // Nothing typed: offer what was typed before, then the way in, then the
406
- // syntax — which is the only place the advanced grammar is discoverable.
407
- if (recents.length) {
408
- list.push({
409
- key: 'recents',
410
- label: 'Recent searches',
411
- items: recents.map((query, i) => ({ type: 'recent', query, score: MAX_RECENTS - i })),
412
- });
413
- }
414
- if (fileList.length) {
415
- list.push({
416
- key: 'start',
417
- label: 'Start somewhere',
418
- items: fileList.slice(0, 8).map((file) => ({ type: 'file', path: getPath(file), score: 1 })),
419
- });
420
- }
421
- list.push({
422
- key: 'examples',
423
- label: 'Search syntax',
424
- items: EXAMPLES.map((example) => ({
425
- type: 'example', query: example.query, hint: example.hint, score: 1,
426
- })),
427
- });
428
- groups = list;
429
- flat = flatten(list);
430
- return;
431
- }
432
-
433
- const showFiles = activeMode === 'all' || activeMode === 'files';
434
- const showSymbols = activeMode === 'all' || activeMode === 'symbols';
435
- const showCode = activeMode === 'all' || activeMode === 'code';
436
-
437
- if (showFiles && localFiles.length) {
438
- list.push({ key: 'files', label: 'Files', total: localCounts.files, items: localFiles.slice(0, PREVIEW_LIMIT) });
439
- }
440
- if (showSymbols && localSymbols.length) {
441
- list.push({ key: 'symbols', label: 'Symbols', total: localCounts.symbols, items: localSymbols.slice(0, PREVIEW_LIMIT) });
442
- }
443
- if (showCode) {
444
- const hits = codeMatches();
445
- if (hits.length) {
446
- list.push({
447
- key: 'code',
448
- label: 'Code matches',
449
- total: (code && code.total) || hits.length,
450
- items: hits.slice(0, PREVIEW_LIMIT),
451
- });
452
- }
453
- }
454
-
455
- groups = list;
456
- flat = flatten(list);
457
- }
458
-
459
- // ---- painting -----------------------------------------------------------
460
-
461
- // Every span the query matched, not just the first. The old highlighter
462
- // stopped at one occurrence, which made a two-word query look like it had
463
- // only matched half of itself.
464
- function highlightAll(text, parsed) {
465
- const value = String(text ?? '');
466
- const ranges = highlightRanges(value, parsed);
467
- if (!ranges.length) return escapeHtml(value);
468
- let out = '';
469
- let at = 0;
470
- for (const [start, end] of ranges) {
471
- if (start < at) continue; // merged already, but never double-wrap
472
- out += escapeHtml(value.slice(at, start));
473
- out += `<mark class="search-match">${escapeHtml(value.slice(start, end))}</mark>`;
474
- at = end;
475
- }
476
- return out + escapeHtml(value.slice(at));
477
- }
478
-
479
- function render(parsed) {
480
- if (!overlay || !resultsList) return;
481
- buildGroups(parsed);
482
- renderModes(parsed);
483
- renderChips(parsed);
484
- renderRows(parsed);
485
- renderStatus(parsed);
486
- }
487
-
488
- function renderModes(parsed) {
489
- if (!modeBar) return;
490
- const empty = queryIsEmpty(parsed);
491
- const localSum = localCounts.files + localCounts.symbols;
492
- const codeTotal = (code && code.total) || 0;
493
- const codeText = codeLoading ? '\u2026' : (code ? String(codeTotal) : '');
494
- const allText = codeLoading ? `${localSum}+` : String(localSum + codeTotal);
495
-
496
- const chips = [
497
- ['all', 'All', allText],
498
- ['files', 'Files', String(localCounts.files)],
499
- ['symbols', 'Symbols', String(localCounts.symbols)],
500
- ['code', 'Code', codeText],
501
- ];
502
-
503
- modeBar.innerHTML = chips.map(([mode, label, count]) => {
504
- const active = mode === activeMode ? ' is-active' : '';
505
- const showCount = !empty && count !== '';
506
- const badge = showCount ? `<span class="search-mode-count">${escapeHtml(count)}</span>` : '';
507
- return `<button class="search-mode-chip${active}" data-mode="${mode}" role="tab" `
508
- + `aria-selected="${mode === activeMode}">${escapeHtml(label)}${badge}</button>`;
509
- }).join('');
510
- }
511
-
512
- function renderChips(parsed) {
513
- if (!chipsBar) return;
514
- // Terms are deliberately not chipped: they are already legible in the input,
515
- // and a chip per word would double the width of the row to say what you can
516
- // see. Filters, phrases and regexes are the decisions worth showing — and
517
- // worth being able to take back with one click. The case toggle is not a
518
- // clause, so it lives beside the input rather than here; without a chip of
519
- // its own, an empty query leaves this row hidden.
520
- const parts = queryIsEmpty(parsed)
521
- ? []
522
- : parsed.clauses
523
- .map((clause, index) => ({ clause, index }))
524
- .filter((entry) => entry.clause.kind !== 'term')
525
- .map(({ clause, index }) => {
526
- const kind = CLAUSE_LABELS[clause.kind] || clause.kind;
527
- return `<span class="search-chip"><span class="search-chip-kind">${escapeHtml(kind)}</span>`
528
- + `${escapeHtml(clause.raw)}<button class="search-chip-remove" data-clause="${index}" `
529
- + `title="Remove this filter" aria-label="Remove this filter">\u00d7</button></span>`;
871
+ clearTimeout(debounceTimer);
872
+ if (localFrame !== null) {
873
+ cancelAnimationFrame(localFrame);
874
+ localFrame = null;
875
+ }
876
+ if (overlay)
877
+ overlay.hidden = true;
878
+ isShowing = false;
879
+ for (const [el, inert] of background)
880
+ el.inert = inert;
881
+ background.clear();
882
+ if (wasShowing) {
883
+ document.dispatchEvent(new CustomEvent('search-visibility', { detail: { visible: false } }));
884
+ if (restoreFocus && focusBefore?.isConnected)
885
+ focusBefore.focus({ preventScroll: true });
886
+ }
887
+ }
888
+ function destroy() {
889
+ hide();
890
+ listeners.abort();
891
+ if (overlay)
892
+ overlay.replaceChildren();
893
+ localCache.clear();
894
+ }
895
+ function updateFiles(newFiles, newScanId) {
896
+ fileList = Array.isArray(newFiles) ? newFiles : [];
897
+ localIndex = buildLocalSearchIndex(fileList);
898
+ localCache.clear();
899
+ if (newScanId !== undefined)
900
+ scanIdProvider = () => newScanId;
901
+ // A new scan brings a new session, and the old session's content index went
902
+ // with it. So the previous payload is not merely stale — it is unreachable,
903
+ // and showing it would offer hits into a repo that is no longer loaded.
904
+ code = null;
905
+ codeError = null;
906
+ if (isShowing)
907
+ runSearch();
908
+ }
909
+ const controller = {
910
+ show,
911
+ hide,
912
+ destroy,
913
+ updateFiles,
914
+ isShowing: () => isShowing,
915
+ };
916
+ activeInstance = controller;
917
+ // One listener for the life of the page, because Cmd+K has to work before any
918
+ // scan exists. Only one palette is ever live, so the listener talks to the
919
+ // newest controller and *asks* whether it is open, rather than keeping its own
920
+ // idea of that — the old version captured the first instance's flag in a
921
+ // closure, so Cmd+K stopped toggling correctly after a second scan.
922
+ if (!keyListenerAttached) {
923
+ keyListenerAttached = true;
924
+ window.addEventListener('keydown', (event) => {
925
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
926
+ event.preventDefault();
927
+ if (!activeInstance)
928
+ return;
929
+ if (activeInstance.isShowing())
930
+ activeInstance.hide();
931
+ else
932
+ activeInstance.show();
933
+ return;
934
+ }
935
+ if (event.key === 'Escape' && activeInstance && activeInstance.isShowing()) {
936
+ activeInstance.hide();
937
+ }
530
938
  });
531
-
532
- if (!parts.length) {
533
- chipsBar.innerHTML = '';
534
- chipsBar.hidden = true;
535
- return;
536
- }
537
- chipsBar.hidden = false;
538
- chipsBar.innerHTML = parts.join('');
539
- }
540
-
541
- function rowHtml(item, index, parsed) {
542
- const on = index === selectedIndex;
543
- const attrs = `class="search-result is-${item.type}${on ? ' is-selected' : ''}" `
544
- + `role="option" aria-selected="${on}" id="search-option-${index}" data-index="${index}"`;
545
-
546
- if (item.type === 'recent') {
547
- return `<div ${attrs}><div class="search-item-main">`
548
- + `<span class="search-ext-badge is-recent">\u21ba</span>`
549
- + `<span class="search-title"><span class="search-name">${escapeHtml(item.query)}</span>`
550
- + `<span class="search-dir">recent search</span></span></div></div>`;
551
- }
552
-
553
- if (item.type === 'example') {
554
- return `<div ${attrs}><div class="search-item-main">`
555
- + `<span class="search-ext-badge is-syntax">/</span>`
556
- + `<span class="search-title"><span class="search-name">${escapeHtml(item.query)}</span>`
557
- + `<span class="search-dir">${escapeHtml(item.hint || '')}</span></span></div></div>`;
558
- }
559
-
560
- const path = item.path;
561
- const name = path.split('/').pop();
562
- const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
563
-
564
- if (item.type === 'symbol') {
565
- return `<div ${attrs}><div class="search-item-main">`
566
- + `<span class="search-ext-badge is-symbol">SYM</span>`
567
- + `<span class="search-title"><span class="search-name">${highlightAll(item.name, parsed)}</span>`
568
- + `<span class="search-dir">${escapeHtml(path)}${item.line ? ' \u00b7 L' + item.line : ''}</span></span>`
569
- + `<span class="search-type-tag is-sym">${escapeHtml(item.kind || 'symbol')}</span>`
570
- + `</div></div>`;
571
- }
572
-
573
- if (item.type === 'code') {
574
- const snippet = item.snippet
575
- ? `<div class="search-snippet"><code>${highlightAll(item.snippet, parsed)}</code></div>`
576
- : '';
577
- return `<div ${attrs}><div class="search-item-main">`
578
- + `<span class="search-ext-badge is-code">CODE</span>`
579
- + `<span class="search-title"><span class="search-name">${escapeHtml(name)}</span>`
580
- + `<span class="search-dir">${escapeHtml(path)}${item.line ? ' \u00b7 Line ' + item.line : ''}</span></span>`
581
- + `<span class="search-type-tag is-code">match</span>`
582
- + `</div>${snippet}</div>`;
583
- }
584
-
585
- return `<div ${attrs}><div class="search-item-main">`
586
- + `<span class="search-ext-badge">${escapeHtml(extensionOf(path))}</span>`
587
- + `<span class="search-title"><span class="search-name">${highlightAll(name, parsed)}</span>`
588
- + `<span class="search-dir">${escapeHtml(dir)}</span></span>`
589
- + `<span class="search-type-tag">file</span></div></div>`;
590
- }
591
-
592
- function renderRows(parsed) {
593
- if (!resultsList) return;
594
- if (!groups.length) {
595
- resultsList.innerHTML = emptyState(parsed);
596
- return;
597
- }
598
- if (selectedIndex >= flat.length) selectedIndex = Math.max(0, flat.length - 1);
599
-
600
- let index = -1;
601
- resultsList.innerHTML = groups.map((group) => {
602
- const rows = group.items.map((item) => {
603
- index++;
604
- return rowHtml(item, index, parsed);
605
- }).join('');
606
- const count = group.total === undefined ? group.items.length : group.total;
607
- return `<div class="search-group" role="group" aria-label="${escapeHtml(group.label)}">`
608
- + `<div class="search-group-head"><span>${escapeHtml(group.label)}</span>`
609
- + `<span class="search-group-count">${escapeHtml(String(count))}</span></div>`
610
- + `${rows}</div>`;
611
- }).join('');
612
- }
613
-
614
- function emptyState(parsed) {
615
- if (parseError) {
616
- return '<div class="search-empty"><strong>That pattern will not compile.</strong>'
617
- + `<div class="search-empty-hint">${escapeHtml(parseError)}</div></div>`;
618
- }
619
- if (codeLoading) {
620
- return '<div class="search-empty"><span class="search-spinner"></span> Searching file contents\u2026</div>';
621
- }
622
- if (queryIsEmpty(parsed)) {
623
- return '<div class="search-empty">Type to search this codebase.</div>';
624
- }
625
- return `<div class="search-empty">Nothing matched <code>${escapeHtml(parsed.raw.trim())}</code>.`
626
- + '<div class="search-empty-hint">Try fewer words, or drop a filter — <code>ext:</code>, '
627
- + '<code>path:</code> and <code>-word</code> all narrow the same list, and <kbd>Tab</kbd> '
628
- + 'switches what kind of result you are looking at.</div></div>';
629
- }
630
-
631
- function renderStatus(parsed) {
632
- if (!statusBar) return;
633
- const bits = [];
634
-
635
- if (parseError) {
636
- bits.push(`<span class="search-err">${escapeHtml(parseError)}</span>`);
637
- } else if (codeError) {
638
- bits.push(`<span class="search-err">${escapeHtml(codeError)}</span>`);
639
- } else if (queryIsEmpty(parsed)) {
640
- bits.push('Type to search. <kbd>Tab</kbd> changes kind, <kbd>Esc</kbd> closes.');
641
- } else {
642
- const total = localCounts.files + localCounts.symbols + ((code && code.total) || 0);
643
- bits.push(`${total} match${total === 1 ? '' : 'es'}`);
644
- if (codeLoading) bits.push('<span class="search-spinner"></span> searching contents');
645
- if (code) {
646
- bits.push(`${code.indexed || 0} files indexed`);
647
- if (code.capped) bits.push(`showing the top ${code.results.length}`);
648
- // Saying that the filters are enforced is the honest counterpart to the
649
- // forgiving ranking a plain word search still gets.
650
- if (code.advanced) bits.push('filters enforced');
651
- }
652
- const skipped = code && code.stats && code.stats.skipped;
653
- if (skipped) {
654
- const count = Object.keys(skipped).reduce((sum, key) => sum + (skipped[key] || 0), 0);
655
- if (count) bits.push(`${count} not indexed`);
656
- }
657
- }
658
-
659
- statusBar.innerHTML = `<div class="search-status-line">${bits.join('<span class="search-sep">\u00b7</span>')}</div>`
660
- + '<div class="search-keys"><span><kbd>\u2191</kbd><kbd>\u2193</kbd> move</span>'
661
- + '<span><kbd>\u21b5</kbd> open</span><span><kbd>\u2318\u21b5</kbd> code</span>'
662
- + '<span><kbd>tab</kbd> kind</span><span><kbd>esc</kbd> close</span></div>';
663
- }
664
-
665
- // ---- interaction --------------------------------------------------------
666
-
667
- let currentParsed = parseQuery('');
668
-
669
- function parsedQuery(raw) {
670
- return parseQuery(raw, { caseSensitive });
671
- }
672
-
673
- function moveSelection(delta) {
674
- if (!flat.length) return;
675
- selectedIndex = Math.min(Math.max(selectedIndex + delta, 0), flat.length - 1);
676
- paintSelection();
677
- }
678
-
679
- function paintSelection() {
680
- if (!resultsList) return;
681
- const rows = resultsList.querySelectorAll('.search-result');
682
- if (!rows.length) return;
683
- for (let i = 0; i < rows.length; i++) {
684
- const row = rows[i];
685
- const on = Number(row.dataset.index) === selectedIndex;
686
- row.classList.toggle('is-selected', on);
687
- row.setAttribute('aria-selected', String(on));
688
- if (on) row.scrollIntoView({ block: 'nearest' });
689
- }
690
- const selected = rows[selectedIndex];
691
- if (input) input.setAttribute('aria-activedescendant', selected ? selected.id : '');
692
- }
693
-
694
- function onInputKeyDown(event) {
695
- const key = event.key;
696
- // The palette is modal, so the keys it handles stop here instead of reaching
697
- // the window listener — otherwise one Escape would clear the query and close
698
- // the palette in the same keystroke.
699
- if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Tab' || key === 'Home'
700
- || key === 'End' || key === 'Enter' || key === 'Escape') {
701
- event.stopPropagation();
702
- }
703
-
704
- if (key === 'ArrowDown') { event.preventDefault(); return moveSelection(1); }
705
- if (key === 'ArrowUp') { event.preventDefault(); return moveSelection(-1); }
706
- if (key === 'Home') { event.preventDefault(); selectedIndex = 0; return paintSelection(); }
707
- if (key === 'End') {
708
- event.preventDefault();
709
- selectedIndex = Math.max(0, flat.length - 1);
710
- return paintSelection();
711
- }
712
- if (key === 'Tab') { event.preventDefault(); return cycleMode(event.shiftKey ? -1 : 1); }
713
-
714
- if (key === 'Enter') {
715
- event.preventDefault();
716
- const item = flat[selectedIndex];
717
- if (item) select(item, { forceCode: event.metaKey || event.ctrlKey });
718
- return undefined;
719
- }
720
-
721
- if (key === 'Escape') {
722
- event.preventDefault();
723
- // One Escape clears the query, a second closes the palette. Losing a long
724
- // query to a stray keypress is worse than one more keystroke to close.
725
- if (input && input.value) {
726
- input.value = '';
727
- selectedIndex = 0;
728
- return runSearch();
729
- }
730
- return hide();
731
- }
732
-
733
- if (key === 'Backspace' && input && !input.value) {
734
- // Backspace on an empty input takes back the last filter, the way a token
735
- // editor does. There is nothing else for the key to mean here.
736
- const removable = currentParsed.clauses.filter((clause) => clause.kind !== 'term');
737
- if (removable.length) {
738
- event.preventDefault();
739
- return removeChip(currentParsed.clauses.indexOf(removable[removable.length - 1]));
740
- }
741
- }
742
- return undefined;
743
- }
744
-
745
- function setMode(mode) {
746
- if (!mode || mode === activeMode) return;
747
- activeMode = mode;
748
- selectedIndex = 0;
749
- runSearch();
750
- if (input) input.focus();
751
- }
752
-
753
- function cycleMode(delta) {
754
- const order = ['all', 'files', 'symbols', 'code'];
755
- const at = order.indexOf(activeMode);
756
- setMode(order[(at + delta + order.length) % order.length]);
757
- }
758
-
759
- function toggleCase() {
760
- caseSensitive = !caseSensitive;
761
- selectedIndex = 0;
762
- runSearch();
763
- if (input) input.focus();
764
- }
765
-
766
- function useQuery(query) {
767
- if (!input) return;
768
- input.value = query;
769
- selectedIndex = 0;
770
- runSearch();
771
- input.focus();
772
- }
773
-
774
- function removeChip(index) {
775
- if (!input) return;
776
- const clause = currentParsed.clauses[index];
777
- if (!clause) return;
778
- input.value = removeClause(input.value, clause);
779
- selectedIndex = 0;
780
- runSearch();
781
- input.focus();
782
- }
783
-
784
- function rememberSearch(query) {
785
- const value = (query || '').trim();
786
- if (!value) return;
787
- const list = readRecents().filter((entry) => entry !== value);
788
- list.unshift(value);
789
- writeRecents(list);
790
- }
791
-
792
- // Where a result takes you. A content match belongs in the Code tab at the
793
- // line it matched, and a symbol belongs there too, at the line it was
794
- // declared — both are facts the scan already knows, so neither should dump
795
- // you at line 1. A file's natural home is the graph. Cmd+Enter overrides all
796
- // of it for when the graph is not what you meant.
797
- function select(item, flags) {
798
- if (!item) return;
799
- if (item.type === 'example' || item.type === 'recent') return useQuery(item.query);
800
-
801
- rememberSearch(lastQuery);
802
- const toCode = !!((flags && flags.forceCode)
803
- || item.type === 'symbol' || item.type === 'code');
804
- document.dispatchEvent(new CustomEvent('search-select', {
805
- detail: { path: item.path, line: item.line, target: toCode ? 'code' : 'files' },
806
- }));
807
- return hide();
808
- }
809
-
810
- function show() {
811
- buildShell();
812
- recents = readRecents();
813
- overlay.hidden = false;
814
- isShowing = true;
815
- if (input) {
816
- input.value = '';
817
- input.focus();
818
- }
819
- selectedIndex = 0;
820
- runSearch();
821
- }
822
-
823
- function hide() {
824
- if (inFlight) {
825
- inFlight.abort();
826
- inFlight = null;
827
- }
828
- clearTimeout(debounceTimer);
829
- if (overlay) overlay.hidden = true;
830
- isShowing = false;
831
- }
832
-
833
- function updateFiles(newFiles, newScanId) {
834
- fileList = Array.isArray(newFiles) ? newFiles : [];
835
- if (newScanId) scanIdProvider = () => newScanId;
836
- // A new scan brings a new session, and the old session's content index went
837
- // with it. So the previous payload is not merely stale — it is unreachable,
838
- // and showing it would offer hits into a repo that is no longer loaded.
839
- code = null;
840
- codeError = null;
841
- if (isShowing) runSearch();
842
- }
843
-
844
- const controller = {
845
- show,
846
- hide,
847
- updateFiles,
848
- isShowing: () => isShowing,
849
- };
850
- activeInstance = controller;
851
-
852
- // One listener for the life of the page, because Cmd+K has to work before any
853
- // scan exists. Only one palette is ever live, so the listener talks to the
854
- // newest controller and *asks* whether it is open, rather than keeping its own
855
- // idea of that — the old version captured the first instance's flag in a
856
- // closure, so Cmd+K stopped toggling correctly after a second scan.
857
- if (!keyListenerAttached) {
858
- keyListenerAttached = true;
859
- window.addEventListener('keydown', (event) => {
860
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
861
- event.preventDefault();
862
- if (!activeInstance) return;
863
- if (activeInstance.isShowing()) activeInstance.hide();
864
- else activeInstance.show();
865
- return;
866
- }
867
- if (event.key === 'Escape' && activeInstance && activeInstance.isShowing()) {
868
- activeInstance.hide();
869
- }
870
- });
871
- }
872
-
873
- return controller;
939
+ }
940
+ return controller;
874
941
  }
942
+ //# sourceMappingURL=search.js.map