@fresh-editor/fresh-editor 0.3.1 → 0.3.4
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 +138 -0
- package/package.json +1 -1
- package/plugins/config-schema.json +48 -6
- package/plugins/diagnostics_panel.ts +4 -0
- package/plugins/diff_nav.ts +20 -1
- package/plugins/find_references.ts +4 -0
- package/plugins/flash.ts +11 -3
- package/plugins/git_explorer.ts +9 -1
- package/plugins/lib/finder.ts +81 -10
- package/plugins/lib/fresh.d.ts +40 -4
- package/plugins/live_diff.i18n.json +450 -0
- package/plugins/live_diff.ts +946 -0
- package/plugins/live_grep.i18n.json +42 -14
- package/plugins/live_grep.ts +491 -41
- package/plugins/markdown_compose.ts +17 -3
- package/plugins/schemas/theme.schema.json +510 -12
- package/plugins/search_replace.ts +5 -0
- package/plugins/theme_editor.i18n.json +224 -0
- package/plugins/theme_editor.ts +58 -50
- package/plugins/tsconfig.json +1 -0
- package/themes/dark.json +4 -0
- package/themes/dracula.json +2 -0
- package/themes/high-contrast.json +4 -0
- package/themes/light.json +4 -0
- package/themes/nord.json +7 -0
- package/themes/nostalgia.json +4 -0
- package/themes/solarized-dark.json +7 -0
- package/themes/terminal.json +128 -0
package/plugins/live_grep.ts
CHANGED
|
@@ -3,19 +3,41 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Live Grep Plugin
|
|
5
5
|
*
|
|
6
|
-
* Project-wide search
|
|
7
|
-
*
|
|
6
|
+
* Project-wide search rendered as a centred floating overlay
|
|
7
|
+
* (issue #1796). Search results stream in as the user types; arrow
|
|
8
|
+
* keys navigate; Enter opens at the match location.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Search backend is pluggable. The plugin ships three built-in
|
|
11
|
+
* providers (ripgrep → git grep → grep) selected by priority on
|
|
12
|
+
* each invocation; users and other plugins can register additional
|
|
13
|
+
* providers via the exported plugin API:
|
|
14
|
+
*
|
|
15
|
+
* const liveGrep = editor.getPluginApi("live-grep");
|
|
16
|
+
* liveGrep?.registerProvider({
|
|
17
|
+
* name: "fff",
|
|
18
|
+
* priority: 100, // higher wins
|
|
19
|
+
* isAvailable: async () => {
|
|
20
|
+
* const r = await editor.spawnProcess("fff", ["--version"], editor.getCwd());
|
|
21
|
+
* return r.exit_code === 0;
|
|
22
|
+
* },
|
|
23
|
+
* search: async (query, { cwd, maxResults }) => {
|
|
24
|
+
* const r = await editor.spawnProcess("fff", [query], cwd);
|
|
25
|
+
* return parseFFFOutput(r.stdout);
|
|
26
|
+
* },
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* The provider whose `isAvailable()` returns true with the highest
|
|
30
|
+
* priority is selected on each Live Grep invocation; the result is
|
|
31
|
+
* cached for the duration of the prompt.
|
|
12
32
|
*/
|
|
13
33
|
|
|
14
34
|
import { Finder, parseGrepOutput } from "./lib/finder.ts";
|
|
15
35
|
|
|
16
36
|
const editor = getEditor();
|
|
17
37
|
|
|
18
|
-
//
|
|
38
|
+
// One Live Grep match. Mirrors the JSON shape ripgrep emits with
|
|
39
|
+
// `--line-number --column --no-heading`; built-in non-rg providers
|
|
40
|
+
// (git grep, grep) normalise to this shape via parseGrepOutput.
|
|
19
41
|
interface GrepMatch {
|
|
20
42
|
file: string;
|
|
21
43
|
line: number;
|
|
@@ -23,7 +45,351 @@ interface GrepMatch {
|
|
|
23
45
|
content: string;
|
|
24
46
|
}
|
|
25
47
|
|
|
26
|
-
|
|
48
|
+
/** Options passed to a provider's `search` callback. */
|
|
49
|
+
export interface SearchOpts {
|
|
50
|
+
/** Working directory the search should run in (the editor's cwd). */
|
|
51
|
+
cwd: string;
|
|
52
|
+
/** Caller's preferred result cap. Providers may return fewer.
|
|
53
|
+
* Returning more is allowed; the Finder caps at its own
|
|
54
|
+
* `maxResults`. */
|
|
55
|
+
maxResults: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A registered Live Grep backend. */
|
|
59
|
+
export interface LiveGrepProvider {
|
|
60
|
+
/** Stable id, surfaced in status messages. Two providers with the
|
|
61
|
+
* same name are both kept; only the higher-priority one is ever
|
|
62
|
+
* selected unless it becomes unavailable. */
|
|
63
|
+
name: string;
|
|
64
|
+
/** Higher priority wins. Built-ins use 0/-1/-2; user-registered
|
|
65
|
+
* providers default to 0 if omitted. */
|
|
66
|
+
priority?: number;
|
|
67
|
+
/** Cheap probe — typically `editor.spawnProcess("foo", [], cwd)`
|
|
68
|
+
* and check `exit_code`. May be sync or async. Failures (thrown
|
|
69
|
+
* errors) are treated as "not available". */
|
|
70
|
+
isAvailable: () => boolean | Promise<boolean>;
|
|
71
|
+
/** Run the search. Return an array of matches; an empty array
|
|
72
|
+
* means "no matches" (not "provider broken"). Errors thrown
|
|
73
|
+
* here surface as a status message and bypass the next
|
|
74
|
+
* provider — the registry doesn't fall back automatically once
|
|
75
|
+
* a provider is selected. */
|
|
76
|
+
search: (query: string, opts: SearchOpts) => Promise<GrepMatch[]>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Public surface exposed via `editor.getPluginApi("live-grep")`. */
|
|
80
|
+
export type LiveGrepApi = {
|
|
81
|
+
/** Add a provider. Returns an unregister function. */
|
|
82
|
+
registerProvider(provider: LiveGrepProvider): () => void;
|
|
83
|
+
/** Remove every provider whose name matches. Returns true if at
|
|
84
|
+
* least one was removed. */
|
|
85
|
+
unregisterProvider(name: string): boolean;
|
|
86
|
+
/** Inspect the current provider list, sorted by priority desc.
|
|
87
|
+
* Useful for status / debugging / settings UIs. */
|
|
88
|
+
listProviders(): { name: string; priority: number }[];
|
|
89
|
+
/** Forget the cached "selected provider" — the next search runs a
|
|
90
|
+
* fresh `isAvailable()` probe. Call from init.ts after late
|
|
91
|
+
* registrations or after the user installs a new binary. */
|
|
92
|
+
resetSelection(): void;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
declare global {
|
|
96
|
+
interface FreshPluginRegistry {
|
|
97
|
+
"live-grep": LiveGrepApi;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Registry ──────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
const providers: LiveGrepProvider[] = [];
|
|
104
|
+
let cachedSelected: LiveGrepProvider | null | undefined = undefined;
|
|
105
|
+
|
|
106
|
+
function sortByPriority(): void {
|
|
107
|
+
providers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function registerProvider(provider: LiveGrepProvider): () => void {
|
|
111
|
+
if (typeof provider !== "object" || provider === null) {
|
|
112
|
+
throw new Error("live-grep.registerProvider: provider must be an object");
|
|
113
|
+
}
|
|
114
|
+
if (typeof provider.name !== "string" || provider.name.length === 0) {
|
|
115
|
+
throw new Error("live-grep.registerProvider: name must be a non-empty string");
|
|
116
|
+
}
|
|
117
|
+
if (typeof provider.isAvailable !== "function") {
|
|
118
|
+
throw new Error("live-grep.registerProvider: isAvailable must be a function");
|
|
119
|
+
}
|
|
120
|
+
if (typeof provider.search !== "function") {
|
|
121
|
+
throw new Error("live-grep.registerProvider: search must be a function");
|
|
122
|
+
}
|
|
123
|
+
providers.push(provider);
|
|
124
|
+
sortByPriority();
|
|
125
|
+
cachedSelected = undefined; // re-probe on next invocation
|
|
126
|
+
return () => {
|
|
127
|
+
const i = providers.indexOf(provider);
|
|
128
|
+
if (i >= 0) {
|
|
129
|
+
providers.splice(i, 1);
|
|
130
|
+
cachedSelected = undefined;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unregisterProvider(name: string): boolean {
|
|
136
|
+
let removed = false;
|
|
137
|
+
for (let i = providers.length - 1; i >= 0; i--) {
|
|
138
|
+
if (providers[i].name === name) {
|
|
139
|
+
providers.splice(i, 1);
|
|
140
|
+
removed = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (removed) cachedSelected = undefined;
|
|
144
|
+
return removed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function updateOverlayTitle(provider: LiveGrepProvider | null): void {
|
|
148
|
+
// Reflect the active provider in the floating overlay's frame
|
|
149
|
+
// header so the user always knows which backend is producing
|
|
150
|
+
// the results, even after the search-result status overwrites
|
|
151
|
+
// any one-shot "switched to" message. Append the actual bound
|
|
152
|
+
// shortcuts (whatever the user remapped to) as hints — pulled
|
|
153
|
+
// from the editor's keybinding registry, not hardcoded, so they
|
|
154
|
+
// always match the user's actual config.
|
|
155
|
+
const hints: string[] = [];
|
|
156
|
+
const cycleKey = editor.getKeybindingLabel(
|
|
157
|
+
"cycle_live_grep_provider",
|
|
158
|
+
"prompt"
|
|
159
|
+
);
|
|
160
|
+
if (cycleKey) hints.push(`${cycleKey} cycle`);
|
|
161
|
+
const exportKey = editor.getKeybindingLabel(
|
|
162
|
+
"live_grep_export_quickfix",
|
|
163
|
+
"prompt"
|
|
164
|
+
);
|
|
165
|
+
if (exportKey) hints.push(`${exportKey} → Quickfix`);
|
|
166
|
+
const resumeKey = editor.getKeybindingLabel("resume_live_grep", "normal");
|
|
167
|
+
if (resumeKey) hints.push(`${resumeKey} resume`);
|
|
168
|
+
const hintSuffix = hints.length > 0 ? ` · ${hints.join(" · ")}` : "";
|
|
169
|
+
const label = provider
|
|
170
|
+
? `Live Grep · ${provider.name}${hintSuffix}`
|
|
171
|
+
: `Live Grep${hintSuffix}`;
|
|
172
|
+
editor.setPromptTitle(label);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function selectProvider(): Promise<LiveGrepProvider | null> {
|
|
176
|
+
if (cachedSelected !== undefined) {
|
|
177
|
+
updateOverlayTitle(cachedSelected);
|
|
178
|
+
return cachedSelected;
|
|
179
|
+
}
|
|
180
|
+
for (const p of providers) {
|
|
181
|
+
try {
|
|
182
|
+
const ok = await Promise.resolve(p.isAvailable());
|
|
183
|
+
if (ok) {
|
|
184
|
+
cachedSelected = p;
|
|
185
|
+
editor.debug(`[live-grep] selected provider: ${p.name}`);
|
|
186
|
+
updateOverlayTitle(p);
|
|
187
|
+
return p;
|
|
188
|
+
}
|
|
189
|
+
} catch (e) {
|
|
190
|
+
editor.debug(`[live-grep] ${p.name}.isAvailable threw: ${e}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
cachedSelected = null;
|
|
194
|
+
updateOverlayTitle(null);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Built-in providers ──────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
registerProvider({
|
|
201
|
+
name: "ripgrep",
|
|
202
|
+
priority: -1,
|
|
203
|
+
isAvailable: async () => {
|
|
204
|
+
try {
|
|
205
|
+
const r = await editor.spawnProcess("rg", ["--version"], editor.getCwd());
|
|
206
|
+
return r.exit_code === 0;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
search: async (query, { cwd, maxResults }) => {
|
|
212
|
+
const r = await editor.spawnProcess(
|
|
213
|
+
"rg",
|
|
214
|
+
[
|
|
215
|
+
"--line-number",
|
|
216
|
+
"--column",
|
|
217
|
+
"--no-heading",
|
|
218
|
+
"--color=never",
|
|
219
|
+
"--smart-case",
|
|
220
|
+
`--max-count=${maxResults}`,
|
|
221
|
+
"-g", "!.git",
|
|
222
|
+
"-g", "!node_modules",
|
|
223
|
+
"-g", "!target",
|
|
224
|
+
"-g", "!*.lock",
|
|
225
|
+
"--",
|
|
226
|
+
query,
|
|
227
|
+
],
|
|
228
|
+
cwd
|
|
229
|
+
);
|
|
230
|
+
if (r.exit_code === 0) {
|
|
231
|
+
return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
|
|
232
|
+
}
|
|
233
|
+
return [];
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
registerProvider({
|
|
238
|
+
name: "ag",
|
|
239
|
+
priority: -2,
|
|
240
|
+
isAvailable: async () => {
|
|
241
|
+
try {
|
|
242
|
+
const r = await editor.spawnProcess("ag", ["--version"], editor.getCwd());
|
|
243
|
+
return r.exit_code === 0;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
search: async (query, { cwd, maxResults }) => {
|
|
249
|
+
const r = await editor.spawnProcess(
|
|
250
|
+
"ag",
|
|
251
|
+
[
|
|
252
|
+
"--column",
|
|
253
|
+
"--numbers",
|
|
254
|
+
"--nogroup",
|
|
255
|
+
"--nocolor",
|
|
256
|
+
"--smart-case",
|
|
257
|
+
"--ignore", ".git",
|
|
258
|
+
"--ignore", "node_modules",
|
|
259
|
+
"--ignore", "target",
|
|
260
|
+
"--ignore", "*.lock",
|
|
261
|
+
"--",
|
|
262
|
+
query,
|
|
263
|
+
],
|
|
264
|
+
cwd
|
|
265
|
+
);
|
|
266
|
+
if (r.exit_code === 0 || r.exit_code === 1) {
|
|
267
|
+
return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
|
|
268
|
+
}
|
|
269
|
+
return [];
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
registerProvider({
|
|
274
|
+
name: "git-grep",
|
|
275
|
+
// Top priority. git grep is the default *when available* — i.e.
|
|
276
|
+
// when the working directory is inside a git repo with `git`
|
|
277
|
+
// installed. `isAvailable` checks both, and outside a repo the
|
|
278
|
+
// registry falls through to ripgrep / ag / ack / grep in order.
|
|
279
|
+
priority: 0,
|
|
280
|
+
isAvailable: async () => {
|
|
281
|
+
try {
|
|
282
|
+
// git grep needs both `git` on PATH and to be inside a repo.
|
|
283
|
+
const cwd = editor.getCwd();
|
|
284
|
+
const ver = await editor.spawnProcess("git", ["--version"], cwd);
|
|
285
|
+
if (ver.exit_code !== 0) return false;
|
|
286
|
+
const inRepo = await editor.spawnProcess(
|
|
287
|
+
"git",
|
|
288
|
+
["rev-parse", "--is-inside-work-tree"],
|
|
289
|
+
cwd
|
|
290
|
+
);
|
|
291
|
+
return inRepo.exit_code === 0;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
search: async (query, { cwd, maxResults }) => {
|
|
297
|
+
const r = await editor.spawnProcess(
|
|
298
|
+
"git",
|
|
299
|
+
["grep", "-n", "--column", "-I", "-e", query],
|
|
300
|
+
cwd
|
|
301
|
+
);
|
|
302
|
+
// git grep exits 1 when no matches — treat as empty, not error.
|
|
303
|
+
if (r.exit_code === 0 || r.exit_code === 1) {
|
|
304
|
+
return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
|
|
305
|
+
}
|
|
306
|
+
return [];
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
registerProvider({
|
|
311
|
+
name: "ack",
|
|
312
|
+
priority: -3,
|
|
313
|
+
// Note: ack/grep are kept at lower priority than ripgrep/ag/
|
|
314
|
+
// git-grep because they're slower on large trees; the cycler
|
|
315
|
+
// skips them automatically when a faster backend is available.
|
|
316
|
+
isAvailable: async () => {
|
|
317
|
+
try {
|
|
318
|
+
const r = await editor.spawnProcess("ack", ["--version"], editor.getCwd());
|
|
319
|
+
return r.exit_code === 0;
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
search: async (query, { cwd, maxResults }) => {
|
|
325
|
+
const r = await editor.spawnProcess(
|
|
326
|
+
"ack",
|
|
327
|
+
[
|
|
328
|
+
"--nocolor",
|
|
329
|
+
"--column",
|
|
330
|
+
"--smart-case",
|
|
331
|
+
"--",
|
|
332
|
+
query,
|
|
333
|
+
],
|
|
334
|
+
cwd
|
|
335
|
+
);
|
|
336
|
+
if (r.exit_code === 0 || r.exit_code === 1) {
|
|
337
|
+
return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
|
|
338
|
+
}
|
|
339
|
+
return [];
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Note: `fff` is *not* shipped as a built-in. There's no canonical
|
|
344
|
+
// "fff" grep tool with a known argument shape — the most popular
|
|
345
|
+
// binary named `fff` is the bash file-manager
|
|
346
|
+
// (https://github.com/dylanaraps/fff), which is interactive and
|
|
347
|
+
// doesn't accept a search pattern as an argument. Wiring a guess
|
|
348
|
+
// here would silently return zero results for that flavour. Users
|
|
349
|
+
// who have their own `fff` (or any other custom tool) should
|
|
350
|
+
// register it from init.ts where the exact CLI is known. The
|
|
351
|
+
// starter init.ts template documents the pattern.
|
|
352
|
+
|
|
353
|
+
registerProvider({
|
|
354
|
+
name: "grep",
|
|
355
|
+
priority: -4,
|
|
356
|
+
isAvailable: async () => {
|
|
357
|
+
try {
|
|
358
|
+
const r = await editor.spawnProcess("grep", ["--version"], editor.getCwd());
|
|
359
|
+
return r.exit_code === 0;
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
search: async (query, { cwd, maxResults }) => {
|
|
365
|
+
const r = await editor.spawnProcess(
|
|
366
|
+
"grep",
|
|
367
|
+
[
|
|
368
|
+
"-rn",
|
|
369
|
+
"--column",
|
|
370
|
+
"-I",
|
|
371
|
+
"--exclude-dir=.git",
|
|
372
|
+
"--exclude-dir=node_modules",
|
|
373
|
+
"--exclude-dir=target",
|
|
374
|
+
"--",
|
|
375
|
+
query,
|
|
376
|
+
".",
|
|
377
|
+
],
|
|
378
|
+
cwd
|
|
379
|
+
);
|
|
380
|
+
if (r.exit_code === 0 || r.exit_code === 1) {
|
|
381
|
+
// POSIX grep doesn't emit `path:line:col:content` natively
|
|
382
|
+
// even with `--column`; on most BSD/GNU greps the format is
|
|
383
|
+
// still `path:line:content`. parseGrepOutput tolerates the
|
|
384
|
+
// missing column.
|
|
385
|
+
return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
|
|
386
|
+
}
|
|
387
|
+
return [];
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ── Wiring ──────────────────────────────────────────────────────
|
|
392
|
+
|
|
27
393
|
const finder = new Finder<GrepMatch>(editor, {
|
|
28
394
|
id: "live-grep",
|
|
29
395
|
format: (match) => ({
|
|
@@ -38,57 +404,127 @@ const finder = new Finder<GrepMatch>(editor, {
|
|
|
38
404
|
column: match.column,
|
|
39
405
|
},
|
|
40
406
|
}),
|
|
41
|
-
preview:
|
|
407
|
+
preview: false,
|
|
42
408
|
maxResults: 100,
|
|
43
409
|
});
|
|
44
410
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
411
|
+
/**
|
|
412
|
+
* Switch to the next *available* registered provider, in priority
|
|
413
|
+
* order, wrapping at the end. Unavailable providers (those whose
|
|
414
|
+
* `isAvailable()` returns false right now) are skipped — pressing
|
|
415
|
+
* the cycle key never lands on a backend that can't actually run.
|
|
416
|
+
*
|
|
417
|
+
* Side effects: updates `cachedSelected` so the next search uses
|
|
418
|
+
* the new provider, fires a status message naming the new
|
|
419
|
+
* provider, and re-runs the current query (via the prompt-changed
|
|
420
|
+
* hook the Finder is already listening for).
|
|
421
|
+
*/
|
|
422
|
+
async function cycleProvider(): Promise<void> {
|
|
423
|
+
if (providers.length === 0) {
|
|
424
|
+
editor.setStatus("Live Grep: no providers registered");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Find the position to start scanning from. If a provider is
|
|
428
|
+
// currently cached, start *after* it so we genuinely move on; if
|
|
429
|
+
// not, start from the top of the list.
|
|
430
|
+
const currentIdx =
|
|
431
|
+
cachedSelected != null ? providers.indexOf(cachedSelected) : -1;
|
|
432
|
+
// Walk the full list once (mod len), skipping any provider whose
|
|
433
|
+
// probe says unavailable. If we wrap back to where we started
|
|
434
|
+
// without finding a different available provider, surface a
|
|
435
|
+
// status message and leave the selection alone.
|
|
436
|
+
for (let step = 1; step <= providers.length; step++) {
|
|
437
|
+
const idx = (currentIdx + step + providers.length) % providers.length;
|
|
438
|
+
const candidate = providers[idx];
|
|
439
|
+
if (candidate === cachedSelected) {
|
|
440
|
+
// Looped past the start without finding anything else
|
|
441
|
+
// available; only the current one is usable.
|
|
442
|
+
editor.setStatus(
|
|
443
|
+
`Live Grep: no other available providers (still on ${candidate.name})`
|
|
444
|
+
);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
let ok = false;
|
|
448
|
+
try {
|
|
449
|
+
ok = await Promise.resolve(candidate.isAvailable());
|
|
450
|
+
} catch (e) {
|
|
451
|
+
editor.debug(`[live-grep] ${candidate.name}.isAvailable threw: ${e}`);
|
|
452
|
+
}
|
|
453
|
+
if (!ok) continue;
|
|
454
|
+
cachedSelected = candidate;
|
|
455
|
+
// Reflect the new provider in the overlay's title bar
|
|
456
|
+
// immediately — the status row gets clobbered by the search
|
|
457
|
+
// result count, but the title stays put.
|
|
458
|
+
updateOverlayTitle(candidate);
|
|
459
|
+
// Re-run the current query through the new provider so the
|
|
460
|
+
// result list updates without the user having to type a
|
|
461
|
+
// throwaway character. `refresh()` itself sets status to
|
|
462
|
+
// "Found N matches" — we want the user to see the *cycle*
|
|
463
|
+
// result, so re-set the status afterwards.
|
|
464
|
+
await finder.refresh();
|
|
465
|
+
editor.setStatus(`Live Grep: switched to ${candidate.name}`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
editor.setStatus("Live Grep: no available providers");
|
|
469
|
+
}
|
|
470
|
+
registerHandler("live_grep_cycle_provider", cycleProvider);
|
|
471
|
+
// `registerHandler` only sets a globalThis function — to make the
|
|
472
|
+
// editor's `execute_action` path find it across the plugin-context
|
|
473
|
+
// boundary the action also has to live in the registered-actions
|
|
474
|
+
// table. `registerCommand` is the public-facing mechanism that
|
|
475
|
+
// inserts that entry. Doubles as a palette-discoverable command.
|
|
476
|
+
editor.registerCommand(
|
|
477
|
+
"%cmd.live_grep_cycle_provider",
|
|
478
|
+
"%cmd.live_grep_cycle_provider_desc",
|
|
479
|
+
"live_grep_cycle_provider",
|
|
480
|
+
null
|
|
481
|
+
);
|
|
70
482
|
|
|
71
|
-
|
|
72
|
-
|
|
483
|
+
async function search(query: string): Promise<GrepMatch[]> {
|
|
484
|
+
const provider = await selectProvider();
|
|
485
|
+
if (!provider) {
|
|
486
|
+
editor.setStatus(
|
|
487
|
+
"Live Grep: no search backend available — install ripgrep, or register a provider via init.ts (`editor.getPluginApi(\"live-grep\")?.registerProvider(...)`)."
|
|
488
|
+
);
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
return await provider.search(query, {
|
|
493
|
+
cwd: editor.getCwd(),
|
|
494
|
+
maxResults: 100,
|
|
495
|
+
});
|
|
496
|
+
} catch (e) {
|
|
497
|
+
editor.setStatus(`Live Grep (${provider.name}) failed: ${e}`);
|
|
498
|
+
return [];
|
|
73
499
|
}
|
|
74
|
-
return [];
|
|
75
500
|
}
|
|
76
501
|
|
|
77
|
-
|
|
78
|
-
function start_live_grep() : void {
|
|
502
|
+
function start_live_grep(): void {
|
|
79
503
|
finder.prompt({
|
|
80
504
|
title: editor.t("prompt.live_grep"),
|
|
81
505
|
source: {
|
|
82
506
|
mode: "search",
|
|
83
|
-
search
|
|
507
|
+
search,
|
|
84
508
|
debounceMs: 150,
|
|
85
509
|
minQueryLength: 2,
|
|
86
510
|
},
|
|
511
|
+
floatingOverlay: true,
|
|
87
512
|
});
|
|
513
|
+
// Pre-populate the overlay's frame title with the cached
|
|
514
|
+
// provider name (if any) before the user types — avoids the
|
|
515
|
+
// brief "Live Grep" → "Live Grep · ripgrep" flash when the
|
|
516
|
+
// first search resolves selectProvider().
|
|
517
|
+
if (cachedSelected) {
|
|
518
|
+
updateOverlayTitle(cachedSelected);
|
|
519
|
+
} else {
|
|
520
|
+
// Kick off provider probing in the background so the title
|
|
521
|
+
// updates as soon as the first available probe resolves,
|
|
522
|
+
// rather than waiting for the first keystroke.
|
|
523
|
+
void selectProvider();
|
|
524
|
+
}
|
|
88
525
|
}
|
|
89
526
|
registerHandler("start_live_grep", start_live_grep);
|
|
90
527
|
|
|
91
|
-
// Register command
|
|
92
528
|
editor.registerCommand(
|
|
93
529
|
"%cmd.live_grep",
|
|
94
530
|
"%cmd.live_grep_desc",
|
|
@@ -96,4 +532,18 @@ editor.registerCommand(
|
|
|
96
532
|
null
|
|
97
533
|
);
|
|
98
534
|
|
|
99
|
-
editor.
|
|
535
|
+
editor.exportPluginApi("live-grep", {
|
|
536
|
+
registerProvider,
|
|
537
|
+
unregisterProvider,
|
|
538
|
+
listProviders(): { name: string; priority: number }[] {
|
|
539
|
+
return providers.map((p) => ({
|
|
540
|
+
name: p.name,
|
|
541
|
+
priority: p.priority ?? 0,
|
|
542
|
+
}));
|
|
543
|
+
},
|
|
544
|
+
resetSelection(): void {
|
|
545
|
+
cachedSelected = undefined;
|
|
546
|
+
},
|
|
547
|
+
} satisfies LiveGrepApi);
|
|
548
|
+
|
|
549
|
+
editor.debug("Live Grep plugin loaded (provider registry)");
|
|
@@ -1425,7 +1425,21 @@ function processLineSoftBreaks(
|
|
|
1425
1425
|
}
|
|
1426
1426
|
|
|
1427
1427
|
// Walk through the line content and find word-wrap break points
|
|
1428
|
-
// We need to find Space positions where wrapping should occur
|
|
1428
|
+
// We need to find Space positions where wrapping should occur.
|
|
1429
|
+
//
|
|
1430
|
+
// The wrap budget must reserve columns to match the Rust renderer's
|
|
1431
|
+
// `apply_wrapping_transform`, which subtracts one from `content_width`
|
|
1432
|
+
// to keep the end-of-line cursor off the scrollbar track. If the
|
|
1433
|
+
// plugin uses the full viewport width, it produces lines that fit
|
|
1434
|
+
// exactly N columns; the renderer then re-wraps them at N-1, splitting
|
|
1435
|
+
// off the trailing word into a single-word "orphan" visual row
|
|
1436
|
+
// (issue #1789).
|
|
1437
|
+
//
|
|
1438
|
+
// We subtract two rather than just one so the plugin's wrap output
|
|
1439
|
+
// stays a column inside the renderer's threshold across platforms,
|
|
1440
|
+
// covering minor differences in scrollbar / gutter / EOL-cursor
|
|
1441
|
+
// reservation between terminals.
|
|
1442
|
+
const wrapBudget = Math.max(1, width - 2);
|
|
1429
1443
|
let column = 0;
|
|
1430
1444
|
let i = 0;
|
|
1431
1445
|
|
|
@@ -1440,8 +1454,8 @@ function processLineSoftBreaks(
|
|
|
1440
1454
|
nextWordLen += charW[j];
|
|
1441
1455
|
}
|
|
1442
1456
|
|
|
1443
|
-
// Check if space + next word would exceed
|
|
1444
|
-
if (column + 1 + nextWordLen >
|
|
1457
|
+
// Check if space + next word would exceed wrap budget
|
|
1458
|
+
if (column + 1 + nextWordLen > wrapBudget && nextWordLen > 0) {
|
|
1445
1459
|
// Add a soft break at this space's buffer position
|
|
1446
1460
|
const breakBytePos = byteStart + editor.utf8ByteLength(lineContent.slice(0, i));
|
|
1447
1461
|
editor.addSoftBreak(bufferId, "md-wrap", breakBytePos, hangingIndent);
|