@fresh-editor/fresh-editor 0.3.4 → 0.3.6

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.
@@ -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
  /**
@@ -54,6 +77,28 @@ interface PanelState {
54
77
  cursorPos: number;
55
78
  // Virtual scroll offset for matches tree
56
79
  scrollOffset: number;
80
+ // Per-file expansion state mirrored from the Tree widget's host
81
+ // instance state. The widget owns expansion (host re-renders on
82
+ // disclosure click / Right / Left without the plugin reacting);
83
+ // this set is only read by the plugin's `activate` handler so
84
+ // Enter on a file row can toggle expansion via
85
+ // `panel.setExpandedKeys`. Both sets are cleared at the start of
86
+ // every fresh search.
87
+ expandedFileKeys: Set<string>;
88
+ // Memo of file-row keys we've already seen during the current
89
+ // search. Used by `buildMatchListSpec` to auto-expand newly-
90
+ // discovered files (default = expanded) without overriding user
91
+ // collapse state on previously-seen files.
92
+ knownFileKeys: Set<string>;
93
+ // Widget panel handle. The panel mounts a `Col[Raw{body}, HintBar{hints}]`
94
+ // spec — the body keeps the existing hand-rolled rendering for now,
95
+ // and the footer is built by the host's HintBar widget so its keys are
96
+ // styled consistently with every other plugin's footer (theme-keyed
97
+ // `ui.help_key_fg`). Subsequent migration passes will pull the
98
+ // search/replace inputs, the toggles, and the match tree out of
99
+ // `Raw` and into typed widgets. See
100
+ // `docs/internal/plugin-widget-library-design.md` §10.
101
+ widgetPanel: WidgetPanel | null;
57
102
  }
58
103
  let panel: PanelState | null = null;
59
104
 
@@ -123,7 +168,6 @@ function truncate(s: string, maxLen: number): string {
123
168
  const sLen = charLen(s);
124
169
  if (sLen <= maxLen) return s;
125
170
  if (maxLen <= 3) {
126
- // Take first maxLen codepoints
127
171
  let result = "";
128
172
  let count = 0;
129
173
  for (const c of s) {
@@ -133,7 +177,6 @@ function truncate(s: string, maxLen: number): string {
133
177
  }
134
178
  return result;
135
179
  }
136
- // Take first (maxLen-3) codepoints + "..."
137
180
  let result = "";
138
181
  let count = 0;
139
182
  for (const c of s) {
@@ -173,6 +216,8 @@ const modeBindings: [string, string][] = [
173
216
  ["S-Tab", "search_replace_shift_tab"],
174
217
  ["Up", "search_replace_nav_up"],
175
218
  ["Down", "search_replace_nav_down"],
219
+ ["PageUp", "search_replace_nav_page_up"],
220
+ ["PageDown", "search_replace_nav_page_down"],
176
221
  ["Left", "search_replace_nav_left"],
177
222
  ["Right", "search_replace_nav_right"],
178
223
  ["M-c", "search_replace_toggle_case"],
@@ -189,21 +234,14 @@ const modeBindings: [string, string][] = [
189
234
 
190
235
  editor.defineMode("search-replace-list", modeBindings, true, true);
191
236
 
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
237
+ // Printable input flows through the widget runtime: mode_text_input
238
+ // → widgetCommand(textInputChar(text)) host computes new value +
239
+ // cursor on the focused TextInput → widget_event "change" → plugin
240
+ // updates its model from the event payload (see the widget_event
241
+ // handler at the bottom of the file).
203
242
  function mode_text_input(args: { text: string }): void {
204
- if (args && args.text) {
205
- insertCharAtCursor(args.text);
206
- }
243
+ if (!panel || !args?.text) return;
244
+ panel.widgetPanel?.command(textInputChar(args.text));
207
245
  }
208
246
  registerHandler("mode_text_input", mode_text_input);
209
247
 
@@ -252,16 +290,19 @@ interface FlatItem {
252
290
  matchIndex?: number;
253
291
  }
254
292
 
293
+ // Emit every file row + every match row in declaration order. The
294
+ // Tree widget filters out descendants of collapsed nodes at render
295
+ // time — the plugin always sends the full hierarchy. Plugin code
296
+ // that needs to map a `selected_index` back to the underlying match
297
+ // (e.g. `doReplaceScoped`) walks this same flat list.
255
298
  function buildFlatItems(): FlatItem[] {
256
299
  if (!panel) return [];
257
300
  const items: FlatItem[] = [];
258
301
  for (let fi = 0; fi < panel.fileGroups.length; fi++) {
259
- const group = panel.fileGroups[fi];
260
302
  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
- }
303
+ const group = panel.fileGroups[fi];
304
+ for (let mi = 0; mi < group.matches.length; mi++) {
305
+ items.push({ type: "match", fileIndex: fi, matchIndex: mi });
265
306
  }
266
307
  }
267
308
  return items;
@@ -287,125 +328,333 @@ function getViewportHeight(): number {
287
328
  // Panel content builder — compact two-line control bar + match tree
288
329
  // =============================================================================
289
330
 
290
- function buildPanelEntries(): TextPropertyEntry[] {
291
- if (!panel) return [];
292
- const { searchPattern, replaceText, searchResults, fileGroups, focusPanel, queryField,
293
- optionIndex, caseSensitive, useRegex, wholeWords, cursorPos } = panel;
294
-
331
+ // Build the typed Row spec for the options line (3 toggles + Replace
332
+ // All button). Was previously hand-built into entries with manual
333
+ // byte-offset overlay arithmetic (see git history pre-widget); now
334
+ // dispatched through the host's Toggle/Button widgets so styling,
335
+ // theme keys, and focus affordance match every other plugin.
336
+ function buildOptionsRowSpec(): WidgetSpec {
337
+ if (!panel) return col();
338
+ const { focusPanel, optionIndex, caseSensitive, useRegex, wholeWords } = panel;
295
339
  const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
296
- const entries: TextPropertyEntry[] = [];
340
+ const oFocus = focusPanel === "options";
297
341
 
298
- const totalMatches = searchResults.length;
299
- const fileCount = fileGroups.length;
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
+ const caseLabel = editor.t("panel.case_toggle");
346
+ const regexLabel = editor.t("panel.regex_toggle");
347
+ const wholeLabel = editor.t("panel.whole_toggle");
348
+ const replLabel = editor.t("panel.replace_all_btn");
349
+ void oFocus;
350
+ void optionIndex;
351
+
352
+ return row(
353
+ spacer(1),
354
+ toggle(caseSensitive, caseLabel, { key: "case" }),
355
+ spacer(2),
356
+ toggle(useRegex, regexLabel, { key: "regex" }),
357
+ spacer(2),
358
+ toggle(wholeWords, wholeLabel, { key: "whole" }),
359
+ flexSpacer(),
360
+ button(replLabel, { intent: "primary", key: "replaceAll" }),
361
+ );
362
+ }
300
363
 
301
- // ── Line 1: Query fields + match count ──
364
+ // Build the typed Row spec for line 1 (search + replace fields with
365
+ // trailing match-count stats). Was previously hand-rolled with two
366
+ // `buildFieldDisplay` calls + manual cursor overlays; now uses the
367
+ // host's TextInput widget for both fields (theme-keyed focus + input
368
+ // background, cursor highlight at the right byte position). The
369
+ // match-stats portion stays in Raw because it has bespoke
370
+ // 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;
374
+ const totalMatches = panel.searchResults.length;
375
+ const fileCount = panel.fileGroups.length;
302
376
  const qFocusSearch = focusPanel === "query" && queryField === "search";
303
377
  const qFocusReplace = focusPanel === "query" && queryField === "replace";
304
-
305
- // Build search field display with cursor
306
378
  const searchVal = searchPattern || "";
307
379
  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") : "";
380
+ // The plugin tracks `cursorPos` as a character offset; the widget
381
+ // wants a UTF-8 byte offset. For ASCII they're equal; for the
382
+ // multi-byte case we convert via byteLen of the prefix.
383
+ const searchCursorByte = qFocusSearch ? byteLen(searchVal.substring(0, cursorPos)) : -1;
384
+ const replaceCursorByte = qFocusReplace ? byteLen(replaceVal.substring(0, cursorPos)) : -1;
385
+ const searchLabel = editor.t("panel.search_label");
386
+ const replLabel = editor.t("panel.replace_label");
387
+
388
+ const truncatedSuffix = truncated ? " " + editor.t("panel.limited") : "";
317
389
  const matchStats = totalMatches > 0
318
390
  ? " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) }) + truncatedSuffix
319
391
  : (searchPattern ? " " + editor.t("panel.no_matches") : "");
320
392
 
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);
346
- }
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 } });
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
+ });
357
411
  } else {
358
- const msEnd = msStart + byteLen(matchStats);
359
- line1Overlays.push({ start: msStart, end: msEnd, style: { fg: totalMatches > 0 ? C.statusOk : C.statusDim } });
412
+ overlays.push({
413
+ start: 0,
414
+ end: byteLen(matchStats),
415
+ style: { fg: totalMatches > 0 ? C.statusOk : C.statusDim },
416
+ });
360
417
  }
418
+ matchStatsEntries.push({ text: matchStats, inlineOverlays: overlays });
361
419
  }
362
420
 
363
- entries.push({
364
- text: line1 + "\n",
365
- properties: { type: "query-line" },
366
- inlineOverlays: line1Overlays,
367
- });
368
-
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") + "]";
374
-
375
- const line2Text = " " + optCase + " " + optRegex + " " + optWhole + " " + replBtn;
376
- const line2 = padStr(line2Text, W);
421
+ return row(
422
+ spacer(1),
423
+ textInput(searchVal, {
424
+ label: searchLabel,
425
+ focused: qFocusSearch,
426
+ cursorByte: searchCursorByte,
427
+ fieldWidth: 25,
428
+ key: "searchField",
429
+ }),
430
+ spacer(2),
431
+ textInput(replaceVal, {
432
+ label: replLabel,
433
+ focused: qFocusReplace,
434
+ cursorByte: replaceCursorByte,
435
+ fieldWidth: 25,
436
+ key: "replaceField",
437
+ }),
438
+ raw(matchStatsEntries),
439
+ );
440
+ }
377
441
 
378
- const line2Overlays: InlineOverlay[] = [];
379
- const oFocus = focusPanel === "options";
442
+ // Stable key for a flat tree item — used as the List item key so
443
+ // click events bounce back to the same logical match across
444
+ // re-renders. File rows use `file:<n>`; match rows use
445
+ // `match:<file>/<m>`.
446
+ function flatItemKey(item: FlatItem): string {
447
+ if (item.type === "file") return `file:${item.fileIndex}`;
448
+ return `match:${item.fileIndex}/${item.matchIndex}`;
449
+ }
380
450
 
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(" ");
451
+ // Render one flat tree item as a single TextPropertyEntry. The
452
+ // Tree widget owns the indent (depth * 2 spaces) + disclosure glyph
453
+ // (▶ / ) prefix and the selection bg — this function emits *just*
454
+ // the row's content starting from offset 0 of the row's body. Files
455
+ // pass `depth: 0, hasChildren: true`; matches pass `depth: 1,
456
+ // hasChildren: false` (see `buildMatchListSpec`).
457
+ //
458
+ // Row content is described as a sequence of styled segments rather
459
+ // than a pre-rendered string + offset overlays. The host concats
460
+ // segments and computes the byte offsets natively in Rust, so the
461
+ // plugin doesn't count codepoints or bytes for layout-piece widths
462
+ // at all. Per-row freeform overlays (e.g. pattern-match highlights
463
+ // inside the context substring) ride on the relevant segment via
464
+ // its `overlays` field, addressed in char units relative to that
465
+ // segment alone.
466
+ function renderFlatItemEntry(item: FlatItem, W: number): TextPropertyEntry {
467
+ if (!panel) return { text: "" };
468
+ if (item.type === "file") {
469
+ const group = panel.fileGroups[item.fileIndex];
470
+ const badge = getFileExtBadge(group.relPath);
471
+ const matchCount = group.matches.length;
472
+ const selectedInFile = group.matches.filter(m => m.selected).length;
473
+ return styledRow(
474
+ [
475
+ { text: badge, style: { fg: C.fileIcon, bold: true } },
476
+ { text: " " },
477
+ { text: group.relPath, style: { fg: C.filePath } },
478
+ { text: ` (${selectedInFile}/${matchCount})` },
479
+ ],
480
+ {
481
+ // Host prefix at depth 0: disclosure (▶/▼) + space + checkbox
482
+ // ([v]/[ ]) + space = 6 cols.
483
+ padToChars: Math.max(0, W - 6),
484
+ properties: { type: "file-row", fileIndex: item.fileIndex },
485
+ },
486
+ );
487
+ }
488
+ // Match row. The Tree widget's prefix at depth=1 is 6 cols
489
+ // (4 indent + 2 alignment). Use the remaining width for content.
490
+ const group = panel.fileGroups[item.fileIndex];
491
+ const result = group.matches[item.matchIndex!];
492
+ const location = `${group.relPath}:${result.match.line}`;
493
+ const context = result.match.context.trim();
494
+ // Host prefix consumes:
495
+ // indent (depth=1) = 2
496
+ // leaf-alignment = 2 (in lieu of disclosure glyph)
497
+ // checkbox + space = 4 ([v] + " ")
498
+ // Total: 8 cols.
499
+ const innerWidth = Math.max(0, W - 8);
500
+
501
+ // Best-effort context budget: enough room for the fixed leading
502
+ // pieces plus " - " plus the context itself. JS `.length` gives
503
+ // UTF-16 code-unit counts which match codepoint counts for the
504
+ // overwhelmingly-ASCII case (paths + line numbers); slight
505
+ // over-counting on rare non-BMP filenames just trims a little
506
+ // more of the context, which is fine.
507
+ const maxCtx = innerWidth - location.length - 3;
508
+ const displayCtx = truncate(context, Math.max(10, maxCtx));
509
+
510
+ // Pattern-match highlights inside the context substring. Emitted
511
+ // in segment-local char units; the host shifts them by the
512
+ // context segment's char start during entry concatenation.
513
+ const ctxOverlays: InlineOverlay[] = [];
514
+ if (panel.searchPattern) {
515
+ highlightMatches(displayCtx, panel.searchPattern, panel.useRegex, panel.caseSensitive, ctxOverlays);
390
516
  }
391
517
 
392
- addToggleOverlay(optCase, 0);
393
- addToggleOverlay(optRegex, 1);
394
- addToggleOverlay(optWhole, 2);
518
+ const segments: StyledSegment[] = [
519
+ { text: location, style: { fg: C.lineNum } },
520
+ { text: " - " },
521
+ { text: displayCtx, overlays: ctxOverlays },
522
+ ];
395
523
 
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 },
524
+ return styledRow(segments, {
525
+ padToChars: innerWidth,
526
+ properties: { type: "match-row", fileIndex: item.fileIndex, matchIndex: item.matchIndex },
402
527
  });
528
+ }
403
529
 
404
- entries.push({
405
- text: line2 + "\n",
406
- properties: { type: "options-line" },
407
- inlineOverlays: line2Overlays,
530
+ // Build the typed spec for the matches body — either a Tree widget
531
+ // (when there are matches) or a Raw cell with the empty/prompt
532
+ // message. The Tree widget owns scroll, selection styling, click
533
+ // routing, and host-managed expand/collapse — the plugin sends
534
+ // the *full* hierarchy on every render and the host filters
535
+ // children of collapsed file rows.
536
+ function buildMatchListSpec(): WidgetSpec {
537
+ if (!panel) return col();
538
+ const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
539
+ const totalMatches = panel.searchResults.length;
540
+
541
+ if (panel.searchPattern && totalMatches === 0) {
542
+ return raw([{
543
+ text: padStr(" " + editor.t("panel.no_matches"), W),
544
+ properties: { type: "empty" },
545
+ style: { fg: C.dim },
546
+ }]);
547
+ }
548
+ 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
+ }]);
554
+ }
555
+
556
+ const flatItems = buildFlatItems();
557
+ 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
+ });
587
+ const selectedIndex = panel.focusPanel === "matches" ? panel.matchIndex : -1;
588
+ // Tree visible rows = panel viewport height minus the chrome
589
+ // (line 1 + options row + separator + footer = 4 rows) — same
590
+ // calculation that sized the previous List.
591
+ const fixedRows = 5;
592
+ const visibleRows = Math.max(3, getViewportHeight() - fixedRows);
593
+
594
+ return tree({
595
+ nodes,
596
+ itemKeys,
597
+ selectedIndex,
598
+ visibleRows,
599
+ expandedKeys: [...panel.expandedFileKeys],
600
+ checkable: true,
601
+ key: "matchTree",
408
602
  });
603
+ }
604
+
605
+ // Phase selector for `buildPanelEntries`. The hand-rolled options
606
+ // row and line-1 query fields were extracted into typed widget specs
607
+ // (`buildOptionsRowSpec`, `buildLine1Spec`); this parameter lets
608
+ // callers ask for the body before the options row ("preOptions"),
609
+ // the body after it ("postOptions"), or — for tests / fallback
610
+ // paths — both with no gap ("all"). Today "preOptions" is empty
611
+ // because line 1 lives in `buildLine1Spec`; the parameter remains
612
+ // for symmetry and to keep the boundary explicit.
613
+ type BuildPhase = "all" | "preOptions" | "postOptions";
614
+
615
+ function buildPanelEntries(phase: BuildPhase = "all"): TextPropertyEntry[] {
616
+ if (!panel) return [];
617
+ const { searchPattern, replaceText, searchResults, fileGroups, focusPanel, queryField,
618
+ optionIndex, caseSensitive, useRegex, wholeWords, cursorPos } = panel;
619
+ // The line-1 + options-row variables are still destructured for
620
+ // readability with the rest of the function but are now consumed
621
+ // by `buildLine1Spec()` and `buildOptionsRowSpec()` (composed into
622
+ // the spec at update time).
623
+ void searchPattern;
624
+ void replaceText;
625
+ void searchResults;
626
+ void fileGroups;
627
+ void focusPanel;
628
+ void queryField;
629
+ void cursorPos;
630
+ void optionIndex;
631
+ void caseSensitive;
632
+ void useRegex;
633
+ void wholeWords;
634
+
635
+ const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
636
+ const entries: TextPropertyEntry[] = [];
637
+
638
+ const totalMatches = searchResults.length;
639
+ const fileCount = fileGroups.length;
640
+
641
+ // ── Line 1 (search/replace fields + match-count stats) is now
642
+ // rendered by `buildLine1Spec()` — see updatePanelContent. The
643
+ // pre-options phase therefore returns no entries; the spec
644
+ // composes the typed Row directly between the col children. ──
645
+
646
+ // ── Line 2 (options toggles + Replace All button) is now rendered
647
+ // by the host as a `Row { Toggle, Toggle, Toggle, Spacer, Button }`
648
+ // spec — see `buildOptionsRowSpec` and `updatePanelContent`.
649
+ // `buildPanelEntries` is split into a "pre-options" half (this
650
+ // function up to here) and a "post-options" tail (everything from
651
+ // the separator onward). `updatePanelContent` weaves the spec
652
+ // between them so the visual order stays identical to before. ──
653
+ if (phase === "preOptions") return entries;
654
+ // ── For phase==="postOptions", also drop the line-1 entry pushed
655
+ // above so the caller can compose: `col(raw(pre), optionsRow,
656
+ // raw(post), hintBar)` without duplicating line 1.
657
+ if (phase === "postOptions") entries.length = 0;
409
658
 
410
659
  // ── Separator ──
411
660
  const sepChar = "─";
@@ -427,122 +676,34 @@ function buildPanelEntries(): TextPropertyEntry[] {
427
676
  }],
428
677
  });
429
678
 
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
- });
679
+ // ── Matches tree is now rendered by `buildMatchListSpec()`
680
+ // see `updatePanelContent`. The List widget owns scroll
681
+ // offset (auto-clamps to keep selection in view) and click
682
+ // routing. ──
542
683
 
684
+ // The help footer is no longer pushed here — it's now rendered by
685
+ // the host's HintBar widget (see updatePanelContent).
543
686
  return entries;
544
687
  }
545
688
 
689
+ // Build the hint entries for the panel footer.
690
+ //
691
+ // Source of truth is the existing `panel.help` i18n string (format:
692
+ // `Tab:section ↑↓:nav …`); `parseHintString` splits it into typed
693
+ // `HintEntry[]` so the host's HintBar widget can style the keys
694
+ // portion via the `ui.help_key_fg` theme key — matching every other
695
+ // plugin's footer.
696
+ function buildHelpHints(): HintEntry[] {
697
+ // Source of truth is the existing `panel.help` i18n string. The
698
+ // pre-widget version appended a `↑↓` scroll indicator computed
699
+ // from `panel.scrollOffset`; the List widget now owns scroll
700
+ // state, so the plugin no longer knows the scroll position.
701
+ // Scroll feedback is implicit (the visible window of items shifts
702
+ // visibly when navigating); explicit indicators can come back as
703
+ // a List-emitted prop once needed.
704
+ return parseHintString(editor.t("panel.help"));
705
+ }
706
+
546
707
  // Build field display string: [value] with cursor
547
708
  function buildFieldDisplay(value: string, cursorPos: number, maxLen: number): string {
548
709
  const display = value.length > maxLen ? value.slice(0, maxLen - 1) + "…" : value;
@@ -564,8 +725,18 @@ function addCursorOverlay(value: string, cursorPos: number, fieldByteStart: numb
564
725
  overlays.push({ start: cursorBytePos, end: cursorByteEnd, style: { fg: [0, 0, 0], bg: C.cursorBg } });
565
726
  }
566
727
 
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 {
728
+ // Append pattern-match highlight overlays (one per occurrence) to
729
+ // `overlays`. Offsets are in char (codepoint) units within `text`
730
+ // itself — the caller is expected to attach `overlays` to a
731
+ // segment whose body equals `text`, so the host shifts them into
732
+ // entry-coordinate space during segment resolution.
733
+ //
734
+ // `text` and `pattern` are treated as JS UTF-16 strings. For BMP
735
+ // content (which includes nearly all source code) UTF-16 code unit
736
+ // indices and Unicode codepoint indices coincide, so `indexOf` /
737
+ // `RegExp.exec` indices map directly to char offsets without a
738
+ // per-overlay codepoint walk.
739
+ function highlightMatches(text: string, pattern: string, isRegex: boolean, caseSensitive: boolean, overlays: InlineOverlay[]): void {
569
740
  if (!pattern) return;
570
741
  try {
571
742
  if (!isRegex) {
@@ -579,9 +750,7 @@ function highlightMatches(text: string, pattern: string, baseByteOffset: number,
579
750
  while (pos < searchText.length) {
580
751
  const idx = searchText.indexOf(searchPat, pos);
581
752
  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 } });
753
+ overlays.push({ start: idx, end: idx + pattern.length, style: { bg: C.matchBg, fg: C.matchFg }, unit: "char" });
585
754
  pos = idx + pattern.length;
586
755
  }
587
756
  } else {
@@ -590,9 +759,7 @@ function highlightMatches(text: string, pattern: string, baseByteOffset: number,
590
759
  let m;
591
760
  while ((m = re.exec(text)) !== null) {
592
761
  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 } });
762
+ overlays.push({ start: m.index, end: m.index + m[0].length, style: { bg: C.matchBg, fg: C.matchFg }, unit: "char" });
596
763
  }
597
764
  }
598
765
  } catch (_e) { /* invalid regex */ }
