@fresh-editor/fresh-editor 0.3.9 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,9 +116,37 @@ export interface FinderConfig<T> {
116
116
  /** Panel-specific: navigate source split when cursor moves (preview without focus change) */
117
117
  navigateOnCursorMove?: boolean;
118
118
 
119
+ /**
120
+ * Panel-specific: whether selecting an entry (Enter) closes the
121
+ * panel. Defaults to `true` — the panel dismisses as it jumps, which
122
+ * suits one-shot lists like Find References. Set `false` to keep the
123
+ * panel docked so the user can step through entries (Vim quickfix /
124
+ * VS Code results list). Only consulted by the default selection
125
+ * handler — a custom `onSelect` owns close behaviour itself.
126
+ *
127
+ * Note: leaving the panel open while `navigateOnCursorMove` is also
128
+ * on re-introduces the focus race the close was guarding against
129
+ * (a `focusSplit` queued after the jump can re-grab the panel), so
130
+ * the two shouldn't be combined without care.
131
+ */
132
+ closeOnSelect?: boolean;
133
+
119
134
  /** Called when the panel or prompt is closed (e.g. via Escape) */
120
135
  onClose?: () => void;
121
136
 
137
+ /**
138
+ * Panel-specific: extra key bindings active while the panel buffer
139
+ * is focused, as `[key, command]` pairs (e.g. `["q", "my_close"]`).
140
+ * `command` is dispatched as a plugin action, so it should name a
141
+ * handler registered via `registerHandler`. These augment the
142
+ * built-in `Return` (select) and `Escape` (close) bindings.
143
+ *
144
+ * Without this, keys a plugin advertises in its panel UI (e.g.
145
+ * "q: close") fall through to the read-only text layer and trip
146
+ * "Editing disabled in this buffer" (issue #2125).
147
+ */
148
+ panelKeys?: Array<[string, string]>;
149
+
122
150
  /**
123
151
  * When true, panels created by this Finder are routed into the
124
152
  * shared Utility Dock (issue #1796 / Section 2 of
@@ -1111,12 +1139,17 @@ export class Finder<T> {
1111
1139
  private registerPanelHandlers(): void {
1112
1140
  const self = this;
1113
1141
 
1114
- // Define panel mode
1142
+ // Define panel mode. The built-in Return/Escape bindings are
1143
+ // augmented with any caller-supplied `panelKeys` so plugin-specific
1144
+ // shortcuts (e.g. Diagnostics' "q: close | a: toggle filter") resolve
1145
+ // to their handlers instead of falling through to the read-only text
1146
+ // layer (issue #2125).
1115
1147
  this.editor.defineMode(
1116
1148
  this.modeName,
1117
1149
  [
1118
1150
  ["Return", `${this.handlerPrefix}_panel_select`],
1119
1151
  ["Escape", `${this.handlerPrefix}_panel_close`],
1152
+ ...(this.config.panelKeys ?? []),
1120
1153
  ],
1121
1154
  true
1122
1155
  );
@@ -1398,13 +1431,17 @@ export class Finder<T> {
1398
1431
  } else if (entry.location) {
1399
1432
  const loc = entry.location;
1400
1433
 
1401
- // Close the panel first. This is necessary because
1402
- // navigateOnCursorMove's focusSplit(panelSplitId) can interfere with
1403
- // the jump it queues a FocusSplit that runs after OpenFileInSplit
1404
- // and restores the panel as the active split.
1405
- this.closePanel();
1434
+ // Close the panel first (unless the consumer opted to keep it
1435
+ // docked via `closeOnSelect: false`). Closing is the default
1436
+ // because navigateOnCursorMove's focusSplit(panelSplitId) can
1437
+ // interfere with the jump it queues a FocusSplit that runs after
1438
+ // OpenFileInSplit and restores the panel as the active split.
1439
+ if (this.config.closeOnSelect !== false) {
1440
+ this.closePanel();
1441
+ }
1406
1442
 
1407
- // Now navigate with the panel gone only one split remains
1443
+ // openFile routes away from the dock leaf, so with the panel kept
1444
+ // open the file lands in the editor pane and the list stays put.
1408
1445
  this.editor.openFile(loc.file, loc.line, loc.column);
1409
1446
  this.editor.setStatus(`Jumped to ${loc.file}:${loc.line}`);
1410
1447
  }
@@ -736,7 +736,7 @@ type CursorInfo = {
736
736
  end: number;
737
737
  } | null;
738
738
  /**
739
- * 0-indexed line number of the cursor. `None` when the line index is
739
+ * 0-indexed line number of the cursor. `null` when the line index is
740
740
  * unavailable — e.g. a huge file whose line scan hasn't completed, where
741
741
  * the editor positions purely by byte offset. Plugins must treat `null`
742
742
  * as "unknown", never as line 0.
@@ -903,6 +903,15 @@ type WidgetSpec = {
903
903
  "kind": "row";
904
904
  children: Array<WidgetSpec>;
905
905
  key?: string | null;
906
+ /**
907
+ * When true, children that don't fit on one line reflow onto
908
+ * additional lines (growing the row's height) instead of being
909
+ * truncated. Children are never split — wrap happens at child
910
+ * boundaries — so wrap a logical group (e.g. a toggle + its
911
+ * accelerator) in a nested non-wrapping `Row` to keep it intact.
912
+ * Ignored when the row contains multi-line (block) children.
913
+ */
914
+ wrap: boolean;
906
915
  } | {
907
916
  "kind": "col";
908
917
  children: Array<WidgetSpec>;
@@ -1213,6 +1222,16 @@ interface SearchHandle {
1213
1222
  take(): SearchTakeResult;
1214
1223
  cancel(): void;
1215
1224
  }
1225
+ type ReplaceResult = {
1226
+ /**
1227
+ * Number of replacements made
1228
+ */
1229
+ replacements: number;
1230
+ /**
1231
+ * Buffer ID of the edited buffer
1232
+ */
1233
+ bufferId: number;
1234
+ };
1216
1235
  type AuthorityFilesystem = {
1217
1236
  kind: "local";
1218
1237
  };
@@ -1496,16 +1515,6 @@ type RemoteIndicatorStatePayload = {
1496
1515
  kind: "disconnected";
1497
1516
  label?: string | null;
1498
1517
  };
1499
- type ReplaceResult = {
1500
- /**
1501
- * Number of replacements made
1502
- */
1503
- replacements: number;
1504
- /**
1505
- * Buffer ID of the edited buffer
1506
- */
1507
- bufferId: number;
1508
- };
1509
1518
  type SpawnResult = {
1510
1519
  /**
1511
1520
  * Complete stdout as string
@@ -1629,6 +1638,13 @@ interface EditorAPI {
1629
1638
  */
1630
1639
  executeAction(actionName: string): boolean;
1631
1640
  /**
1641
+ * Cancel the active prompt / overlay — the same teardown the
1642
+ * Escape key triggers. Lets a plugin dismiss a prompt it opened
1643
+ * (e.g. exporting Live Grep results to a dock panel) without
1644
+ * routing a synthetic keypress.
1645
+ */
1646
+ cancelPrompt(): boolean;
1647
+ /**
1632
1648
  * Register a custom statusbar token.
1633
1649
  * Token will be named "plugin_name:token_name" where plugin_name is the current plugin.
1634
1650
  * Returns true if registration succeeded, false if invalid or already registered.
@@ -2126,21 +2142,21 @@ interface EditorAPI {
2126
2142
  */
2127
2143
  getDataDir(): string;
2128
2144
  /**
2129
- * Per-working-directory data root for plugin state that should be scoped
2130
- * to the current project root / worktree rather than shared across all of
2131
- * them (`<data_dir>/workdirs/<encoded-cwd>/`). Prefer this over
2132
- * `getDataDir()` for per-project state; the directory is not created for
2133
- * you. Note: terminal scrollback and orchestrator state use their own
2134
- * dedicated layouts (see `getTerminalDir()`), not this root.
2135
- */
2136
- getWorkingDataDir(): string;
2137
- /**
2138
2145
  * Directory holding terminal scrollback backing files for the current
2139
2146
  * working directory. Each project root / worktree has its own subdir, so
2140
- * a search can stay scoped to the active project's terminals.
2147
+ * Universal Search's terminal scope can stay scoped to the active
2148
+ * project rather than spanning every project's terminals.
2141
2149
  */
2142
2150
  getTerminalDir(): string;
2143
2151
  /**
2152
+ * Per-working-directory data root for plugin state scoped to the current
2153
+ * project root / worktree (`<data_dir>/workdirs/<encoded-cwd>/`). Use
2154
+ * instead of `getDataDir()` for state that should not be shared across
2155
+ * worktrees. The directory is not created here — callers create what
2156
+ * they need under it.
2157
+ */
2158
+ getWorkingDataDir(): string;
2159
+ /**
2144
2160
  * Get themes directory path
2145
2161
  */
2146
2162
  getThemesDir(): string;
@@ -2274,6 +2290,12 @@ interface EditorAPI {
2274
2290
  */
2275
2291
  clearOverlaysInRange(bufferId: number, start: number, end: number): boolean;
2276
2292
  /**
2293
+ * Clear overlays in a single namespace that overlap with a byte range.
2294
+ * Unlike clearOverlaysInRange, overlays in other namespaces (e.g.
2295
+ * editor-owned LSP diagnostics) are left untouched.
2296
+ */
2297
+ clearOverlaysInRangeForNamespace(bufferId: number, namespace: string, start: number, end: number): boolean;
2298
+ /**
2277
2299
  * Remove an overlay by its handle
2278
2300
  */
2279
2301
  removeOverlay(bufferId: number, handle: string): boolean;
@@ -2479,26 +2501,21 @@ interface EditorAPI {
2479
2501
  */
2480
2502
  setPromptFooter(footer: StyledText[]): boolean;
2481
2503
  /**
2482
- * Set the floating-overlay prompt's header toolbar as a `WidgetSpec`
2483
- * (real, clickable `Toggle`/`Button` widgets), rendered in place of the
2484
- * styled-text title. Give each control a `key` equal to the action it
2485
- * should fire on click (e.g. `"live_grep_toggle_files"`). Pass `null` to
2486
- * clear it. No visible effect on non-overlay prompts.
2504
+ * Set the floating-overlay prompt's input-row status text (right-aligned,
2505
+ * left of the match count). Empty string clears it.
2487
2506
  */
2488
- setPromptToolbar(spec: WidgetSpec | null): boolean;
2507
+ setPromptStatus(status: string): boolean;
2489
2508
  /**
2490
- * Set the floating-overlay prompt's input-row status text, shown
2491
- * right-aligned just left of the `selected / total` count (e.g.
2492
- * "Searching…", "No matches"). Empty string clears it. No effect on
2493
- * non-overlay prompts.
2509
+ * Set the floating-overlay prompt's toolbar as a `WidgetSpec` (real,
2510
+ * clickable `Toggle`/`Button` widgets rendered in the header band, in
2511
+ * place of the styled-text title). Pass `null`/`undefined` to clear it.
2494
2512
  */
2495
- setPromptStatus(status: string): boolean;
2513
+ setPromptToolbar(specObj: unknown): boolean;
2496
2514
  /**
2497
2515
  * Toggle a floating-overlay toolbar control by its widget `key`. The host
2498
- * owns the toggle's checked state, flips it in place, and emits a
2499
- * `widget_event` (`event_type: "toggle"`, payload `{ checked }`). Use this
2500
- * to route a plugin's own keyboard shortcut through the same host path as
2501
- * a click or Space on the toggle, then react in your `widget_event` handler.
2516
+ * owns the toggle's checked state, flips it, and emits a `widget_event`
2517
+ * the plugin can listen for. Lets a plugin route its own Alt+… shortcut
2518
+ * through the same host path as a click / Space on the toggle.
2502
2519
  */
2503
2520
  toggleOverlayToolbarWidget(key: string): boolean;
2504
2521
  /**
@@ -2966,13 +2983,14 @@ interface EditorAPI {
2966
2983
  caseSensitive?: boolean;
2967
2984
  maxResults?: number;
2968
2985
  wholeWords?: boolean;
2986
+ sourceBufferId?: number;
2969
2987
  }): SearchHandle;
2970
2988
  /**
2971
2989
  * Replace matches in a file's buffer (async)
2972
2990
  * Opens the file if not already in a buffer, applies edits via the buffer model,
2973
2991
  * and saves. All edits are grouped as a single undo action.
2974
2992
  */
2975
- replaceInFile(filePath: string, matches: number[][], replacement: string): Promise<ReplaceResult>;
2993
+ replaceInFile(filePath: string, matches: number[][], replacement: string, bufferId?: number): Promise<ReplaceResult>;
2976
2994
  /**
2977
2995
  * Send LSP request (async, returns request_id)
2978
2996
  */
@@ -978,7 +978,18 @@ async function recompute(bufferId: number): Promise<void> {
978
978
  }
979
979
 
980
980
  const length = editor.getBufferLength(bufferId);
981
- const newText = await editor.getBufferText(bufferId, 0, length);
981
+ let newText: string;
982
+ try {
983
+ newText = await editor.getBufferText(bufferId, 0, length);
984
+ } catch (e) {
985
+ // The buffer can close between the awaits above (the git `show`
986
+ // for the reference is slow) and this fetch — e.g. the user
987
+ // closes the file, or an external process churns it while it's
988
+ // open. `buffer_closed` deletes our state, so if it's gone the
989
+ // recompute is moot: bail quietly instead of logging an error.
990
+ if (!states.has(bufferId)) return;
991
+ throw e;
992
+ }
982
993
 
983
994
  // Skip 1: same buffer text as last recompute. `lines_changed` fires
984
995
  // on viewport scrolls (cursor up/down past the visible area), and
@@ -179,6 +179,11 @@ let overlayActive = false;
179
179
  // pre-filled (rather than a bespoke cached-results overlay).
180
180
  let lastQuery = "";
181
181
 
182
+ // The most recent merged result set, captured by `search` so the
183
+ // Quickfix export can snapshot exactly what the user is looking at into
184
+ // the dock panel without re-running the search.
185
+ let lastResults: GrepMatch[] = [];
186
+
182
187
  // ── Search modes ──────────────────────────────────────────────────
183
188
  //
184
189
  // Separate from *where* we search (scopes): these control *how* the
@@ -314,7 +319,7 @@ function buildToolbarSpec(provider: LiveGrepProvider | null): WidgetSpec {
314
319
  // an atomic group of `toggle + accelerator` — so the wrapping parent never
315
320
  // splits a label from its `Alt+…` hint across lines.
316
321
  const prefix = (text: string): WidgetSpec =>
317
- raw([styledRow([{ text, style: { fg: "ui.popup_border_fg" } }])]);
322
+ raw([styledRow([{ text, style: { fg: "ui.suggestion_fg" } }])]);
318
323
 
319
324
  const sources: WidgetSpec[] = [spacer(1), prefix(editor.t("label.search_in"))];
320
325
  SCOPES.forEach((s) => {
@@ -355,13 +360,14 @@ function buildToolbarSpec(provider: LiveGrepProvider | null): WidgetSpec {
355
360
  function buildMetaRow(provider: LiveGrepProvider | null): WidgetSpec | null {
356
361
  const hintStyle = { fg: "ui.help_key_fg" };
357
362
  const sepStyle = { fg: "ui.popup_border_fg" };
363
+ const labelStyle = { fg: "ui.suggestion_fg" };
358
364
  const parts: WidgetSpec[] = [];
359
365
 
360
366
  // Provider button — only when a file-backed scope is on (irrelevant when
361
367
  // searching only buffers/terminals/diagnostics). The button is keyed
362
368
  // "provider"; activating it (click / Space / Alt+P) cycles the backend.
363
369
  if (provider && (scopeEnabled.files || scopeEnabled.ignored)) {
364
- parts.push(raw([styledRow([{ text: "Provider: ", style: sepStyle }])]));
370
+ parts.push(raw([styledRow([{ text: "Provider: ", style: labelStyle }])]));
365
371
  parts.push(button(provider.name, { key: "provider" }));
366
372
  const pAccel = editor.getKeybindingLabel("cycle_live_grep_provider", "prompt");
367
373
  if (pAccel) {
@@ -661,6 +667,63 @@ const finder = new Finder<GrepMatch>(editor, {
661
667
  maxResults: MAX_RESULTS,
662
668
  });
663
669
 
670
+ // The Quickfix list is just another "list of locations" surface, so it
671
+ // rides the same Finder panel abstraction as Diagnostics and Find
672
+ // References (dockable via `useUtilityDock`, Enter → openFile for free).
673
+ // Exporting hands it a static snapshot of the current matches; the
674
+ // bespoke Rust quickfix buffer it replaced is gone.
675
+ const quickfixFinder = new Finder<GrepMatch>(editor, {
676
+ id: "quickfix",
677
+ format: (match) => ({
678
+ label: `${match.file}:${match.line}:${match.column}`,
679
+ description: match.content.trim(),
680
+ location: {
681
+ file: match.file,
682
+ line: match.line,
683
+ column: match.column,
684
+ },
685
+ }),
686
+ useUtilityDock: true,
687
+ // Keep the Quickfix list docked when jumping to an entry — like Vim's
688
+ // quickfix and VS Code's results list — so the user can step through
689
+ // matches. (Find References / Diagnostics keep the default close.)
690
+ closeOnSelect: false,
691
+ });
692
+
693
+ // Snapshot the current Live Grep results into the Quickfix dock panel.
694
+ // Bound to the `live_grep_export_quickfix` keybinding (Alt+M / Alt+Q,
695
+ // when=prompt) — a plain plugin action now that the Rust action is gone.
696
+ function exportQuickfix(): void {
697
+ if (!overlayActive) return;
698
+ if (lastResults.length === 0) {
699
+ editor.setStatus("No Live Grep results to export");
700
+ return;
701
+ }
702
+ const query = lastQuery;
703
+ const matches = lastResults;
704
+ // Dismiss the host overlay first so the panel opens against the editor
705
+ // pane (which is then routed into the dock), not behind the overlay.
706
+ // `cancelPrompt` is the same teardown Escape triggers; the plugin's
707
+ // own prompt state is cleared via the resulting `prompt_cancelled`
708
+ // hook.
709
+ editor.cancelPrompt();
710
+ void quickfixFinder.panel({
711
+ title: `Quickfix: ${query} (${matches.length} matches)`,
712
+ items: matches,
713
+ });
714
+ }
715
+ registerHandler("live_grep_export_quickfix", exportQuickfix);
716
+ // Register the action→handler mapping so the `live_grep_export_quickfix`
717
+ // keybinding (a PluginAction now) resolves across the plugin boundary.
718
+ // A never-activated context keeps it out of the palette — it's only
719
+ // meaningful from inside the Live Grep overlay.
720
+ editor.registerCommand(
721
+ "Live Grep: Export to Quickfix (internal)",
722
+ "",
723
+ "live_grep_export_quickfix",
724
+ "live-grep-internal"
725
+ );
726
+
664
727
  /**
665
728
  * Switch to the next *available* registered provider, in priority
666
729
  * order, wrapping at the end. Unavailable providers (those whose
@@ -936,6 +999,7 @@ async function search(query: string): Promise<GrepMatch[]> {
936
999
  if (lastSearchTruncated !== wasTruncated) {
937
1000
  updateOverlayTitle(cachedSelected ?? null);
938
1001
  }
1002
+ lastResults = results;
939
1003
  return results;
940
1004
  }
941
1005
 
@@ -1046,7 +1046,9 @@ function processLineConceals(
1046
1046
  // the one-frame glitch where conceals are cleared but not yet rebuilt.
1047
1047
  editor.debug(`[mc] processLine clear+rebuild bytes=${byteStart}..${byteEnd} content="${lineContent.slice(0,40)}"`);
1048
1048
  editor.clearConcealsInRange(bufferId, byteStart, byteEnd);
1049
- editor.clearOverlaysInRange(bufferId, byteStart, byteEnd);
1049
+ // Only clear our own emphasis overlays — clearing ALL overlays in the range
1050
+ // would also wipe editor-owned overlays like LSP diagnostics (issue #2146).
1051
+ editor.clearOverlaysInRangeForNamespace(bufferId, "md-emphasis", byteStart, byteEnd);
1050
1052
 
1051
1053
  const cursorOnLine = cursors.some(c => c >= byteStart && c <= byteEnd);
1052
1054
  // Strict version: excludes the boundary at byteEnd so that the cursor