codebase-onboarder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/bin/onboarder.js +14 -0
  4. package/bin/postinstall.js +6 -0
  5. package/cli/commands.js +436 -0
  6. package/cli/main.js +129 -0
  7. package/cli/prompt.js +101 -0
  8. package/cli/ui.js +55 -0
  9. package/cli/wizard.js +325 -0
  10. package/package.json +48 -0
  11. package/public/app.js +1339 -0
  12. package/public/index.html +455 -0
  13. package/public/js/about.js +284 -0
  14. package/public/js/aiDraft.js +163 -0
  15. package/public/js/analysisPanel.js +270 -0
  16. package/public/js/analysisReport.js +154 -0
  17. package/public/js/api.js +264 -0
  18. package/public/js/atlas.js +98 -0
  19. package/public/js/blameView.js +41 -0
  20. package/public/js/codeTab.js +256 -0
  21. package/public/js/codeViewer.js +184 -0
  22. package/public/js/components/FileChip.js +102 -0
  23. package/public/js/components/MetricSparkline.js +89 -0
  24. package/public/js/components/RiskBadge.js +105 -0
  25. package/public/js/deepAnalysisView.js +496 -0
  26. package/public/js/diagramPane.js +154 -0
  27. package/public/js/diffView.js +352 -0
  28. package/public/js/docsView.js +566 -0
  29. package/public/js/fileSourceBrowser.js +47 -0
  30. package/public/js/flameGraph.js +75 -0
  31. package/public/js/forceGraph.js +592 -0
  32. package/public/js/heatmap.js +185 -0
  33. package/public/js/highlight.js +122 -0
  34. package/public/js/html.js +50 -0
  35. package/public/js/insightsView.js +236 -0
  36. package/public/js/inspector.js +535 -0
  37. package/public/js/llm.js +345 -0
  38. package/public/js/mapView.js +159 -0
  39. package/public/js/markdown.js +119 -0
  40. package/public/js/mindmap.js +289 -0
  41. package/public/js/repoFiles.js +44 -0
  42. package/public/js/sbomView.js +142 -0
  43. package/public/js/scanCache.js +76 -0
  44. package/public/js/search.js +874 -0
  45. package/public/js/serverSettings.js +169 -0
  46. package/public/js/state.js +192 -0
  47. package/public/js/tour.js +17 -0
  48. package/public/js/transitions.js +15 -0
  49. package/public/js/tree.js +200 -0
  50. package/public/js/workflowsView.js +95 -0
  51. package/public/styles.css +2767 -0
  52. package/public/vendor/mermaid.min.js +3587 -0
  53. package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  54. package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  55. package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  56. package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  57. package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  58. package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  59. package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  60. package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  61. package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  62. package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  63. package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  64. package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  65. package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  66. package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  67. package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  68. package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  69. package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  70. package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  71. package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  72. package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  73. package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  74. package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  75. package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  76. package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  77. package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  78. package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  79. package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  80. package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  81. package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  82. package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  83. package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  84. package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  85. package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  86. package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  87. package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  88. package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  89. package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  90. package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  91. package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  92. package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  93. package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  94. package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  95. package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  96. package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  97. package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  98. package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  99. package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  100. package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  101. package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  102. package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  103. package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  104. package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  105. package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  106. package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  107. package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  108. package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  109. package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  110. package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  111. package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  112. package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  113. package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  114. package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  115. package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  116. package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  117. package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  118. package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  119. package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  120. package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  121. package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  122. package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  123. package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  124. package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  125. package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  126. package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  127. package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  128. package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  129. package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  130. package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  131. package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  132. package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  133. package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  134. package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  135. package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  136. package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  137. package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  138. package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  139. package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  140. package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  141. package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  142. package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  143. package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  144. package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  145. package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  146. package/public/vendor/monaco/vs/loader.js +11 -0
  147. package/public/vendor/monaco/worker-boot.js +9 -0
  148. package/server/.fuse_hidden0000000800000001 +36 -0
  149. package/server/apiDiff.js +25 -0
  150. package/server/apiDocs.js +67 -0
  151. package/server/apiFile.js +36 -0
  152. package/server/apiGitBlame.js +63 -0
  153. package/server/apiMcp.js +110 -0
  154. package/server/apiScan.js +119 -0
  155. package/server/apiSearch.js +253 -0
  156. package/server/apiSettings.js +118 -0
  157. package/server/apiTools.js +90 -0
  158. package/server/config.js +227 -0
  159. package/server/fileSourceNode.js +44 -0
  160. package/server/gitClone.js +95 -0
  161. package/server/gitDiff.js +184 -0
  162. package/server/gitHistory.js +110 -0
  163. package/server/htmlText.js +44 -0
  164. package/server/http.js +55 -0
  165. package/server/httpGuards.js +87 -0
  166. package/server/index.js +156 -0
  167. package/server/llmProxy.js +162 -0
  168. package/server/logger.js +29 -0
  169. package/server/mcp/analysis.js +209 -0
  170. package/server/mcp/http.js +213 -0
  171. package/server/mcp/runner.js +278 -0
  172. package/server/mcp/server.js +241 -0
  173. package/server/mcp/standalone.js +42 -0
  174. package/server/mcp/tools.js +683 -0
  175. package/server/paths.js +39 -0
  176. package/server/router.js +218 -0
  177. package/server/searchIndex.js +118 -0
  178. package/server/sessions.js +163 -0
  179. package/server/static.js +59 -0
  180. package/server/tools/install.js +246 -0
  181. package/server/tools/parse.js +170 -0
  182. package/server/tools/platform.js +91 -0
  183. package/server/tools/registry.js +212 -0
  184. package/server/tools/scan.js +136 -0
  185. package/server/tools.js +212 -0
  186. package/server/tunnel.js +92 -0
  187. package/shared/analyzer/docs.js +135 -0
  188. package/shared/analyzer/explainLocal.js +163 -0
  189. package/shared/analyzer/graph.js +461 -0
  190. package/shared/analyzer/health.js +215 -0
  191. package/shared/analyzer/history.js +146 -0
  192. package/shared/analyzer/languages/csharp.js +39 -0
  193. package/shared/analyzer/languages/generic.js +131 -0
  194. package/shared/analyzer/languages/go.js +70 -0
  195. package/shared/analyzer/languages/index.js +43 -0
  196. package/shared/analyzer/languages/java.js +39 -0
  197. package/shared/analyzer/languages/javascript.js +240 -0
  198. package/shared/analyzer/languages/python.js +120 -0
  199. package/shared/analyzer/languages/rust.js +42 -0
  200. package/shared/analyzer/languages/typescript.js +138 -0
  201. package/shared/analyzer/licenses.js +151 -0
  202. package/shared/analyzer/metrics.js +120 -0
  203. package/shared/analyzer/pathUtil.js +69 -0
  204. package/shared/analyzer/patterns.js +272 -0
  205. package/shared/analyzer/scan.js +473 -0
  206. package/shared/analyzer/security.js +187 -0
  207. package/shared/analyzer/services.js +157 -0
  208. package/shared/analyzer/stack.js +173 -0
  209. package/shared/analyzer/tour.js +69 -0
  210. package/shared/analyzer/util.js +123 -0
  211. package/shared/analyzer/workflows.js +143 -0
  212. package/shared/diagram/aiFacts.js +145 -0
  213. package/shared/diagram/aiMermaid.js +59 -0
  214. package/shared/diagram/atlas.js +93 -0
  215. package/shared/diagram/mermaid.js +459 -0
  216. package/shared/search/query.js +518 -0