@@ -603,10 +770,50 @@ function highlightMatches(text: string, pattern: string, baseByteOffset: number,
603
770
  // =============================================================================
604
771
 
605
772
  function updatePanelContent(): void {
606
- if (panel) {
607
- // Refresh viewport width each time
608
- panel.viewportWidth = getViewportWidth();
609
- editor.setVirtualBufferContent(panel.resultsBufferId, buildPanelEntries());
773
+ if (!panel) return;
774
+ // Refresh viewport width each time
775
+ panel.viewportWidth = getViewportWidth();
776
+
777
+ // Migration step 4 (see docs/internal/plugin-widget-library-design.md
778
+ // §10): the entire visible panel is now typed widgets except for
779
+ // a single `Raw` separator entry.
780
+ //
781
+ // * `Row{ Spacer, TextInput, Spacer, TextInput, Raw{ stats } }`
782
+ // — search/replace inputs +
783
+ // trailing match-count stats.
784
+ // * `Row{ Toggle, Toggle, Toggle, Spacer, Button }`
785
+ // — case/regex/whole + Replace All.
786
+ // * `Raw{ separator entry }` — matches divider.
787
+ // * `List{ ... }` or `Raw{empty msg}` — virtual-scrolled match
788
+ // rows (host owns scroll +
789
+ // selection styling +
790
+ // click routing).
791
+ // * `HintBar{ ... }` — keyboard-hint footer.
792
+ if (!panel.widgetPanel) {
793
+ panel.widgetPanel = new WidgetPanel(panel.resultsBufferId);
794
+ }
795
+ panel.widgetPanel.set(
796
+ col(
797
+ buildLine1Spec(),
798
+ buildOptionsRowSpec(),
799
+ raw(buildPanelEntries("postOptions")),
800
+ buildMatchListSpec(),
801
+ hintBar(buildHelpHints()),
802
+ ),
803
+ );
804
+ // The Tree's `expandedKeys` field on the spec is initial-only —
805
+ // `mountWidgetPanel` seeds the host's instance state, and
806
+ // `updateWidgetPanel` ignores it (instance state is authoritative
807
+ // after first render). So we push expansion changes through the
808
+ // explicit mutator on every update; this covers the case where
809
+ // a new file group enters the result set in a later search and
810
+ // needs to be force-expanded by default. The mutator is a no-op
811
+ // when the tree isn't mounted yet (first `set()` call).
812
+ if (panel.searchPattern && panel.searchResults.length > 0) {
813
+ panel.widgetPanel.setExpandedKeys(
814
+ "matchTree",
815
+ [...panel.expandedFileKeys],
816
+ );
610
817
  }
611
818
  }
612
819
 
@@ -616,58 +823,99 @@ function updatePanelContent(): void {
616
823
 
617
824
  /** Current search generation — incremented on each new search to discard stale results. */
618
825
  let currentSearchGeneration = 0;
826
+ /** The active search handle, kept so a superseding search can cancel it. */
827
+ let activeSearchHandle: SearchHandle | null = null;
828
+ /** Pump cadence between successive `take()` drains (ms). The host writes
829
+ * matches at full speed; this knob bounds the UI rebuild rate. */
830
+ const SEARCH_PUMP_INTERVAL_MS = 50;
619
831
 
620
832
  /**
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.
833
+ * Perform a streaming search using a pull-based handle. The host writes
834
+ * matches at full speed into shared state; this loop drains them via
835
+ * `handle.take()` and rebuilds the UI between drains. There are no
836
+ * per-chunk callbacks crossing the FFI boundary, so the host's main
837
+ * thread is free to process input and render between pumps.
624
838
  */
625
839
  async function performSearch(pattern: string, silent?: boolean): Promise<SearchResult[]> {
626
840
  if (!panel) return [];
627
841
 
628
842
  const generation = ++currentSearchGeneration;
629
- let lastUiUpdate = Date.now();
630
- const UI_UPDATE_INTERVAL_MS = 100; // Force maximum 10 UI updates per second
843
+ // Each fresh search resets the per-file expansion set: previous
844
+ // results may have included files that don't appear in the new
845
+ // result set, and the user's collapse state for the *previous*
846
+ // result set isn't meaningful for the new one.
847
+ panel.expandedFileKeys.clear();
848
+ panel.knownFileKeys.clear();
849
+
850
+ // Cancel any in-flight search before kicking off a new one. Without
851
+ // this the prior search would keep walking the project until it
852
+ // hit max_results, wasting CPU.
853
+ if (activeSearchHandle) {
854
+ try { activeSearchHandle.cancel(); } catch (_e) { /* ignore */ }
855
+ activeSearchHandle = null;
856
+ }
631
857
 
632
858
  try {
633
859
  const fixedString = !panel.useRegex;
634
- let allResults: SearchResult[] = [];
860
+ const allResults: SearchResult[] = [];
635
861
 
636
862
  // 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 });
653
- }
654
- panel.searchResults = allResults;
655
- }
863
+ const handle = editor.beginSearch(pattern, {
864
+ fixedString,
865
+ caseSensitive: panel.caseSensitive,
866
+ maxResults: MAX_RESULTS,
867
+ wholeWords: panel.wholeWords,
868
+ });
869
+ activeSearchHandle = handle;
656
870
 
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;
871
+ let truncated = false;
872
+ let producerError: string | null = null;
873
+
874
+ while (true) {
875
+ // Discard the in-flight search if a newer one started while we slept.
876
+ if (generation !== currentSearchGeneration || !panel) {
877
+ try { handle.cancel(); } catch (_e) { /* ignore */ }
878
+ return allResults;
879
+ }
880
+
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 });
663
885
  }
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);
895
+ updatePanelContent();
664
896
  }
