@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.
- package/CHANGELOG.md +72 -0
- package/README.md +9 -2
- package/package.json +1 -1
- package/plugins/config-schema.json +7 -1
- package/plugins/dashboard.ts +16 -93
- package/plugins/git_grep.ts +3 -1
- package/plugins/git_log.ts +196 -224
- package/plugins/goto_with_selection.i18n.json +58 -0
- package/plugins/goto_with_selection.ts +17 -0
- package/plugins/lib/finder.ts +27 -6
- package/plugins/lib/fresh.d.ts +620 -14
- package/plugins/lib/index.ts +34 -0
- package/plugins/lib/widgets.ts +796 -0
- package/plugins/live_diff.ts +324 -29
- package/plugins/live_grep.ts +114 -48
- package/plugins/orchestrator.ts +1685 -0
- package/plugins/pkg.ts +234 -53
- package/plugins/rust-lsp.ts +58 -40
- package/plugins/schemas/theme.schema.json +4 -0
- package/plugins/search_replace.ts +780 -517
- package/plugins/theme_editor.i18n.json +84 -0
- package/plugins/theme_editor.ts +30 -5
- package/plugins/tsconfig.json +2 -0
- package/plugins/vi_mode.ts +38 -17
- package/themes/terminal.json +3 -0
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
/// <reference path="./lib/fresh.d.ts" />
|
|
2
|
+
import {
|
|
3
|
+
button,
|
|
4
|
+
col,
|
|
5
|
+
flexSpacer,
|
|
6
|
+
hintBar,
|
|
7
|
+
key as widgetKey,
|
|
8
|
+
parseHintString,
|
|
9
|
+
raw,
|
|
10
|
+
row,
|
|
11
|
+
spacer,
|
|
12
|
+
type StyledSegment,
|
|
13
|
+
styledRow,
|
|
14
|
+
textInput,
|
|
15
|
+
textInputChar,
|
|
16
|
+
toggle,
|
|
17
|
+
tree,
|
|
18
|
+
treeNode,
|
|
19
|
+
type TreeNode,
|
|
20
|
+
type WidgetAction,
|
|
21
|
+
WidgetPanel,
|
|
22
|
+
type WidgetSpec,
|
|
23
|
+
} from "./lib/widgets.ts";
|
|
24
|
+
|
|
2
25
|
const editor = getEditor();
|
|
3
26
|
|
|
4
27
|
/**
|
|
@@ -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
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
205
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
340
|
+
const oFocus = focusPanel === "options";
|
|
297
341
|
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
const truncatedSuffix =
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
518
|
+
const segments: StyledSegment[] = [
|
|
519
|
+
{ text: location, style: { fg: C.lineNum } },
|
|
520
|
+
{ text: " - " },
|
|
521
|
+
{ text: displayCtx, overlays: ctxOverlays },
|
|
522
|
+
];
|
|
395
523
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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 (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
//
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
622
|
-
*
|
|
623
|
-
*
|
|
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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
860
|
+
const allResults: SearchResult[] = [];
|
|
635
861
|
|
|
636
862
|
// Whole-word filtering is done Rust-side so maxResults is respected correctly
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
registerHandler("
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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",
|