@fresh-editor/fresh-editor 0.3.6 → 0.3.8

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.
@@ -68,10 +68,28 @@ interface PanelState {
68
68
  caseSensitive: boolean;
69
69
  useRegex: boolean;
70
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;
71
81
  // Layout
72
82
  viewportWidth: number;
73
83
  // State
74
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;
75
93
  truncated: boolean;
76
94
  // Inline editing cursor position
77
95
  cursorPos: number;
@@ -109,6 +127,57 @@ const SEARCH_DEBOUNCE_MS = 150;
109
127
 
110
128
  let searchDebounceGeneration = 0;
111
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
+
112
181
  // =============================================================================
113
182
  // Colors
114
183
  // =============================================================================
@@ -197,6 +266,10 @@ function getActiveFieldText(): string {
197
266
  function setActiveFieldText(text: string): void {
198
267
  if (!panel) return;
199
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
+ }
200
273
  panel.searchPattern = text;
201
274
  } else {
202
275
  panel.replaceText = text;
@@ -335,22 +408,31 @@ function getViewportHeight(): number {
335
408
  // theme keys, and focus affordance match every other plugin.
336
409
  function buildOptionsRowSpec(): WidgetSpec {
337
410
  if (!panel) return col();
338
- const { focusPanel, optionIndex, caseSensitive, useRegex, wholeWords } = panel;
411
+ const { focusPanel, optionIndex, caseSensitive, useRegex, wholeWords, allFiles } = panel;
339
412
  const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
340
413
  const oFocus = focusPanel === "options";
341
414
 
342
- // The flex Spacer fills whatever's left of the row so the
343
- // "Replace All" button right-aligns regardless of label width or
344
- // panel width. No more byteLen-summing of labels.
345
415
  const caseLabel = editor.t("panel.case_toggle");
346
416
  const regexLabel = editor.t("panel.regex_toggle");
347
417
  const wholeLabel = editor.t("panel.whole_toggle");
348
- const replLabel = editor.t("panel.replace_all_btn");
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");
349
428
  void oFocus;
350
429
  void optionIndex;
430
+ void W;
351
431
 
352
432
  return row(
353
433
  spacer(1),
434
+ toggle(allFiles, allFilesLabel, { key: "allFiles" }),
435
+ spacer(2),
354
436
  toggle(caseSensitive, caseLabel, { key: "case" }),
355
437
  spacer(2),
356
438
  toggle(useRegex, regexLabel, { key: "regex" }),
@@ -361,6 +443,22 @@ function buildOptionsRowSpec(): WidgetSpec {
361
443
  );
362
444
  }
363
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
+
364
462
  // Build the typed Row spec for line 1 (search + replace fields with
365
463
  // trailing match-count stats). Was previously hand-rolled with two
366
464
  // `buildFieldDisplay` calls + manual cursor overlays; now uses the
@@ -368,11 +466,52 @@ function buildOptionsRowSpec(): WidgetSpec {
368
466
  // background, cursor highlight at the right byte position). The
369
467
  // match-stats portion stays in Raw because it has bespoke
370
468
  // truncated-warning styling (`[255, 180, 50]`) and isn't a control.
371
- function buildLine1Spec(): WidgetSpec {
372
- if (!panel) return col();
373
- const { searchPattern, replaceText, focusPanel, queryField, cursorPos, truncated } = panel;
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 [];
374
476
  const totalMatches = panel.searchResults.length;
375
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
+ }
511
+
512
+ function buildLine1Spec(): WidgetSpec {
513
+ if (!panel) return col();
514
+ const { searchPattern, replaceText, focusPanel, queryField, cursorPos } = panel;
376
515
  const qFocusSearch = focusPanel === "query" && queryField === "search";
377
516
  const qFocusReplace = focusPanel === "query" && queryField === "replace";
378
517
  const searchVal = searchPattern || "";
@@ -385,39 +524,6 @@ function buildLine1Spec(): WidgetSpec {
385
524
  const searchLabel = editor.t("panel.search_label");
386
525
  const replLabel = editor.t("panel.replace_label");
387
526
 
388
- const truncatedSuffix = truncated ? " " + editor.t("panel.limited") : "";
389
- const matchStats = totalMatches > 0
390
- ? " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) }) + truncatedSuffix
391
- : (searchPattern ? " " + editor.t("panel.no_matches") : "");
392
-
393
- // Build the matchStats inline-overlay-styled Raw cell for the row.
394
- // Truncated case keeps the warning-color tail; otherwise the whole
395
- // stats string uses the ok/dim color depending on result presence.
396
- const matchStatsEntries: TextPropertyEntry[] = [];
397
- if (matchStats.length > 0) {
398
- const overlays: InlineOverlay[] = [];
399
- if (truncated && totalMatches > 0) {
400
- const statsWithoutSuffix = " " + editor.t("panel.match_stats", {
401
- count: String(totalMatches),
402
- files: String(fileCount),
403
- });
404
- const countEnd = byteLen(statsWithoutSuffix);
405
- overlays.push({ start: 0, end: countEnd, style: { fg: C.statusOk } });
406
- overlays.push({
407
- start: countEnd,
408
- end: countEnd + byteLen(truncatedSuffix),
409
- style: { fg: [255, 180, 50] as RGB, bold: true },
410
- });
411
- } else {
412
- overlays.push({
413
- start: 0,
414
- end: byteLen(matchStats),
415
- style: { fg: totalMatches > 0 ? C.statusOk : C.statusDim },
416
- });
417
- }
418
- matchStatsEntries.push({ text: matchStats, inlineOverlays: overlays });
419
- }
420
-
421
527
  return row(
422
528
  spacer(1),
423
529
  textInput(searchVal, {
@@ -435,7 +541,7 @@ function buildLine1Spec(): WidgetSpec {
435
541
  fieldWidth: 25,
436
542
  key: "replaceField",
437
543
  }),
438
- raw(matchStatsEntries),
544
+ raw(buildMatchStatsEntries(), "matchStats"),
439
545
  );
440
546
  }
441
547
 
@@ -490,7 +596,20 @@ function renderFlatItemEntry(item: FlatItem, W: number): TextPropertyEntry {
490
596
  const group = panel.fileGroups[item.fileIndex];
491
597
  const result = group.matches[item.matchIndex!];
492
598
  const location = `${group.relPath}:${result.match.line}`;
493
- const context = result.match.context.trim();
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();
494
613
  // Host prefix consumes:
495
614
  // indent (depth=1) = 2
496
615
  // leaf-alignment = 2 (in lieu of disclosure glyph)
@@ -527,6 +646,35 @@ function renderFlatItemEntry(item: FlatItem, W: number): TextPropertyEntry {
527
646
  });
528
647
  }
529
648
 
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
+ }
677
+
530
678
  // Build the typed spec for the matches body — either a Tree widget
531
679
  // (when there are matches) or a Raw cell with the empty/prompt
532
680
  // message. The Tree widget owns scroll, selection styling, click
@@ -538,52 +686,39 @@ function buildMatchListSpec(): WidgetSpec {
538
686
  const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
539
687
  const totalMatches = panel.searchResults.length;
540
688
 
541
- if (panel.searchPattern && totalMatches === 0) {
542
- return raw([{
543
- text: padStr(" " + editor.t("panel.no_matches"), W),
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),
544
701
  properties: { type: "empty" },
545
702
  style: { fg: C.dim },
546
703
  }]);
547
- }
548
704
  if (!panel.searchPattern) {
549
- return raw([{
550
- text: padStr(" " + editor.t("panel.type_pattern"), W),
551
- properties: { type: "empty" },
552
- style: { fg: C.dim },
553
- }]);
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");
554
717
  }
555
718
 
556
719
  const flatItems = buildFlatItems();
557
720
  const itemKeys = flatItems.map(flatItemKey);
558
- // Track the file-row keys present in this render. Newly-discovered
559
- // file groups are auto-added to `expandedFileKeys` (default state =
560
- // expanded). Files the user has collapsed remain absent from the
561
- // set; we never re-add a key that's already known but currently
562
- // collapsed, since `clearedThisRender` would tag them as "first
563
- // time seen". Tracking is via the per-search reset in
564
- // `performSearch`: at the start of a search the set is empty, so
565
- // every file is auto-added on its first appearance, then user
566
- // collapse events remove them.
567
- const nodes: TreeNode[] = flatItems.map((item, i) => {
568
- const entry = renderFlatItemEntry(item, W);
569
- if (item.type === "file") {
570
- const k = itemKeys[i];
571
- if (!panel!.knownFileKeys.has(k)) {
572
- panel!.knownFileKeys.add(k);
573
- panel!.expandedFileKeys.add(k);
574
- }
575
- // File-row checkbox derives from children: checked iff every
576
- // match in this file is selected. Mixed (some selected, some
577
- // not) renders as `[ ]` for v1 — adding a tristate `[~]`
578
- // glyph is a future host-side option but not needed to wire
579
- // the toggle path end-to-end.
580
- const fileChecked = panel!.fileGroups[item.fileIndex].matches.every(m => m.selected);
581
- return treeNode(entry, { depth: 0, hasChildren: true, checked: fileChecked });
582
- }
583
- const matchSelected = panel!.fileGroups[item.fileIndex]
584
- .matches[item.matchIndex!].selected;
585
- return treeNode(entry, { depth: 1, hasChildren: false, checked: matchSelected });
586
- });
721
+ const nodes = flatItemsToTreeNodes(flatItems, itemKeys, W);
587
722
  const selectedIndex = panel.focusPanel === "matches" ? panel.matchIndex : -1;
588
723
  // Tree visible rows = panel viewport height minus the chrome
589
724
  // (line 1 + options row + separator + footer = 4 rows) — same
@@ -796,7 +931,8 @@ function updatePanelContent(): void {
796
931
  col(
797
932
  buildLine1Spec(),
798
933
  buildOptionsRowSpec(),
799
- raw(buildPanelEntries("postOptions")),
934
+ buildScopeRowSpec(),
935
+ raw(buildPanelEntries("postOptions"), "separator"),
800
936
  buildMatchListSpec(),
801
937
  hintBar(buildHelpHints()),
802
938
  ),
@@ -828,6 +964,50 @@ let activeSearchHandle: SearchHandle | null = null;
828
964
  /** Pump cadence between successive `take()` drains (ms). The host writes
829
965
  * matches at full speed; this knob bounds the UI rebuild rate. */
830
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;
831
1011
 
832
1012
  /**
833
1013
  * Perform a streaming search using a pull-based handle. The host writes
@@ -846,6 +1026,21 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
846
1026
  // result set isn't meaningful for the new one.
847
1027
  panel.expandedFileKeys.clear();
848
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 = [];
849
1044
 
850
1045
  // Cancel any in-flight search before kicking off a new one. Without
851
1046
  // this the prior search would keep walking the project until it
@@ -878,30 +1073,168 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
878
1073
  return allResults;
879
1074
  }
880
1075
 
881
- const batch = handle.take();
882
- if (batch.matches.length > 0) {
883
- for (const m of batch.matches) {
884
- allResults.push({ match: m, selected: true });
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);
885
1132
  }
886
- panel.searchResults = allResults;
887
- panel.fileGroups = buildFileGroups(allResults);
888
- updatePanelContent();
889
- } else if (batch.done) {
890
- // Final iteration with no new matches still needs a UI flush
891
- // when the previous tick ended on a non-empty batch but didn't
892
- // know it was the last one.
893
- panel.searchResults = allResults;
894
- panel.fileGroups = buildFileGroups(allResults);
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 = [];
895
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"));
1212
+ }
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"));
896
1223
  }
897
1224
 
898
- if (batch.done) {
899
- truncated = batch.truncated;
900
- producerError = batch.error ?? null;
1225
+ if (producerFinished) {
1226
+ truncated = batchTruncated;
1227
+ producerError = batchError;
901
1228
  break;
902
1229
  }
903
1230
 
904
- await editor.delay(SEARCH_PUMP_INTERVAL_MS);
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);
905
1238
  }
906
1239
 
907
1240
  if (activeSearchHandle === handle) {
@@ -939,30 +1272,40 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
939
1272
  // Panel lifecycle
940
1273
  // =============================================================================
941
1274
 
942
- async function openPanel(): Promise<void> {
1275
+ async function openPanel(opts?: { allFiles?: boolean }): Promise<void> {
943
1276
  // Try to pre-fill search from editor selection
944
1277
  let prefill = "";
1278
+ let sourceBufferPath = "";
945
1279
  try {
1280
+ const activeId = editor.getActiveBufferId();
1281
+ sourceBufferPath = editor.getBufferPath(activeId) || "";
946
1282
  const cursor = editor.getPrimaryCursor();
947
1283
  if (cursor && cursor.selection) {
948
1284
  const start = Math.min(cursor.selection.start, cursor.selection.end);
949
1285
  const end = Math.max(cursor.selection.start, cursor.selection.end);
950
1286
  if (end - start > 0 && end - start < 200) {
951
- const bufferId = editor.getActiveBufferId();
952
- const text = await editor.getBufferText(bufferId, start, end);
1287
+ const text = await editor.getBufferText(activeId, start, end);
953
1288
  if (text && !text.includes("\n")) {
954
1289
  prefill = text;
955
1290
  }
956
1291
  }
957
1292
  }
958
- } catch (_e) { /* no selection */ }
1293
+ } catch (_e) { /* no selection / no buffer */ }
1294
+
1295
+ const allFiles = opts?.allFiles ?? true;
1296
+ const sourceBufferRelPath = sourceBufferPath ? getRelativePath(sourceBufferPath) : "";
959
1297
 
960
1298
  if (panel) {
961
1299
  panel.focusPanel = "query";
962
1300
  panel.queryField = "search";
963
1301
  if (prefill) panel.searchPattern = prefill;
964
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;
965
1307
  updatePanelContent();
1308
+ if (panel.searchPattern) rerunSearchDebounced();
966
1309
  return;
967
1310
  }
968
1311
 
@@ -983,8 +1326,12 @@ async function openPanel(): Promise<void> {
983
1326
  caseSensitive: false,
984
1327
  useRegex: false,
985
1328
  wholeWords: false,
1329
+ allFiles,
1330
+ sourceBufferPath,
1331
+ sourceBufferRelPath,
986
1332
  viewportWidth: DEFAULT_WIDTH,
987
1333
  busy: false,
1334
+ searchPerformed: false,
988
1335
  truncated: false,
989
1336
  cursorPos: prefill.length,
990
1337
  scrollOffset: 0,
@@ -1075,19 +1422,42 @@ async function executeReplacements(results?: SearchResult[]): Promise<string> {
1075
1422
 
1076
1423
  async function rerunSearch(): Promise<void> {
1077
1424
  if (!panel || !panel.searchPattern) return;
1078
- 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;
1079
1438
  panel.truncated = false;
1080
1439
  panel.busy = true;
1081
1440
  panel.matchIndex = 0;
1082
1441
  panel.scrollOffset = 0;
1083
- const results = await performSearch(panel.searchPattern);
1084
- // performSearch already updates panel.searchResults/fileGroups incrementally;
1085
- // just ensure final state is consistent
1086
- if (panel) {
1087
- panel.searchResults = results;
1088
- 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) {
1089
1452
  panel.busy = false;
1090
- 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
+ }
1091
1461
  }
1092
1462
  }
1093
1463
 
@@ -1104,6 +1474,7 @@ function rerunSearchDebounced(): void {
1104
1474
  async function rerunSearchQuiet(): Promise<void> {
1105
1475
  if (!panel || !panel.searchPattern) return;
1106
1476
  if (panel.busy) return;
1477
+ searchDebounceGeneration++;
1107
1478
  panel.busy = true;
1108
1479
  const results = await performSearch(panel.searchPattern, true);
1109
1480
  if (panel) {
@@ -1112,6 +1483,7 @@ async function rerunSearchQuiet(): Promise<void> {
1112
1483
  panel.matchIndex = 0;
1113
1484
  panel.scrollOffset = 0;
1114
1485
  panel.busy = false;
1486
+ panel.searchPerformed = true;
1115
1487
  updatePanelContent();
1116
1488
  }
1117
1489
  }
@@ -1136,8 +1508,63 @@ registerHandler("search_replace_home", () => dispatch(widgetKey("Home")));
1136
1508
  registerHandler("search_replace_end", () => dispatch(widgetKey("End")));
1137
1509
  registerHandler("search_replace_nav_left", () => dispatch(widgetKey("Left")));
1138
1510
  registerHandler("search_replace_nav_right", () => dispatch(widgetKey("Right")));
1139
- registerHandler("search_replace_nav_up", () => dispatch(widgetKey("Up")));
1140
- registerHandler("search_replace_nav_down", () => dispatch(widgetKey("Down")));
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();
1522
+ }
1523
+
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;
1529
+ }
1530
+
1531
+ registerHandler("search_replace_nav_up", () => {
1532
+ if (!panel) return;
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;
1542
+ } else {
1543
+ return; // already at the oldest entry
1544
+ }
1545
+ applyHistoryEntry(searchHistory[historyIndex]);
1546
+ return;
1547
+ }
1548
+ dispatch(widgetKey("Up"));
1549
+ });
1550
+ registerHandler("search_replace_nav_down", () => {
1551
+ if (!panel) return;
1552
+ if (shouldInterceptForHistory() && historyIndex >= 0) {
1553
+ if (historyIndex > 0) {
1554
+ historyIndex -= 1;
1555
+ applyHistoryEntry(searchHistory[historyIndex]);
1556
+ return;
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);
1564
+ return;
1565
+ }
1566
+ dispatch(widgetKey("Down"));
1567
+ });
1141
1568
  registerHandler("search_replace_nav_page_up", () => dispatch(widgetKey("PageUp")));
1142
1569
  registerHandler("search_replace_nav_page_down", () => dispatch(widgetKey("PageDown")));
1143
1570
 
@@ -1213,13 +1640,49 @@ registerHandler("search_replace_replace_scoped", search_replace_replace_scoped);
1213
1640
  registerHandler("search_replace_enter", () => dispatch(widgetKey("Enter")));
1214
1641
  registerHandler("search_replace_space", () => dispatch(widgetKey("Space")));
1215
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;
1651
+
1216
1652
  async function doReplaceAll(): Promise<void> {
1217
- if (!panel || panel.busy) return;
1653
+ if (!panel) return;
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"));
1660
+ return;
1661
+ }
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.
1665
+ return;
1666
+ }
1667
+ replaceInProgress = true;
1668
+ try {
1669
+ await doReplaceAllInner();
1670
+ } finally {
1671
+ replaceInProgress = false;
1672
+ }
1673
+ }
1674
+
1675
+ async function doReplaceAllInner(): Promise<void> {
1676
+ if (!panel) return;
1218
1677
  const selected = panel.searchResults.filter(r => r.selected);
1219
1678
  if (selected.length === 0) {
1220
1679
  editor.setStatus(editor.t("status.no_items_selected"));
1221
1680
  return;
1222
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);
1223
1686
  // Confirm before applying. Replacements write to disk immediately; Undo
1224
1687
  // only covers files that remain open in this session (see bug #1 report).
1225
1688
  const fileCount = new Set(selected.map(r => r.match.file)).size;
@@ -1251,7 +1714,22 @@ async function doReplaceAll(): Promise<void> {
1251
1714
  }
1252
1715
 
1253
1716
  async function doReplaceScoped(): Promise<void> {
1254
- 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;
1255
1733
  const flat = buildFlatItems();
1256
1734
  const item = flat[panel.matchIndex];
1257
1735
  if (!item) return;
@@ -1263,6 +1741,8 @@ async function doReplaceScoped(): Promise<void> {
1263
1741
  const result = panel.fileGroups[item.fileIndex].matches[item.matchIndex!];
1264
1742
  if (result.selected) toReplace = [result];
1265
1743
  }
1744
+ // Same as doReplaceAll: explicit commit, push immediately.
1745
+ if (historyIndex < 0) historyPush(panel.searchPattern);
1266
1746
 
1267
1747
  if (toReplace.length === 0) {
1268
1748
  editor.setStatus(editor.t("status.no_selected"));
@@ -1296,12 +1776,30 @@ async function doReplaceScoped(): Promise<void> {
1296
1776
 
1297
1777
  function search_replace_close(): void {
1298
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;
1299
1792
  panel.widgetPanel?.unmount();
1300
1793
  editor.closeBuffer(panel.resultsBufferId);
1301
1794
  if (panel.resultsSplitId !== panel.sourceSplitId) {
1302
1795
  editor.closeSplit(panel.resultsSplitId);
1303
1796
  }
1304
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);
1305
1803
  editor.setStatus(editor.t("status.closed"));
1306
1804
  }
1307
1805
  registerHandler("search_replace_close", search_replace_close);
@@ -1315,6 +1813,14 @@ function start_search_replace(): void {
1315
1813
  }
1316
1814
  registerHandler("start_search_replace", start_search_replace);
1317
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
+
1318
1824
  // =============================================================================
1319
1825
  // Event handlers (resize updates width)
1320
1826
  // =============================================================================
@@ -1367,6 +1873,15 @@ editor.on("buffer_closed", (args) => {
1367
1873
  editor.on("widget_event", (args) => {
1368
1874
  if (!panel || args.panel_id !== panel.widgetPanel?.id()) return;
1369
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
+
1370
1885
  // `change` — fired for TextInput edits (Backspace, Delete,
1371
1886
  // arrows, Home/End, mode_text_input). Payload carries the new
1372
1887
  // value and cursor byte offset. The host already updated the
@@ -1384,9 +1899,28 @@ editor.on("widget_event", (args) => {
1384
1899
  ? payload.cursorByte
1385
1900
  : payload.value.length;
1386
1901
  if (args.widget_key === "searchField") {
1387
- panel.searchPattern = payload.value;
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.
1388
1923
  panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
1389
- rerunSearchDebounced();
1390
1924
  } else if (args.widget_key === "replaceField") {
1391
1925
  panel.replaceText = payload.value;
1392
1926
  panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
@@ -1449,6 +1983,10 @@ editor.on("widget_event", (args) => {
1449
1983
  [...panel.expandedFileKeys],
1450
1984
  );
1451
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);
1452
1990
  const group = panel.fileGroups[item.fileIndex];
1453
1991
  const result = group.matches[item.matchIndex!];
1454
1992
  editor.openFileInSplit(
@@ -1474,6 +2012,18 @@ editor.on("widget_event", (args) => {
1474
2012
  ?.checked;
1475
2013
  if (typeof newChecked !== "boolean") return;
1476
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;
1477
2027
  case "case":
1478
2028
  panel.caseSensitive = newChecked;
1479
2029
  panel.widgetPanel?.setChecked("case", newChecked);
@@ -1564,4 +2114,11 @@ editor.registerCommand(
1564
2114
  null
1565
2115
  );
1566
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
+
1567
2124
  editor.debug("Search & Replace plugin loaded");