665
- );
897
+
898
+ if (batch.done) {
899
+ truncated = batch.truncated;
900
+ producerError = batch.error ?? null;
901
+ break;
902
+ }
903
+
904
+ await editor.delay(SEARCH_PUMP_INTERVAL_MS);
905
+ }
906
+
907
+ if (activeSearchHandle === handle) {
908
+ activeSearchHandle = null;
909
+ }
666
910
 
667
911
  // Final state
668
912
  if (generation !== currentSearchGeneration || !panel) return allResults;
669
913
 
670
- panel.truncated = !!(result && (result as any).truncated);
914
+ if (producerError) {
915
+ throw new Error(producerError);
916
+ }
917
+
918
+ panel.truncated = truncated;
671
919
 
672
920
  if (!silent) {
673
921
  if (allResults.length === 0) {
@@ -740,6 +988,9 @@ async function openPanel(): Promise<void> {
740
988
  truncated: false,
741
989
  cursorPos: prefill.length,
742
990
  scrollOffset: 0,
991
+ expandedFileKeys: new Set<string>(),
992
+ knownFileKeys: new Set<string>(),
993
+ widgetPanel: null,
743
994
  };
744
995
 
745
996
  try {
@@ -869,176 +1120,42 @@ async function rerunSearchQuiet(): Promise<void> {
869
1120
  // Text editing handlers (inline editing of query fields)
870
1121
  // =============================================================================
871
1122
 
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();
1123
+ // All editing / navigation keys route through the widget runtime
1124
+ // via the smart `Key` dispatch — the host knows which widget is
1125
+ // focused and routes accordingly (Backspace into TextInput; Up/Down
1126
+ // across List rows; Enter/Space activate Toggle/Button/List;
1127
+ // printable Space inserts into TextInput; Tab/Shift+Tab cycles
1128
+ // focus). See WidgetAction::Key for the full table.
1129
+ function dispatch(action: WidgetAction): void {
1130
+ panel?.widgetPanel?.command(action);
904
1131
  }
905
- registerHandler("search_replace_end", search_replace_end);
906
1132
 
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
- }
925
- }
926
- registerHandler("search_replace_nav_down", search_replace_nav_down);
927
-
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
- }
941
- }
942
- registerHandler("search_replace_nav_up", search_replace_nav_up);
943
-
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();
967
- }
968
- registerHandler("search_replace_tab", search_replace_tab);
969
-
970
- function search_replace_shift_tab(): void {
971
- 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;
982
- } 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();
997
- }
998
- return;
999
- }
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 {
1022
- 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();
1029
- }
1030
- return;
1031
- }
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);
1133
+ registerHandler("search_replace_backspace", () => dispatch(widgetKey("Backspace")));
1134
+ registerHandler("search_replace_delete", () => dispatch(widgetKey("Delete")));
1135
+ registerHandler("search_replace_home", () => dispatch(widgetKey("Home")));
1136
+ registerHandler("search_replace_end", () => dispatch(widgetKey("End")));
1137
+ registerHandler("search_replace_nav_left", () => dispatch(widgetKey("Left")));
1138
+ 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")));
1141
+ registerHandler("search_replace_nav_page_up", () => dispatch(widgetKey("PageUp")));
1142
+ registerHandler("search_replace_nav_page_down", () => dispatch(widgetKey("PageDown")));
1143
+
1144
+ // Tab / Shift+Tab now cycle focus through the host's tabbable
1145
+ // widget set (declared in spec via `key`s — searchField,
1146
+ // replaceField, case, regex, whole, replaceAll, matchTree).
1147
+ // The host re-renders with focus styling on the new widget; the
1148
+ // plugin needn't track focusPanel/queryField/optionIndex anymore
1149
+ // (the legacy fields linger in PanelState until the rest of the
1150
+ // plugin migrates off them).
1151
+ registerHandler("search_replace_tab", () => dispatch(widgetKey("Tab")));
1152
+ registerHandler("search_replace_shift_tab", () => dispatch(widgetKey("Shift+Tab")));
1153
+
1154
+ // Left/Right route through the smart-key dispatcher: the host
1155
+ // expands/collapses Tree nodes (when the matchTree is focused) or
1156
+ // moves the TextInput cursor (when a search/replace field is
1157
+ // focused). Plugin no longer needs separate file-row expand
1158
+ // handling.
1042
1159
 
