@fresh-editor/fresh-editor 0.3.4 → 0.3.5

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Release Notes
2
2
 
3
+ ## 0.3.5
4
+
5
+ ### Improvements
6
+
7
+ * **Live Grep overlay polish**: Surrounding editor is now visibly dimmed; shortcut hints and the active provider moved into a toolbar row with plainer labels; match cap raised from 100 to 1000 (with a `1000+ matches` indicator); provider errors render as a disabled result entry instead of a silent "0 matches"; `ripgrep` provider renamed to `rg`.
8
+
9
+ ### Bug Fixes
10
+
11
+ * **Live Grep on Windows returned no results**: `git grep` outputs `\r\n`; splitting on `\n` left a trailing `\r` that broke the result regex. Split on `\r?\n` instead.
12
+
13
+ * **Plain `grep` provider was broken**: `--column` is a ripgrep-only flag and was being passed unconditionally. Removed.
14
+
15
+ ### Under the Hood
16
+
17
+ * **Plugin API: `setPromptTitle` takes styled segments** (`{ text, style? }[]`) instead of a single string, so plugins control hint colouring directly instead of the renderer guessing structure from punctuation.
18
+
3
19
  ## 0.3.4
4
20
 
5
21
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fresh-editor/fresh-editor",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "A modern terminal-based text editor with plugin support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,8 +48,10 @@ async function searchWithGitGrep(query: string): Promise<GrepMatch[]> {
48
48
  );
49
49
 
50
50
  if (result.exit_code === 0) {
51
- return parseGrepOutput(result.stdout, 100) as GrepMatch[];
51
+ return parseGrepOutput(result.stdout, 100, (msg) => editor.debug(msg)) as GrepMatch[];
52
52
  }
53
+ editor.error(`[git_grep] process exited with code ${result.exit_code}: ${result.stderr}`);
54
+ editor.setStatus(`git grep failed (exit ${result.exit_code})`);
53
55
  return [];
54
56
  }
55
57
 
