@fresh-editor/fresh-editor 0.3.5 → 0.3.7

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +9 -2
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.i18n.json +84 -0
  5. package/plugins/audit_mode.ts +139 -3
  6. package/plugins/config-schema.json +33 -3
  7. package/plugins/dashboard.ts +34 -111
  8. package/plugins/flash.ts +22 -4
  9. package/plugins/git_blame.ts +10 -6
  10. package/plugins/git_log.ts +705 -323
  11. package/plugins/git_statusbar.i18n.json +72 -0
  12. package/plugins/git_statusbar.ts +133 -0
  13. package/plugins/goto_with_selection.i18n.json +58 -0
  14. package/plugins/goto_with_selection.ts +17 -0
  15. package/plugins/lib/fresh.d.ts +911 -15
  16. package/plugins/lib/index.ts +34 -0
  17. package/plugins/lib/widgets.ts +903 -0
  18. package/plugins/live_diff.ts +442 -32
  19. package/plugins/merge_conflict.ts +89 -64
  20. package/plugins/orchestrator.ts +3425 -0
  21. package/plugins/pkg.ts +235 -54
  22. package/plugins/rust-lsp.ts +58 -40
  23. package/plugins/schemas/theme.schema.json +18 -0
  24. package/plugins/search_replace.i18n.json +140 -28
  25. package/plugins/search_replace.ts +1335 -515
  26. package/plugins/tab_actions.i18n.json +212 -0
  27. package/plugins/tab_actions.ts +76 -0
  28. package/plugins/theme_editor.i18n.json +112 -0
  29. package/plugins/theme_editor.ts +30 -5
  30. package/plugins/tsconfig.json +3 -0
  31. package/plugins/vi_mode.ts +49 -17
  32. package/themes/dark.json +1 -0
  33. package/themes/dracula.json +1 -0
  34. package/themes/high-contrast.json +1 -0
  35. package/themes/light.json +1 -0
  36. package/themes/nord.json +1 -0
  37. package/themes/nostalgia.json +1 -0
  38. package/themes/solarized-dark.json +1 -0
  39. package/themes/terminal.json +4 -0
@@ -1,4 +1,27 @@
1
1
  /// <reference path="./lib/fresh.d.ts" />
2
+ import {
3
+ button,
4
+ col,
5
+ flexSpacer,
6
+ hintBar,
7
+ key as widgetKey,
8
+ parseHintString,
9
+ raw,
10
+ row,
11
+ spacer,
12
+ type StyledSegment,
13
+ styledRow,
14
+ textInput,
15
+ textInputChar,
16
+ toggle,
17
+ tree,
18
+ treeNode,
19
+ type TreeNode,
20
+ type WidgetAction,
21
+ WidgetPanel,
22
+ type WidgetSpec,
23
+ } from "./lib/widgets.ts";
24
+
2
25
  const editor = getEditor();
3
26
 
4
27
  /**
@@ -45,15 +68,55 @@ interface PanelState {
45
68
  caseSensitive: boolean;
46
69
  useRegex: boolean;
47
70
  wholeWords: boolean;
71
+ // Scope (§1): when false, results are restricted to `sourceBufferPath`.
72
+ // `sourceBufferPath` is the absolute path of the buffer that was
73
+ // active when the panel opened; `sourceBufferRelPath` is the
74
+ // cwd-relative display form. Empty path means the source buffer was
75
+ // unsaved/virtual; in that case the "current file" mode degrades to
76
+ // "no matches" and the toggle visually still flips, but the user
77
+ // can't usefully restrict to an unnamed buffer.
78
+ allFiles: boolean;
79
+ sourceBufferPath: string;
80
+ sourceBufferRelPath: string;
48
81
  // Layout
49
82
  viewportWidth: number;
50
83
  // State
51
84
  busy: boolean;
85
+ /** True once the current `searchPattern` has been used to run a real
86
+ * search to completion. Reset whenever the pattern is mutated (or a
87
+ * search-affecting toggle changes). Distinguishes "user is typing,
88
+ * no search has happened" from "search ran and found nothing", so we
89
+ * don't show a misleading "No matches" placeholder before any work
90
+ * has been done. See §17 of
91
+ * `docs/internal/search-replace-scope-replan-on-widgets.md`. */
92
+ searchPerformed: boolean;
52
93
  truncated: boolean;
53
94
  // Inline editing cursor position
54
95
  cursorPos: number;
55
96
  // Virtual scroll offset for matches tree
56
97
  scrollOffset: number;
98
+ // Per-file expansion state mirrored from the Tree widget's host
99
+ // instance state. The widget owns expansion (host re-renders on
100
+ // disclosure click / Right / Left without the plugin reacting);
101
+ // this set is only read by the plugin's `activate` handler so
102
+ // Enter on a file row can toggle expansion via
103
+ // `panel.setExpandedKeys`. Both sets are cleared at the start of
104
+ // every fresh search.
105
+ expandedFileKeys: Set<string>;
106
+ // Memo of file-row keys we've already seen during the current
107
+ // search. Used by `buildMatchListSpec` to auto-expand newly-
108
+ // discovered files (default = expanded) without overriding user
109
+ // collapse state on previously-seen files.
110
+ knownFileKeys: Set<string>;
111
+ // Widget panel handle. The panel mounts a `Col[Raw{body}, HintBar{hints}]`
112
+ // spec — the body keeps the existing hand-rolled rendering for now,
113
+ // and the footer is built by the host's HintBar widget so its keys are
114
+ // styled consistently with every other plugin's footer (theme-keyed
115
+ // `ui.help_key_fg`). Subsequent migration passes will pull the
116
+ // search/replace inputs, the toggles, and the match tree out of
117
+ // `Raw` and into typed widgets. See
118
+ // `docs/internal/plugin-widget-library-design.md` §10.
119
+ widgetPanel: WidgetPanel | null;
57
120
  }
58
121
  let panel: PanelState | null = null;
59
122
 
@@ -64,6 +127,57 @@ const SEARCH_DEBOUNCE_MS = 150;
64
127
 
65
128
  let searchDebounceGeneration = 0;
66
129
 
130
+ /** Most-recent-first history of search patterns, capped at HISTORY_MAX.
131
+ * Up arrow in the search field walks back into older entries; Down
132
+ * walks forward. Persistence across editor restarts is a follow-up.
133
+ * See §11 of docs/internal/search-replace-scope-replan-on-widgets.md. */
134
+ const searchHistory: string[] = [];
135
+ const HISTORY_MAX = 20;
136
+ /** -1 = not navigating history. 0..searchHistory.length-1 = currently
137
+ * displaying the history entry at that index. */
138
+ let historyIndex = -1;
139
+ /** Whatever the user had in the search field before they pressed Up
140
+ * to enter history-walk mode. Restored when they Down past the most
141
+ * recent history entry. */
142
+ let historySavedPattern: string | null = null;
143
+ /** Most recent widget_event we saw a widget_key for. Used to decide
144
+ * whether Up/Down should walk history (when focus appears to be on
145
+ * the search field) or fall through to the widget runtime. The
146
+ * widget runtime doesn't expose focus directly to the plugin, but
147
+ * every event that's relevant (change/select/toggle/activate/expand)
148
+ * carries widget_key. Best-effort proxy. */
149
+ let lastFocusedWidget: string | null = null;
150
+
151
+ function historyPush(pattern: string): void {
152
+ if (!pattern) return;
153
+ const existing = searchHistory.indexOf(pattern);
154
+ if (existing === 0) return;
155
+ if (existing > 0) searchHistory.splice(existing, 1);
156
+ searchHistory.unshift(pattern);
157
+ if (searchHistory.length > HISTORY_MAX) {
158
+ searchHistory.length = HISTORY_MAX;
159
+ }
160
+ }
161
+
162
+ // "Has the user settled on this query?" lives on a separate timer
163
+ // from the 150ms search debounce. Pushing to history on every
164
+ // debounce tick captures intermediate prefixes (typing "foo bar"
165
+ // in fits and starts → "f", "fo", "foo", … all end up in history).
166
+ // Wait 2 seconds of pattern-stability before pushing; any change
167
+ // cancels the pending push.
168
+ const HISTORY_SETTLE_MS = 2000;
169
+ let historySettleGeneration = 0;
170
+ function scheduleHistoryPush(pattern: string): void {
171
+ if (!pattern) return;
172
+ const gen = ++historySettleGeneration;
173
+ editor.delay(HISTORY_SETTLE_MS).then(() => {
174
+ if (gen !== historySettleGeneration) return;
175
+ if (!panel || panel.searchPattern !== pattern) return;
176
+ if (historyIndex >= 0) return; // walking history; not user input
177
+ historyPush(pattern);
178
+ });
179
+ }
180
+
67
181
  // =============================================================================
68
182
  // Colors
69
183
  // =============================================================================
