@fresh-editor/fresh-editor 0.3.8 → 0.3.10

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,134 @@ 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
+ // The most recent merged result set, captured by `search` so the
183
+ // Quickfix export can snapshot exactly what the user is looking at into
184
+ // the dock panel without re-running the search.
185
+ let lastResults: GrepMatch[] = [];
186
+
187
+ // ── Search modes ──────────────────────────────────────────────────
188
+ //
189
+ // Separate from *where* we search (scopes): these control *how* the
190
+ // query is interpreted, and are threaded to every provider (and the
191
+ // JS-side scopes) so each can escape/format it correctly. `regex` is
192
+ // on by default (matches the historical rg/git-grep behaviour);
193
+ // `wholeWord` is off.
194
+ type ModeId = "word" | "regex";
195
+
196
+ interface ModeDef {
197
+ id: ModeId;
198
+ /** Stable widget key for the toolbar toggle. */
199
+ key: string;
200
+ /** i18n key for the toggle label. */
201
+ labelKey: string;
202
+ /** Plugin action a keybinding resolves to (drives the inline accelerator
203
+ * and the Alt+… shortcut, like the scope toggles). */
204
+ action: string;
205
+ }
206
+
207
+ const MODES: ModeDef[] = [
208
+ { id: "word", key: "mode_word", labelKey: "mode.word", action: "live_grep_toggle_word" },
209
+ { id: "regex", key: "mode_regex", labelKey: "mode.regex", action: "live_grep_toggle_regex" },
210
+ ];
211
+
212
+ const searchModes: Record<ModeId, boolean> = {
213
+ word: false,
214
+ regex: true,
215
+ };
216
+
217
+ /** A compiled matcher for the JS-side scopes (buffers, diagnostics).
218
+ * Returns the 1-based column of the first match on a line, or -1. */
219
+ type LineMatcher = (line: string) => number;
220
+
221
+ /** Build a line matcher honouring the current `searchModes`. Smart-case:
222
+ * case-insensitive unless the query has an uppercase letter. An invalid
223
+ * regex matches nothing (the provider scopes surface the rg/grep error;
224
+ * the JS scopes just contribute no rows). */
225
+ function buildLineMatcher(query: string): LineMatcher {
226
+ const smartCaseInsensitive = query === query.toLowerCase();
227
+ if (searchModes.regex) {
228
+ const flags = smartCaseInsensitive ? "i" : "";
229
+ const pattern = searchModes.word ? `\\b(?:${query})\\b` : query;
230
+ let re: RegExp;
231
+ try {
232
+ re = new RegExp(pattern, flags);
233
+ } catch {
234
+ return () => -1;
235
+ }
236
+ return (line) => {
237
+ const m = re.exec(line);
238
+ return m ? m.index + 1 : -1;
239
+ };
240
+ }
241
+ // Literal (fixed-string) matching.
242
+ const needle = smartCaseInsensitive ? query.toLowerCase() : query;
243
+ const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
244
+ return (line) => {
245
+ const hay = smartCaseInsensitive ? line.toLowerCase() : line;
246
+ let from = 0;
247
+ for (;;) {
248
+ const idx = hay.indexOf(needle, from);
249
+ if (idx < 0) return -1;
250
+ if (!searchModes.word) return idx + 1;
251
+ const before = idx > 0 ? hay[idx - 1] : "";
252
+ const after = idx + needle.length < hay.length ? hay[idx + needle.length] : "";
253
+ if (!isWord(before) && !isWord(after)) return idx + 1;
254
+ from = idx + 1;
255
+ }
256
+ };
257
+ }
258
+
107
259
  // ── Registry ──────────────────────────────────────────────────────
108
260
 
109
261
  const providers: LiveGrepProvider[] = [];
@@ -154,58 +306,102 @@ function unregisterProvider(name: string): boolean {
154
306
  return removed;
155
307
  }