@@ -346,7 +346,8 @@ export function parseGrepLine(line: string): {
346
346
  */
347
347
  export function parseGrepOutput(
348
348
  stdout: string,
349
- maxResults: number = 100
349
+ maxResults: number = 100,
350
+ debug?: (msg: string) => void
350
351
  ): Array<{ file: string; line: number; column: number; content: string }> {
351
352
  const results: Array<{
352
353
  file: string;
@@ -355,14 +356,16 @@ export function parseGrepOutput(
355
356
  content: string;
356
357
  }> = [];
357
358
 
358
- for (const line of stdout.split("\n")) {
359
- if (!line.trim()) continue;
359
+ for (const line of stdout.split(/\r?\n/)) {
360
+ if (!line) continue;
360
361
  const match = parseGrepLine(line);
361
362
  if (match) {
362
363
  results.push(match);
363
364
  if (results.length >= maxResults) {
364
365
  break;
365
366
  }
367
+ } else if (debug) {
368
+ debug(`[parseGrepOutput] failed to parse line: ${line}`);
366
369
  }
367
370
  }
368
371
 
@@ -784,7 +787,8 @@ export class Finder<T> {
784
787
  // Parse as grep output by default
785
788
  const parsed = parseGrepOutput(
786
789
  result.stdout,
787
- this.config.maxResults
790
+ this.config.maxResults,
791
+ (msg) => this.editor.debug(msg)
788
792
  ) as unknown as T[];
789
793
  this.updatePromptResults(parsed);
790
794
 
@@ -827,9 +831,26 @@ export class Finder<T> {
827
831
  }
828
832
  } catch (e) {
829
833
  const errorMsg = String(e);
830
- if (!errorMsg.includes("killed") && !errorMsg.includes("not found")) {
831
- this.editor.setStatus(`Search error: ${e}`);
834
+ // "killed" / "not found" come from the cancellation path (a
835
+ // newer search aborted this one). Suppress those entirely —
836
+ // the user didn't ask for them.
837
+ if (errorMsg.includes("killed") || errorMsg.includes("not found")) {
838
+ return;
832
839
  }
840
+ // Render the error inside the overlay's result list itself.
841
+ // The status bar is shared and clobbered by other code paths,
842
+ // so it's not a reliable place to surface a feature-scoped
843
+ // error — the overlay is where the user is looking.
844
+ this.promptState.results = [];
845
+ this.promptState.entries = [];
846
+ const display = errorMsg.replace(/^Error:\s*/, "");
847
+ this.editor.setPromptSuggestions([
848
+ {
849
+ text: `⚠ ${display}`,
850
+ value: "",
851
+ disabled: true,
852
+ },
853
+ ]);
833
854
  }
834
855
  }
835
856
 
@@ -989,6 +989,10 @@ type SpawnResult = {
989
989
  */
990
990
  exit_code: number;
991
991
  };
992
+ type StyledText = {
993
+ text: string;
994
+ style?: Partial<OverlayOptions>;
995
+ };
992
996
  type TextPropertiesAtCursor = Array<Record<string, unknown>>;
993
997
  type TsHighlightSpan = {
994
998
  start: number;
@@ -1735,11 +1739,16 @@ interface EditorAPI {
1735
1739
  setPromptInputSync(sync: boolean): boolean;
1736
1740
  /**
1737
1741
  * Set the title shown in the floating-overlay prompt's frame
1738
- * header (issue #1796). Pass `null` or omit the argument to
1739
- * clear the title and fall back to the default. Has no
1740
- * visible effect on non-overlay prompts.
1741
- */
1742
- setPromptTitle(title?: string | null): boolean;
1742
+ * header (issue #1796) as styled segments. Each segment
1743
+ * carries optional `Partial<OverlayOptions>`, the same
1744
+ * styling primitive used by virtual text — plugins mark
1745
+ * keybinding hints with `{ fg: "ui.help_key_fg" }`,
1746
+ * separators with `{ fg: "ui.popup_border_fg" }`, etc. Pass
1747
+ * an empty array to clear the title and fall back to the
1748
+ * prompt-type default. Has no visible effect on non-overlay
1749
+ * prompts.
1750
+ */
1751
+ setPromptTitle(title: StyledText[]): boolean;
1743
1752
  /**
1744
1753
  * Define a buffer mode (takes bindings as array of [key, command] pairs)
1745
1754
  */
@@ -98,10 +98,20 @@ declare global {
98
98
  }
99
99
  }
100
100
 
101
+ // Cap on the number of matches a single search returns. Higher than
102
+ // the previous 100 to actually fit a typical refactor's worth of
103
+ // hits in one snapshot, but bounded so a runaway query doesn't
104
+ // stream the entire codebase into the overlay.
105
+ const MAX_RESULTS = 1000;
106
+
101
107
  // ── Registry ──────────────────────────────────────────────────────
102
108
 
103
109
  const providers: LiveGrepProvider[] = [];
104
110
  let cachedSelected: LiveGrepProvider | null | undefined = undefined;
111
+ // Set by `search` after each query so the toolbar can show
112
+ // "1000+ matches" when a result set was clipped at MAX_RESULTS.
113
+ // Reset to false on every new query (before the provider call).
114
+ let lastSearchTruncated = false;
105
115
 
106
116
  function sortByPriority(): void {
107
117
  providers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
@@ -145,31 +155,57 @@ function unregisterProvider(name: string): boolean {
145
155
  }
146
156
 
147
157
  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"
158
+ // The input row's prefix already says "Live grep: …", so the
159
+ // frame title doesn't repeat the feature name it's reserved
160
+ // for the active provider plus shortcut hints. Shortcuts come
161
+ // from the keybinding registry (not hardcoded) so labels match
162
+ // the user's actual binds. Each segment carries its own theme
163
+ // key (`ui.help_key_fg` for keys, `ui.popup_border_fg` for
164
+ // separators) so the renderer doesn't have to parse the title.
165
+ // `resume_live_grep` is intentionally NOT shown here — it only
166
+ // matters once the prompt is closed; it's surfaced in the
167
+ // status bar at that point instead.
168
+ const sepStyle = { fg: "ui.popup_border_fg" };
169
+ const hintStyle = { fg: "ui.help_key_fg" };
170
+ const segments: StyledText[] = [];
171
+ const pushSegment = (parts: StyledText[]) => {
172
+ if (segments.length > 0) {
173
+ segments.push({ text: " · ", style: sepStyle });
174
+ }
175
+ segments.push(...parts);
176
+ };
177
+ if (provider) {
178
+ pushSegment([
179
+ { text: "Provider: " },
180
+ { text: provider.name, style: { bold: true } },
181
+ ]);
182
+ }
183
+ // Match-count indicator goes BEFORE the keybinding hints so a
184
+ // narrow terminal that truncates the toolbar still shows it —
185
+ // the trailing hints are easier to lose than the result-set
186
+ // status.
187
+ if (lastSearchTruncated) {
188
+ pushSegment([{ text: `${MAX_RESULTS}+ matches` }]);
189
+ }
190
+ const pushHint = (key: string | null, label: string) => {
191
+ if (!key) return;
192
+ pushSegment([
193
+ { text: key, style: hintStyle },
194
+ { text: ` ${label}` },
195
+ ]);
196
+ };
197
+ pushHint(
198
+ editor.getKeybindingLabel("cycle_live_grep_provider", "prompt"),
199
+ "switch grep provider"
159
200
  );
160
- if (cycleKey) hints.push(`${cycleKey} cycle`);
161
- const exportKey = editor.getKeybindingLabel(
162
- "live_grep_export_quickfix",
163
- "prompt"
201
+ pushHint(
202
+ editor.getKeybindingLabel("live_grep_export_quickfix", "prompt"),
203
+ "save matches"
164
204
  );
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);
205
+ if (segments.length > 0) {
206
+ segments.push({ text: " " });
207
+ }
208
+ editor.setPromptTitle(segments);
173
209
  }
174
210
 
175
211
  async function selectProvider(): Promise<LiveGrepProvider | null> {
@@ -198,7 +234,7 @@ async function selectProvider(): Promise<LiveGrepProvider | null> {
198
234
  // ── Built-in providers ──────────────────────────────────────────
199
235
 
200
236
  registerProvider({
201
- name: "ripgrep",
237
+ name: "rg",
202
238
  priority: -1,
203
239
  isAvailable: async () => {
204
240
  try {
@@ -228,9 +264,9 @@ registerProvider({
228
264
  cwd
229
265
  );
230
266
  if (r.exit_code === 0) {
231
- return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
267
+ return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
232
268
  }
233
- return [];
269
+ throw new Error(`rg exited with code ${r.exit_code}: ${r.stderr}`);
234
270
  },
235
271
  });
236
272
 
@@ -264,9 +300,9 @@ registerProvider({
264
300
  cwd
265
301
  );
266
302
  if (r.exit_code === 0 || r.exit_code === 1) {
267
- return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
303
+ return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
268
304
  }
269
- return [];
305
+ throw new Error(`ag exited with code ${r.exit_code}: ${r.stderr}`);
270
306
  },
271
307
  });
272
308
 
@@ -301,9 +337,9 @@ registerProvider({
301
337
  );
302
338
  // git grep exits 1 when no matches — treat as empty, not error.
303
339
  if (r.exit_code === 0 || r.exit_code === 1) {
304
- return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
340
+ return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
305
341
  }
306
- return [];
342
+ throw new Error(`git grep exited with code ${r.exit_code}: ${r.stderr}`);
307
343
  },
308
344
  });
309
345
 
@@ -334,9 +370,9 @@ registerProvider({
334
370
  cwd
335
371
  );
336
372
  if (r.exit_code === 0 || r.exit_code === 1) {
337
- return parseGrepOutput(r.stdout, maxResults) as GrepMatch[];
373
+ return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
338
374
  }
339
- return [];
375
+ throw new Error(`ack exited with code ${r.exit_code}: ${r.stderr}`);
340
376
  },
341
377
  });
342
378
 
@@ -366,7 +402,6 @@ registerProvider({
366
402
  "grep",
367
403
  [
368
404
  "-rn",
369
- "--column",
370
405
  "-I",
371
406
  "--exclude-dir=.git",
372
407
  "--exclude-dir=node_modules",
@@ -378,13 +413,11 @@ registerProvider({
378
413
  cwd
379
414
  );
380
415
  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[];
416
+ // grep emits `path:line:content` (no column). parseGrepOutput's
417
+ // 3-field fallback handles the missing column (defaults to 1).
418
+ return parseGrepOutput(r.stdout, maxResults, (msg) => editor.debug(msg)) as GrepMatch[];
386
419
  }
387
- return [];
420
+ throw new Error(`grep exited with code ${r.exit_code}: ${r.stderr}`);
388
421
  },
389
422
  });
390
423
 
@@ -404,8 +437,26 @@ const finder = new Finder<GrepMatch>(editor, {
404
437
  column: match.column,
405
438
  },
406
439
  }),
440
+ // Override the Finder's default "open file + status: Opened X"
441
+ // so we can surface the resume shortcut here. The shortcut is
442
+ // hidden inside the overlay (it can't apply while the overlay
443
+ // is open), but it's exactly what the user needs to know once
444
+ // they've jumped to a result and want to keep browsing.
445
+ onSelect: (_item, entry) => {
446
+ if (entry.location) {
447
+ editor.openFile(
448
+ entry.location.file,
449
+ entry.location.line,
450
+ entry.location.column
451
+ );
452
+ }
453
+ const resumeKey = editor.getKeybindingLabel("resume_live_grep", "normal");
454
+ if (resumeKey) {
455
+ editor.setStatus(`${resumeKey} to resume search`);
456
+ }
457
+ },
407
458
  preview: false,
408
- maxResults: 100,
459
+ maxResults: MAX_RESULTS,
409
460
  });
410
461
 
411
462
  /**
@@ -483,19 +534,34 @@ editor.registerCommand(
483
534
  async function search(query: string): Promise<GrepMatch[]> {
484
535
  const provider = await selectProvider();
485
536
  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(...)`)."
537
+ // Throw rather than return [] — the Finder catches and shows
538
+ // the message inside the overlay. Returning [] would be
539
+ // indistinguishable from a real "no matches" result.
540
+ throw new Error(
541
+ "no search backend available — install ripgrep, or register a provider via init.ts (`editor.getPluginApi(\"live-grep\")?.registerProvider(...)`)."
488
542
  );
489
- return [];
490
543
  }
544
+ const wasTruncated = lastSearchTruncated;
491
545
  try {
492
- return await provider.search(query, {
546
+ const results = await provider.search(query, {
493
547
  cwd: editor.getCwd(),
494
- maxResults: 100,
548
+ maxResults: MAX_RESULTS,
495
549
  });
550
+ lastSearchTruncated = results.length >= MAX_RESULTS;
551
+ // Refresh the toolbar whenever the truncation indicator
552
+ // changes so it appears (or disappears) alongside the new
553
+ // results in the same render.
554
+ if (lastSearchTruncated !== wasTruncated) {
555
+ updateOverlayTitle(provider);
556
+ }
557
+ return results;
496
558
  } catch (e) {
497
- editor.setStatus(`Live Grep (${provider.name}) failed: ${e}`);
498
- return [];
559
+ // Log to tracing for diagnostics, then re-throw so the Finder
560
+ // surfaces the failure in the overlay itself.
561
+ editor.error(`[live_grep:${provider.name}] ${e}`);
562
+ throw new Error(
563
+ `${provider.name}: ${e instanceof Error ? e.message : String(e)}`
564
+ );
499
565
  }
500
566
  }
501
567
 
@@ -512,7 +578,7 @@ function start_live_grep(): void {
512
578
  });
513
579
  // Pre-populate the overlay's frame title with the cached
514
580
  // provider name (if any) before the user types — avoids the
515
- // brief "Live Grep" → "Live Grep · ripgrep" flash when the
581
+ // brief "Live Grep" → "Live Grep · rg" flash when the
516
582
  // first search resolves selectProvider().
517
583
  if (cachedSelected) {
518
584
  updateOverlayTitle(cachedSelected);