@fresh-editor/fresh-editor 0.3.8 → 0.3.9

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.
@@ -32,17 +32,28 @@
32
32
  */
33
33
 
34
34
  import { Finder, parseGrepOutput } from "./lib/finder.ts";
35
+ import { button, col, raw, row, spacer, styledRow, toggle, wrappingRow } from "./lib/widgets.ts";
35
36
 
36
37
  const editor = getEditor();
37
38
 
39
+ /** The data sources Universal Search can look in. `files` is the
40
+ * classic project-file grep; the others are opt-in scopes layered on
41
+ * top. Each enabled scope contributes tagged matches to one merged
42
+ * result list. See `docs/internal/global-search-ux.md`. */
43
+ type ScopeId = "files" | "ignored" | "buffers" | "terminals" | "diagnostics";
44
+
38
45
  // One Live Grep match. Mirrors the JSON shape ripgrep emits with
39
46
  // `--line-number --column --no-heading`; built-in non-rg providers
40
47
  // (git grep, grep) normalise to this shape via parseGrepOutput.
48
+ // `source` tags which scope produced the match so the result row can
49
+ // show a badge and (later) pick a scope-appropriate open action.
50
+ // Undefined means the classic file source (`files`).
41
51
  interface GrepMatch {
42
52
  file: string;
43
53
  line: number;
44
54
  column: number;
45
55
  content: string;
56
+ source?: ScopeId;
46
57
  }
47
58
 
48
59
  /** Options passed to a provider's `search` callback. */
@@ -53,6 +64,19 @@ export interface SearchOpts {
53
64
  * Returning more is allowed; the Finder caps at its own
54
65
  * `maxResults`. */
55
66
  maxResults: number;
67
+ /** When true, the "Ignored & hidden" scope is on: providers should
68
+ * also search `.gitignore`d / hidden files. Built-in `rg` and
69
+ * `git-grep` honour this; other built-ins (ag/ack/grep) currently
70
+ * ignore it and always search their default set. */
71
+ includeIgnored?: boolean;
72
+ /** When true, match whole words only (rg `-w`, git-grep/grep `-w`).
73
+ * Providers should add the appropriate flag. */
74
+ wholeWord?: boolean;
75
+ /** When true (the default), the query is a regular expression; when
76
+ * false, it's a literal/fixed string the provider must escape (rg
77
+ * `-F`, git-grep/grep `-F`). Custom providers should honour this so
78
+ * the query is interpreted consistently with the toolbar toggle. */
79
+ regex?: boolean;
56
80
  }
57
81
 
58
82
  /** A registered Live Grep backend. */