@@ -0,0 +1,874 @@
1
+ // The search palette: one query language over files, symbols and contents.
2
+ //
3
+ // Three passes, in the order that keeps it feeling instant:
4
+ //
5
+ // 1. Files and symbols come from the scan the tab already holds, so they are
6
+ // filtered and ranked synchronously on every keystroke. No round trip, no
7
+ // spinner, no waiting on a server to tell you the file you are looking at
8
+ // is the file you are looking at.
9
+ // 2. Code contents come from `/api/search`, which owns the TF-IDF index built
10
+ // once when the scan finished. That request is debounced, abortable and
11
+ // *sequenced*: an answer for a query you have already moved on from can
12
+ // never paint over the newer one.
13
+ // 3. The query itself is parsed by `shared/search/query.js` — the same module
14
+ // the server parses it with — so `ext:js` filters the list here and the
15
+ // contents there, and the two cannot drift apart. That module's header is
16
+ // the grammar reference.
17
+ //
18
+ // The shell is built by this module, into the `#searchOverlay` element that is
19
+ // already in `index.html`. The previous version looked that element up, found
20
+ // it, and returned early on the assumption that something had filled it in —
21
+ // nothing had, so `input` was null and the first keystroke after opening the
22
+ // palette threw. Building the contents here is what makes the palette work at
23
+ // all; the empty overlay in the page is only a placeholder for position.
24
+ //
25
+ // All DOM work happens inside `initSearch`. The module is importable in Node,
26
+ // which is the rule that keeps it testable — see Agent.md.
27
+
28
+ import { escapeHtml } from './html.js';
29
+ import {
30
+ highlightRanges, matchesPathFilters, parseQuery, queryIsEmpty,
31
+ removeClause, scorePath, scoreSymbol,
32
+ } from '/shared/search/query.js';
33
+
34
+ // Remembering what you searched for is a courtesy, never a hard dependency.
35
+ const RECENTS_KEY = 'onboarder:recent-searches';
36
+ const MAX_RECENTS = 8;
37
+
38
+ // Rows drawn per group. The *counts* stay honest — the header says how many
39
+ // there are — but the palette is for jumping somewhere, not for scrolling a
40
+ // list of four hundred files.
41
+ const PREVIEW_LIMIT = 40;
42
+ const CODE_LIMIT = 40;
43
+
44
+ // Long enough that a fast typist makes one request instead of nine, short
45
+ // enough that a slow one never notices.
46
+ const DEBOUNCE_MS = 140;
47
+
48
+ 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' },
55
+ ];
56
+
57
+ // localStorage throws in private windows, and in browser settings that forbid
58
+ // it. A search box that refused to open because it could not remember your last
59
+ // query would be an absurd way to fail, so every access is guarded.
60
+ 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
+ }
69
+ }
70
+
71
+ function writeRecents(list) {
72
+ try {
73
+ localStorage.setItem(RECENTS_KEY, JSON.stringify(list.slice(0, MAX_RECENTS)));
74
+ } catch {
75
+ /* remembering is optional */
76
+ }
77
+ }
78
+
79
+ 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();
84
+ }
85
+
86
+ // The human word for each clause the parser found, used on the chips row.
87
+ const CLAUSE_LABELS = {
88
+ ext: 'ext', path: 'path', kind: 'kind', is: 'is',
89
+ neg: 'not', phrase: 'phrase', regex: 'regex',
90
+ };
91
+
92
+ // One palette is live at a time, and one Cmd+K listener serves them all — it is
93
+ // installed once, on the first `initSearch`, and routes to the newest controller.
94
+ let keyListenerAttached = false;
95
+ let activeInstance = null;
96
+
97
+ 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">
159
+ <div class="search-header">
160
+ <div class="search-inputrow">
161
+ <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
+ <input class="search-input" type="text" role="combobox" aria-expanded="true"
163
+ 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>
166
+ </div>
167
+ <div class="search-modes" role="tablist" aria-label="Result kinds"></div>
168
+ <div class="search-chips"></div>
169
+ </div>
170
+ <div class="search-results" id="searchResultList" role="listbox" aria-label="Search results"></div>
171
+ <div class="search-status"></div>
172
+ </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) {
324
+ 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) {
337
+ codeLoading = false;
338
+ 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>`;
530
+ });
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;
874
+ }