@@ -123,7 +237,6 @@ function truncate(s: string, maxLen: number): string {
123
237
  const sLen = charLen(s);
124
238
  if (sLen <= maxLen) return s;
125
239
  if (maxLen <= 3) {
126
- // Take first maxLen codepoints
127
240
  let result = "";
128
241
  let count = 0;
129
242
  for (const c of s) {
@@ -133,7 +246,6 @@ function truncate(s: string, maxLen: number): string {
133
246
  }
134
247
  return result;
135
248
  }
136
- // Take first (maxLen-3) codepoints + "..."
137
249
  let result = "";
138
250
  let count = 0;
139
251
  for (const c of s) {
@@ -154,6 +266,10 @@ function getActiveFieldText(): string {
154
266
  function setActiveFieldText(text: string): void {
155
267
  if (!panel) return;
156
268
  if (panel.queryField === "search") {
269
+ if (panel.searchPattern !== text) {
270
+ // Pattern changed → any cached result no longer applies. See §17.
271
+ panel.searchPerformed = false;
272
+ }
157
273
  panel.searchPattern = text;
158
274
  } else {
159
275
  panel.replaceText = text;
@@ -173,6 +289,8 @@ const modeBindings: [string, string][] = [
173
289
  ["S-Tab", "search_replace_shift_tab"],
174
290
  ["Up", "search_replace_nav_up"],
175
291
  ["Down", "search_replace_nav_down"],
292
+ ["PageUp", "search_replace_nav_page_up"],
293
+ ["PageDown", "search_replace_nav_page_down"],
176
294
  ["Left", "search_replace_nav_left"],
177
295
  ["Right", "search_replace_nav_right"],
178
296
  ["M-c", "search_replace_toggle_case"],
@@ -189,21 +307,14 @@ const modeBindings: [string, string][] = [
189
307
 
190
308
  editor.defineMode("search-replace-list", modeBindings, true, true);
191
309
 
192
- // Single handler for all character input (any keyboard layout, including Unicode)
193
- function insertCharAtCursor(ch: string): void {
194
- if (!panel || panel.focusPanel !== "query") return;
195
- const text = getActiveFieldText();
196
- const pos = panel.cursorPos;
197
- setActiveFieldText(text.slice(0, pos) + ch + text.slice(pos));
198
- panel.cursorPos = pos + ch.length;
199
- updatePanelContent();
200
- }
201
-
202
- // Handler for mode_text_input events dispatched by the mode system
310
+ // Printable input flows through the widget runtime: mode_text_input
311
+ // → widgetCommand(textInputChar(text)) host computes new value +
312
+ // cursor on the focused TextInput → widget_event "change" → plugin
313
+ // updates its model from the event payload (see the widget_event
314
+ // handler at the bottom of the file).
203
315
  function mode_text_input(args: { text: string }): void {
204
- if (args && args.text) {
205
- insertCharAtCursor(args.text);
206
- }
316
+ if (!panel || !args?.text) return;
317
+ panel.widgetPanel?.command(textInputChar(args.text));
207
318
  }
208
319
  registerHandler("mode_text_input", mode_text_input);
209
320
 
@@ -252,16 +363,19 @@ interface FlatItem {
252
363
  matchIndex?: number;
253
364
  }
254
365
 
366
+ // Emit every file row + every match row in declaration order. The
367
+ // Tree widget filters out descendants of collapsed nodes at render
368
+ // time — the plugin always sends the full hierarchy. Plugin code
369
+ // that needs to map a `selected_index` back to the underlying match
370
+ // (e.g. `doReplaceScoped`) walks this same flat list.
255
371
  function buildFlatItems(): FlatItem[] {
256
372
  if (!panel) return [];
257
373
  const items: FlatItem[] = [];
258
374
  for (let fi = 0; fi < panel.fileGroups.length; fi++) {
259
- const group = panel.fileGroups[fi];
260
375
  items.push({ type: "file", fileIndex: fi });
261
- if (group.expanded) {
262
- for (let mi = 0; mi < group.matches.length; mi++) {
263
- items.push({ type: "match", fileIndex: fi, matchIndex: mi });
264
- }
376
+ const group = panel.fileGroups[fi];
377
+ for (let mi = 0; mi < group.matches.length; mi++) {
378
+ items.push({ type: "match", fileIndex: fi, matchIndex: mi });
265
379
  }
266
380
  }
267
381
  return items;
@@ -287,125 +401,395 @@ function getViewportHeight(): number {
287
401
  // Panel content builder — compact two-line control bar + match tree
288
402
  // =============================================================================
289
403
 
290
- function buildPanelEntries(): TextPropertyEntry[] {
291
- if (!panel) return [];
292
- const { searchPattern, replaceText, searchResults, fileGroups, focusPanel, queryField,
293
- optionIndex, caseSensitive, useRegex, wholeWords, cursorPos } = panel;
294
-
404
+ // Build the typed Row spec for the options line (3 toggles + Replace
405
+ // All button). Was previously hand-built into entries with manual
406
+ // byte-offset overlay arithmetic (see git history pre-widget); now
407
+ // dispatched through the host's Toggle/Button widgets so styling,
408
+ // theme keys, and focus affordance match every other plugin.
409
+ function buildOptionsRowSpec(): WidgetSpec {
410
+ if (!panel) return col();
411
+ const { focusPanel, optionIndex, caseSensitive, useRegex, wholeWords, allFiles } = panel;
295
412
  const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
296
- const entries: TextPropertyEntry[] = [];
413
+ const oFocus = focusPanel === "options";
297
414
 
298
- const totalMatches = searchResults.length;
299
- const fileCount = fileGroups.length;
415
+ const caseLabel = editor.t("panel.case_toggle");
416
+ const regexLabel = editor.t("panel.regex_toggle");
417
+ const wholeLabel = editor.t("panel.whole_toggle");
418
+ const allFilesLabel = editor.t("panel.all_files_toggle");
419
+ // Replace All button label tracks scope (§1):
420
+ // * allFiles=true → "Replace All (Alt+Ret)"
421
+ // * allFiles=false → "Replace All in <file> (Alt+Ret)"
422
+ // sourceBufferRelPath is empty for an unsaved buffer, in which
423
+ // case we fall back to the all-files label since restricting to
424
+ // a path-less buffer can't match anything anyway.
425
+ const replLabel = (!allFiles && panel.sourceBufferRelPath)
426
+ ? editor.t("panel.replace_all_in_file_btn", { file: panel.sourceBufferRelPath })
427
+ : editor.t("panel.replace_all_btn");
428
+ void oFocus;
429
+ void optionIndex;
430
+ void W;
431
+
432
+ return row(
433
+ spacer(1),
434
+ toggle(allFiles, allFilesLabel, { key: "allFiles" }),
435
+ spacer(2),
436
+ toggle(caseSensitive, caseLabel, { key: "case" }),
437
+ spacer(2),
438
+ toggle(useRegex, regexLabel, { key: "regex" }),
439
+ spacer(2),
440
+ toggle(wholeWords, wholeLabel, { key: "whole" }),
441
+ flexSpacer(),
442
+ button(replLabel, { intent: "primary", key: "replaceAll" }),
443
+ );
444
+ }
445
+
446
+ // Build the scope-info row shown only when allFiles=false. Tells the
447
+ // user which single file the search is restricted to. When allFiles=true
448
+ // the function returns an empty col() (the spec composer skips it).
449
+ function buildScopeRowSpec(): WidgetSpec {
450
+ if (!panel) return col();
451
+ if (panel.allFiles) return col();
452
+ const label = panel.sourceBufferRelPath
453
+ ? editor.t("panel.scope_row_file", { file: panel.sourceBufferRelPath })
454
+ : editor.t("panel.scope_row_unnamed");
455
+ return raw([{
456
+ text: " " + label,
457
+ properties: { type: "scope-row" },
458
+ style: { fg: C.label, italic: true },
459
+ }]);
460
+ }
461
+
462
+ // Build the typed Row spec for line 1 (search + replace fields with
463
+ // trailing match-count stats). Was previously hand-rolled with two
464
+ // `buildFieldDisplay` calls + manual cursor overlays; now uses the
465
+ // host's TextInput widget for both fields (theme-keyed focus + input
466
+ // background, cursor highlight at the right byte position). The
467
+ // match-stats portion stays in Raw because it has bespoke
468
+ // truncated-warning styling (`[255, 180, 50]`) and isn't a control.
469
+ // Build just the matchStats text + inline overlays. Pulled out of
470
+ // `buildLine1Spec` so the streaming pump can refresh it via the
471
+ // `setRawEntries` mutation on the keyed `matchStats` raw widget —
472
+ // without re-emitting the full panel spec (which forces js_to_json
473
+ // over the entire 5 000-node tree and blocks the JS thread).
474
+ function buildMatchStatsEntries(): TextPropertyEntry[] {
475
+ if (!panel) return [];
476
+ const totalMatches = panel.searchResults.length;
477
+ const fileCount = panel.fileGroups.length;
478
+ const truncated = panel.truncated;
479
+ const truncatedSuffix = truncated ? " " + editor.t("panel.limited") : "";
480
+ let matchStats = "";
481
+ if (totalMatches > 0) {
482
+ matchStats = " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) }) + truncatedSuffix;
483
+ } else if (panel.busy && panel.searchPattern) {
484
+ matchStats = " " + editor.t("panel.searching");
485
+ } else if (panel.searchPattern && panel.searchPerformed && !panel.busy) {
486
+ matchStats = " " + editor.t("panel.no_matches");
487
+ }
488
+ if (matchStats.length === 0) return [];
489
+ const overlays: InlineOverlay[] = [];
490
+ if (truncated && totalMatches > 0) {
491
+ const statsWithoutSuffix = " " + editor.t("panel.match_stats", {
492
+ count: String(totalMatches),
493
+ files: String(fileCount),
494
+ });
495
+ const countEnd = byteLen(statsWithoutSuffix);
496
+ overlays.push({ start: 0, end: countEnd, style: { fg: C.statusOk } });
497
+ overlays.push({
498
+ start: countEnd,
499
+ end: countEnd + byteLen(truncatedSuffix),
500
+ style: { fg: [255, 180, 50] as RGB, bold: true },
501
+ });
502
+ } else {
503
+ overlays.push({
504
+ start: 0,
505
+ end: byteLen(matchStats),
506
+ style: { fg: totalMatches > 0 ? C.statusOk : C.statusDim },
507
+ });
508
+ }
509
+ return [{ text: matchStats, inlineOverlays: overlays }];
510
+ }
300
511
 
301
- // ── Line 1: Query fields + match count ──
512
+ function buildLine1Spec(): WidgetSpec {
513
+ if (!panel) return col();
514
+ const { searchPattern, replaceText, focusPanel, queryField, cursorPos } = panel;
302
515
  const qFocusSearch = focusPanel === "query" && queryField === "search";
303
516
  const qFocusReplace = focusPanel === "query" && queryField === "replace";
304
-
305
- // Build search field display with cursor
306
517
  const searchVal = searchPattern || "";
307
518
  const replaceVal = replaceText || "";
308
- const searchCursorPos = qFocusSearch ? cursorPos : -1;
309
- const replaceCursorPos = qFocusReplace ? cursorPos : -1;
310
-
311
- const searchDisp = buildFieldDisplay(searchVal, searchCursorPos, 25);
312
- const replDisp = buildFieldDisplay(replaceVal, replaceCursorPos, 25);
313
-
314
- const searchLabel = " " + editor.t("panel.search_label") + " ";
315
- const replSep = " " + editor.t("panel.replace_label") + " ";
316
- const truncatedSuffix = panel.truncated ? " " + editor.t("panel.limited") : "";
317
- const matchStats = totalMatches > 0
318
- ? " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) }) + truncatedSuffix
319
- : (searchPattern ? " " + editor.t("panel.no_matches") : "");
320
-
321
- const line1Text = searchLabel + searchDisp + replSep + replDisp + matchStats;
322
- const line1 = padStr(line1Text, W);
323
-
324
- const line1Overlays: InlineOverlay[] = [];
325
- // Search label
326
- line1Overlays.push({ start: byteLen(" "), end: byteLen(searchLabel), style: { fg: C.label } });
327
- // Search value
328
- const svStart = byteLen(searchLabel);
329
- const svEnd = svStart + byteLen(searchDisp);
330
- line1Overlays.push({ start: svStart, end: svEnd, style: { fg: C.value, bg: qFocusSearch ? C.inputBg : undefined } });
331
- // Cursor highlight in search field
332
- if (qFocusSearch) {
333
- addCursorOverlay(searchVal, searchCursorPos, svStart + byteLen("["), line1Overlays);
334
- }
335
- // Replace label
336
- const rlStart = svEnd;
337
- const rlEnd = rlStart + byteLen(replSep);
338
- line1Overlays.push({ start: rlStart, end: rlEnd, style: { fg: C.label } });
339
- // Replace value
340
- const rvStart = rlEnd;
341
- const rvEnd = rvStart + byteLen(replDisp);
342
- line1Overlays.push({ start: rvStart, end: rvEnd, style: { fg: C.value, bg: qFocusReplace ? C.inputBg : undefined } });
343
- // Cursor highlight in replace field
344
- if (qFocusReplace) {
345
- addCursorOverlay(replaceVal, replaceCursorPos, rvStart + byteLen("["), line1Overlays);
519
+ // The plugin tracks `cursorPos` as a character offset; the widget
520
+ // wants a UTF-8 byte offset. For ASCII they're equal; for the
521
+ // multi-byte case we convert via byteLen of the prefix.
522
+ const searchCursorByte = qFocusSearch ? byteLen(searchVal.substring(0, cursorPos)) : -1;
523
+ const replaceCursorByte = qFocusReplace ? byteLen(replaceVal.substring(0, cursorPos)) : -1;
524
+ const searchLabel = editor.t("panel.search_label");
525
+ const replLabel = editor.t("panel.replace_label");
526
+
527
+ return row(
528
+ spacer(1),
529
+ textInput(searchVal, {
530
+ label: searchLabel,
531
+ focused: qFocusSearch,
532
+ cursorByte: searchCursorByte,
533
+ fieldWidth: 25,
534
+ key: "searchField",
535
+ }),
536
+ spacer(2),
537
+ textInput(replaceVal, {
538
+ label: replLabel,
539
+ focused: qFocusReplace,
540
+ cursorByte: replaceCursorByte,
541
+ fieldWidth: 25,
542
+ key: "replaceField",
543
+ }),
544
+ raw(buildMatchStatsEntries(), "matchStats"),
545
+ );
546
+ }
547
+
548
+ // Stable key for a flat tree item — used as the List item key so
549
+ // click events bounce back to the same logical match across
550
+ // re-renders. File rows use `file:<n>`; match rows use
551
+ // `match:<file>/<m>`.
552
+ function flatItemKey(item: FlatItem): string {
553
+ if (item.type === "file") return `file:${item.fileIndex}`;
554
+ return `match:${item.fileIndex}/${item.matchIndex}`;
555
+ }
556
+
557
+ // Render one flat tree item as a single TextPropertyEntry. The
558
+ // Tree widget owns the indent (depth * 2 spaces) + disclosure glyph
559
+ // (▶ / ▼) prefix and the selection bg — this function emits *just*
560
+ // the row's content starting from offset 0 of the row's body. Files
561
+ // pass `depth: 0, hasChildren: true`; matches pass `depth: 1,
562
+ // hasChildren: false` (see `buildMatchListSpec`).
563
+ //
564
+ // Row content is described as a sequence of styled segments rather
565
+ // than a pre-rendered string + offset overlays. The host concats
566
+ // segments and computes the byte offsets natively in Rust, so the
567
+ // plugin doesn't count codepoints or bytes for layout-piece widths
568
+ // at all. Per-row freeform overlays (e.g. pattern-match highlights
569
+ // inside the context substring) ride on the relevant segment via
570
+ // its `overlays` field, addressed in char units relative to that
571
+ // segment alone.
572
+ function renderFlatItemEntry(item: FlatItem, W: number): TextPropertyEntry {
573
+ if (!panel) return { text: "" };
574
+ if (item.type === "file") {
575
+ const group = panel.fileGroups[item.fileIndex];
576
+ const badge = getFileExtBadge(group.relPath);
577
+ const matchCount = group.matches.length;
578
+ const selectedInFile = group.matches.filter(m => m.selected).length;
579
+ return styledRow(
580
+ [
581
+ { text: badge, style: { fg: C.fileIcon, bold: true } },
582
+ { text: " " },
583
+ { text: group.relPath, style: { fg: C.filePath } },
584
+ { text: ` (${selectedInFile}/${matchCount})` },
585
+ ],
586
+ {
587
+ // Host prefix at depth 0: disclosure (▶/▼) + space + checkbox
588
+ // ([v]/[ ]) + space = 6 cols.
589
+ padToChars: Math.max(0, W - 6),
590
+ properties: { type: "file-row", fileIndex: item.fileIndex },
591
+ },
592
+ );
346
593
  }
347
- // Stats
348
- if (matchStats) {
349
- const msStart = rvEnd;
350
- if (panel.truncated && totalMatches > 0) {
351
- // Color the count part normally, then the truncated suffix in warning color
352
- const statsWithoutSuffix = " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) });
353
- const countEnd = msStart + byteLen(statsWithoutSuffix);
354
- line1Overlays.push({ start: msStart, end: countEnd, style: { fg: C.statusOk } });
355
- const suffixEnd = countEnd + byteLen(truncatedSuffix);
356
- line1Overlays.push({ start: countEnd, end: suffixEnd, style: { fg: [255, 180, 50] as RGB, bold: true } });
357
- } else {
358
- const msEnd = msStart + byteLen(matchStats);
359
- line1Overlays.push({ start: msStart, end: msEnd, style: { fg: totalMatches > 0 ? C.statusOk : C.statusDim } });
360
- }
594
+ // Match row. The Tree widget's prefix at depth=1 is 6 cols
595
+ // (4 indent + 2 alignment). Use the remaining width for content.
596
+ const group = panel.fileGroups[item.fileIndex];
597
+ const result = group.matches[item.matchIndex!];
598
+ const location = `${group.relPath}:${result.match.line}`;
599
+ // Hard-cap the context length BEFORE any per-codepoint work below.
600
+ // Minified CSS / JSON / single-line generated files routinely have
601
+ // match context strings 5 000-50 000 chars long. The downstream
602
+ // `truncate()` does `for (const c of s)` (per-codepoint iteration
603
+ // + O(N²) string concatenation in QuickJS); at 5 000 chars and 50
604
+ // items per flush that adds up to several hundred ms of JS work
605
+ // per pump iteration, blocking Tab and other queued requests.
606
+ // A panel viewport is at most a few hundred chars wide, so anything
607
+ // past ~512 chars is invisible anyway.
608
+ const CONTEXT_HARD_CAP = 512;
609
+ const rawCtx = result.match.context;
610
+ const context = (rawCtx.length > CONTEXT_HARD_CAP
611
+ ? rawCtx.slice(0, CONTEXT_HARD_CAP)
612
+ : rawCtx).trim();
613
+ // Host prefix consumes:
614
+ // indent (depth=1) = 2
615
+ // leaf-alignment = 2 (in lieu of disclosure glyph)
616
+ // checkbox + space = 4 ([v] + " ")
617
+ // Total: 8 cols.
618
+ const innerWidth = Math.max(0, W - 8);
619
+
620
+ // Best-effort context budget: enough room for the fixed leading
621
+ // pieces plus " - " plus the context itself. JS `.length` gives
622
+ // UTF-16 code-unit counts which match codepoint counts for the
623
+ // overwhelmingly-ASCII case (paths + line numbers); slight
624
+ // over-counting on rare non-BMP filenames just trims a little
625
+ // more of the context, which is fine.
626
+ const maxCtx = innerWidth - location.length - 3;
627
+ const displayCtx = truncate(context, Math.max(10, maxCtx));
628
+
629
+ // Pattern-match highlights inside the context substring. Emitted
630
+ // in segment-local char units; the host shifts them by the
631
+ // context segment's char start during entry concatenation.
632
+ const ctxOverlays: InlineOverlay[] = [];
633
+ if (panel.searchPattern) {
634
+ highlightMatches(displayCtx, panel.searchPattern, panel.useRegex, panel.caseSensitive, ctxOverlays);
361
635
  }
362
636
 
363
- entries.push({
364
- text: line1 + "\n",
365
- properties: { type: "query-line" },
366
- inlineOverlays: line1Overlays,
637
+ const segments: StyledSegment[] = [
638
+ { text: location, style: { fg: C.lineNum } },
639
+ { text: " - " },
640
+ { text: displayCtx, overlays: ctxOverlays },
641
+ ];
642
+
643
+ return styledRow(segments, {
644
+ padToChars: innerWidth,
645
+ properties: { type: "match-row", fileIndex: item.fileIndex, matchIndex: item.matchIndex },
367
646
  });
647
+ }
368
648
 
369
- // ── Line 2: Options toggles + Replace All button ──
370
- const optCase = (caseSensitive ? "[v]" : "[ ]") + " " + editor.t("panel.case_toggle");
371
- const optRegex = (useRegex ? "[v]" : "[ ]") + " " + editor.t("panel.regex_toggle");
372
- const optWhole = (wholeWords ? "[v]" : "[ ]") + " " + editor.t("panel.whole_toggle");
373
- const replBtn = "[" + editor.t("panel.replace_all_btn") + "]";
649
+ // Convert a slice of `FlatItem`s into the corresponding TreeNodes.
650
+ // Pulled out of `buildMatchListSpec` so the streaming path can use it
651
+ // to build deltas for `appendTreeNodes` it must produce nodes
652
+ // identical to the full-spec rebuild for the same items, so
653
+ // auto-expand of first-seen file rows happens here.
654
+ function flatItemsToTreeNodes(
655
+ flatItems: FlatItem[],
656
+ itemKeys: string[],
657
+ W: number,
658
+ ): TreeNode[] {
659
+ return flatItems.map((item, i) => {
660
+ const entry = renderFlatItemEntry(item, W);
661
+ if (item.type === "file") {
662
+ const k = itemKeys[i];
663
+ if (!panel!.knownFileKeys.has(k)) {
664
+ panel!.knownFileKeys.add(k);
665
+ panel!.expandedFileKeys.add(k);
666
+ }
667
+ // File-row checkbox derives from children: checked iff every
668
+ // match in this file is selected.
669
+ const fileChecked = panel!.fileGroups[item.fileIndex].matches.every(m => m.selected);
670
+ return treeNode(entry, { depth: 0, hasChildren: true, checked: fileChecked });
671
+ }
672
+ const matchSelected = panel!.fileGroups[item.fileIndex]
673
+ .matches[item.matchIndex!].selected;
674
+ return treeNode(entry, { depth: 1, hasChildren: false, checked: matchSelected });
675
+ });
676
+ }
374
677
 
375
- const line2Text = " " + optCase + " " + optRegex + " " + optWhole + " " + replBtn;
376
- const line2 = padStr(line2Text, W);
678
+ // Build the typed spec for the matches body either a Tree widget
679
+ // (when there are matches) or a Raw cell with the empty/prompt
680
+ // message. The Tree widget owns scroll, selection styling, click
681
+ // routing, and host-managed expand/collapse — the plugin sends
682
+ // the *full* hierarchy on every render and the host filters
683
+ // children of collapsed file rows.
684
+ function buildMatchListSpec(): WidgetSpec {
685
+ if (!panel) return col();
686
+ const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
687
+ const totalMatches = panel.searchResults.length;
688
+
689
+ // Empty-state branches: pristine / searching / no-results /
690
+ // pattern-set-but-no-search-yet. See §17 of
691
+ // docs/internal/search-replace-scope-replan-on-widgets.md.
692
+ //
693
+ // When the pattern is mutated while a previous search's results are
694
+ // still in panel.searchResults, render the stale results (fall
695
+ // through to the Tree branch below) until the next search
696
+ // completes — dropping back to "Type a search pattern above"
697
+ // mid-edit feels jumpy.
698
+ const emptyState = (key: string) =>
699
+ raw([{
700
+ text: padStr(" " + editor.t(key), W),
701
+ properties: { type: "empty" },
702
+ style: { fg: C.dim },
703
+ }]);
704
+ if (!panel.searchPattern) {
705
+ return emptyState("panel.type_pattern");
706
+ }
707
+ if (panel.busy && totalMatches === 0) {
708
+ return emptyState("panel.searching");
709
+ }
710
+ if (totalMatches === 0 && panel.searchPerformed && !panel.busy) {
711
+ return emptyState("panel.no_matches");
712
+ }
713
+ if (totalMatches === 0) {
714
+ // Pattern in flight but no search has run yet (and no cached
715
+ // results). Same friendly hint as pristine.
716
+ return emptyState("panel.type_pattern");
717
+ }
377
718
 
378
- const line2Overlays: InlineOverlay[] = [];
379
- const oFocus = focusPanel === "options";
719
+ const flatItems = buildFlatItems();
720
+ const itemKeys = flatItems.map(flatItemKey);
721
+ const nodes = flatItemsToTreeNodes(flatItems, itemKeys, W);
722
+ const selectedIndex = panel.focusPanel === "matches" ? panel.matchIndex : -1;
723
+ // Tree visible rows = panel viewport height minus the chrome
724
+ // (line 1 + options row + separator + footer = 4 rows) — same
725
+ // calculation that sized the previous List.
726
+ const fixedRows = 5;
727
+ const visibleRows = Math.max(3, getViewportHeight() - fixedRows);
728
+
729
+ return tree({
730
+ nodes,
731
+ itemKeys,
732
+ selectedIndex,
733
+ visibleRows,
734
+ expandedKeys: [...panel.expandedFileKeys],
735
+ checkable: true,
736
+ key: "matchTree",
737
+ });
738
+ }
380
739
 
381
- let pos = byteLen(" ");
382
- function addToggleOverlay(text: string, idx: number): void {
383
- const isOn = text.startsWith("[v]");
384
- const isFoc = oFocus && optionIndex === idx;
385
- const checkEnd = pos + byteLen(text.substring(0, 3));
386
- line2Overlays.push({ start: pos, end: checkEnd, style: { fg: isOn ? C.toggleOn : C.toggleOff, bold: isFoc } });
387
- const labelEnd = pos + byteLen(text);
388
- line2Overlays.push({ start: checkEnd, end: labelEnd, style: { fg: C.label, bg: isFoc ? C.selectedBg : undefined, bold: isFoc } });
389
- pos = labelEnd + byteLen(" ");
390
- }
740
+ // Phase selector for `buildPanelEntries`. The hand-rolled options
741
+ // row and line-1 query fields were extracted into typed widget specs
742
+ // (`buildOptionsRowSpec`, `buildLine1Spec`); this parameter lets
743
+ // callers ask for the body before the options row ("preOptions"),
744
+ // the body after it ("postOptions"), or — for tests / fallback
745
+ // paths both with no gap ("all"). Today "preOptions" is empty
746
+ // because line 1 lives in `buildLine1Spec`; the parameter remains
747
+ // for symmetry and to keep the boundary explicit.
748
+ type BuildPhase = "all" | "preOptions" | "postOptions";
749
+
750
+ function buildPanelEntries(phase: BuildPhase = "all"): TextPropertyEntry[] {
751
+ if (!panel) return [];
752
+ const { searchPattern, replaceText, searchResults, fileGroups, focusPanel, queryField,
753
+ optionIndex, caseSensitive, useRegex, wholeWords, cursorPos } = panel;
754
+ // The line-1 + options-row variables are still destructured for
755
+ // readability with the rest of the function but are now consumed
756
+ // by `buildLine1Spec()` and `buildOptionsRowSpec()` (composed into
757
+ // the spec at update time).
758
+ void searchPattern;
759
+ void replaceText;
760
+ void searchResults;
761
+ void fileGroups;
762
+ void focusPanel;
763
+ void queryField;
764
+ void cursorPos;
765
+ void optionIndex;
766
+ void caseSensitive;
767
+ void useRegex;
768
+ void wholeWords;
391
769
 
392
- addToggleOverlay(optCase, 0);
393
- addToggleOverlay(optRegex, 1);
394
- addToggleOverlay(optWhole, 2);
770
+ const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
771
+ const entries: TextPropertyEntry[] = [];
395
772
 
396
- // Replace All button
397
- pos = byteLen(line2Text) - byteLen(replBtn);
398
- const btnFoc = oFocus && optionIndex === 3;
399
- line2Overlays.push({
400
- start: pos, end: pos + byteLen(replBtn),
401
- style: { fg: btnFoc ? C.buttonFg : C.button, bg: btnFoc ? C.button : undefined, bold: btnFoc },
402
- });
773
+ const totalMatches = searchResults.length;
774
+ const fileCount = fileGroups.length;
403
775
 
404
- entries.push({
405
- text: line2 + "\n",
406
- properties: { type: "options-line" },
407
- inlineOverlays: line2Overlays,
408
- });
776
+ // ── Line 1 (search/replace fields + match-count stats) is now
777
+ // rendered by `buildLine1Spec()` — see updatePanelContent. The
778
+ // pre-options phase therefore returns no entries; the spec
779
+ // composes the typed Row directly between the col children. ──
780
+
781
+ // ── Line 2 (options toggles + Replace All button) is now rendered
782
+ // by the host as a `Row { Toggle, Toggle, Toggle, Spacer, Button }`
783
+ // spec — see `buildOptionsRowSpec` and `updatePanelContent`.
784
+ // `buildPanelEntries` is split into a "pre-options" half (this
785
+ // function up to here) and a "post-options" tail (everything from
786
+ // the separator onward). `updatePanelContent` weaves the spec
787
+ // between them so the visual order stays identical to before. ──
788
+ if (phase === "preOptions") return entries;
789
+ // ── For phase==="postOptions", also drop the line-1 entry pushed
790
+ // above so the caller can compose: `col(raw(pre), optionsRow,
791
+ // raw(post), hintBar)` without duplicating line 1.
792
+ if (phase === "postOptions") entries.length = 0;
409
793
 
410
794
  // ── Separator ──
411
795
  const sepChar = "─";
@@ -427,122 +811,34 @@ function buildPanelEntries(): TextPropertyEntry[] {
427
811
  }],
428
812
  });
429
813
 
430
- // ── Matches tree (virtual-scrolled) ──
431
- const flatItems = buildFlatItems();
432
- const fixedRows = 5;
433
- const treeVisibleRows = Math.max(3, getViewportHeight() - fixedRows);
434
-
435
- if (searchPattern && totalMatches === 0) {
436
- entries.push({
437
- text: padStr(" " + editor.t("panel.no_matches"), W) + "\n",
438
- properties: { type: "empty" },
439
- style: { fg: C.dim },
440
- });
441
- } else if (!searchPattern) {
442
- entries.push({
443
- text: padStr(" " + editor.t("panel.type_pattern"), W) + "\n",
444
- properties: { type: "empty" },
445
- style: { fg: C.dim },
446
- });
447
- } else {
448
- let selectedLineIdx = focusPanel === "matches" ? panel.matchIndex : -1;
449
-
450
- // Adjust scroll offset to keep selected line visible
451
- if (selectedLineIdx >= 0) {
452
- if (selectedLineIdx < panel.scrollOffset) {
453
- panel.scrollOffset = selectedLineIdx;
454
- }
455
- if (selectedLineIdx >= panel.scrollOffset + treeVisibleRows) {
456
- panel.scrollOffset = selectedLineIdx - treeVisibleRows + 1;
457
- }
458
- }
459
- const maxOffset = Math.max(0, flatItems.length - treeVisibleRows);
460
- if (panel.scrollOffset > maxOffset) panel.scrollOffset = maxOffset;
461
- if (panel.scrollOffset < 0) panel.scrollOffset = 0;
462
-
463
- // ONLY loop through the items that are literally on the screen right now
464
- for (let i = panel.scrollOffset; i < panel.scrollOffset + treeVisibleRows; i++) {
465
- if (i >= flatItems.length) break;
466
- const item = flatItems[i];
467
- const isSelected = focusPanel === "matches" && panel.matchIndex === i;
468
-
469
- if (item.type === "file") {
470
- const group = fileGroups[item.fileIndex];
471
- const expandIcon = group.expanded ? "v" : ">";
472
- const badge = getFileExtBadge(group.relPath);
473
- const matchCount = group.matches.length;
474
- const selectedInFile = group.matches.filter(m => m.selected).length;
475
- const fileLineText = ` ${expandIcon} ${badge} ${group.relPath} (${selectedInFile}/${matchCount})`;
476
-
477
- const fileOverlays: InlineOverlay[] = [];
478
- const eiStart = byteLen(" ");
479
- const eiEnd = eiStart + byteLen(expandIcon);
480
- fileOverlays.push({ start: eiStart, end: eiEnd, style: { fg: C.expandIcon } });
481
- const bgStart = eiEnd + byteLen(" ");
482
- const bgEnd = bgStart + byteLen(badge);
483
- fileOverlays.push({ start: bgStart, end: bgEnd, style: { fg: C.fileIcon, bold: true } });
484
- const fpStart = bgEnd + byteLen(" ");
485
- const fpEnd = fpStart + byteLen(group.relPath);
486
- fileOverlays.push({ start: fpStart, end: fpEnd, style: { fg: C.filePath } });
487
-
488
- entries.push({
489
- text: padStr(fileLineText, W) + "\n",
490
- properties: { type: "file-row", fileIndex: item.fileIndex },
491
- style: isSelected ? { bg: C.selectedBg } : undefined,
492
- inlineOverlays: fileOverlays,
493
- });
494
- } else {
495
- const group = fileGroups[item.fileIndex];
496
- const result = group.matches[item.matchIndex!];
497
- const checkbox = result.selected ? "[v]" : "[ ]";
498
- const location = `${group.relPath}:${result.match.line}`;
499
- const context = result.match.context.trim();
500
- const prefixText = ` ${isSelected ? ">" : " "} ${checkbox} `;
501
- const maxCtx = W - charLen(prefixText) - charLen(location) - 3;
502
- const displayCtx = truncate(context, Math.max(10, maxCtx));
503
- const matchLineText = `${prefixText}${location} - ${displayCtx}`;
504
-
505
- const inlines: InlineOverlay[] = [];
506
- const cbStart = byteLen(` ${isSelected ? ">" : " "} `);
507
- const cbEnd = cbStart + byteLen(checkbox);
508
- inlines.push({ start: cbStart, end: cbEnd, style: { fg: result.selected ? C.checkOn : C.checkOff } });
509
- const locStart = cbEnd + byteLen(" ");
510
- const locEnd = locStart + byteLen(location);
511
- inlines.push({ start: locStart, end: locEnd, style: { fg: C.lineNum } });
512
-
513
- if (panel.searchPattern) {
514
- const ctxStart = locEnd + byteLen(" - ");
515
- highlightMatches(displayCtx, panel.searchPattern, ctxStart, panel.useRegex, panel.caseSensitive, inlines);
516
- }
517
-
518
- entries.push({
519
- text: padStr(matchLineText, W) + "\n",
520
- properties: { type: "match-row", fileIndex: item.fileIndex, matchIndex: item.matchIndex },
521
- style: isSelected ? { bg: C.selectedBg } : undefined,
522
- inlineOverlays: inlines.length > 0 ? inlines : undefined,
523
- });
524
- }
525
- }
526
- }
527
-
528
- // Scroll indicators
529
- const canScrollUp = panel.scrollOffset > 0;
530
- const canScrollDown = panel.scrollOffset + treeVisibleRows < flatItems.length;
531
- const scrollHint = canScrollUp || canScrollDown
532
- ? " " + (canScrollUp ? "↑" : " ") + (canScrollDown ? "↓" : " ")
533
- : "";
534
-
535
- // ── Help bar ──
536
- const helpText = " " + editor.t("panel.help") + scrollHint;
537
- entries.push({
538
- text: truncate(helpText, W) + "\n",
539
- properties: { type: "help" },
540
- style: { fg: C.help },
541
- });
814
+ // ── Matches tree is now rendered by `buildMatchListSpec()`
815
+ // see `updatePanelContent`. The List widget owns scroll
816
+ // offset (auto-clamps to keep selection in view) and click
817
+ // routing. ──
542
818
 
819
+ // The help footer is no longer pushed here — it's now rendered by
820
+ // the host's HintBar widget (see updatePanelContent).
543
821
  return entries;
544
822
  }
545
823
 
824
+ // Build the hint entries for the panel footer.
825
+ //
826
+ // Source of truth is the existing `panel.help` i18n string (format:
827
+ // `Tab:section ↑↓:nav …`); `parseHintString` splits it into typed
828
+ // `HintEntry[]` so the host's HintBar widget can style the keys
829
+ // portion via the `ui.help_key_fg` theme key — matching every other
830
+ // plugin's footer.
831
+ function buildHelpHints(): HintEntry[] {
832
+ // Source of truth is the existing `panel.help` i18n string. The
833
+ // pre-widget version appended a `↑↓` scroll indicator computed
834
+ // from `panel.scrollOffset`; the List widget now owns scroll
835
+ // state, so the plugin no longer knows the scroll position.
836
+ // Scroll feedback is implicit (the visible window of items shifts
837
+ // visibly when navigating); explicit indicators can come back as
838
+ // a List-emitted prop once needed.
839
+ return parseHintString(editor.t("panel.help"));
840
+ }
841
+
546
842
  // Build field display string: [value] with cursor
547
843
  function buildFieldDisplay(value: string, cursorPos: number, maxLen: number): string {
548
844
  const display = value.length > maxLen ? value.slice(0, maxLen - 1) + "…" : value;
@@ -564,8 +860,18 @@ function addCursorOverlay(value: string, cursorPos: number, fieldByteStart: numb
564
860
  overlays.push({ start: cursorBytePos, end: cursorByteEnd, style: { fg: [0, 0, 0], bg: C.cursorBg } });
565
861
  }
566
862
 
567
- // Highlight search pattern occurrences in a display string
568
- function highlightMatches(text: string, pattern: string, baseByteOffset: number, isRegex: boolean, caseSensitive: boolean, overlays: InlineOverlay[]): void {
863
+ // Append pattern-match highlight overlays (one per occurrence) to
864
+ // `overlays`. Offsets are in char (codepoint) units within `text`
865
+ // itself — the caller is expected to attach `overlays` to a
866
+ // segment whose body equals `text`, so the host shifts them into
867
+ // entry-coordinate space during segment resolution.
868
+ //
869
+ // `text` and `pattern` are treated as JS UTF-16 strings. For BMP
870
+ // content (which includes nearly all source code) UTF-16 code unit
871
+ // indices and Unicode codepoint indices coincide, so `indexOf` /
872
+ // `RegExp.exec` indices map directly to char offsets without a
873
+ // per-overlay codepoint walk.
874
+ function highlightMatches(text: string, pattern: string, isRegex: boolean, caseSensitive: boolean, overlays: InlineOverlay[]): void {
569
875
  if (!pattern) return;
570
876
  try {
571
877
  if (!isRegex) {
@@ -579,9 +885,7 @@ function highlightMatches(text: string, pattern: string, baseByteOffset: number,
579
885
  while (pos < searchText.length) {
580
886
  const idx = searchText.indexOf(searchPat, pos);
581
887
  if (idx < 0) break;
582
- const startByte = baseByteOffset + byteLen(text.substring(0, idx));
583
- const endByte = startByte + byteLen(text.substring(idx, idx + pattern.length));
584
- overlays.push({ start: startByte, end: endByte, style: { bg: C.matchBg, fg: C.matchFg } });
888
+ overlays.push({ start: idx, end: idx + pattern.length, style: { bg: C.matchBg, fg: C.matchFg }, unit: "char" });
585
889
  pos = idx + pattern.length;
586
890
  }
587
891
  } else {
@@ -590,9 +894,7 @@ function highlightMatches(text: string, pattern: string, baseByteOffset: number,
590
894
  let m;
591
895
  while ((m = re.exec(text)) !== null) {
592
896
  if (m[0].length === 0) { re.lastIndex++; continue; }
593
- const startByte = baseByteOffset + byteLen(text.substring(0, m.index));
594
- const endByte = startByte + byteLen(m[0]);
595
- overlays.push({ start: startByte, end: endByte, style: { bg: C.matchBg, fg: C.matchFg } });
897
+ overlays.push({ start: m.index, end: m.index + m[0].length, style: { bg: C.matchBg, fg: C.matchFg }, unit: "char" });
596
898
  }
597
899
  }
598
900
  } catch (_e) { /* invalid regex */ }
@@ -603,10 +905,51 @@ function highlightMatches(text: string, pattern: string, baseByteOffset: number,
603
905
  // =============================================================================
604
906
 
605
907
  function updatePanelContent(): void {
606
- if (panel) {
607
- // Refresh viewport width each time
608
- panel.viewportWidth = getViewportWidth();
609
- editor.setVirtualBufferContent(panel.resultsBufferId, buildPanelEntries());
908
+ if (!panel) return;
909
+ // Refresh viewport width each time
910
+ panel.viewportWidth = getViewportWidth();
911
+
912
+ // Migration step 4 (see docs/internal/plugin-widget-library-design.md
913
+ // §10): the entire visible panel is now typed widgets except for
914
+ // a single `Raw` separator entry.
915
+ //
916
+ // * `Row{ Spacer, TextInput, Spacer, TextInput, Raw{ stats } }`
917
+ // — search/replace inputs +
918
+ // trailing match-count stats.
919
+ // * `Row{ Toggle, Toggle, Toggle, Spacer, Button }`
920
+ // — case/regex/whole + Replace All.
921
+ // * `Raw{ separator entry }` — matches divider.
922
+ // * `List{ ... }` or `Raw{empty msg}` — virtual-scrolled match
923
+ // rows (host owns scroll +
924
+ // selection styling +
925
+ // click routing).
926
+ // * `HintBar{ ... }` — keyboard-hint footer.
927
+ if (!panel.widgetPanel) {
928
+ panel.widgetPanel = new WidgetPanel(panel.resultsBufferId);
929
+ }
930
+ panel.widgetPanel.set(
931
+ col(
932
+ buildLine1Spec(),
933
+ buildOptionsRowSpec(),
934
+ buildScopeRowSpec(),
935
+ raw(buildPanelEntries("postOptions"), "separator"),
936
+ buildMatchListSpec(),
937
+ hintBar(buildHelpHints()),
938
+ ),
939
+ );
940
+ // The Tree's `expandedKeys` field on the spec is initial-only —
941
+ // `mountWidgetPanel` seeds the host's instance state, and
942
+ // `updateWidgetPanel` ignores it (instance state is authoritative
943
+ // after first render). So we push expansion changes through the
944
+ // explicit mutator on every update; this covers the case where
945
+ // a new file group enters the result set in a later search and
946
+ // needs to be force-expanded by default. The mutator is a no-op
947
+ // when the tree isn't mounted yet (first `set()` call).
948
+ if (panel.searchPattern && panel.searchResults.length > 0) {
949
+ panel.widgetPanel.setExpandedKeys(
950
+ "matchTree",
951
+ [...panel.expandedFileKeys],
952
+ );
610
953
  }
611
954
  }
612
955
 
@@ -616,58 +959,296 @@ function updatePanelContent(): void {
616
959
 
617
960
  /** Current search generation — incremented on each new search to discard stale results. */
618
961
  let currentSearchGeneration = 0;
962
+ /** The active search handle, kept so a superseding search can cancel it. */
963
+ let activeSearchHandle: SearchHandle | null = null;
964
+ /** Pump cadence between successive `take()` drains (ms). The host writes
965
+ * matches at full speed; this knob bounds the UI rebuild rate. */
966
+ const SEARCH_PUMP_INTERVAL_MS = 50;
967
+ /** Number of `buildFlatItems()` entries the streaming path has already
968
+ * pushed to the host via `appendTreeNodes`. Zero means "no streaming
969
+ * append has happened for the current search"; the first batch of
970
+ * results will do a full `updatePanelContent()` instead so the Tree
971
+ * exists for subsequent appends. Reset at the start of each search
972
+ * and after `batch.done` (which forces a full re-emit). */
973
+ let lastStreamingFlatCount = 0;
974
+
975
+ /** Absolute-path → index-into-`panel.fileGroups`, maintained while a
976
+ * search is streaming so each new match locates its file group in
977
+ * O(1) instead of triggering a full `buildFileGroups(allResults)`
978
+ * rebuild (which is O(N) per batch and pins the JS event loop on
979
+ * large result sets). Cleared at the start of each search. */
980
+ let streamingFileIndexByPath: Map<string, number> | null = null;
981
+
982
+ /** Carryover queue: matches the host handed us in a `take()` but that
983
+ * we haven't processed yet because the batch was too big to drain in
984
+ * a single pump iteration. Drained CHUNK at a time inside the pump
985
+ * loop; `take()` is only re-called once this is empty so we don't
986
+ * flood the queue. Reset at the start of each search. */
987
+ let pendingMatches: GrepMatch[] = [];
988
+
989
+ /** Pending tree-append delta that hasn't been flushed to the host yet.
990
+ * Each pump chunk pushes its `FlatItem[]` here; the loop coalesces
991
+ * several chunks worth before firing one `appendTreeNodes` IPC, so
992
+ * the host's main thread isn't pinned servicing ~20 ms IPCs back to
993
+ * back during a long streaming search. */
994
+ let pendingTreeDeltaItems: FlatItem[] = [];
995
+ /** Parallel pending list of new file-row keys whose expansion state
996
+ * must be pushed to the host on the next flush. */
997
+ let pendingNewExpandedKeys: string[] = [];
998
+ /** Wall-clock ms of the last UI flush (the last appendTreeNodes IPC).
999
+ * Compared against UI_FLUSH_INTERVAL_MS to decide when to flush. */
1000
+ let lastUiFlush = 0;
1001
+ /** Don't flush more often than this. */
1002
+ const UI_FLUSH_INTERVAL_MS = 80;
1003
+ /** Hard cap on each `appendTreeNodes` flush payload. Each TreeNode in
1004
+ * the payload costs ~60 µs in `js_to_json` + `serde_json::from_value`
1005
+ * on the JS thread (measured: AppendTreeNodes(1296) = 88 ms).
1006
+ * Larger payloads → longer per-iteration JS block → user input
1007
+ * (Tab, typed char, Esc) waits in the plugin thread's request
1008
+ * channel. Keeping the cap at ~100 keeps each flush ≤ 10 ms so
1009
+ * queued Tab requests can interleave between pump iterations. */
1010
+ const UI_FLUSH_MAX_DELTA = 100;
619
1011
 
620
1012
  /**
621
- * Perform a streaming search. Results arrive incrementally per-file via the
622
- * progress callback and are merged into the panel state as they arrive.
623
- * Returns the final complete list of results.
1013
+ * Perform a streaming search using a pull-based handle. The host writes
1014
+ * matches at full speed into shared state; this loop drains them via
1015
+ * `handle.take()` and rebuilds the UI between drains. There are no
1016
+ * per-chunk callbacks crossing the FFI boundary, so the host's main
1017
+ * thread is free to process input and render between pumps.
624
1018
  */
625
1019
  async function performSearch(pattern: string, silent?: boolean): Promise<SearchResult[]> {
626
1020
  if (!panel) return [];
627
1021
 
628
1022
  const generation = ++currentSearchGeneration;
629
- let lastUiUpdate = Date.now();
630
- const UI_UPDATE_INTERVAL_MS = 100; // Force maximum 10 UI updates per second
1023
+ // Each fresh search resets the per-file expansion set: previous
1024
+ // results may have included files that don't appear in the new
1025
+ // result set, and the user's collapse state for the *previous*
1026
+ // result set isn't meaningful for the new one.
1027
+ panel.expandedFileKeys.clear();
1028
+ panel.knownFileKeys.clear();
1029
+ // New search → reset the streaming-append checkpoint. The first
1030
+ // batch of results will trigger a full `updatePanelContent()`
1031
+ // (mounting the empty Tree); subsequent batches append deltas to
1032
+ // that mounted Tree.
1033
+ lastStreamingFlatCount = 0;
1034
+ streamingFileIndexByPath = new Map();
1035
+ pendingMatches = [];
1036
+ pendingTreeDeltaItems = [];
1037
+ pendingNewExpandedKeys = [];
1038
+ lastUiFlush = 0;
1039
+ // Reset accumulating state so a re-search (debounce from typing,
1040
+ // toggle flip, scope change) starts from empty rather than
1041
+ // appending to the previous run's results.
1042
+ panel.searchResults = [];
1043
+ panel.fileGroups = [];
1044
+
1045
+ // Cancel any in-flight search before kicking off a new one. Without
1046
+ // this the prior search would keep walking the project until it
1047
+ // hit max_results, wasting CPU.
1048
+ if (activeSearchHandle) {
1049
+ try { activeSearchHandle.cancel(); } catch (_e) { /* ignore */ }
1050
+ activeSearchHandle = null;
1051
+ }
631
1052
 
632
1053
  try {
633
1054
  const fixedString = !panel.useRegex;
634
- let allResults: SearchResult[] = [];
1055
+ const allResults: SearchResult[] = [];
635
1056
 
636
1057
  // Whole-word filtering is done Rust-side so maxResults is respected correctly
637
- const result = await editor.grepProjectStreaming(
638
- pattern,
639
- {
640
- fixedString,
641
- caseSensitive: panel.caseSensitive,
642
- maxResults: MAX_RESULTS,
643
- wholeWords: panel.wholeWords,
644
- },
645
- (matches: GrepMatch[], done: boolean) => {
646
- // Discard if a newer search has started
647
- if (generation !== currentSearchGeneration || !panel) return;
648
-
649
- if (matches.length > 0) {
650
- // Use push loop instead of allResults.concat() to save massive memory allocations
651
- for (const m of matches) {
652
- allResults.push({ match: m, selected: true });
1058
+ const handle = editor.beginSearch(pattern, {
1059
+ fixedString,
1060
+ caseSensitive: panel.caseSensitive,
1061
+ maxResults: MAX_RESULTS,
1062
+ wholeWords: panel.wholeWords,
1063
+ });
1064
+ activeSearchHandle = handle;
1065
+
1066
+ let truncated = false;
1067
+ let producerError: string | null = null;
1068
+
1069
+ while (true) {
1070
+ // Discard the in-flight search if a newer one started while we slept.
1071
+ if (generation !== currentSearchGeneration || !panel) {
1072
+ try { handle.cancel(); } catch (_e) { /* ignore */ }
1073
+ return allResults;
1074
+ }
1075
+
1076
+ // Drain matches in chunks of at most CHUNK per pump iteration.
1077
+ // `pendingMatches` accumulates anything the host gave us that we
1078
+ // haven't processed yet. This caps the per-iteration synchronous
1079
+ // JS work at O(CHUNK) so the event loop yields back to the host
1080
+ // promptly — without this, a single batch of 3000+ matches takes
1081
+ // ~700ms of JS time and queues every user keypress (Tab, typed
1082
+ // chars, Esc) for the duration of the search.
1083
+ //
1084
+ // Only call `handle.take()` when our queue is empty, so the host
1085
+ // doesn't keep us flooded; the producer pauses when the take()
1086
+ // returns nothing left to drain.
1087
+ let batchDone = false;
1088
+ let batchTruncated = false;
1089
+ let batchError: string | null = null;
1090
+ if (pendingMatches.length === 0) {
1091
+ const batch = handle.take();
1092
+ batchDone = batch.done;
1093
+ batchTruncated = batch.truncated;
1094
+ batchError = batch.error ?? null;
1095
+ for (const m of batch.matches) pendingMatches.push(m);
1096
+ }
1097
+ // Hard cap on per-iteration work. Each match in the chunk turns
1098
+ // into a TreeNode in the `appendTreeNodes` flush, and each
1099
+ // TreeNode costs ~60 µs in `js_to_json` + `from_value` on the
1100
+ // JS thread. Keeping the chunk small means each pump iteration
1101
+ // stays ≤ ~10 ms — short enough that queued Tab/typed-char
1102
+ // requests interleave smoothly between iterations.
1103
+ const CHUNK = 80;
1104
+ const chunkSize = Math.min(CHUNK, pendingMatches.length);
1105
+ const chunk = pendingMatches.splice(0, chunkSize);
1106
+ const moreInQueue = pendingMatches.length > 0;
1107
+ const deltaItems: FlatItem[] = [];
1108
+ const newExpandedKeys: string[] = []; // file rows added this batch
1109
+ for (const m of chunk) {
1110
+ // §1 scope filter: when scope is "current file only", drop
1111
+ // matches from any other path. Done client-side because the
1112
+ // host grep API is project-wide. Empty sourceBufferPath
1113
+ // (unsaved buffer) filters everything out by design.
1114
+ if (!panel.allFiles && m.file !== panel.sourceBufferPath) continue;
1115
+ const result: SearchResult = { match: m, selected: true };
1116
+ allResults.push(result);
1117
+ let fileIdx = streamingFileIndexByPath?.get(m.file);
1118
+ if (fileIdx === undefined) {
1119
+ fileIdx = panel.fileGroups.length;
1120
+ streamingFileIndexByPath?.set(m.file, fileIdx);
1121
+ panel.fileGroups.push({
1122
+ relPath: getRelativePath(m.file),
1123
+ absPath: m.file,
1124
+ expanded: true,
1125
+ matches: [],
1126
+ });
1127
+ deltaItems.push({ type: "file", fileIndex: fileIdx });
1128
+ const fileKey = `file:${fileIdx}`;
1129
+ panel.expandedFileKeys.add(fileKey);
1130
+ panel.knownFileKeys.add(fileKey);
1131
+ newExpandedKeys.push(fileKey);
1132
+ }
1133
+ const matchIdx = panel.fileGroups[fileIdx].matches.length;
1134
+ panel.fileGroups[fileIdx].matches.push(result);
1135
+ deltaItems.push({ type: "match", fileIndex: fileIdx, matchIndex: matchIdx });
1136
+ }
1137
+ panel.searchResults = allResults;
1138
+ // Coalesce the per-chunk delta into a pending buffer. Each
1139
+ // `appendTreeNodes` IPC costs ~20 ms on the host (spec mutation
1140
+ // + Tree-visible-rows recompute + virtual-buffer repaint).
1141
+ // Flushing every 250-match chunk means 20+ IPCs over a 5 000-
1142
+ // match search — that pile of host main-thread work is exactly
1143
+ // when queued Tab / typed-key events sit waiting. Flush only
1144
+ // every UI_FLUSH_INTERVAL_MS so the user sees the result list
1145
+ // grow at a steady ~5 Hz while leaving the host free to dispatch
1146
+ // input events between flushes.
1147
+ for (const it of deltaItems) pendingTreeDeltaItems.push(it);
1148
+ for (const k of newExpandedKeys) pendingNewExpandedKeys.push(k);
1149
+ const nowMs = Date.now();
1150
+ const producerFinished = batchDone && pendingMatches.length === 0;
1151
+ const dueToFlush =
1152
+ producerFinished ||
1153
+ pendingTreeDeltaItems.length >= UI_FLUSH_MAX_DELTA ||
1154
+ nowMs - lastUiFlush >= UI_FLUSH_INTERVAL_MS;
1155
+ if (
1156
+ dueToFlush &&
1157
+ pendingTreeDeltaItems.length > 0 &&
1158
+ panel.widgetPanel &&
1159
+ lastStreamingFlatCount > 0
1160
+ ) {
1161
+ const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
1162
+ const flushed = pendingTreeDeltaItems;
1163
+ const flushedNewExp = pendingNewExpandedKeys;
1164
+ pendingTreeDeltaItems = [];
1165
+ pendingNewExpandedKeys = [];
1166
+ const newItemKeys = flushed.map(flatItemKey);
1167
+ const newNodes = flatItemsToTreeNodes(flushed, newItemKeys, W);
1168
+ panel.widgetPanel.appendTreeNodes("matchTree", newNodes, newItemKeys);
1169
+ lastStreamingFlatCount += flushed.length;
1170
+ lastUiFlush = nowMs;
1171
+ if (flushedNewExp.length > 0) {
1172
+ panel.widgetPanel.setExpandedKeys(
1173
+ "matchTree",
1174
+ [...panel.expandedFileKeys],
1175
+ );
1176
+ }
1177
+ } else if (lastStreamingFlatCount === 0 && panel.fileGroups.length > 0) {
1178
+ // First time we have any results — mount the Tree via a full
1179
+ // panel update. Subsequent batches use the cheap append path.
1180
+ // Also drain the pending buffer into the spec since
1181
+ // updatePanelContent rebuilds from `panel.fileGroups` directly.
1182
+ pendingTreeDeltaItems = [];
1183
+ pendingNewExpandedKeys = [];
1184
+ updatePanelContent();
1185
+ lastStreamingFlatCount = panel.fileGroups.length + panel.searchResults.length;
1186
+ lastUiFlush = nowMs;
1187
+ }
1188
+ if (producerFinished) {
1189
+ // Streaming finished. The tree is already current in the host
1190
+ // via the per-batch `appendTreeNodes` mutations — its nodes
1191
+ // don't need refreshing. The only state that drifted is the
1192
+ // small chrome strings: the matchStats label next to the
1193
+ // input fields, and the "Matches (N in M files)" header in
1194
+ // the separator. Update them in place via `setRawEntries`
1195
+ // (a few-hundred-byte mutation) instead of re-emitting the
1196
+ // full panel spec — the latter would force `js_to_json` over
1197
+ // every TreeNode (~447 bytes × 5 000 nodes = 2.2 MB) and
1198
+ // block the JS thread for ~1 second, exactly when user input
1199
+ // piles up unread in the request channel. See the
1200
+ // RESOLVE_CB_DONE dur_us=1095122 case in the perf trace.
1201
+ if (panel.widgetPanel) {
1202
+ if (panel.fileGroups.length === 0) {
1203
+ // Special case: 0 matches. The matches body is an
1204
+ // empty-state `raw()`, not a `tree()` — we have to swap
1205
+ // widget kinds, which `setRawEntries` alone can't do.
1206
+ // The full re-emit here is cheap because the tree is
1207
+ // empty (no per-node serialization cost).
1208
+ updatePanelContent();
1209
+ } else {
1210
+ panel.widgetPanel.setRawEntries("matchStats", buildMatchStatsEntries());
1211
+ panel.widgetPanel.setRawEntries("separator", buildPanelEntries("postOptions"));
653
1212
  }
654
- panel.searchResults = allResults;
655
1213
  }
1214
+ lastStreamingFlatCount = 0;
1215
+ pendingTreeDeltaItems = [];
1216
+ pendingNewExpandedKeys = [];
1217
+ }
1218
+ // Also refresh the matchStats label on every streaming flush so
1219
+ // the count updates in real time as results stream in.
1220
+ if (!producerFinished && dueToFlush && panel.widgetPanel) {
1221
+ panel.widgetPanel.setRawEntries("matchStats", buildMatchStatsEntries());
1222
+ panel.widgetPanel.setRawEntries("separator", buildPanelEntries("postOptions"));
1223
+ }
656
1224
 
657
- const now = Date.now();
658
- // Only trigger the expensive UI rebuild if enough time passed or stream finished
659
- if (done || now - lastUiUpdate > UI_UPDATE_INTERVAL_MS) {
660
- panel.fileGroups = buildFileGroups(allResults);
661
- updatePanelContent();
662
- lastUiUpdate = now;
663
- }
1225
+ if (producerFinished) {
1226
+ truncated = batchTruncated;
1227
+ producerError = batchError;
1228
+ break;
664
1229
  }
665
- );
1230
+
1231
+ // Yield to the JS event loop between chunks. `delay(0)` is
1232
+ // enough — it lets queued plugin handlers (Tab, typed input,
1233
+ // Esc) run between our streaming work. When there's no
1234
+ // carryover, wait the usual pump interval so we don't hot-loop
1235
+ // on `handle.take()`.
1236
+ const yieldMs = moreInQueue ? 0 : SEARCH_PUMP_INTERVAL_MS;
1237
+ await editor.delay(yieldMs);
1238
+ }
1239
+
1240
+ if (activeSearchHandle === handle) {
1241
+ activeSearchHandle = null;
1242
+ }
666
1243
 
667
1244
  // Final state
668
1245
  if (generation !== currentSearchGeneration || !panel) return allResults;
669
1246
 
670
- panel.truncated = !!(result && (result as any).truncated);
1247
+ if (producerError) {
1248
+ throw new Error(producerError);
1249
+ }
1250
+
1251
+ panel.truncated = truncated;
671
1252
 
672
1253
  if (!silent) {
673
1254
  if (allResults.length === 0) {
@@ -691,30 +1272,40 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
691
1272
  // Panel lifecycle
692
1273
  // =============================================================================
693
1274
 
694
- async function openPanel(): Promise<void> {
1275
+ async function openPanel(opts?: { allFiles?: boolean }): Promise<void> {
695
1276
  // Try to pre-fill search from editor selection
696
1277
  let prefill = "";
1278
+ let sourceBufferPath = "";
697
1279
  try {
1280
+ const activeId = editor.getActiveBufferId();
1281
+ sourceBufferPath = editor.getBufferPath(activeId) || "";
698
1282
  const cursor = editor.getPrimaryCursor();
699
1283
  if (cursor && cursor.selection) {
700
1284
  const start = Math.min(cursor.selection.start, cursor.selection.end);
701
1285
  const end = Math.max(cursor.selection.start, cursor.selection.end);
702
1286
  if (end - start > 0 && end - start < 200) {
703
- const bufferId = editor.getActiveBufferId();
704
- const text = await editor.getBufferText(bufferId, start, end);
1287
+ const text = await editor.getBufferText(activeId, start, end);
705
1288
  if (text && !text.includes("\n")) {
706
1289
  prefill = text;
707
1290
  }
708
1291
  }
709
1292
  }
710
- } catch (_e) { /* no selection */ }
1293
+ } catch (_e) { /* no selection / no buffer */ }
1294
+
1295
+ const allFiles = opts?.allFiles ?? true;
1296
+ const sourceBufferRelPath = sourceBufferPath ? getRelativePath(sourceBufferPath) : "";
711
1297
 
712
1298
  if (panel) {
713
1299
  panel.focusPanel = "query";
714
1300
  panel.queryField = "search";
715
1301
  if (prefill) panel.searchPattern = prefill;
716
1302
  panel.cursorPos = panel.searchPattern.length;
1303
+ // Re-opening from a different file/scope refreshes scope context.
1304
+ panel.allFiles = allFiles;
1305
+ panel.sourceBufferPath = sourceBufferPath;
1306
+ panel.sourceBufferRelPath = sourceBufferRelPath;
717
1307
  updatePanelContent();
1308
+ if (panel.searchPattern) rerunSearchDebounced();
718
1309
  return;
719
1310
  }
720
1311
 
@@ -735,11 +1326,18 @@ async function openPanel(): Promise<void> {
735
1326
  caseSensitive: false,
736
1327
  useRegex: false,
737
1328
  wholeWords: false,
1329
+ allFiles,
1330
+ sourceBufferPath,
1331
+ sourceBufferRelPath,
738
1332
  viewportWidth: DEFAULT_WIDTH,
739
1333
  busy: false,
1334
+ searchPerformed: false,
740
1335
  truncated: false,
741
1336
  cursorPos: prefill.length,
742
1337
  scrollOffset: 0,
1338
+ expandedFileKeys: new Set<string>(),
1339
+ knownFileKeys: new Set<string>(),
1340
+ widgetPanel: null,
743
1341
  };
744
1342
 
745
1343
  try {
@@ -824,19 +1422,42 @@ async function executeReplacements(results?: SearchResult[]): Promise<string> {
824
1422
 
825
1423
  async function rerunSearch(): Promise<void> {
826
1424
  if (!panel || !panel.searchPattern) return;
827
- if (panel.busy) return; // guard against re-entrant search
1425
+ // No `panel.busy` early-return: if a search is already running for
1426
+ // an older pattern (e.g. user typed "pr" then "proj"), we want the
1427
+ // newer search to start NOW, not after the old one finishes
1428
+ // walking the project. `performSearch` increments
1429
+ // `currentSearchGeneration` and cancels the prior handle; the older
1430
+ // in-flight `performSearch` sees the gen mismatch on its next
1431
+ // pump tick and bails out without writing to panel state.
1432
+ searchDebounceGeneration++;
1433
+ // Capture the generation this rerunSearch will own once performSearch
1434
+ // increments it. If a newer rerunSearch slots in while we're awaiting,
1435
+ // currentSearchGeneration moves past `myGen` and we know not to
1436
+ // finalize busy/searchPerformed for this stale invocation.
1437
+ const myGen = currentSearchGeneration + 1;
828
1438
  panel.truncated = false;
829
1439
  panel.busy = true;
830
1440
  panel.matchIndex = 0;
831
1441
  panel.scrollOffset = 0;
832
- const results = await performSearch(panel.searchPattern);
833
- // performSearch already updates panel.searchResults/fileGroups incrementally;
834
- // just ensure final state is consistent
835
- if (panel) {
836
- panel.searchResults = results;
837
- panel.fileGroups = buildFileGroups(results);
1442
+ await performSearch(panel.searchPattern);
1443
+ // performSearch maintains panel.searchResults / panel.fileGroups
1444
+ // incrementally during streaming and pushes matchStats / separator
1445
+ // updates via cheap targeted mutations on batch.done — no full
1446
+ // spec re-emit needed here. Only finalize busy + searchPerformed
1447
+ // if we are still the latest search; the busy flip drives the
1448
+ // "No matches" empty-state branch in `buildMatchListSpec`, so
1449
+ // when totalMatches===0 we also need to refresh the matches Raw
1450
+ // (a tiny mutation) so the user sees the empty-state label.
1451
+ if (panel && currentSearchGeneration === myGen) {
838
1452
  panel.busy = false;
839
- updatePanelContent();
1453
+ panel.searchPerformed = true;
1454
+ // Only one tiny mutation needed: refresh matchStats since it
1455
+ // depends on the busy flag and searchPerformed (showing
1456
+ // "No matches" vs "Searching…"). The tree's nodes already
1457
+ // reflect the final state — no full re-emit needed.
1458
+ if (panel.widgetPanel) {
1459
+ panel.widgetPanel.setRawEntries("matchStats", buildMatchStatsEntries());
1460
+ }
840
1461
  }
841
1462
  }
842
1463
 
@@ -853,6 +1474,7 @@ function rerunSearchDebounced(): void {
853
1474
  async function rerunSearchQuiet(): Promise<void> {
854
1475
  if (!panel || !panel.searchPattern) return;
855
1476
  if (panel.busy) return;
1477
+ searchDebounceGeneration++;
856
1478
  panel.busy = true;
857
1479
  const results = await performSearch(panel.searchPattern, true);
858
1480
  if (panel) {
@@ -861,6 +1483,7 @@ async function rerunSearchQuiet(): Promise<void> {
861
1483
  panel.matchIndex = 0;
862
1484
  panel.scrollOffset = 0;
863
1485
  panel.busy = false;
1486
+ panel.searchPerformed = true;
864
1487
  updatePanelContent();
865
1488
  }
866
1489
  }
@@ -869,176 +1492,97 @@ async function rerunSearchQuiet(): Promise<void> {
869
1492
  // Text editing handlers (inline editing of query fields)
870
1493
  // =============================================================================
871
1494
 
872
- function search_replace_backspace(): void {
873
- if (!panel || panel.focusPanel !== "query") return;
874
- const text = getActiveFieldText();
875
- const pos = panel.cursorPos;
876
- if (pos <= 0) return;
877
- setActiveFieldText(text.slice(0, pos - 1) + text.slice(pos));
878
- panel.cursorPos = pos - 1;
879
- updatePanelContent();
880
- }
881
- registerHandler("search_replace_backspace", search_replace_backspace);
882
-
883
- function search_replace_delete(): void {
884
- if (!panel || panel.focusPanel !== "query") return;
885
- const text = getActiveFieldText();
886
- const pos = panel.cursorPos;
887
- if (pos >= text.length) return;
888
- setActiveFieldText(text.slice(0, pos) + text.slice(pos + 1));
889
- updatePanelContent();
890
- }
891
- registerHandler("search_replace_delete", search_replace_delete);
892
-
893
- function search_replace_home(): void {
894
- if (!panel || panel.focusPanel !== "query") return;
895
- panel.cursorPos = 0;
896
- updatePanelContent();
897
- }
898
- registerHandler("search_replace_home", search_replace_home);
899
-
900
- function search_replace_end(): void {
901
- if (!panel || panel.focusPanel !== "query") return;
902
- panel.cursorPos = getActiveFieldText().length;
903
- updatePanelContent();
904
- }
905
- registerHandler("search_replace_end", search_replace_end);
906
-
907
- // =============================================================================
908
- // Navigation handlers
909
- // =============================================================================
910
-
911
- function search_replace_nav_down(): void {
912
- if (!panel) return;
913
- if (panel.focusPanel === "query") {
914
- if (panel.queryField === "search") {
915
- panel.queryField = "replace";
916
- panel.cursorPos = panel.replaceText.length;
917
- }
918
- updatePanelContent();
919
- } else if (panel.focusPanel === "options") {
920
- if (panel.optionIndex < 3) { panel.optionIndex++; updatePanelContent(); }
921
- } else {
922
- const flat = buildFlatItems();
923
- if (panel.matchIndex < flat.length - 1) { panel.matchIndex++; updatePanelContent(); }
924
- }
1495
+ // All editing / navigation keys route through the widget runtime
1496
+ // via the smart `Key` dispatch — the host knows which widget is
1497
+ // focused and routes accordingly (Backspace into TextInput; Up/Down
1498
+ // across List rows; Enter/Space activate Toggle/Button/List;
1499
+ // printable Space inserts into TextInput; Tab/Shift+Tab cycles
1500
+ // focus). See WidgetAction::Key for the full table.
1501
+ function dispatch(action: WidgetAction): void {
1502
+ panel?.widgetPanel?.command(action);
925
1503
  }
926
- registerHandler("search_replace_nav_down", search_replace_nav_down);
927
1504
 
928
- function search_replace_nav_up(): void {
929
- if (!panel) return;
930
- if (panel.focusPanel === "query") {
931
- if (panel.queryField === "replace") {
932
- panel.queryField = "search";
933
- panel.cursorPos = panel.searchPattern.length;
934
- }
935
- updatePanelContent();
936
- } else if (panel.focusPanel === "options") {
937
- if (panel.optionIndex > 0) { panel.optionIndex--; updatePanelContent(); }
938
- } else {
939
- if (panel.matchIndex > 0) { panel.matchIndex--; updatePanelContent(); }
940
- }
1505
+ registerHandler("search_replace_backspace", () => dispatch(widgetKey("Backspace")));
1506
+ registerHandler("search_replace_delete", () => dispatch(widgetKey("Delete")));
1507
+ registerHandler("search_replace_home", () => dispatch(widgetKey("Home")));
1508
+ registerHandler("search_replace_end", () => dispatch(widgetKey("End")));
1509
+ registerHandler("search_replace_nav_left", () => dispatch(widgetKey("Left")));
1510
+ registerHandler("search_replace_nav_right", () => dispatch(widgetKey("Right")));
1511
+ /** Apply a stored history entry to the search field. Mutates the
1512
+ * widget's value via setValue so the host instance state stays in
1513
+ * sync with the plugin's panel.searchPattern, and triggers a
1514
+ * debounced re-search. See §11. */
1515
+ function applyHistoryEntry(text: string): void {
1516
+ if (!panel || !panel.widgetPanel) return;
1517
+ panel.searchPattern = text;
1518
+ panel.cursorPos = text.length;
1519
+ panel.searchPerformed = false;
1520
+ panel.widgetPanel.setValue("searchField", text, byteLen(text));
1521
+ rerunSearchDebounced();
941
1522
  }
942
- registerHandler("search_replace_nav_up", search_replace_nav_up);
943
1523
 
944
- function search_replace_tab(): void {
945
- editor.debug("search_replace_tab CALLED, panel=" + (panel ? "yes" : "null"));
946
- if (!panel) return;
947
- if (panel.focusPanel === "query") {
948
- if (panel.queryField === "search") {
949
- // Search → Replace
950
- panel.queryField = "replace";
951
- panel.cursorPos = panel.replaceText.length;
952
- updatePanelContent();
953
- return;
954
- } else {
955
- // Replace → Options
956
- panel.focusPanel = "options";
957
- }
958
- } else if (panel.focusPanel === "options") {
959
- panel.focusPanel = "matches";
960
- } else {
961
- // Matches → Query/Search
962
- panel.focusPanel = "query";
963
- panel.queryField = "search";
964
- panel.cursorPos = panel.searchPattern.length;
965
- }
966
- updatePanelContent();
1524
+ /** Whether Up/Down should be intercepted for history walk (instead of
1525
+ * being passed to the focused widget). True only when the most recent
1526
+ * widget_event indicated focus was on the search field. */
1527
+ function shouldInterceptForHistory(): boolean {
1528
+ return lastFocusedWidget === "searchField" || lastFocusedWidget === null;
967
1529
  }
968
- registerHandler("search_replace_tab", search_replace_tab);
969
1530
 
970
- function search_replace_shift_tab(): void {
1531
+ registerHandler("search_replace_nav_up", () => {
971
1532
  if (!panel) return;
972
- if (panel.focusPanel === "matches") {
973
- panel.focusPanel = "options";
974
- } else if (panel.focusPanel === "options") {
975
- panel.focusPanel = "query";
976
- panel.queryField = "replace";
977
- panel.cursorPos = panel.replaceText.length;
978
- } else {
979
- if (panel.queryField === "replace") {
980
- panel.queryField = "search";
981
- panel.cursorPos = panel.searchPattern.length;
1533
+ if (shouldInterceptForHistory()) {
1534
+ if (searchHistory.length === 0) return;
1535
+ if (historyIndex < 0) {
1536
+ // Entering history walk — snapshot what the user had typed so
1537
+ // a Down past the most recent entry restores it.
1538
+ historySavedPattern = panel.searchPattern;
1539
+ historyIndex = 0;
1540
+ } else if (historyIndex < searchHistory.length - 1) {
1541
+ historyIndex += 1;
982
1542
  } else {
983
- panel.focusPanel = "matches";
984
- }
985
- }
986
- updatePanelContent();
987
- }
988
- registerHandler("search_replace_shift_tab", search_replace_shift_tab);
989
-
990
- function search_replace_nav_left(): void {
991
- if (!panel) return;
992
- // When in query panel, move cursor left
993
- if (panel.focusPanel === "query") {
994
- if (panel.cursorPos > 0) {
995
- panel.cursorPos--;
996
- updatePanelContent();
1543
+ return; // already at the oldest entry
997
1544
  }
1545
+ applyHistoryEntry(searchHistory[historyIndex]);
998
1546
  return;
999
1547
  }
1000
- if (panel.focusPanel !== "matches") return;
1001
- const flat = buildFlatItems();
1002
- const item = flat[panel.matchIndex];
1003
- if (!item) return;
1004
- if (item.type === "file") {
1005
- if (panel.fileGroups[item.fileIndex].expanded) {
1006
- panel.fileGroups[item.fileIndex].expanded = false;
1007
- updatePanelContent();
1008
- }
1009
- } else {
1010
- for (let i = panel.matchIndex - 1; i >= 0; i--) {
1011
- if (flat[i].type === "file" && flat[i].fileIndex === item.fileIndex) {
1012
- panel.matchIndex = i;
1013
- updatePanelContent();
1014
- break;
1015
- }
1016
- }
1017
- }
1018
- }
1019
- registerHandler("search_replace_nav_left", search_replace_nav_left);
1020
-
1021
- function search_replace_nav_right(): void {
1548
+ dispatch(widgetKey("Up"));
1549
+ });
1550
+ registerHandler("search_replace_nav_down", () => {
1022
1551
  if (!panel) return;
1023
- // When in query panel, move cursor right
1024
- if (panel.focusPanel === "query") {
1025
- const text = getActiveFieldText();
1026
- if (panel.cursorPos < text.length) {
1027
- panel.cursorPos++;
1028
- updatePanelContent();
1552
+ if (shouldInterceptForHistory() && historyIndex >= 0) {
1553
+ if (historyIndex > 0) {
1554
+ historyIndex -= 1;
1555
+ applyHistoryEntry(searchHistory[historyIndex]);
1556
+ return;
1029
1557
  }
1558
+ // Down past the most recent entry → exit history walk and restore
1559
+ // whatever the user had typed before they hit Up.
1560
+ historyIndex = -1;
1561
+ const restore = historySavedPattern ?? "";
1562
+ historySavedPattern = null;
1563
+ applyHistoryEntry(restore);
1030
1564
  return;
1031
1565
  }
1032
- if (panel.focusPanel !== "matches") return;
1033
- const flat = buildFlatItems();
1034
- const item = flat[panel.matchIndex];
1035
- if (!item) return;
1036
- if (item.type === "file" && !panel.fileGroups[item.fileIndex].expanded) {
1037
- panel.fileGroups[item.fileIndex].expanded = true;
1038
- updatePanelContent();
1039
- }
1040
- }
1041
- registerHandler("search_replace_nav_right", search_replace_nav_right);
1566
+ dispatch(widgetKey("Down"));
1567
+ });
1568
+ registerHandler("search_replace_nav_page_up", () => dispatch(widgetKey("PageUp")));
1569
+ registerHandler("search_replace_nav_page_down", () => dispatch(widgetKey("PageDown")));
1570
+
1571
+ // Tab / Shift+Tab now cycle focus through the host's tabbable
1572
+ // widget set (declared in spec via `key`s — searchField,
1573
+ // replaceField, case, regex, whole, replaceAll, matchTree).
1574
+ // The host re-renders with focus styling on the new widget; the
1575
+ // plugin needn't track focusPanel/queryField/optionIndex anymore
1576
+ // (the legacy fields linger in PanelState until the rest of the
1577
+ // plugin migrates off them).
1578
+ registerHandler("search_replace_tab", () => dispatch(widgetKey("Tab")));
1579
+ registerHandler("search_replace_shift_tab", () => dispatch(widgetKey("Shift+Tab")));
1580
+
1581
+ // Left/Right route through the smart-key dispatcher: the host
1582
+ // expands/collapses Tree nodes (when the matchTree is focused) or
1583
+ // moves the TextInput cursor (when a search/replace field is
1584
+ // focused). Plugin no longer needs separate file-row expand
1585
+ // handling.
1042
1586
 
1043
1587
  // Global option toggles (Alt+C, Alt+R, Alt+W)
1044
1588
  function search_replace_toggle_case(): void {
@@ -1079,89 +1623,66 @@ registerHandler("search_replace_replace_scoped", search_replace_replace_scoped);
1079
1623
  // Action handlers
1080
1624
  // =============================================================================
1081
1625
 
1082
- function search_replace_enter(): void {
1083
- editor.debug("search_replace_enter CALLED, panel=" + (panel ? "yes" : "null"));
1084
- if (!panel) return;
1085
- if (panel.focusPanel === "query") {
1086
- // Enter in query field = confirm and run search
1087
- if (panel.queryField === "search") {
1088
- // Move to replace field
1089
- panel.queryField = "replace";
1090
- panel.cursorPos = panel.replaceText.length;
1091
- updatePanelContent();
1092
- } else {
1093
- // Confirm replace field and run search
1094
- if (panel.searchPattern) {
1095
- rerunSearch().then(() => {
1096
- if (panel) {
1097
- panel.focusPanel = "matches";
1098
- panel.matchIndex = 0;
1099
- panel.scrollOffset = 0;
1100
- updatePanelContent();
1101
- }
1102
- });
1103
- }
1104
- }
1105
- } else if (panel.focusPanel === "options") {
1106
- if (panel.optionIndex === 3) {
1107
- doReplaceAll();
1108
- } else {
1109
- search_replace_space();
1110
- }
1111
- } else {
1112
- const flat = buildFlatItems();
1113
- const item = flat[panel.matchIndex];
1114
- if (!item) return;
1115
- if (item.type === "file") {
1116
- panel.fileGroups[item.fileIndex].expanded = !panel.fileGroups[item.fileIndex].expanded;
1117
- updatePanelContent();
1118
- } else {
1119
- const group = panel.fileGroups[item.fileIndex];
1120
- const result = group.matches[item.matchIndex!];
1121
- editor.openFileInSplit(panel.sourceSplitId, result.match.file, result.match.line, result.match.column);
1122
- }
1123
- }
1124
- }
1125
- registerHandler("search_replace_enter", search_replace_enter);
1626
+ // Enter / Space route to the widget runtime. The host decides what
1627
+ // each does based on the focused widget kind:
1628
+ // * Toggle (case/regex/whole) → fires `widget_event` "toggle".
1629
+ // * Button (replaceAll) → fires `widget_event` "activate".
1630
+ // * Tree (matchTree) → fires `widget_event` "activate"
1631
+ // with the focused row's index/key.
1632
+ // Plugin handler opens the match
1633
+ // for leaf rows or toggles
1634
+ // expansion for file rows.
1635
+ // * TextInput + Space → inserts " " (fires "change").
1636
+ // * TextInput + Enter → no-op (plugin can still bind a
1637
+ // separate handler if it wants
1638
+ // Enter to mean "submit").
1639
+ // Per-event handling lives in the `widget_event` listener below.
1640
+ registerHandler("search_replace_enter", () => dispatch(widgetKey("Enter")));
1641
+ registerHandler("search_replace_space", () => dispatch(widgetKey("Space")));
1642
+
1643
+ /** Lock against re-entrant Replace All / Replace Scoped. Set as soon
1644
+ * as doReplaceAll/doReplaceScoped enters and cleared in a try/finally
1645
+ * around the whole flow. Without this, a user mashing Alt+Enter
1646
+ * during a streaming search produces N stacked confirmation prompts
1647
+ * once the search finishes — the host queues each keystroke and
1648
+ * drains them all when the JS event loop frees up; by then
1649
+ * `panel.busy` is false so the busy guard doesn't fire. */
1650
+ let replaceInProgress = false;
1126
1651
 
1127
- function search_replace_space(): void {
1652
+ async function doReplaceAll(): Promise<void> {
1128
1653
  if (!panel) return;
1129
- if (panel.focusPanel === "query") {
1130
- // Space in query field = insert space character
1131
- insertCharAtCursor(" ");
1654
+ if (panel.busy) {
1655
+ // Search is still streaming. Don't block silently — tell the user
1656
+ // to wait, and don't queue the replace. (The host's event
1657
+ // dispatcher would otherwise hold the keystroke and run it when
1658
+ // the pump finishes, which feels like an unexplained delay.)
1659
+ editor.setStatus(editor.t("status.replace_wait_for_search"));
1132
1660
  return;
1133
1661
  }
1134
- if (panel.focusPanel === "options") {
1135
- if (panel.optionIndex === 0) { panel.caseSensitive = !panel.caseSensitive; updatePanelContent(); rerunSearchDebounced(); }
1136
- else if (panel.optionIndex === 1) { panel.useRegex = !panel.useRegex; updatePanelContent(); rerunSearchDebounced(); }
1137
- else if (panel.optionIndex === 2) { panel.wholeWords = !panel.wholeWords; updatePanelContent(); rerunSearchDebounced(); }
1138
- else if (panel.optionIndex === 3) { doReplaceAll(); }
1662
+ if (replaceInProgress) {
1663
+ // First Alt+Enter is already showing its prompt or running the
1664
+ // rewrites. Drop the duplicate so we don't stack prompts.
1139
1665
  return;
1140
1666
  }
1141
- if (panel.focusPanel === "matches") {
1142
- const flat = buildFlatItems();
1143
- const item = flat[panel.matchIndex];
1144
- if (!item) return;
1145
- if (item.type === "file") {
1146
- const group = panel.fileGroups[item.fileIndex];
1147
- const allSelected = group.matches.every(m => m.selected);
1148
- for (const m of group.matches) m.selected = !allSelected;
1149
- } else {
1150
- const group = panel.fileGroups[item.fileIndex];
1151
- group.matches[item.matchIndex!].selected = !group.matches[item.matchIndex!].selected;
1152
- }
1153
- updatePanelContent();
1667
+ replaceInProgress = true;
1668
+ try {
1669
+ await doReplaceAllInner();
1670
+ } finally {
1671
+ replaceInProgress = false;
1154
1672
  }
1155
1673
  }
1156
- registerHandler("search_replace_space", search_replace_space);
1157
1674
 
1158
- async function doReplaceAll(): Promise<void> {
1159
- if (!panel || panel.busy) return;
1675
+ async function doReplaceAllInner(): Promise<void> {
1676
+ if (!panel) return;
1160
1677
  const selected = panel.searchResults.filter(r => r.selected);
1161
1678
  if (selected.length === 0) {
1162
1679
  editor.setStatus(editor.t("status.no_items_selected"));
1163
1680
  return;
1164
1681
  }
1682
+ // The user committed to this pattern by triggering Replace All —
1683
+ // a clear "settle" signal, so commit it to history now even if the
1684
+ // 2s scheduleHistoryPush hasn't fired yet.
1685
+ if (historyIndex < 0) historyPush(panel.searchPattern);
1165
1686
  // Confirm before applying. Replacements write to disk immediately; Undo
1166
1687
  // only covers files that remain open in this session (see bug #1 report).
1167
1688
  const fileCount = new Set(selected.map(r => r.match.file)).size;
@@ -1193,7 +1714,22 @@ async function doReplaceAll(): Promise<void> {
1193
1714
  }
1194
1715
 
1195
1716
  async function doReplaceScoped(): Promise<void> {
1196
- if (!panel || panel.busy || panel.focusPanel !== "matches") return;
1717
+ if (!panel || panel.focusPanel !== "matches") return;
1718
+ if (panel.busy) {
1719
+ editor.setStatus(editor.t("status.replace_wait_for_search"));
1720
+ return;
1721
+ }
1722
+ if (replaceInProgress) return;
1723
+ replaceInProgress = true;
1724
+ try {
1725
+ await doReplaceScopedInner();
1726
+ } finally {
1727
+ replaceInProgress = false;
1728
+ }
1729
+ }
1730
+
1731
+ async function doReplaceScopedInner(): Promise<void> {
1732
+ if (!panel) return;
1197
1733
  const flat = buildFlatItems();
1198
1734
  const item = flat[panel.matchIndex];
1199
1735
  if (!item) return;
@@ -1205,6 +1741,8 @@ async function doReplaceScoped(): Promise<void> {
1205
1741
  const result = panel.fileGroups[item.fileIndex].matches[item.matchIndex!];
1206
1742
  if (result.selected) toReplace = [result];
1207
1743
  }
1744
+ // Same as doReplaceAll: explicit commit, push immediately.
1745
+ if (historyIndex < 0) historyPush(panel.searchPattern);
1208
1746
 
1209
1747
  if (toReplace.length === 0) {
1210
1748
  editor.setStatus(editor.t("status.no_selected"));
@@ -1238,11 +1776,30 @@ async function doReplaceScoped(): Promise<void> {
1238
1776
 
1239
1777
  function search_replace_close(): void {
1240
1778
  if (!panel) return;
1779
+ // If the user actually ran a search to completion with this
1780
+ // pattern (results were observed) and isn't walking history,
1781
+ // treat panel-close as a settle and commit to history. The
1782
+ // searchPerformed guard avoids capturing half-typed patterns
1783
+ // that never made it past the empty-state.
1784
+ if (
1785
+ historyIndex < 0
1786
+ && panel.searchPattern
1787
+ && panel.searchPerformed
1788
+ ) {
1789
+ historyPush(panel.searchPattern);
1790
+ }
1791
+ const sourceSplitId = panel.sourceSplitId;
1792
+ panel.widgetPanel?.unmount();
1241
1793
  editor.closeBuffer(panel.resultsBufferId);
1242
1794
  if (panel.resultsSplitId !== panel.sourceSplitId) {
1243
1795
  editor.closeSplit(panel.resultsSplitId);
1244
1796
  }
1245
1797
  panel = null;
1798
+ // Restore focus to the split the user came from. Without this,
1799
+ // `getActiveBufferId()` on the next invocation can return the
1800
+ // utility dock's leftover buffer, and the §1 current-file scope
1801
+ // shows "(unsaved buffer)" instead of the real filename.
1802
+ editor.focusSplit(sourceSplitId);
1246
1803
  editor.setStatus(editor.t("status.closed"));
1247
1804
  }
1248
1805
  registerHandler("search_replace_close", search_replace_close);
@@ -1256,6 +1813,14 @@ function start_search_replace(): void {
1256
1813
  }
1257
1814
  registerHandler("start_search_replace", start_search_replace);
1258
1815
 
1816
+ // §1: open the panel with scope already restricted to the active
1817
+ // buffer. Useful when the user wants single-file search/replace from
1818
+ // the keymap without flipping the toggle by hand.
1819
+ function start_search_replace_in_buffer(): void {
1820
+ openPanel({ allFiles: false });
1821
+ }
1822
+ registerHandler("start_search_replace_in_buffer", start_search_replace_in_buffer);
1823
+
1259
1824
  // =============================================================================
1260
1825
  // Event handlers (resize updates width)
1261
1826
  // =============================================================================
@@ -1290,10 +1855,258 @@ editor.on("prompt_cancelled", (args) => {
1290
1855
 
1291
1856
  editor.on("buffer_closed", (args) => {
1292
1857
  if (panel && args.buffer_id === panel.resultsBufferId) {
1858
+ panel.widgetPanel?.unmount();
1293
1859
  panel = null;
1294
1860
  }
1295
1861
  });
1296
1862
 
1863
+ // Click → semantic event. The host hit-tests mouse clicks against the
1864
+ // mounted widget panel and fires `widget_event` for clicks that land
1865
+ // on a Toggle or Button. We dispatch on `widget_key` (set in
1866
+ // `buildOptionsRowSpec`); the existing keyboard-driven path
1867
+ // (Alt+C / Alt+R / Alt+W / Alt+Ret) still works unchanged.
1868
+ //
1869
+ // Mouse-click on a toggle should also focus it, so the user's next
1870
+ // Tab cycle starts from the clicked control. We do that by syncing
1871
+ // `focusPanel`/`optionIndex` to the clicked widget before applying
1872
+ // the state change.
1873
+ editor.on("widget_event", (args) => {
1874
+ if (!panel || args.panel_id !== panel.widgetPanel?.id()) return;
1875
+
1876
+ // Track most-recent focused widget so Up/Down can decide whether to
1877
+ // walk search history (search field) or pass through to the widget
1878
+ // runtime (matches tree, toggles, button). The widget runtime
1879
+ // doesn't expose focus to the plugin directly; this best-effort
1880
+ // proxy is good enough for the history-walk gesture. See §11.
1881
+ if (typeof args.widget_key === "string" && args.widget_key.length > 0) {
1882
+ lastFocusedWidget = args.widget_key;
1883
+ }
1884
+
1885
+ // `change` — fired for TextInput edits (Backspace, Delete,
1886
+ // arrows, Home/End, mode_text_input). Payload carries the new
1887
+ // value and cursor byte offset. The host already updated the
1888
+ // widget's instance state in place; we just sync the plugin's
1889
+ // model. **No** `updatePanelContent()` here — the widget has
1890
+ // already painted, and the rest of the spec doesn't depend on
1891
+ // the field value. This is the IPC fast path discussed in §3
1892
+ // of the design doc Q&A.
1893
+ if (args.event_type === "change") {
1894
+ const payload = args.payload as
1895
+ | { value?: string; cursorByte?: number }
1896
+ | undefined;
1897
+ if (typeof payload?.value !== "string") return;
1898
+ const cursorByte = typeof payload.cursorByte === "number"
1899
+ ? payload.cursorByte
1900
+ : payload.value.length;
1901
+ if (args.widget_key === "searchField") {
1902
+ if (panel.searchPattern !== payload.value) {
1903
+ // Pattern mutated by the user; cached "no matches" / result
1904
+ // set no longer reflects this query. See §17.
1905
+ panel.searchPerformed = false;
1906
+ // User-driven typing exits any in-flight history walk so a
1907
+ // subsequent Up doesn't snap back to a history entry under
1908
+ // the cursor. See §11.
1909
+ historyIndex = -1;
1910
+ historySavedPattern = null;
1911
+ panel.searchPattern = payload.value;
1912
+ panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
1913
+ updatePanelContent();
1914
+ rerunSearchDebounced();
1915
+ scheduleHistoryPush(payload.value);
1916
+ return;
1917
+ }
1918
+ // Cursor-only update (Left/Right arrows, Home/End, click reposition):
1919
+ // the search field's text is unchanged, so don't re-run the search
1920
+ // or perturb the history-settle timer. Just sync the plugin's
1921
+ // cached cursor position so the next render shows the cursor in
1922
+ // the right place.
1923
+ panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
1924
+ } else if (args.widget_key === "replaceField") {
1925
+ panel.replaceText = payload.value;
1926
+ panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
1927
+ }
1928
+ return;
1929
+ }
1930
+
1931
+ // `select` — fired when the user clicks a Tree row or the host
1932
+ // moves selection (Up/Down). The host already updated the
1933
+ // tree's selectedIndex in instance state; mirror it into the
1934
+ // plugin model and skip re-emit.
1935
+ if (args.event_type === "select") {
1936
+ const idx = (args.payload as { index?: number } | undefined)?.index;
1937
+ if (typeof idx === "number") {
1938
+ panel.matchIndex = idx;
1939
+ }
1940
+ return;
1941
+ }
1942
+
1943
+ // `expand` — fired when the host changes a Tree node's
1944
+ // expansion state (Right/Left key, or click on the disclosure
1945
+ // glyph). Mirror the change into our local set so a subsequent
1946
+ // file-row Enter (which goes through `setExpandedKeys`) reads
1947
+ // the right state.
1948
+ if (args.event_type === "expand") {
1949
+ const payload = args.payload as
1950
+ | { key?: string; expanded?: boolean }
1951
+ | undefined;
1952
+ if (typeof payload?.key === "string" && typeof payload.expanded === "boolean") {
1953
+ if (payload.expanded) panel.expandedFileKeys.add(payload.key);
1954
+ else panel.expandedFileKeys.delete(payload.key);
1955
+ }
1956
+ return;
1957
+ }
1958
+
1959
+ // `activate` — fired by Enter/Space on a focused Button or Tree.
1960
+ // For the Replace All button: run replace. For the matchTree:
1961
+ // open the focused match's source location, or toggle expansion
1962
+ // for file rows (so Enter is a shortcut for Right/Left/click).
1963
+ if (args.event_type === "activate") {
1964
+ if (args.widget_key === "replaceAll") {
1965
+ doReplaceAll();
1966
+ return;
1967
+ }
1968
+ if (args.widget_key === "matchTree") {
1969
+ const idx = (args.payload as { index?: number } | undefined)?.index;
1970
+ if (typeof idx !== "number") return;
1971
+ const flat = buildFlatItems();
1972
+ const item = flat[idx];
1973
+ if (!item) return;
1974
+ if (item.type === "file") {
1975
+ const k = `file:${item.fileIndex}`;
1976
+ if (panel.expandedFileKeys.has(k)) {
1977
+ panel.expandedFileKeys.delete(k);
1978
+ } else {
1979
+ panel.expandedFileKeys.add(k);
1980
+ }
1981
+ panel.widgetPanel?.setExpandedKeys(
1982
+ "matchTree",
1983
+ [...panel.expandedFileKeys],
1984
+ );
1985
+ } else {
1986
+ // Opening a result is a "this is the search I wanted" signal —
1987
+ // commit it to history immediately, regardless of how long
1988
+ // the pattern has been stable. See §11 follow-up.
1989
+ if (historyIndex < 0) historyPush(panel.searchPattern);
1990
+ const group = panel.fileGroups[item.fileIndex];
1991
+ const result = group.matches[item.matchIndex!];
1992
+ editor.openFileInSplit(
1993
+ panel.sourceSplitId,
1994
+ result.match.file,
1995
+ result.match.line,
1996
+ result.match.column,
1997
+ );
1998
+ }
1999
+ return;
2000
+ }
2001
+ }
2002
+
2003
+ // `toggle` — fired by Enter/Space on a Toggle and by mouse click.
2004
+ // The host fires the event but doesn't mutate the spec's
2005
+ // `checked` field — the plugin owns its model and pushes the
2006
+ // new state back via the targeted `setChecked` mutator (cheaper
2007
+ // than a full spec re-emit). The search rerun happens
2008
+ // independently on debounce; when it finishes it re-emits the
2009
+ // full spec with new matches.
2010
+ if (args.event_type === "toggle") {
2011
+ const newChecked = (args.payload as { checked?: boolean } | undefined)
2012
+ ?.checked;
2013
+ if (typeof newChecked !== "boolean") return;
2014
+ switch (args.widget_key) {
2015
+ case "allFiles":
2016
+ // Scope flip (§1). Push the new checked state back to the
2017
+ // widget instance and rebuild the whole spec so the scope
2018
+ // row + Replace All button label switch in lock-step. Then
2019
+ // re-run the search so the results pane reflects the new
2020
+ // scope (the search itself is project-wide; filtering
2021
+ // happens in performSearch).
2022
+ panel.allFiles = newChecked;
2023
+ panel.widgetPanel?.setChecked("allFiles", newChecked);
2024
+ updatePanelContent();
2025
+ rerunSearchDebounced();
2026
+ break;
2027
+ case "case":
2028
+ panel.caseSensitive = newChecked;
2029
+ panel.widgetPanel?.setChecked("case", newChecked);
2030
+ rerunSearchDebounced();
2031
+ break;
2032
+ case "regex":
2033
+ panel.useRegex = newChecked;
2034
+ panel.widgetPanel?.setChecked("regex", newChecked);
2035
+ rerunSearchDebounced();
2036
+ break;
2037
+ case "whole":
2038
+ panel.wholeWords = newChecked;
2039
+ panel.widgetPanel?.setChecked("whole", newChecked);
2040
+ rerunSearchDebounced();
2041
+ break;
2042
+ case "matchTree": {
2043
+ // The `[v]`/`[ ]` glyph on a tree row was clicked. Plugin
2044
+ // owns the source-of-truth (`result.selected`) — flip it
2045
+ // and push the new spec state via the targeted mutator.
2046
+ // For file rows we cascade to every child match so a
2047
+ // single click on the file checkbox checks/unchecks the
2048
+ // whole file's matches at once.
2049
+ const idx = (args.payload as { index?: number } | undefined)?.index;
2050
+ if (typeof idx !== "number") return;
2051
+ applyMatchTreeToggle(idx, newChecked);
2052
+ break;
2053
+ }
2054
+ }
2055
+ }
2056
+ });
2057
+
2058
+ /// Toggle the selected state of a match-tree row at `idx` to
2059
+ /// `newChecked`. For a match row, just flips that match. For a
2060
+ /// file header, cascades to every child match. Updates the host's
2061
+ /// view via `setCheckedKeys` (one call per row that changed
2062
+ /// glyph) so the next render reflects the new state without a
2063
+ /// full spec re-emit.
2064
+ function applyMatchTreeToggle(idx: number, newChecked: boolean): void {
2065
+ if (!panel) return;
2066
+ const flat = buildFlatItems();
2067
+ const item = flat[idx];
2068
+ if (!item) return;
2069
+ if (item.type === "match") {
2070
+ const fileGroup = panel.fileGroups[item.fileIndex];
2071
+ fileGroup.matches[item.matchIndex!].selected = newChecked;
2072
+ const matchKey = flatItemKey(item);
2073
+ panel.widgetPanel?.setCheckedKeys("matchTree", newChecked, [matchKey]);
2074
+ // The file header's checked glyph is derived (all-or-nothing).
2075
+ // After flipping a single match, recompute and push the file
2076
+ // row's new state so it stays in sync with its children.
2077
+ const fileAllSelected = fileGroup.matches.every(m => m.selected);
2078
+ const fileKey = flatItemKey({ type: "file", fileIndex: item.fileIndex });
2079
+ panel.widgetPanel?.setCheckedKeys("matchTree", fileAllSelected, [fileKey]);
2080
+ } else {
2081
+ // File row — cascade to every child.
2082
+ const fileGroup = panel.fileGroups[item.fileIndex];
2083
+ for (const m of fileGroup.matches) m.selected = newChecked;
2084
+ const fileKey = flatItemKey(item);
2085
+ const matchKeys = fileGroup.matches.map((_, mi) =>
2086
+ flatItemKey({ type: "match", fileIndex: item.fileIndex, matchIndex: mi })
2087
+ );
2088
+ panel.widgetPanel?.setCheckedKeys(
2089
+ "matchTree",
2090
+ newChecked,
2091
+ [fileKey, ...matchKeys],
2092
+ );
2093
+ }
2094
+ }
2095
+
2096
+ // Convert a UTF-8 byte offset into a JS-string character offset,
2097
+ // because the host's TextInput cursor model uses bytes (matching the
2098
+ // inline-overlay coordinate space) but the plugin's existing code
2099
+ // stores `panel.cursorPos` as a char offset. Pure walk over the
2100
+ // string until we hit `byteOffset`.
2101
+ function byteToCharOffset(value: string, byteOffset: number): number {
2102
+ let bytes = 0;
2103
+ for (let i = 0; i < value.length; i++) {
2104
+ if (bytes >= byteOffset) return i;
2105
+ bytes += byteLen(value[i]);
2106
+ }
2107
+ return value.length;
2108
+ }
2109
+
1297
2110
  editor.registerCommand(
1298
2111
  "%cmd.search_replace",
1299
2112
  "%cmd.search_replace_desc",
@@ -1301,4 +2114,11 @@ editor.registerCommand(
1301
2114
  null
1302
2115
  );
1303
2116
 
2117
+ editor.registerCommand(
2118
+ "%cmd.search_replace_in_buffer",
2119
+ "%cmd.search_replace_in_buffer_desc",
2120
+ "start_search_replace_in_buffer",
2121
+ null
2122
+ );
2123
+
1304
2124
  editor.debug("Search & Replace plugin loaded");