1043
1160
  // Global option toggles (Alt+C, Alt+R, Alt+W)
1044
1161
  function search_replace_toggle_case(): void {
@@ -1079,81 +1196,22 @@ registerHandler("search_replace_replace_scoped", search_replace_replace_scoped);
1079
1196
  // Action handlers
1080
1197
  // =============================================================================
1081
1198
 
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);
1126
-
1127
- function search_replace_space(): void {
1128
- if (!panel) return;
1129
- if (panel.focusPanel === "query") {
1130
- // Space in query field = insert space character
1131
- insertCharAtCursor(" ");
1132
- return;
1133
- }
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(); }
1139
- return;
1140
- }
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();
1154
- }
1155
- }
1156
- registerHandler("search_replace_space", search_replace_space);
1199
+ // Enter / Space route to the widget runtime. The host decides what
1200
+ // each does based on the focused widget kind:
1201
+ // * Toggle (case/regex/whole) → fires `widget_event` "toggle".
1202
+ // * Button (replaceAll) → fires `widget_event` "activate".
1203
+ // * Tree (matchTree) → fires `widget_event` "activate"
1204
+ // with the focused row's index/key.
1205
+ // Plugin handler opens the match
1206
+ // for leaf rows or toggles
1207
+ // expansion for file rows.
1208
+ // * TextInput + Space → inserts " " (fires "change").
1209
+ // * TextInput + Enter → no-op (plugin can still bind a
1210
+ // separate handler if it wants
1211
+ // Enter to mean "submit").
1212
+ // Per-event handling lives in the `widget_event` listener below.
1213
+ registerHandler("search_replace_enter", () => dispatch(widgetKey("Enter")));
1214
+ registerHandler("search_replace_space", () => dispatch(widgetKey("Space")));
1157
1215
 