156
308
 
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" };
309
+ // Build the scope toolbar as real `Toggle` widgets (themed + clickable),
310
+ // each keyed to the plugin action it fires on click the host maps a click
311
+ // straight to that action, the same one the Alt+… binding triggers. The
312
+ // per-control accelerator (`⌥L` etc.) is rendered right after its toggle in
313
+ // the keybinding-hint colour, so the affordance sits at the control rather
314
+ // than in a footer list.
315
+ function buildToolbarSpec(provider: LiveGrepProvider | null): WidgetSpec {
316
+ // Three stacked rows: the search *sources* ("Search in: …"), the search
317
+ // *modes* ("Match: …"), and a *meta* row (active provider, match-count,
318
+ // provider-cycle / save hints). Each toggle is a nested non-wrapping row
319
+ // an atomic group of `toggle + accelerator` — so the wrapping parent never
320
+ // splits a label from its `Alt+…` hint across lines.
321
+ const prefix = (text: string): WidgetSpec =>
322
+ raw([styledRow([{ text, style: { fg: "ui.suggestion_fg" } }])]);
323
+
324
+ const sources: WidgetSpec[] = [spacer(1), prefix(editor.t("label.search_in"))];
325
+ SCOPES.forEach((s) => {
326
+ sources.push(spacer(2));
327
+ const parts: WidgetSpec[] = [
328
+ toggle(scopeEnabled[s.id], editor.t(s.labelKey), { key: s.id }),
329
+ ];
330
+ const accel = editor.getKeybindingLabel(s.action, "prompt");
331
+ if (accel) {
332
+ parts.push(raw([styledRow([{ text: ` ${accel}`, style: { fg: "ui.help_key_fg" } }])]));
333
+ }
334
+ sources.push(row(...parts));
335
+ });
336
+
337
+ const modes: WidgetSpec[] = [spacer(1), prefix(editor.t("label.match"))];
338
+ MODES.forEach((m) => {
339
+ modes.push(spacer(2));
340
+ const parts: WidgetSpec[] = [
341
+ toggle(searchModes[m.id], editor.t(m.labelKey), { key: m.key }),
342
+ ];
343
+ const accel = editor.getKeybindingLabel(m.action, "prompt");
344
+ if (accel) {
345
+ parts.push(raw([styledRow([{ text: ` ${accel}`, style: { fg: "ui.help_key_fg" } }])]));
346
+ }
347
+ modes.push(row(...parts));
348
+ });
349
+
350
+ const rows: WidgetSpec[] = [wrappingRow(...sources), wrappingRow(...modes)];
351
+ const metaRow = buildMetaRow(provider);
352
+ if (metaRow) rows.push(metaRow);
353
+ return col(...rows);
354
+ }
355
+
356
+ // Meta row (beneath the toggles): the active provider as a focusable/clickable
357
+ // button (cycles backends) with its Alt+P accelerator inline, plus the
358
+ // truncation indicator and the save-matches hint as text. Returns null when
359
+ // there's nothing to show.
360
+ function buildMetaRow(provider: LiveGrepProvider | null): WidgetSpec | null {
169
361
  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 });
362
+ const sepStyle = { fg: "ui.popup_border_fg" };
363
+ const labelStyle = { fg: "ui.suggestion_fg" };
364
+ const parts: WidgetSpec[] = [];
365
+
366
+ // Provider button — only when a file-backed scope is on (irrelevant when
367
+ // searching only buffers/terminals/diagnostics). The button is keyed
368
+ // "provider"; activating it (click / Space / Alt+P) cycles the backend.
369
+ if (provider && (scopeEnabled.files || scopeEnabled.ignored)) {
370
+ parts.push(raw([styledRow([{ text: "Provider: ", style: labelStyle }])]));
371
+ parts.push(button(provider.name, { key: "provider" }));
372
+ const pAccel = editor.getKeybindingLabel("cycle_live_grep_provider", "prompt");
373
+ if (pAccel) {
374
+ parts.push(raw([styledRow([{ text: ` ${pAccel}`, style: hintStyle }])]));
174
375
  }
175
- segments.push(...parts);
176
- };
177
- if (provider) {
178
- pushSegment([
179
- { text: "Provider: " },
180
- { text: provider.name, style: { bold: true } },
181
- ]);
182
376
  }
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.
377
+
378
+ // Trailing text: truncation indicator + save-matches hint.
379
+ const tail: StyledText[] = [];
187
380
  if (lastSearchTruncated) {
188
- pushSegment([{ text: `${MAX_RESULTS}+ matches` }]);
381
+ tail.push({ text: `${MAX_RESULTS}+ matches` });
189
382
  }
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: " " });
383
+ const saveKey = editor.getKeybindingLabel("live_grep_export_quickfix", "prompt");
384
+ if (saveKey) {
385
+ if (tail.length > 0) tail.push({ text: " · ", style: sepStyle });
386
+ tail.push({ text: saveKey, style: hintStyle }, { text: " save matches" });
207
387
  }
