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.
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/bin/onboarder.js +14 -0
- package/bin/postinstall.js +6 -0
- package/cli/commands.js +436 -0
- package/cli/main.js +129 -0
- package/cli/prompt.js +101 -0
- package/cli/ui.js +55 -0
- package/cli/wizard.js +325 -0
- package/package.json +48 -0
- package/public/app.js +1339 -0
- package/public/index.html +455 -0
- package/public/js/about.js +284 -0
- package/public/js/aiDraft.js +163 -0
- package/public/js/analysisPanel.js +270 -0
- package/public/js/analysisReport.js +154 -0
- package/public/js/api.js +264 -0
- package/public/js/atlas.js +98 -0
- package/public/js/blameView.js +41 -0
- package/public/js/codeTab.js +256 -0
- package/public/js/codeViewer.js +184 -0
- package/public/js/components/FileChip.js +102 -0
- package/public/js/components/MetricSparkline.js +89 -0
- package/public/js/components/RiskBadge.js +105 -0
- package/public/js/deepAnalysisView.js +496 -0
- package/public/js/diagramPane.js +154 -0
- package/public/js/diffView.js +352 -0
- package/public/js/docsView.js +566 -0
- package/public/js/fileSourceBrowser.js +47 -0
- package/public/js/flameGraph.js +75 -0
- package/public/js/forceGraph.js +592 -0
- package/public/js/heatmap.js +185 -0
- package/public/js/highlight.js +122 -0
- package/public/js/html.js +50 -0
- package/public/js/insightsView.js +236 -0
- package/public/js/inspector.js +535 -0
- package/public/js/llm.js +345 -0
- package/public/js/mapView.js +159 -0
- package/public/js/markdown.js +119 -0
- package/public/js/mindmap.js +289 -0
- package/public/js/repoFiles.js +44 -0
- package/public/js/sbomView.js +142 -0
- package/public/js/scanCache.js +76 -0
- package/public/js/search.js +874 -0
- package/public/js/serverSettings.js +169 -0
- package/public/js/state.js +192 -0
- package/public/js/tour.js +17 -0
- package/public/js/transitions.js +15 -0
- package/public/js/tree.js +200 -0
- package/public/js/workflowsView.js +95 -0
- package/public/styles.css +2767 -0
- package/public/vendor/mermaid.min.js +3587 -0
- package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/public/vendor/monaco/vs/loader.js +11 -0
- package/public/vendor/monaco/worker-boot.js +9 -0
- package/server/.fuse_hidden0000000800000001 +36 -0
- package/server/apiDiff.js +25 -0
- package/server/apiDocs.js +67 -0
- package/server/apiFile.js +36 -0
- package/server/apiGitBlame.js +63 -0
- package/server/apiMcp.js +110 -0
- package/server/apiScan.js +119 -0
- package/server/apiSearch.js +253 -0
- package/server/apiSettings.js +118 -0
- package/server/apiTools.js +90 -0
- package/server/config.js +227 -0
- package/server/fileSourceNode.js +44 -0
- package/server/gitClone.js +95 -0
- package/server/gitDiff.js +184 -0
- package/server/gitHistory.js +110 -0
- package/server/htmlText.js +44 -0
- package/server/http.js +55 -0
- package/server/httpGuards.js +87 -0
- package/server/index.js +156 -0
- package/server/llmProxy.js +162 -0
- package/server/logger.js +29 -0
- package/server/mcp/analysis.js +209 -0
- package/server/mcp/http.js +213 -0
- package/server/mcp/runner.js +278 -0
- package/server/mcp/server.js +241 -0
- package/server/mcp/standalone.js +42 -0
- package/server/mcp/tools.js +683 -0
- package/server/paths.js +39 -0
- package/server/router.js +218 -0
- package/server/searchIndex.js +118 -0
- package/server/sessions.js +163 -0
- package/server/static.js +59 -0
- package/server/tools/install.js +246 -0
- package/server/tools/parse.js +170 -0
- package/server/tools/platform.js +91 -0
- package/server/tools/registry.js +212 -0
- package/server/tools/scan.js +136 -0
- package/server/tools.js +212 -0
- package/server/tunnel.js +92 -0
- package/shared/analyzer/docs.js +135 -0
- package/shared/analyzer/explainLocal.js +163 -0
- package/shared/analyzer/graph.js +461 -0
- package/shared/analyzer/health.js +215 -0
- package/shared/analyzer/history.js +146 -0
- package/shared/analyzer/languages/csharp.js +39 -0
- package/shared/analyzer/languages/generic.js +131 -0
- package/shared/analyzer/languages/go.js +70 -0
- package/shared/analyzer/languages/index.js +43 -0
- package/shared/analyzer/languages/java.js +39 -0
- package/shared/analyzer/languages/javascript.js +240 -0
- package/shared/analyzer/languages/python.js +120 -0
- package/shared/analyzer/languages/rust.js +42 -0
- package/shared/analyzer/languages/typescript.js +138 -0
- package/shared/analyzer/licenses.js +151 -0
- package/shared/analyzer/metrics.js +120 -0
- package/shared/analyzer/pathUtil.js +69 -0
- package/shared/analyzer/patterns.js +272 -0
- package/shared/analyzer/scan.js +473 -0
- package/shared/analyzer/security.js +187 -0
- package/shared/analyzer/services.js +157 -0
- package/shared/analyzer/stack.js +173 -0
- package/shared/analyzer/tour.js +69 -0
- package/shared/analyzer/util.js +123 -0
- package/shared/analyzer/workflows.js +143 -0
- package/shared/diagram/aiFacts.js +145 -0
- package/shared/diagram/aiMermaid.js +59 -0
- package/shared/diagram/atlas.js +93 -0
- package/shared/diagram/mermaid.js +459 -0
- 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
|
+
}
|