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