@@ -104,6 +128,129 @@ declare global {
104
128
  // stream the entire codebase into the overlay.
105
129
  const MAX_RESULTS = 1000;
106
130
 
131
+ // ── Scopes (Universal Search) ─────────────────────────────────────
132
+ //
133
+ // Live Grep is growing into a one-stop search: the user toggles which
134
+ // data sources to look in from the overlay toolbar. `files` is the
135
+ // classic project grep; `ignored`, `buffers`, `diagnostics` layer on
136
+ // top. Toggles are wired through prompt-context keybindings (Alt+…)
137
+ // that resolve to the plugin handlers registered below — no core
138
+ // Action is required (the host dispatches unknown action names as
139
+ // plugin actions). See `docs/internal/global-search-ux.md`.
140
+
141
+ interface ScopeDef {
142
+ id: ScopeId;
143
+ /** i18n key for the toolbar label. */
144
+ labelKey: string;
145
+ /** Plugin action / handler name a keybinding resolves to. */
146
+ action: string;
147
+ /** Short badge shown on a result row from this scope (omitted for
148
+ * `files`, whose rows are the unprefixed default). */
149
+ badge?: string;
150
+ }
151
+
152
+ const SCOPES: ScopeDef[] = [
153
+ { id: "files", labelKey: "scope.files", action: "live_grep_toggle_files" },
154
+ { id: "ignored", labelKey: "scope.ignored", action: "live_grep_toggle_ignored", badge: "ign" },
155
+ { id: "buffers", labelKey: "scope.buffers", action: "live_grep_toggle_buffers", badge: "buf" },
156
+ { id: "terminals", labelKey: "scope.terminals", action: "live_grep_toggle_terminals", badge: "term" },
157
+ { id: "diagnostics", labelKey: "scope.diagnostics", action: "live_grep_toggle_diagnostics", badge: "diag" },
158
+ ];
159
+
160
+ // Default scope set: same as the classic Live Grep, *minus* ignored
161
+ // files (off — they were noisy) and *plus* unsaved open buffers and
162
+ // terminal scrollback (on). `files` on, `ignored` off, `buffers` on,
163
+ // `terminals` on, `diagnostics` off.
164
+ const scopeEnabled: Record<ScopeId, boolean> = {
165
+ files: true,
166
+ ignored: false,
167
+ buffers: true,
168
+ terminals: true,
169
+ diagnostics: false,
170
+ };
171
+
172
+ // True only while our floating overlay is open. The scope-toggle
173
+ // keybindings live in the shared `prompt` context, so they can fire
174
+ // inside *any* prompt; the handlers no-op unless our overlay owns the
175
+ // screen.
176
+ let overlayActive = false;
177
+
178
+ // The most recent query, so Resume can re-open the *same* flow with it
179
+ // pre-filled (rather than a bespoke cached-results overlay).
180
+ let lastQuery = "";
181
+
182
+ // ── Search modes ──────────────────────────────────────────────────
183
+ //
184
+ // Separate from *where* we search (scopes): these control *how* the
185
+ // query is interpreted, and are threaded to every provider (and the
186
+ // JS-side scopes) so each can escape/format it correctly. `regex` is
187
+ // on by default (matches the historical rg/git-grep behaviour);
188
+ // `wholeWord` is off.
189
+ type ModeId = "word" | "regex";
190
+
191
+ interface ModeDef {
192
+ id: ModeId;
193
+ /** Stable widget key for the toolbar toggle. */
194
+ key: string;
195
+ /** i18n key for the toggle label. */
196
+ labelKey: string;
197
+ /** Plugin action a keybinding resolves to (drives the inline accelerator
198
+ * and the Alt+… shortcut, like the scope toggles). */
199
+ action: string;
200
+ }
201
+
202
+ const MODES: ModeDef[] = [
203
+ { id: "word", key: "mode_word", labelKey: "mode.word", action: "live_grep_toggle_word" },
204
+ { id: "regex", key: "mode_regex", labelKey: "mode.regex", action: "live_grep_toggle_regex" },
205
+ ];
206
+
207
+ const searchModes: Record<ModeId, boolean> = {
208
+ word: false,
209
+ regex: true,
210
+ };
211
+
212
+ /** A compiled matcher for the JS-side scopes (buffers, diagnostics).
213
+ * Returns the 1-based column of the first match on a line, or -1. */
214
+ type LineMatcher = (line: string) => number;
215
+
216
+ /** Build a line matcher honouring the current `searchModes`. Smart-case:
217
+ * case-insensitive unless the query has an uppercase letter. An invalid
218
+ * regex matches nothing (the provider scopes surface the rg/grep error;
219
+ * the JS scopes just contribute no rows). */
220
+ function buildLineMatcher(query: string): LineMatcher {
221
+ const smartCaseInsensitive = query === query.toLowerCase();
222
+ if (searchModes.regex) {
223
+ const flags = smartCaseInsensitive ? "i" : "";
224
+ const pattern = searchModes.word ? `\\b(?:${query})\\b` : query;
225
+ let re: RegExp;
226
+ try {
227
+ re = new RegExp(pattern, flags);
228
+ } catch {
229
+ return () => -1;
230
+ }
231
+ return (line) => {
232
+ const m = re.exec(line);
233
+ return m ? m.index + 1 : -1;
234
+ };
235
+ }
236
+ // Literal (fixed-string) matching.
237
+ const needle = smartCaseInsensitive ? query.toLowerCase() : query;
238
+ const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
239
+ return (line) => {
240
+ const hay = smartCaseInsensitive ? line.toLowerCase() : line;
241
+ let from = 0;
242
+ for (;;) {
243
+ const idx = hay.indexOf(needle, from);
244
+ if (idx < 0) return -1;
245
+ if (!searchModes.word) return idx + 1;
246
+ const before = idx > 0 ? hay[idx - 1] : "";
247
+ const after = idx + needle.length < hay.length ? hay[idx + needle.length] : "";
248
+ if (!isWord(before) && !isWord(after)) return idx + 1;
249
+ from = idx + 1;
250
+ }
251
+ };
252
+ }
253
+
107
254
  // ── Registry ──────────────────────────────────────────────────────
108
255
 
109
256
  const providers: LiveGrepProvider[] = [];
@@ -154,58 +301,101 @@ function unregisterProvider(name: string): boolean {
154
301
  return removed;
155
302
  }
156
303
 
157
- function updateOverlayTitle(provider: LiveGrepProvider | null): void {
158
- // The input row's prefix already says "Live grep: …", so the
159
- // frame title doesn't repeat the feature name it's reserved
160
- // for the active provider plus shortcut hints. Shortcuts come
161
- // from the keybinding registry (not hardcoded) so labels match
162
- // the user's actual binds. Each segment carries its own theme
163
- // key (`ui.help_key_fg` for keys, `ui.popup_border_fg` for
164
- // separators) so the renderer doesn't have to parse the title.
165
- // `resume_live_grep` is intentionally NOT shown here it only
166
- // matters once the prompt is closed; it's surfaced in the
167
- // status bar at that point instead.
168
- const sepStyle = { fg: "ui.popup_border_fg" };
304
+ // Build the scope toolbar as real `Toggle` widgets (themed + clickable),
305
+ // each keyed to the plugin action it fires on click the host maps a click
306
+ // straight to that action, the same one the Alt+… binding triggers. The
307
+ // per-control accelerator (`⌥L` etc.) is rendered right after its toggle in
308
+ // the keybinding-hint colour, so the affordance sits at the control rather
309
+ // than in a footer list.
310
+ function buildToolbarSpec(provider: LiveGrepProvider | null): WidgetSpec {
311
+ // Three stacked rows: the search *sources* ("Search in: …"), the search
312
+ // *modes* ("Match: …"), and a *meta* row (active provider, match-count,
313
+ // provider-cycle / save hints). Each toggle is a nested non-wrapping row
314
+ // an atomic group of `toggle + accelerator` — so the wrapping parent never
315
+ // splits a label from its `Alt+…` hint across lines.
316
+ const prefix = (text: string): WidgetSpec =>
317
+ raw([styledRow([{ text, style: { fg: "ui.popup_border_fg" } }])]);
318
+
319
+ const sources: WidgetSpec[] = [spacer(1), prefix(editor.t("label.search_in"))];
320
+ SCOPES.forEach((s) => {
321
+ sources.push(spacer(2));
322
+ const parts: WidgetSpec[] = [
323
+ toggle(scopeEnabled[s.id], editor.t(s.labelKey), { key: s.id }),
324
+ ];
325
+ const accel = editor.getKeybindingLabel(s.action, "prompt");
326
+ if (accel) {
327
+ parts.push(raw([styledRow([{ text: ` ${accel}`, style: { fg: "ui.help_key_fg" } }])]));
328
+ }
329
+ sources.push(row(...parts));
330
+ });
331
+
332
+ const modes: WidgetSpec[] = [spacer(1), prefix(editor.t("label.match"))];
333
+ MODES.forEach((m) => {
334
+ modes.push(spacer(2));
335
+ const parts: WidgetSpec[] = [
336
+ toggle(searchModes[m.id], editor.t(m.labelKey), { key: m.key }),
337
+ ];
338
+ const accel = editor.getKeybindingLabel(m.action, "prompt");
339
+ if (accel) {
340
+ parts.push(raw([styledRow([{ text: ` ${accel}`, style: { fg: "ui.help_key_fg" } }])]));
341
+ }
342
+ modes.push(row(...parts));
343
+ });
344
+
345
+ const rows: WidgetSpec[] = [wrappingRow(...sources), wrappingRow(...modes)];
346
+ const metaRow = buildMetaRow(provider);
347
+ if (metaRow) rows.push(metaRow);
348
+ return col(...rows);
349
+ }
350
+
351
+ // Meta row (beneath the toggles): the active provider as a focusable/clickable
352
+ // button (cycles backends) with its Alt+P accelerator inline, plus the
353
+ // truncation indicator and the save-matches hint as text. Returns null when
354
+ // there's nothing to show.
355
+ function buildMetaRow(provider: LiveGrepProvider | null): WidgetSpec | null {
169
356
  const hintStyle = { fg: "ui.help_key_fg" };
170
- const segments: StyledText[] = [];
171
- const pushSegment = (parts: StyledText[]) => {
172
- if (segments.length > 0) {
173
- segments.push({ text: " · ", style: sepStyle });
357
+ const sepStyle = { fg: "ui.popup_border_fg" };
358
+ const parts: WidgetSpec[] = [];
359
+
360
+ // Provider button only when a file-backed scope is on (irrelevant when
361
+ // searching only buffers/terminals/diagnostics). The button is keyed
362
+ // "provider"; activating it (click / Space / Alt+P) cycles the backend.
363
+ if (provider && (scopeEnabled.files || scopeEnabled.ignored)) {
364
+ parts.push(raw([styledRow([{ text: "Provider: ", style: sepStyle }])]));
365
+ parts.push(button(provider.name, { key: "provider" }));
366
+ const pAccel = editor.getKeybindingLabel("cycle_live_grep_provider", "prompt");
367
+ if (pAccel) {
368
+ parts.push(raw([styledRow([{ text: ` ${pAccel}`, style: hintStyle }])]));
174
369
  }
175
- segments.push(...parts);
176
- };
177
- if (provider) {
178
- pushSegment([
179
- { text: "Provider: " },
180
- { text: provider.name, style: { bold: true } },
181
- ]);
182
370
  }
183
- // Match-count indicator goes BEFORE the keybinding hints so a
184
- // narrow terminal that truncates the toolbar still shows it —
185
- // the trailing hints are easier to lose than the result-set
186
- // status.
371
+
372
+ // Trailing text: truncation indicator + save-matches hint.
373
+ const tail: StyledText[] = [];
187
374
  if (lastSearchTruncated) {
188
- pushSegment([{ text: `${MAX_RESULTS}+ matches` }]);
375
+ tail.push({ text: `${MAX_RESULTS}+ matches` });
189
376
  }
190
- const pushHint = (key: string | null, label: string) => {
191
- if (!key) return;
192
- pushSegment([
193
- { text: key, style: hintStyle },
194
- { text: ` ${label}` },
195
- ]);
196
- };
197
- pushHint(
198
- editor.getKeybindingLabel("cycle_live_grep_provider", "prompt"),
199
- "switch grep provider"
200
- );
201
- pushHint(
202
- editor.getKeybindingLabel("live_grep_export_quickfix", "prompt"),
203
- "save matches"
204
- );
205
- if (segments.length > 0) {
206
- segments.push({ text: " " });
377
+ const saveKey = editor.getKeybindingLabel("live_grep_export_quickfix", "prompt");
378
+ if (saveKey) {
379
+ if (tail.length > 0) tail.push({ text: " · ", style: sepStyle });
380
+ tail.push({ text: saveKey, style: hintStyle }, { text: " save matches" });
207
381
  }
208
- editor.setPromptTitle(segments);
382
+ if (tail.length > 0) {
383
+ if (parts.length > 0) tail.unshift({ text: " · ", style: sepStyle });
384
+ parts.push(raw([styledRow(tail)]));
385
+ }
386
+
387
+ return parts.length > 0 ? row(...parts) : null;
388
+ }
389
+
390
+ // Refresh the overlay chrome: the scope toolbar (header band) and the footer
391
+ // hints. Name kept as `updateOverlayTitle` for its many call sites; it no
392
+ // longer sets a styled-text title — the widget toolbar replaces it.
393
+ function updateOverlayTitle(provider: LiveGrepProvider | null): void {
394
+ // The provider/meta line lives in the header band (third toolbar row). The
395
+ // footer is left for the Finder's search-status line ("Searching…",
396
+ // "Found N matches", …), which is shown inside the overlay rather than the
397
+ // easy-to-miss editor status bar — so don't touch it here.
398
+ editor.setPromptToolbar(buildToolbarSpec(provider));
209
399
  }
210
400
 
211
401
  async function selectProvider(): Promise<LiveGrepProvider | null> {
@@ -244,25 +434,31 @@ registerProvider({
244
434
  return false;
245
435
  }
246
436
  },
247
- search: async (query, { cwd, maxResults }) => {
248
- const r = await editor.spawnProcess(
249
- "rg",
250
- [
251
- "--line-number",
252
- "--column",
253
- "--no-heading",
254
- "--color=never",
255
- "--smart-case",
256
- `--max-count=${maxResults}`,
257
- "-g", "!.git",
258
- "-g", "!node_modules",
259
- "-g", "!target",
260
- "-g", "!*.lock",
261
- "--",
262
- query,
263
- ],
264
- cwd
265
- );
437
+ search: async (query, { cwd, maxResults, includeIgnored, wholeWord, regex }) => {
438
+ const args = [
439
+ "--line-number",
440
+ "--column",
441
+ "--no-heading",
442
+ "--color=never",
443
+ "--smart-case",
444
+ `--max-count=${maxResults}`,
445
+ // Always skip the VCS metadata dir — even with the Ignored scope
446
+ // on, `.git` internals are never what the user is looking for.
447
+ "-g", "!.git",
448
+ ];
449
+ if (regex === false) args.push("--fixed-strings");
450
+ if (wholeWord) args.push("--word-regexp");
451
+ if (includeIgnored) {
452
+ // Search ignored *and* hidden files (dotfiles). `.git` stays
453
+ // excluded via the glob above.
454
+ args.push("--no-ignore", "--hidden");
455
+ } else {
456
+ // Default: respect ignore files, plus prune the usual heavy
457
+ // build/vendor dirs and lockfiles that bury real hits.
458
+ args.push("-g", "!node_modules", "-g", "!target", "-g", "!*.lock");
459
+ }
460
+ args.push("--", query);
461
+ const r = await editor.spawnProcess("rg", args, cwd);
266
462
  if (r.exit_code === 0) {
267
463
  return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
268
464
  }
@@ -281,24 +477,22 @@ registerProvider({
281
477
  return false;
282
478
  }
283
479
  },
284
- search: async (query, { cwd, maxResults }) => {
285
- const r = await editor.spawnProcess(
286
- "ag",
287
- [
288
- "--column",
289
- "--numbers",
290
- "--nogroup",
291
- "--nocolor",
292
- "--smart-case",
293
- "--ignore", ".git",
294
- "--ignore", "node_modules",
295
- "--ignore", "target",
296
- "--ignore", "*.lock",
297
- "--",
298
- query,
299
- ],
300
- cwd
301
- );
480
+ search: async (query, { cwd, maxResults, wholeWord, regex }) => {
481
+ const args = [
482
+ "--column",
483
+ "--numbers",
484
+ "--nogroup",
485
+ "--nocolor",
486
+ "--smart-case",
487
+ "--ignore", ".git",
488
+ "--ignore", "node_modules",
489
+ "--ignore", "target",
490
+ "--ignore", "*.lock",
491
+ ];
492
+ if (regex === false) args.push("--literal");
493
+ if (wholeWord) args.push("--word-regexp");
494
+ args.push("--", query);
495
+ const r = await editor.spawnProcess("ag", args, cwd);
302
496
  if (r.exit_code === 0 || r.exit_code === 1) {
303
497
  return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
304
498
  }
@@ -329,12 +523,20 @@ registerProvider({
329
523
  return false;
330
524
  }
331
525
  },
332
- search: async (query, { cwd, maxResults }) => {
333
- const r = await editor.spawnProcess(
334
- "git",
335
- ["grep", "-n", "--column", "-I", "-e", query],
336
- cwd
337
- );
526
+ search: async (query, { cwd, maxResults, includeIgnored, wholeWord, regex }) => {
527
+ const args = ["grep", "-n", "--column", "-I"];
528
+ // Default git-grep is basic regex; use extended when regex is on, or
529
+ // fixed-strings when it's off so the query is matched literally.
530
+ args.push(regex === false ? "-F" : "-E");
531
+ if (wholeWord) args.push("-w");
532
+ if (includeIgnored) {
533
+ // Widen beyond tracked files: include untracked, and stop
534
+ // honouring the standard ignore files so `.gitignore`d content
535
+ // is searched too.
536
+ args.push("--untracked", "--no-exclude-standard");
537
+ }
538
+ args.push("-e", query);
539
+ const r = await editor.spawnProcess("git", args, cwd);
338
540
  // git grep exits 1 when no matches — treat as empty, not error.
339
541
  if (r.exit_code === 0 || r.exit_code === 1) {
340
542
  return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
@@ -357,18 +559,12 @@ registerProvider({
357
559
  return false;
358
560
  }
359
561
  },
360
- search: async (query, { cwd, maxResults }) => {
361
- const r = await editor.spawnProcess(
362
- "ack",
363
- [
364
- "--nocolor",
365
- "--column",
366
- "--smart-case",
367
- "--",
368
- query,
369
- ],
370
- cwd
371
- );
562
+ search: async (query, { cwd, maxResults, wholeWord, regex }) => {
563
+ const args = ["--nocolor", "--column", "--smart-case"];
564
+ if (regex === false) args.push("--literal");
565
+ if (wholeWord) args.push("--word-regexp");
566
+ args.push("--", query);
567
+ const r = await editor.spawnProcess("ack", args, cwd);
372
568
  if (r.exit_code === 0 || r.exit_code === 1) {
373
569
  return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
374
570
  }
@@ -397,21 +593,18 @@ registerProvider({
397
593
  return false;
398
594
  }
399
595
  },
400
- search: async (query, { cwd, maxResults }) => {
401
- const r = await editor.spawnProcess(
402
- "grep",
403
- [
404
- "-rn",
405
- "-I",
406
- "--exclude-dir=.git",
407
- "--exclude-dir=node_modules",
408
- "--exclude-dir=target",
409
- "--",
410
- query,
411
- ".",
412
- ],
413
- cwd
414
- );
596
+ search: async (query, { cwd, maxResults, wholeWord, regex }) => {
597
+ const args = [
598
+ "-rn",
599
+ "-I",
600
+ "--exclude-dir=.git",
601
+ "--exclude-dir=node_modules",
602
+ "--exclude-dir=target",
603
+ ];
604
+ args.push(regex === false ? "-F" : "-E");
605
+ if (wholeWord) args.push("-w");
606
+ args.push("--", query, ".");
607
+ const r = await editor.spawnProcess("grep", args, cwd);
415
608
  if (r.exit_code === 0 || r.exit_code === 1) {
416
609
  // grep emits `path:line:content` (no column). parseGrepOutput's
417
610
  // 3-field fallback handles the missing column (defaults to 1).
@@ -423,10 +616,16 @@ registerProvider({
423
616
 
424
617
  // ── Wiring ──────────────────────────────────────────────────────
425
618
 
619
+ function badgeFor(source: ScopeId | undefined): string {
620
+ if (!source || source === "files") return "";
621
+ const def = SCOPES.find((s) => s.id === source);
622
+ return def?.badge ? `[${def.badge}] ` : "";
623
+ }
624
+
426
625
  const finder = new Finder<GrepMatch>(editor, {
427
626
  id: "live-grep",
428
627
  format: (match) => ({
429
- label: `${match.file}:${match.line}`,
628
+ label: `${badgeFor(match.source)}${match.file}:${match.line}`,
430
629
  description:
431
630
  match.content.length > 60
432
631
  ? match.content.substring(0, 57).trim() + "..."
@@ -437,6 +636,9 @@ const finder = new Finder<GrepMatch>(editor, {
437
636
  column: match.column,
438
637
  },
439
638
  }),
639
+ onClose: () => {
640
+ overlayActive = false;
641
+ },
440
642
  // Override the Finder's default "open file + status: Opened X"
441
643
  // so we can surface the resume shortcut here. The shortcut is
442
644
  // hidden inside the overlay (it can't apply while the overlay
@@ -531,41 +733,272 @@ editor.registerCommand(
531
733
  null
532
734
  );
533
735
 
534
- async function search(query: string): Promise<GrepMatch[]> {
535
- const provider = await selectProvider();
536
- if (!provider) {
537
- // Throw rather than return [] — the Finder catches and shows
538
- // the message inside the overlay. Returning [] would be
539
- // indistinguishable from a real "no matches" result.
540
- throw new Error(
541
- "no search backend available — install ripgrep, or register a provider via init.ts (`editor.getPluginApi(\"live-grep\")?.registerProvider(...)`)."
542
- );
736
+ // Don't pull whole multi-MB buffers across the FFI boundary to grep
737
+ // them line-by-line in JS — cap at a sane size and skip the rest.
738
+ const MAX_BUFFER_SCAN_BYTES = 2_000_000;
739
+
740
+ // Strip ANSI escape sequences so terminal scrollback (stored with
741
+ // colour codes in the backing file) shows as plain text in results.
742
+ function stripAnsi(s: string): string {
743
+ return s
744
+ // CSI … final byte (colours, cursor moves, etc.)
745
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
746
+ // OSC … terminated by BEL or ST
747
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
748
+ // Remaining two-byte escapes
749
+ .replace(/\x1b[@-Z\\-_]/g, "");
750
+ }
751
+
752
+ /** Search terminal scrollback. Terminal backing files live under the
753
+ * current working directory's terminal subdir
754
+ * (`getTerminalDir()` → `<data_dir>/terminals/<encoded-cwd>/`) — open
755
+ * terminals stream their scrollback there live, and closed terminals
756
+ * are retained there too (renamed `*-closed-*.txt`). Scoping to that
757
+ * subdir keeps the search to *this* project / worktree. We grep it
758
+ * with rg (falling back to grep), then strip ANSI for display.
759
+ * Opening a hit opens the backing file at the matched line. */
760
+ async function searchTerminals(query: string, limit: number): Promise<GrepMatch[]> {
761
+ if (limit <= 0) return [];
762
+ const dir = editor.getTerminalDir();
763
+ const cwd = editor.getCwd();
764
+ let raw: GrepMatch[] = [];
765
+ try {
766
+ const rgArgs = [
767
+ "--line-number", "--column", "--no-heading", "--color=never",
768
+ "--smart-case", "--text", `--max-count=${limit}`,
769
+ // Only the rendered `.txt` backing files — not the raw `.log`
770
+ // replay logs, which would double every hit.
771
+ "-g", "*.txt",
772
+ ];
773
+ if (searchModes.regex === false) rgArgs.push("--fixed-strings");
774
+ if (searchModes.word) rgArgs.push("--word-regexp");
775
+ rgArgs.push("--", query, dir);
776
+ const r = await editor.spawnProcess("rg", rgArgs, cwd);
777
+ if (r.exit_code === 0) {
778
+ raw = parseGrepOutput(r.stdout, limit, (m) => editor.debug(m)) as GrepMatch[];
779
+ } else if (r.exit_code !== 1) {
780
+ // rg missing or path error → fall back to grep (-a: treat the
781
+ // ANSI-laden logs as text rather than skipping them as binary).
782
+ const gArgs = ["-rn", "-a", "--include=*.txt"];
783
+ gArgs.push(searchModes.regex === false ? "-F" : "-E");
784
+ if (searchModes.word) gArgs.push("-w");
785
+ gArgs.push("--", query, dir);
786
+ const g = await editor.spawnProcess("grep", gArgs, cwd);
787
+ if (g.exit_code === 0) {
788
+ raw = parseGrepOutput(g.stdout, limit, (m) => editor.debug(m)) as GrepMatch[];
789
+ }
790
+ }
791
+ } catch (e) {
792
+ editor.debug(`[live_grep:terminals] ${e}`);
543
793
  }
544
- const wasTruncated = lastSearchTruncated;
794
+ return raw.slice(0, limit).map((m) => ({
795
+ ...m,
796
+ source: "terminals" as const,
797
+ content: stripAnsi(m.content),
798
+ }));
799
+ }
800
+
801
+ /** Search the text of currently-open, modified file buffers.
802
+ * Scoped to *modified* buffers on purpose: unmodified buffers are
803
+ * already covered by the on-disk file scan, so this surfaces exactly
804
+ * the unsaved edits a disk grep would miss, without double-reporting. */
805
+ async function searchOpenBuffers(
806
+ query: string,
807
+ limit: number,
808
+ includeUnmodified: boolean,
809
+ ): Promise<GrepMatch[]> {
810
+ if (limit <= 0) return [];
811
+ const out: GrepMatch[] = [];
812
+ const matchCol = buildLineMatcher(query);
813
+ for (const b of editor.listBuffers()) {
814
+ if (out.length >= limit) break;
815
+ // Virtual buffers (terminals, panels) and bufferless/unnamed buffers
816
+ // have no on-disk file to navigate to. When the file scan is also
817
+ // running, restrict to *modified* buffers so saved buffers aren't
818
+ // double-reported (the disk grep already covers them); when buffers is
819
+ // the only file-backed scope, search every open buffer so the user
820
+ // actually finds matches.
821
+ if (b.is_virtual || !b.path) continue;
822
+ if (!includeUnmodified && !b.modified) continue;
823
+ if (b.length > MAX_BUFFER_SCAN_BYTES) continue;
824
+ let text: string;
825
+ try {
826
+ text = await editor.getBufferText(b.id, 0, b.length);
827
+ } catch {
828
+ continue;
829
+ }
830
+ const lines = text.split("\n");
831
+ for (let i = 0; i < lines.length && out.length < limit; i++) {
832
+ const col = matchCol(lines[i]);
833
+ if (col > 0) {
834
+ out.push({ file: b.path, line: i + 1, column: col, content: lines[i], source: "buffers" });
835
+ }
836
+ }
837
+ }
838
+ return out;
839
+ }
840
+
841
+ function severityLabel(sev: number | null | undefined): string {
842
+ switch (sev) {
843
+ case 1: return "error";
844
+ case 2: return "warning";
845
+ case 3: return "info";
846
+ case 4: return "hint";
847
+ default: return "diagnostic";
848
+ }
849
+ }
850
+
851
+ /** Search active LSP diagnostics by message text. Matches jump to the
852
+ * diagnostic's range like any other location. */
853
+ function searchDiagnostics(query: string, limit: number): GrepMatch[] {
854
+ if (limit <= 0) return [];
855
+ const out: GrepMatch[] = [];
856
+ const matchCol = buildLineMatcher(query);
857
+ for (const d of editor.getAllDiagnostics()) {
858
+ if (out.length >= limit) break;
859
+ if (matchCol(d.message) <= 0) continue;
860
+ const file = d.uri.startsWith("file://") ? decodeURIComponent(d.uri.slice("file://".length)) : d.uri;
861
+ out.push({
862
+ file,
863
+ line: (d.range?.start?.line ?? 0) + 1,
864
+ column: (d.range?.start?.character ?? 0) + 1,
865
+ content: `${severityLabel(d.severity)}: ${d.message}`,
866
+ source: "diagnostics",
867
+ });
868
+ }
869
+ return out;
870
+ }
871
+
872
+ // Run the project-file grep for the enabled file-backed scopes
873
+ // (`files` / `ignored`). Returns null when no provider is available so
874
+ // the caller can decide whether that's fatal (no other scope on) or
875
+ // merely a skipped source.
876
+ async function searchFiles(query: string): Promise<GrepMatch[] | null> {
877
+ const provider = await selectProvider();
878
+ if (!provider) return null;
545
879
  try {
546
880
  const results = await provider.search(query, {
547
881
  cwd: editor.getCwd(),
548
882
  maxResults: MAX_RESULTS,
883
+ includeIgnored: scopeEnabled.ignored,
884
+ wholeWord: searchModes.word,
885
+ regex: searchModes.regex,
549
886
  });
550
- lastSearchTruncated = results.length >= MAX_RESULTS;
551
- // Refresh the toolbar whenever the truncation indicator
552
- // changes so it appears (or disappears) alongside the new
553
- // results in the same render.
554
- if (lastSearchTruncated !== wasTruncated) {
555
- updateOverlayTitle(provider);
556
- }
557
- return results;
887
+ return results.map((m) => ({ ...m, source: "files" as const }));
558
888
  } catch (e) {
559
- // Log to tracing for diagnostics, then re-throw so the Finder
560
- // surfaces the failure in the overlay itself.
561
889
  editor.error(`[live_grep:${provider.name}] ${e}`);
562
- throw new Error(
563
- `${provider.name}: ${e instanceof Error ? e.message : String(e)}`
564
- );
890
+ throw new Error(`${provider.name}: ${e instanceof Error ? e.message : String(e)}`);
565
891
  }
566
892
  }
567
893
 
568
- function start_live_grep(): void {
894
+ // Fan the query out across every enabled scope and merge into one
895
+ // capped, tagged result list. Order is files → buffers → diagnostics
896
+ // so the most common hits lead.
897
+ async function search(query: string): Promise<GrepMatch[]> {
898
+ lastQuery = query;
899
+ const wasTruncated = lastSearchTruncated;
900
+ const results: GrepMatch[] = [];
901
+ const remaining = () => MAX_RESULTS - results.length;
902
+
903
+ if (scopeEnabled.files || scopeEnabled.ignored) {
904
+ const fileMatches = await searchFiles(query);
905
+ if (fileMatches === null) {
906
+ // No grep backend. Only fatal if there's nothing else to search.
907
+ if (!scopeEnabled.buffers && !scopeEnabled.diagnostics) {
908
+ throw new Error(
909
+ "no search backend available — install ripgrep, or register a provider via init.ts (`editor.getPluginApi(\"live-grep\")?.registerProvider(...)`)."
910
+ );
911
+ }
912
+ } else {
913
+ for (const m of fileMatches) {
914
+ if (results.length >= MAX_RESULTS) break;
915
+ results.push(m);
916
+ }
917
+ }
918
+ }
919
+
920
+ if (scopeEnabled.buffers && remaining() > 0) {
921
+ const filesActive = scopeEnabled.files || scopeEnabled.ignored;
922
+ results.push(...await searchOpenBuffers(query, remaining(), !filesActive));
923
+ }
924
+
925
+ if (scopeEnabled.terminals && remaining() > 0) {
926
+ results.push(...await searchTerminals(query, remaining()));
927
+ }
928
+
929
+ if (scopeEnabled.diagnostics && remaining() > 0) {
930
+ results.push(...searchDiagnostics(query, remaining()));
931
+ }
932
+
933
+ lastSearchTruncated = results.length >= MAX_RESULTS;
934
+ // Refresh the toolbar whenever the truncation indicator changes so
935
+ // it appears (or disappears) alongside the new results.
936
+ if (lastSearchTruncated !== wasTruncated) {
937
+ updateOverlayTitle(cachedSelected ?? null);
938
+ }
939
+ return results;
940
+ }
941
+
942
+ // Scope/mode toggling is host-owned: the host flips the toggle's checked
943
+ // state (on click, Space on the focused toggle, or the Alt+… shortcuts) and
944
+ // emits a `widget_event`; we react here by syncing the scope/mode set,
945
+ // refreshing the meta row, and re-running the search.
946
+ editor.on("widget_event", (args) => {
947
+ if (!overlayActive) return;
948
+ // The provider button (click / Space / Alt+P) cycles the search backend.
949
+ if (args.event_type === "activate" && args.widget_key === "provider") {
950
+ void cycleProvider();
951
+ return;
952
+ }
953
+ if (args.event_type !== "toggle") return;
954
+ const payload = args.payload as { checked?: boolean } | undefined;
955
+ const scope = SCOPES.find((s) => s.id === args.widget_key);
956
+ const mode = MODES.find((m) => m.key === args.widget_key);
957
+ let label: string;
958
+ let on: boolean;
959
+ if (scope) {
960
+ on = payload?.checked ?? !scopeEnabled[scope.id];
961
+ scopeEnabled[scope.id] = on;
962
+ label = editor.t(scope.labelKey);
963
+ } else if (mode) {
964
+ on = payload?.checked ?? !searchModes[mode.id];
965
+ searchModes[mode.id] = on;
966
+ label = editor.t(mode.labelKey);
967
+ } else {
968
+ return;
969
+ }
970
+ // Rebuild the toolbar so the meta row's provider line tracks the file
971
+ // scopes. The host already flipped the toggle visual, but scopeEnabled/
972
+ // searchModes were just synced above, so the rebuilt spec keeps the same
973
+ // checked state (and toolbar_focus persists across setPromptToolbar).
974
+ updateOverlayTitle(cachedSelected ?? null);
975
+ void finder.refresh();
976
+ editor.setStatus(`Search: ${label} ${on ? "on" : "off"}`);
977
+ });
978
+
979
+ // The per-toggle Alt+… shortcuts (and palette entries) just route through the
980
+ // host toggle path, so click / Space / shortcut all converge on the same
981
+ // widget_event above. The action's keybinding label is what the toolbar
982
+ // shows as each toggle's inline accelerator. Sources are keyed by scope id;
983
+ // modes by their widget key.
984
+ for (const s of SCOPES) {
985
+ registerHandler(s.action, () => {
986
+ editor.toggleOverlayToolbarWidget(s.id);
987
+ });
988
+ editor.registerCommand(`%cmd.${s.action}`, `%cmd.${s.action}_desc`, s.action, null);
989
+ }
990
+ for (const m of MODES) {
991
+ registerHandler(m.action, () => {
992
+ editor.toggleOverlayToolbarWidget(m.key);
993
+ });
994
+ editor.registerCommand(`%cmd.${m.action}`, `%cmd.${m.action}_desc`, m.action, null);
995
+ }
996
+
997
+ // Shared open flow for both fresh start and resume. `initialQuery`
998
+ // pre-fills the input (Resume passes the last query) — Resume is just the
999
+ // same flow with prepopulated data, no bespoke overlay.
1000
+ function openLiveGrep(initialQuery: string): void {
1001
+ overlayActive = true;
569
1002
  finder.prompt({
570
1003
  title: editor.t("prompt.live_grep"),
571
1004
  source: {
@@ -575,6 +1008,7 @@ function start_live_grep(): void {
575
1008
  minQueryLength: 2,
576
1009
  },
577
1010
  floatingOverlay: true,
1011
+ ...(initialQuery ? { initialQuery } : {}),
578
1012
  });
579
1013
  // Pre-populate the overlay's frame title with the cached
580
1014
  // provider name (if any) before the user types — avoids the
@@ -589,8 +1023,29 @@ function start_live_grep(): void {
589
1023
  void selectProvider();
590
1024
  }
591
1025
  }
1026
+
1027
+ function start_live_grep(): void {
1028
+ openLiveGrep("");
1029
+ }
592
1030
  registerHandler("start_live_grep", start_live_grep);
593
1031
 
1032
+ // Resume: identical flow, just seeded with the last query so the user
1033
+ // picks up where they left off — same overlay, same toolbar, same scopes.
1034
+ function resume_live_grep(): void {
1035
+ openLiveGrep(lastQuery);
1036
+ }
1037
+ registerHandler("resume_live_grep", resume_live_grep);
1038
+ // Register the action→plugin-context mapping so the core `resume_live_grep`
1039
+ // action (Alt+R / the built-in "Resume Live Grep" palette command) resolves
1040
+ // to this handler. A never-activated custom context keeps it out of the
1041
+ // palette so it doesn't duplicate the core entry.
1042
+ editor.registerCommand(
1043
+ "Live Grep: Resume (internal)",
1044
+ "",
1045
+ "resume_live_grep",
1046
+ "live-grep-internal"
1047
+ );
1048
+
594
1049
  editor.registerCommand(
595
1050
  "%cmd.live_grep",
596
1051
  "%cmd.live_grep_desc",