1158
1216
  async function doReplaceAll(): Promise<void> {
1159
1217
  if (!panel || panel.busy) return;
@@ -1238,6 +1296,7 @@ async function doReplaceScoped(): Promise<void> {
1238
1296
 
1239
1297
  function search_replace_close(): void {
1240
1298
  if (!panel) return;
1299
+ panel.widgetPanel?.unmount();
1241
1300
  editor.closeBuffer(panel.resultsBufferId);
1242
1301
  if (panel.resultsSplitId !== panel.sourceSplitId) {
1243
1302
  editor.closeSplit(panel.resultsSplitId);
@@ -1290,10 +1349,214 @@ editor.on("prompt_cancelled", (args) => {
1290
1349
 
1291
1350
  editor.on("buffer_closed", (args) => {
1292
1351
  if (panel && args.buffer_id === panel.resultsBufferId) {
1352
+ panel.widgetPanel?.unmount();
1293
1353
  panel = null;
1294
1354
  }
1295
1355
  });
1296
1356
 
1357
+ // Click → semantic event. The host hit-tests mouse clicks against the
1358
+ // mounted widget panel and fires `widget_event` for clicks that land
1359
+ // on a Toggle or Button. We dispatch on `widget_key` (set in
1360
+ // `buildOptionsRowSpec`); the existing keyboard-driven path
1361
+ // (Alt+C / Alt+R / Alt+W / Alt+Ret) still works unchanged.
1362
+ //
1363
+ // Mouse-click on a toggle should also focus it, so the user's next
1364
+ // Tab cycle starts from the clicked control. We do that by syncing
1365
+ // `focusPanel`/`optionIndex` to the clicked widget before applying
1366
+ // the state change.
1367
+ editor.on("widget_event", (args) => {
1368
+ if (!panel || args.panel_id !== panel.widgetPanel?.id()) return;
1369
+
1370
+ // `change` — fired for TextInput edits (Backspace, Delete,
1371
+ // arrows, Home/End, mode_text_input). Payload carries the new
1372
+ // value and cursor byte offset. The host already updated the
1373
+ // widget's instance state in place; we just sync the plugin's
1374
+ // model. **No** `updatePanelContent()` here — the widget has
1375
+ // already painted, and the rest of the spec doesn't depend on
1376
+ // the field value. This is the IPC fast path discussed in §3
1377
+ // of the design doc Q&A.
1378
+ if (args.event_type === "change") {
1379
+ const payload = args.payload as
1380
+ | { value?: string; cursorByte?: number }
1381
+ | undefined;
1382
+ if (typeof payload?.value !== "string") return;
1383
+ const cursorByte = typeof payload.cursorByte === "number"
1384
+ ? payload.cursorByte
1385
+ : payload.value.length;
1386
+ if (args.widget_key === "searchField") {
1387
+ panel.searchPattern = payload.value;
1388
+ panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
1389
+ rerunSearchDebounced();
1390
+ } else if (args.widget_key === "replaceField") {
1391
+ panel.replaceText = payload.value;
1392
+ panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
1393
+ }
1394
+ return;
1395
+ }
1396
+
1397
+ // `select` — fired when the user clicks a Tree row or the host
1398
+ // moves selection (Up/Down). The host already updated the
1399
+ // tree's selectedIndex in instance state; mirror it into the
1400
+ // plugin model and skip re-emit.
1401
+ if (args.event_type === "select") {
1402
+ const idx = (args.payload as { index?: number } | undefined)?.index;
1403
+ if (typeof idx === "number") {
1404
+ panel.matchIndex = idx;
1405
+ }
1406
+ return;
1407
+ }
1408
+
1409
+ // `expand` — fired when the host changes a Tree node's
1410
+ // expansion state (Right/Left key, or click on the disclosure
1411
+ // glyph). Mirror the change into our local set so a subsequent
1412
+ // file-row Enter (which goes through `setExpandedKeys`) reads
1413
+ // the right state.
1414
+ if (args.event_type === "expand") {
1415
+ const payload = args.payload as
1416
+ | { key?: string; expanded?: boolean }
1417
+ | undefined;
1418
+ if (typeof payload?.key === "string" && typeof payload.expanded === "boolean") {
1419
+ if (payload.expanded) panel.expandedFileKeys.add(payload.key);
1420
+ else panel.expandedFileKeys.delete(payload.key);
1421
+ }
1422
+ return;
1423
+ }
1424
+
1425
+ // `activate` — fired by Enter/Space on a focused Button or Tree.
1426
+ // For the Replace All button: run replace. For the matchTree:
1427
+ // open the focused match's source location, or toggle expansion
1428
+ // for file rows (so Enter is a shortcut for Right/Left/click).
1429
+ if (args.event_type === "activate") {
1430
+ if (args.widget_key === "replaceAll") {
1431
+ doReplaceAll();
1432
+ return;
1433
+ }
1434
+ if (args.widget_key === "matchTree") {
1435
+ const idx = (args.payload as { index?: number } | undefined)?.index;
1436
+ if (typeof idx !== "number") return;
1437
+ const flat = buildFlatItems();
1438
+ const item = flat[idx];
1439
+ if (!item) return;
1440
+ if (item.type === "file") {
1441
+ const k = `file:${item.fileIndex}`;
1442
+ if (panel.expandedFileKeys.has(k)) {
1443
+ panel.expandedFileKeys.delete(k);
1444
+ } else {
1445
+ panel.expandedFileKeys.add(k);
1446
+ }
1447
+ panel.widgetPanel?.setExpandedKeys(
1448
+ "matchTree",
1449
+ [...panel.expandedFileKeys],
1450
+ );
1451
+ } else {
1452
+ const group = panel.fileGroups[item.fileIndex];
1453
+ const result = group.matches[item.matchIndex!];
1454
+ editor.openFileInSplit(
1455
+ panel.sourceSplitId,
1456
+ result.match.file,
1457
+ result.match.line,
1458
+ result.match.column,
1459
+ );
1460
+ }
1461
+ return;
1462
+ }
1463
+ }
1464
+
1465
+ // `toggle` — fired by Enter/Space on a Toggle and by mouse click.
1466
+ // The host fires the event but doesn't mutate the spec's
1467
+ // `checked` field — the plugin owns its model and pushes the
1468
+ // new state back via the targeted `setChecked` mutator (cheaper
1469
+ // than a full spec re-emit). The search rerun happens
1470
+ // independently on debounce; when it finishes it re-emits the
1471
+ // full spec with new matches.
1472
+ if (args.event_type === "toggle") {
1473
+ const newChecked = (args.payload as { checked?: boolean } | undefined)
1474
+ ?.checked;
1475
+ if (typeof newChecked !== "boolean") return;
1476
+ switch (args.widget_key) {
1477
+ case "case":
1478
+ panel.caseSensitive = newChecked;
1479
+ panel.widgetPanel?.setChecked("case", newChecked);
1480
+ rerunSearchDebounced();
1481
+ break;
1482
+ case "regex":
1483
+ panel.useRegex = newChecked;
1484
+ panel.widgetPanel?.setChecked("regex", newChecked);
1485
+ rerunSearchDebounced();
1486
+ break;
1487
+ case "whole":
1488
+ panel.wholeWords = newChecked;
1489
+ panel.widgetPanel?.setChecked("whole", newChecked);
1490
+ rerunSearchDebounced();
1491
+ break;
1492
+ case "matchTree": {
1493
+ // The `[v]`/`[ ]` glyph on a tree row was clicked. Plugin
1494
+ // owns the source-of-truth (`result.selected`) — flip it
1495
+ // and push the new spec state via the targeted mutator.
1496
+ // For file rows we cascade to every child match so a
1497
+ // single click on the file checkbox checks/unchecks the
1498
+ // whole file's matches at once.
1499
+ const idx = (args.payload as { index?: number } | undefined)?.index;
1500
+ if (typeof idx !== "number") return;
1501
+ applyMatchTreeToggle(idx, newChecked);
1502
+ break;
1503
+ }
1504
+ }
1505
+ }
1506
+ });
1507
+
1508
+ /// Toggle the selected state of a match-tree row at `idx` to
1509
+ /// `newChecked`. For a match row, just flips that match. For a
1510
+ /// file header, cascades to every child match. Updates the host's
1511
+ /// view via `setCheckedKeys` (one call per row that changed
1512
+ /// glyph) so the next render reflects the new state without a
1513
+ /// full spec re-emit.
1514
+ function applyMatchTreeToggle(idx: number, newChecked: boolean): void {
1515
+ if (!panel) return;
1516
+ const flat = buildFlatItems();
1517
+ const item = flat[idx];
1518
+ if (!item) return;
1519
+ if (item.type === "match") {
1520
+ const fileGroup = panel.fileGroups[item.fileIndex];
1521
+ fileGroup.matches[item.matchIndex!].selected = newChecked;
1522
+ const matchKey = flatItemKey(item);
1523
+ panel.widgetPanel?.setCheckedKeys("matchTree", newChecked, [matchKey]);
1524
+ // The file header's checked glyph is derived (all-or-nothing).
1525
+ // After flipping a single match, recompute and push the file
1526
+ // row's new state so it stays in sync with its children.
1527
+ const fileAllSelected = fileGroup.matches.every(m => m.selected);
1528
+ const fileKey = flatItemKey({ type: "file", fileIndex: item.fileIndex });
1529
+ panel.widgetPanel?.setCheckedKeys("matchTree", fileAllSelected, [fileKey]);
1530
+ } else {
1531
+ // File row — cascade to every child.
1532
+ const fileGroup = panel.fileGroups[item.fileIndex];
1533
+ for (const m of fileGroup.matches) m.selected = newChecked;
1534
+ const fileKey = flatItemKey(item);
1535
+ const matchKeys = fileGroup.matches.map((_, mi) =>
1536
+ flatItemKey({ type: "match", fileIndex: item.fileIndex, matchIndex: mi })
1537
+ );
1538
+ panel.widgetPanel?.setCheckedKeys(
1539
+ "matchTree",
1540
+ newChecked,
1541
+ [fileKey, ...matchKeys],
1542
+ );
1543
+ }
1544
+ }
1545
+
1546
+ // Convert a UTF-8 byte offset into a JS-string character offset,
1547
+ // because the host's TextInput cursor model uses bytes (matching the
1548
+ // inline-overlay coordinate space) but the plugin's existing code
1549
+ // stores `panel.cursorPos` as a char offset. Pure walk over the
1550
+ // string until we hit `byteOffset`.
1551
+ function byteToCharOffset(value: string, byteOffset: number): number {
1552
+ let bytes = 0;
1553
+ for (let i = 0; i < value.length; i++) {
1554
+ if (bytes >= byteOffset) return i;
1555
+ bytes += byteLen(value[i]);
1556
+ }
1557
+ return value.length;
1558
+ }
1559
+
1297
1560
  editor.registerCommand(
1298
1561
  "%cmd.search_replace",
1299
1562
  "%cmd.search_replace_desc",