@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.
@@ -3,19 +3,41 @@
3
3
  /**
4
4
  * Live Grep Plugin
5
5
  *
6
- * Project-wide search with ripgrep and live preview.
7
- * Uses the Finder abstraction for unified search UX.
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
- * - Type to search across all files
10
- * - Navigate results with Up/Down to see preview
11
- * - Press Enter to open file at location
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
- // Result type from ripgrep
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
- // Create the finder instance
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: true,
407
+ preview: false,
42
408
  maxResults: 100,
43
409
  });
44
410
 
45
- // Search function that parses ripgrep output
46
- async function searchWithRipgrep(query: string): Promise<GrepMatch[]> {
47
- const cwd = editor.getCwd();
48
- const result = await editor.spawnProcess(
49
- "rg",
50
- [
51
- "--line-number",
52
- "--column",
53
- "--no-heading",
54
- "--color=never",
55
- "--smart-case",
56
- "--max-count=100",
57
- "-g",
58
- "!.git",
59
- "-g",
60
- "!node_modules",
61
- "-g",
62
- "!target",
63
- "-g",
64
- "!*.lock",
65
- "--",
66
- query,
67
- ],
68
- cwd
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
- if (result.exit_code === 0) {
72
- return parseGrepOutput(result.stdout, 100) as GrepMatch[];
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
- // Start live grep
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: searchWithRipgrep,
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.debug("Live Grep plugin loaded (using Finder abstraction)");
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 width
1444
- if (column + 1 + nextWordLen > width && nextWordLen > 0) {
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);