208
- editor.setPromptTitle(segments);
388
+ if (tail.length > 0) {
389
+ if (parts.length > 0) tail.unshift({ text: " · ", style: sepStyle });
390
+ parts.push(raw([styledRow(tail)]));
391
+ }
392
+
393
+ return parts.length > 0 ? row(...parts) : null;
394
+ }
395
+
396
+ // Refresh the overlay chrome: the scope toolbar (header band) and the footer
397
+ // hints. Name kept as `updateOverlayTitle` for its many call sites; it no
398
+ // longer sets a styled-text title — the widget toolbar replaces it.
399
+ function updateOverlayTitle(provider: LiveGrepProvider | null): void {
400
+ // The provider/meta line lives in the header band (third toolbar row). The
401
+ // footer is left for the Finder's search-status line ("Searching…",
402
+ // "Found N matches", …), which is shown inside the overlay rather than the
403
+ // easy-to-miss editor status bar — so don't touch it here.
404
+ editor.setPromptToolbar(buildToolbarSpec(provider));
209
405
  }
210
406
 
211
407
  async function selectProvider(): Promise<LiveGrepProvider | null> {
@@ -244,25 +440,31 @@ registerProvider({
244
440
  return false;
245
441
  }
246
442
  },
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
- );
443
+ search: async (query, { cwd, maxResults, includeIgnored, wholeWord, regex }) => {
444
+ const args = [
445
+ "--line-number",
446
+ "--column",
447
+ "--no-heading",
448
+ "--color=never",
449
+ "--smart-case",
450
+ `--max-count=${maxResults}`,
451
+ // Always skip the VCS metadata dir — even with the Ignored scope
452
+ // on, `.git` internals are never what the user is looking for.
453
+ "-g", "!.git",
454
+ ];
455
+ if (regex === false) args.push("--fixed-strings");
456
+ if (wholeWord) args.push("--word-regexp");
457
+ if (includeIgnored) {
458
+ // Search ignored *and* hidden files (dotfiles). `.git` stays
459
+ // excluded via the glob above.
460
+ args.push("--no-ignore", "--hidden");
461
+ } else {
462
+ // Default: respect ignore files, plus prune the usual heavy
463
+ // build/vendor dirs and lockfiles that bury real hits.
464
+ args.push("-g", "!node_modules", "-g", "!target", "-g", "!*.lock");
465
+ }
466
+ args.push("--", query);
467
+ const r = await editor.spawnProcess("rg", args, cwd);
266
468
  if (r.exit_code === 0) {
267
469
  return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
268
470
  }
@@ -281,24 +483,22 @@ registerProvider({
281
483
  return false;
282
484
  }
283
485
  },
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
- );
486
+ search: async (query, { cwd, maxResults, wholeWord, regex }) => {
487
+ const args = [
488
+ "--column",
489
+ "--numbers",
490
+ "--nogroup",
491
+ "--nocolor",
492
+ "--smart-case",
493
+ "--ignore", ".git",
494
+ "--ignore", "node_modules",
495
+ "--ignore", "target",
496
+ "--ignore", "*.lock",
497
+ ];
498
+ if (regex === false) args.push("--literal");
499
+ if (wholeWord) args.push("--word-regexp");
500
+ args.push("--", query);
501
+ const r = await editor.spawnProcess("ag", args, cwd);
302
502
  if (r.exit_code === 0 || r.exit_code === 1) {
303
503
  return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
304
504
  }
@@ -329,12 +529,20 @@ registerProvider({
329
529
  return false;
330
530
  }
331
531
  },
332
- search: async (query, { cwd, maxResults }) => {
333
- const r = await editor.spawnProcess(
334
- "git",
335
- ["grep", "-n", "--column", "-I", "-e", query],
336
- cwd
337
- );
532
+ search: async (query, { cwd, maxResults, includeIgnored, wholeWord, regex }) => {
533
+ const args = ["grep", "-n", "--column", "-I"];
534
+ // Default git-grep is basic regex; use extended when regex is on, or
535
+ // fixed-strings when it's off so the query is matched literally.
536
+ args.push(regex === false ? "-F" : "-E");
537
+ if (wholeWord) args.push("-w");
538
+ if (includeIgnored) {
539
+ // Widen beyond tracked files: include untracked, and stop
540
+ // honouring the standard ignore files so `.gitignore`d content
541
+ // is searched too.
542
+ args.push("--untracked", "--no-exclude-standard");
543
+ }
544
+ args.push("-e", query);
545
+ const r = await editor.spawnProcess("git", args, cwd);
338
546
  // git grep exits 1 when no matches — treat as empty, not error.
339
547
  if (r.exit_code === 0 || r.exit_code === 1) {
340
548
  return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
@@ -357,18 +565,12 @@ registerProvider({
357
565
  return false;
358
566
  }
359
567
  },
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
- );
568
+ search: async (query, { cwd, maxResults, wholeWord, regex }) => {
569
+ const args = ["--nocolor", "--column", "--smart-case"];
570
+ if (regex === false) args.push("--literal");
571
+ if (wholeWord) args.push("--word-regexp");
572
+ args.push("--", query);
573
+ const r = await editor.spawnProcess("ack", args, cwd);
372
574
  if (r.exit_code === 0 || r.exit_code === 1) {
373
575
  return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
374
576
  }
@@ -397,21 +599,18 @@ registerProvider({
397
599
  return false;
398
600
  }
399
601
  },
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
- );
602
+ search: async (query, { cwd, maxResults, wholeWord, regex }) => {
603
+ const args = [
604
+ "-rn",
605
+ "-I",
606
+ "--exclude-dir=.git",
607
+ "--exclude-dir=node_modules",
608
+ "--exclude-dir=target",
609
+ ];
610
+ args.push(regex === false ? "-F" : "-E");
611
+ if (wholeWord) args.push("-w");
612
+ args.push("--", query, ".");
613
+ const r = await editor.spawnProcess("grep", args, cwd);
415
614
  if (r.exit_code === 0 || r.exit_code === 1) {
416
615
  // grep emits `path:line:content` (no column). parseGrepOutput's
417
616
  // 3-field fallback handles the missing column (defaults to 1).
@@ -423,10 +622,16 @@ registerProvider({
423
622
 
424
623
  // ── Wiring ──────────────────────────────────────────────────────
425
624
 
625
+ function badgeFor(source: ScopeId | undefined): string {
626
+ if (!source || source === "files") return "";
627
+ const def = SCOPES.find((s) => s.id === source);
628
+ return def?.badge ? `[${def.badge}] ` : "";
629
+ }
630
+
426
631
  const finder = new Finder<GrepMatch>(editor, {
427
632
  id: "live-grep",
428
633
  format: (match) => ({
429
- label: `${match.file}:${match.line}`,
634
+ label: `${badgeFor(match.source)}${match.file}:${match.line}`,
430
635
  description:
431
636
  match.content.length > 60
432
637
  ? match.content.substring(0, 57).trim() + "..."
@@ -437,6 +642,9 @@ const finder = new Finder<GrepMatch>(editor, {
437
642
  column: match.column,
438
643
  },
439
644
  }),
645
+ onClose: () => {
646
+ overlayActive = false;
647
+ },
440
648
  // Override the Finder's default "open file + status: Opened X"
441
649
  // so we can surface the resume shortcut here. The shortcut is
442
650
  // hidden inside the overlay (it can't apply while the overlay
@@ -459,6 +667,63 @@ const finder = new Finder<GrepMatch>(editor, {
459
667
  maxResults: MAX_RESULTS,
460
668
  });
461
669
 
670
+ // The Quickfix list is just another "list of locations" surface, so it
671
+ // rides the same Finder panel abstraction as Diagnostics and Find
672
+ // References (dockable via `useUtilityDock`, Enter → openFile for free).
673
+ // Exporting hands it a static snapshot of the current matches; the
674
+ // bespoke Rust quickfix buffer it replaced is gone.
675
+ const quickfixFinder = new Finder<GrepMatch>(editor, {
676
+ id: "quickfix",
677
+ format: (match) => ({
678
+ label: `${match.file}:${match.line}:${match.column}`,
679
+ description: match.content.trim(),
680
+ location: {
681
+ file: match.file,
682
+ line: match.line,
683
+ column: match.column,
684
+ },
685
+ }),
686
+ useUtilityDock: true,
687
+ // Keep the Quickfix list docked when jumping to an entry — like Vim's
688
+ // quickfix and VS Code's results list — so the user can step through
689
+ // matches. (Find References / Diagnostics keep the default close.)
690
+ closeOnSelect: false,
691
+ });
692
+
693
+ // Snapshot the current Live Grep results into the Quickfix dock panel.
694
+ // Bound to the `live_grep_export_quickfix` keybinding (Alt+M / Alt+Q,
695
+ // when=prompt) — a plain plugin action now that the Rust action is gone.
696
+ function exportQuickfix(): void {
697
+ if (!overlayActive) return;
698
+ if (lastResults.length === 0) {
699
+ editor.setStatus("No Live Grep results to export");
700
+ return;
701
+ }
702
+ const query = lastQuery;
703
+ const matches = lastResults;
704
+ // Dismiss the host overlay first so the panel opens against the editor
705
+ // pane (which is then routed into the dock), not behind the overlay.
706
+ // `cancelPrompt` is the same teardown Escape triggers; the plugin's
707
+ // own prompt state is cleared via the resulting `prompt_cancelled`
708
+ // hook.
709
+ editor.cancelPrompt();
710
+ void quickfixFinder.panel({
711
+ title: `Quickfix: ${query} (${matches.length} matches)`,
712
+ items: matches,
713
+ });
714
+ }
715
+ registerHandler("live_grep_export_quickfix", exportQuickfix);
716
+ // Register the action→handler mapping so the `live_grep_export_quickfix`
717
+ // keybinding (a PluginAction now) resolves across the plugin boundary.
718
+ // A never-activated context keeps it out of the palette — it's only
719
+ // meaningful from inside the Live Grep overlay.
720
+ editor.registerCommand(
721
+ "Live Grep: Export to Quickfix (internal)",
722
+ "",
723
+ "live_grep_export_quickfix",
724
+ "live-grep-internal"
725
+ );
726
+
462
727
  /**
463
728
  * Switch to the next *available* registered provider, in priority
464
729
  * order, wrapping at the end. Unavailable providers (those whose
@@ -531,41 +796,273 @@ editor.registerCommand(
531
796
  null
532
797
  );
533
798
 
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
- );
799
+ // Don't pull whole multi-MB buffers across the FFI boundary to grep
800
+ // them line-by-line in JS — cap at a sane size and skip the rest.
801
+ const MAX_BUFFER_SCAN_BYTES = 2_000_000;
802
+
803
+ // Strip ANSI escape sequences so terminal scrollback (stored with
804
+ // colour codes in the backing file) shows as plain text in results.
805
+ function stripAnsi(s: string): string {
806
+ return s
807
+ // CSI … final byte (colours, cursor moves, etc.)
808
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
809
+ // OSC … terminated by BEL or ST
810
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
811
+ // Remaining two-byte escapes
812
+ .replace(/\x1b[@-Z\\-_]/g, "");
813
+ }
814
+
815
+ /** Search terminal scrollback. Terminal backing files live under the
816
+ * current working directory's terminal subdir
817
+ * (`getTerminalDir()` → `<data_dir>/terminals/<encoded-cwd>/`) — open
818
+ * terminals stream their scrollback there live, and closed terminals
819
+ * are retained there too (renamed `*-closed-*.txt`). Scoping to that
820
+ * subdir keeps the search to *this* project / worktree. We grep it
821
+ * with rg (falling back to grep), then strip ANSI for display.
822
+ * Opening a hit opens the backing file at the matched line. */
823
+ async function searchTerminals(query: string, limit: number): Promise<GrepMatch[]> {
824
+ if (limit <= 0) return [];
825
+ const dir = editor.getTerminalDir();
826
+ const cwd = editor.getCwd();
827
+ let raw: GrepMatch[] = [];
828
+ try {
829
+ const rgArgs = [
830
+ "--line-number", "--column", "--no-heading", "--color=never",
831
+ "--smart-case", "--text", `--max-count=${limit}`,
832
+ // Only the rendered `.txt` backing files — not the raw `.log`
833
+ // replay logs, which would double every hit.
834
+ "-g", "*.txt",
835
+ ];
836
+ if (searchModes.regex === false) rgArgs.push("--fixed-strings");
837
+ if (searchModes.word) rgArgs.push("--word-regexp");
838
+ rgArgs.push("--", query, dir);
839
+ const r = await editor.spawnProcess("rg", rgArgs, cwd);
840
+ if (r.exit_code === 0) {
841
+ raw = parseGrepOutput(r.stdout, limit, (m) => editor.debug(m)) as GrepMatch[];
842
+ } else if (r.exit_code !== 1) {
843
+ // rg missing or path error → fall back to grep (-a: treat the
844
+ // ANSI-laden logs as text rather than skipping them as binary).
845
+ const gArgs = ["-rn", "-a", "--include=*.txt"];
846
+ gArgs.push(searchModes.regex === false ? "-F" : "-E");
847
+ if (searchModes.word) gArgs.push("-w");
848
+ gArgs.push("--", query, dir);
849
+ const g = await editor.spawnProcess("grep", gArgs, cwd);
850
+ if (g.exit_code === 0) {
851
+ raw = parseGrepOutput(g.stdout, limit, (m) => editor.debug(m)) as GrepMatch[];
852
+ }
853
+ }
854
+ } catch (e) {
855
+ editor.debug(`[live_grep:terminals] ${e}`);
543
856
  }
544
- const wasTruncated = lastSearchTruncated;
857
+ return raw.slice(0, limit).map((m) => ({
858
+ ...m,
859
+ source: "terminals" as const,
860
+ content: stripAnsi(m.content),
861
+ }));
862
+ }
863
+
864
+ /** Search the text of currently-open, modified file buffers.
865
+ * Scoped to *modified* buffers on purpose: unmodified buffers are
866
+ * already covered by the on-disk file scan, so this surfaces exactly
867
+ * the unsaved edits a disk grep would miss, without double-reporting. */
868
+ async function searchOpenBuffers(
869
+ query: string,
870
+ limit: number,
871
+ includeUnmodified: boolean,
872
+ ): Promise<GrepMatch[]> {
873
+ if (limit <= 0) return [];
874
+ const out: GrepMatch[] = [];
875
+ const matchCol = buildLineMatcher(query);
876
+ for (const b of editor.listBuffers()) {
877
+ if (out.length >= limit) break;
878
+ // Virtual buffers (terminals, panels) and bufferless/unnamed buffers
879
+ // have no on-disk file to navigate to. When the file scan is also
880
+ // running, restrict to *modified* buffers so saved buffers aren't
881
+ // double-reported (the disk grep already covers them); when buffers is
882
+ // the only file-backed scope, search every open buffer so the user
883
+ // actually finds matches.
884
+ if (b.is_virtual || !b.path) continue;
885
+ if (!includeUnmodified && !b.modified) continue;
886
+ if (b.length > MAX_BUFFER_SCAN_BYTES) continue;
887
+ let text: string;
888
+ try {
889
+ text = await editor.getBufferText(b.id, 0, b.length);
890
+ } catch {
891
+ continue;
892
+ }
893
+ const lines = text.split("\n");
894
+ for (let i = 0; i < lines.length && out.length < limit; i++) {
895
+ const col = matchCol(lines[i]);
896
+ if (col > 0) {
897
+ out.push({ file: b.path, line: i + 1, column: col, content: lines[i], source: "buffers" });
898
+ }
899
+ }
900
+ }
901
+ return out;
902
+ }
903
+
904
+ function severityLabel(sev: number | null | undefined): string {
905
+ switch (sev) {
906
+ case 1: return "error";
907
+ case 2: return "warning";
908
+ case 3: return "info";
909
+ case 4: return "hint";
910
+ default: return "diagnostic";
911
+ }
912
+ }
913
+
914
+ /** Search active LSP diagnostics by message text. Matches jump to the
915
+ * diagnostic's range like any other location. */
916
+ function searchDiagnostics(query: string, limit: number): GrepMatch[] {
917
+ if (limit <= 0) return [];
918
+ const out: GrepMatch[] = [];
919
+ const matchCol = buildLineMatcher(query);
920
+ for (const d of editor.getAllDiagnostics()) {
921
+ if (out.length >= limit) break;
922
+ if (matchCol(d.message) <= 0) continue;
923
+ const file = d.uri.startsWith("file://") ? decodeURIComponent(d.uri.slice("file://".length)) : d.uri;
924
+ out.push({
925
+ file,
926
+ line: (d.range?.start?.line ?? 0) + 1,
927
+ column: (d.range?.start?.character ?? 0) + 1,
928
+ content: `${severityLabel(d.severity)}: ${d.message}`,
929
+ source: "diagnostics",
930
+ });
931
+ }
932
+ return out;
933
+ }
934
+
935
+ // Run the project-file grep for the enabled file-backed scopes
936
+ // (`files` / `ignored`). Returns null when no provider is available so
937
+ // the caller can decide whether that's fatal (no other scope on) or
938
+ // merely a skipped source.
939
+ async function searchFiles(query: string): Promise<GrepMatch[] | null> {
940
+ const provider = await selectProvider();
941
+ if (!provider) return null;
545
942
  try {
546
943
  const results = await provider.search(query, {
547
944
  cwd: editor.getCwd(),
548
945
  maxResults: MAX_RESULTS,
946
+ includeIgnored: scopeEnabled.ignored,
947
+ wholeWord: searchModes.word,
948
+ regex: searchModes.regex,
549
949
  });
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;
950
+ return results.map((m) => ({ ...m, source: "files" as const }));
558
951
  } catch (e) {
559
- // Log to tracing for diagnostics, then re-throw so the Finder
560
- // surfaces the failure in the overlay itself.
561
952
  editor.error(`[live_grep:${provider.name}] ${e}`);
562
- throw new Error(
563
- `${provider.name}: ${e instanceof Error ? e.message : String(e)}`
564
- );
953
+ throw new Error(`${provider.name}: ${e instanceof Error ? e.message : String(e)}`);
565
954
  }
566
955
  }
567
956
 
568
- function start_live_grep(): void {
957
+ // Fan the query out across every enabled scope and merge into one
958
+ // capped, tagged result list. Order is files → buffers → diagnostics
959
+ // so the most common hits lead.
960
+ async function search(query: string): Promise<GrepMatch[]> {
961
+ lastQuery = query;
962
+ const wasTruncated = lastSearchTruncated;
963
+ const results: GrepMatch[] = [];
964
+ const remaining = () => MAX_RESULTS - results.length;
965
+
966
+ if (scopeEnabled.files || scopeEnabled.ignored) {
967
+ const fileMatches = await searchFiles(query);
968
+ if (fileMatches === null) {
969
+ // No grep backend. Only fatal if there's nothing else to search.
970
+ if (!scopeEnabled.buffers && !scopeEnabled.diagnostics) {
971
+ throw new Error(
972
+ "no search backend available — install ripgrep, or register a provider via init.ts (`editor.getPluginApi(\"live-grep\")?.registerProvider(...)`)."
973
+ );
974
+ }
975
+ } else {
976
+ for (const m of fileMatches) {
977
+ if (results.length >= MAX_RESULTS) break;
978
+ results.push(m);
979
+ }
980
+ }
981
+ }
982
+
983
+ if (scopeEnabled.buffers && remaining() > 0) {
984
+ const filesActive = scopeEnabled.files || scopeEnabled.ignored;
985
+ results.push(...await searchOpenBuffers(query, remaining(), !filesActive));
986
+ }
987
+
988
+ if (scopeEnabled.terminals && remaining() > 0) {
989
+ results.push(...await searchTerminals(query, remaining()));
990
+ }
991
+
992
+ if (scopeEnabled.diagnostics && remaining() > 0) {
993
+ results.push(...searchDiagnostics(query, remaining()));
994
+ }
995
+
996
+ lastSearchTruncated = results.length >= MAX_RESULTS;
997
+ // Refresh the toolbar whenever the truncation indicator changes so
998
+ // it appears (or disappears) alongside the new results.
999
+ if (lastSearchTruncated !== wasTruncated) {
1000
+ updateOverlayTitle(cachedSelected ?? null);
1001
+ }
1002
+ lastResults = results;
1003
+ return results;
1004
+ }
1005
+
1006
+ // Scope/mode toggling is host-owned: the host flips the toggle's checked
1007
+ // state (on click, Space on the focused toggle, or the Alt+… shortcuts) and
1008
+ // emits a `widget_event`; we react here by syncing the scope/mode set,
1009
+ // refreshing the meta row, and re-running the search.
1010
+ editor.on("widget_event", (args) => {
1011
+ if (!overlayActive) return;
1012
+ // The provider button (click / Space / Alt+P) cycles the search backend.
1013
+ if (args.event_type === "activate" && args.widget_key === "provider") {
1014
+ void cycleProvider();
1015
+ return;
1016
+ }
1017
+ if (args.event_type !== "toggle") return;
1018
+ const payload = args.payload as { checked?: boolean } | undefined;
1019
+ const scope = SCOPES.find((s) => s.id === args.widget_key);
1020
+ const mode = MODES.find((m) => m.key === args.widget_key);
1021
+ let label: string;
1022
+ let on: boolean;
1023
+ if (scope) {
1024
+ on = payload?.checked ?? !scopeEnabled[scope.id];
1025
+ scopeEnabled[scope.id] = on;
1026
+ label = editor.t(scope.labelKey);
1027
+ } else if (mode) {
1028
+ on = payload?.checked ?? !searchModes[mode.id];
1029
+ searchModes[mode.id] = on;
1030
+ label = editor.t(mode.labelKey);
1031
+ } else {
1032
+ return;
1033
+ }
1034
+ // Rebuild the toolbar so the meta row's provider line tracks the file
1035
+ // scopes. The host already flipped the toggle visual, but scopeEnabled/
1036
+ // searchModes were just synced above, so the rebuilt spec keeps the same
1037
+ // checked state (and toolbar_focus persists across setPromptToolbar).
1038
+ updateOverlayTitle(cachedSelected ?? null);
1039
+ void finder.refresh();
1040
+ editor.setStatus(`Search: ${label} ${on ? "on" : "off"}`);
1041
+ });
1042
+
1043
+ // The per-toggle Alt+… shortcuts (and palette entries) just route through the
1044
+ // host toggle path, so click / Space / shortcut all converge on the same
1045
+ // widget_event above. The action's keybinding label is what the toolbar
1046
+ // shows as each toggle's inline accelerator. Sources are keyed by scope id;
1047
+ // modes by their widget key.
1048
+ for (const s of SCOPES) {
1049
+ registerHandler(s.action, () => {
1050
+ editor.toggleOverlayToolbarWidget(s.id);
1051
+ });
1052
+ editor.registerCommand(`%cmd.${s.action}`, `%cmd.${s.action}_desc`, s.action, null);
1053
+ }
1054
+ for (const m of MODES) {
1055
+ registerHandler(m.action, () => {
1056
+ editor.toggleOverlayToolbarWidget(m.key);
1057
+ });
1058
+ editor.registerCommand(`%cmd.${m.action}`, `%cmd.${m.action}_desc`, m.action, null);
1059
+ }
1060
+
1061
+ // Shared open flow for both fresh start and resume. `initialQuery`
1062
+ // pre-fills the input (Resume passes the last query) — Resume is just the
1063
+ // same flow with prepopulated data, no bespoke overlay.
1064
+ function openLiveGrep(initialQuery: string): void {
1065
+ overlayActive = true;
569
1066
  finder.prompt({
570
1067
  title: editor.t("prompt.live_grep"),
571
1068
  source: {
@@ -575,6 +1072,7 @@ function start_live_grep(): void {
575
1072
  minQueryLength: 2,
576
1073
  },
577
1074
  floatingOverlay: true,
1075
+ ...(initialQuery ? { initialQuery } : {}),
578
1076
  });
579
1077
  // Pre-populate the overlay's frame title with the cached
580
1078
  // provider name (if any) before the user types — avoids the
@@ -589,8 +1087,29 @@ function start_live_grep(): void {
589
1087
  void selectProvider();
590
1088
  }
591
1089
  }
1090
+
1091
+ function start_live_grep(): void {
1092
+ openLiveGrep("");
1093
+ }
592
1094
  registerHandler("start_live_grep", start_live_grep);
593
1095
 
1096
+ // Resume: identical flow, just seeded with the last query so the user
1097
+ // picks up where they left off — same overlay, same toolbar, same scopes.
1098
+ function resume_live_grep(): void {
1099
+ openLiveGrep(lastQuery);
1100
+ }
1101
+ registerHandler("resume_live_grep", resume_live_grep);
1102
+ // Register the action→plugin-context mapping so the core `resume_live_grep`
1103
+ // action (Alt+R / the built-in "Resume Live Grep" palette command) resolves
1104
+ // to this handler. A never-activated custom context keeps it out of the
1105
+ // palette so it doesn't duplicate the core entry.
1106
+ editor.registerCommand(
1107
+ "Live Grep: Resume (internal)",
1108
+ "",
1109
+ "resume_live_grep",
1110
+ "live-grep-internal"
1111
+ );
1112
+
594
1113
  editor.registerCommand(
595
1114
  "%cmd.live_grep",
596
1115
  "%cmd.live_grep_desc",