@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.
- package/CHANGELOG.md +49 -0
- package/README.md +0 -1
- package/package.json +1 -1
- package/plugins/dashboard.ts +3 -3
- package/plugins/examples/bookmarks.ts +3 -2
- package/plugins/lib/finder.ts +57 -10
- package/plugins/lib/fresh.d.ts +71 -31
- package/plugins/lib/widgets.ts +8 -0
- package/plugins/live_grep.i18n.json +349 -27
- package/plugins/live_grep.ts +596 -141
- package/plugins/orchestrator.ts +1227 -393
- 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,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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
// status.
|
|
371
|
+
|
|
372
|
+
// Trailing text: truncation indicator + save-matches hint.
|
|
373
|
+
const tail: StyledText[] = [];
|
|
187
374
|
if (lastSearchTruncated) {
|
|
188
|
-
|
|
375
|
+
tail.push({ text: `${MAX_RESULTS}+ matches` });
|
|
189
376
|
}
|
|
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: " " });
|
|
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
|
-
|
|
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
|
|
249
|
-
"
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
286
|
-
"
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
402
|
-
"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|