@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.
- package/CHANGELOG.md +77 -0
- package/README.md +0 -1
- package/package.json +1 -1
- package/plugins/audit_mode.ts +35 -1
- package/plugins/config-schema.json +9 -3
- package/plugins/dashboard.ts +3 -3
- package/plugins/diagnostics_panel.ts +10 -0
- package/plugins/env-manager.i18n.json +338 -0
- package/plugins/env-manager.ts +23 -33
- package/plugins/examples/bookmarks.ts +3 -2
- package/plugins/lib/finder.ts +101 -17
- package/plugins/lib/fresh.d.ts +100 -42
- package/plugins/lib/widgets.ts +8 -0
- package/plugins/live_diff.ts +12 -1
- package/plugins/live_grep.i18n.json +349 -27
- package/plugins/live_grep.ts +660 -141
- package/plugins/markdown_compose.ts +3 -1
- package/plugins/orchestrator.ts +1493 -504
- package/plugins/schemas/theme.schema.json +15 -2
- package/plugins/search_replace.ts +70 -28
- package/plugins/theme_editor.i18n.json +28 -0
- package/plugins/vi_mode.ts +3 -3
package/plugins/live_grep.ts
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
// status.
|
|
377
|
+
|
|
378
|
+
// Trailing text: truncation indicator + save-matches hint.
|
|
379
|
+
const tail: StyledText[] = [];
|
|
187
380
|
if (lastSearchTruncated) {
|
|
188
|
-
|
|
381
|
+
tail.push({ text: `${MAX_RESULTS}+ matches` });
|
|
189
382
|
}
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
249
|
-
"
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
286
|
-
"
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
402
|
-
"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|