@fresh-editor/fresh-editor 0.3.6 → 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 +91 -0
- 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 +27 -3
- package/plugins/dashboard.ts +18 -18
- package/plugins/flash.ts +22 -4
- package/plugins/git_blame.ts +10 -6
- package/plugins/git_log.ts +534 -124
- package/plugins/git_statusbar.i18n.json +72 -0
- package/plugins/git_statusbar.ts +133 -0
- package/plugins/lib/fresh.d.ts +305 -6
- package/plugins/lib/widgets.ts +111 -4
- package/plugins/live_diff.ts +156 -41
- package/plugins/merge_conflict.ts +89 -64
- package/plugins/orchestrator.ts +1982 -242
- package/plugins/pkg.ts +1 -1
- package/plugins/schemas/theme.schema.json +14 -0
- package/plugins/search_replace.i18n.json +140 -28
- package/plugins/search_replace.ts +674 -117
- package/plugins/tab_actions.i18n.json +212 -0
- package/plugins/tab_actions.ts +76 -0
- package/plugins/theme_editor.i18n.json +28 -0
- package/plugins/tsconfig.json +1 -0
- package/plugins/vi_mode.ts +11 -0
- 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 +1 -0
|
@@ -68,10 +68,28 @@ interface PanelState {
|
|
|
68
68
|
caseSensitive: boolean;
|
|
69
69
|
useRegex: boolean;
|
|
70
70
|
wholeWords: boolean;
|
|
71
|
+
// Scope (§1): when false, results are restricted to `sourceBufferPath`.
|
|
72
|
+
// `sourceBufferPath` is the absolute path of the buffer that was
|
|
73
|
+
// active when the panel opened; `sourceBufferRelPath` is the
|
|
74
|
+
// cwd-relative display form. Empty path means the source buffer was
|
|
75
|
+
// unsaved/virtual; in that case the "current file" mode degrades to
|
|
76
|
+
// "no matches" and the toggle visually still flips, but the user
|
|
77
|
+
// can't usefully restrict to an unnamed buffer.
|
|
78
|
+
allFiles: boolean;
|
|
79
|
+
sourceBufferPath: string;
|
|
80
|
+
sourceBufferRelPath: string;
|
|
71
81
|
// Layout
|
|
72
82
|
viewportWidth: number;
|
|
73
83
|
// State
|
|
74
84
|
busy: boolean;
|
|
85
|
+
/** True once the current `searchPattern` has been used to run a real
|
|
86
|
+
* search to completion. Reset whenever the pattern is mutated (or a
|
|
87
|
+
* search-affecting toggle changes). Distinguishes "user is typing,
|
|
88
|
+
* no search has happened" from "search ran and found nothing", so we
|
|
89
|
+
* don't show a misleading "No matches" placeholder before any work
|
|
90
|
+
* has been done. See §17 of
|
|
91
|
+
* `docs/internal/search-replace-scope-replan-on-widgets.md`. */
|
|
92
|
+
searchPerformed: boolean;
|
|
75
93
|
truncated: boolean;
|
|
76
94
|
// Inline editing cursor position
|
|
77
95
|
cursorPos: number;
|
|
@@ -109,6 +127,57 @@ const SEARCH_DEBOUNCE_MS = 150;
|
|
|
109
127
|
|
|
110
128
|
let searchDebounceGeneration = 0;
|
|
111
129
|
|
|
130
|
+
/** Most-recent-first history of search patterns, capped at HISTORY_MAX.
|
|
131
|
+
* Up arrow in the search field walks back into older entries; Down
|
|
132
|
+
* walks forward. Persistence across editor restarts is a follow-up.
|
|
133
|
+
* See §11 of docs/internal/search-replace-scope-replan-on-widgets.md. */
|
|
134
|
+
const searchHistory: string[] = [];
|
|
135
|
+
const HISTORY_MAX = 20;
|
|
136
|
+
/** -1 = not navigating history. 0..searchHistory.length-1 = currently
|
|
137
|
+
* displaying the history entry at that index. */
|
|
138
|
+
let historyIndex = -1;
|
|
139
|
+
/** Whatever the user had in the search field before they pressed Up
|
|
140
|
+
* to enter history-walk mode. Restored when they Down past the most
|
|
141
|
+
* recent history entry. */
|
|
142
|
+
let historySavedPattern: string | null = null;
|
|
143
|
+
/** Most recent widget_event we saw a widget_key for. Used to decide
|
|
144
|
+
* whether Up/Down should walk history (when focus appears to be on
|
|
145
|
+
* the search field) or fall through to the widget runtime. The
|
|
146
|
+
* widget runtime doesn't expose focus directly to the plugin, but
|
|
147
|
+
* every event that's relevant (change/select/toggle/activate/expand)
|
|
148
|
+
* carries widget_key. Best-effort proxy. */
|
|
149
|
+
let lastFocusedWidget: string | null = null;
|
|
150
|
+
|
|
151
|
+
function historyPush(pattern: string): void {
|
|
152
|
+
if (!pattern) return;
|
|
153
|
+
const existing = searchHistory.indexOf(pattern);
|
|
154
|
+
if (existing === 0) return;
|
|
155
|
+
if (existing > 0) searchHistory.splice(existing, 1);
|
|
156
|
+
searchHistory.unshift(pattern);
|
|
157
|
+
if (searchHistory.length > HISTORY_MAX) {
|
|
158
|
+
searchHistory.length = HISTORY_MAX;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// "Has the user settled on this query?" lives on a separate timer
|
|
163
|
+
// from the 150ms search debounce. Pushing to history on every
|
|
164
|
+
// debounce tick captures intermediate prefixes (typing "foo bar"
|
|
165
|
+
// in fits and starts → "f", "fo", "foo", … all end up in history).
|
|
166
|
+
// Wait 2 seconds of pattern-stability before pushing; any change
|
|
167
|
+
// cancels the pending push.
|
|
168
|
+
const HISTORY_SETTLE_MS = 2000;
|
|
169
|
+
let historySettleGeneration = 0;
|
|
170
|
+
function scheduleHistoryPush(pattern: string): void {
|
|
171
|
+
if (!pattern) return;
|
|
172
|
+
const gen = ++historySettleGeneration;
|
|
173
|
+
editor.delay(HISTORY_SETTLE_MS).then(() => {
|
|
174
|
+
if (gen !== historySettleGeneration) return;
|
|
175
|
+
if (!panel || panel.searchPattern !== pattern) return;
|
|
176
|
+
if (historyIndex >= 0) return; // walking history; not user input
|
|
177
|
+
historyPush(pattern);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
112
181
|
// =============================================================================
|
|
113
182
|
// Colors
|
|
114
183
|
// =============================================================================
|
|
@@ -197,6 +266,10 @@ function getActiveFieldText(): string {
|
|
|
197
266
|
function setActiveFieldText(text: string): void {
|
|
198
267
|
if (!panel) return;
|
|
199
268
|
if (panel.queryField === "search") {
|
|
269
|
+
if (panel.searchPattern !== text) {
|
|
270
|
+
// Pattern changed → any cached result no longer applies. See §17.
|
|
271
|
+
panel.searchPerformed = false;
|
|
272
|
+
}
|
|
200
273
|
panel.searchPattern = text;
|
|
201
274
|
} else {
|
|
202
275
|
panel.replaceText = text;
|
|
@@ -335,22 +408,31 @@ function getViewportHeight(): number {
|
|
|
335
408
|
// theme keys, and focus affordance match every other plugin.
|
|
336
409
|
function buildOptionsRowSpec(): WidgetSpec {
|
|
337
410
|
if (!panel) return col();
|
|
338
|
-
const { focusPanel, optionIndex, caseSensitive, useRegex, wholeWords } = panel;
|
|
411
|
+
const { focusPanel, optionIndex, caseSensitive, useRegex, wholeWords, allFiles } = panel;
|
|
339
412
|
const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
|
|
340
413
|
const oFocus = focusPanel === "options";
|
|
341
414
|
|
|
342
|
-
// The flex Spacer fills whatever's left of the row so the
|
|
343
|
-
// "Replace All" button right-aligns regardless of label width or
|
|
344
|
-
// panel width. No more byteLen-summing of labels.
|
|
345
415
|
const caseLabel = editor.t("panel.case_toggle");
|
|
346
416
|
const regexLabel = editor.t("panel.regex_toggle");
|
|
347
417
|
const wholeLabel = editor.t("panel.whole_toggle");
|
|
348
|
-
const
|
|
418
|
+
const allFilesLabel = editor.t("panel.all_files_toggle");
|
|
419
|
+
// Replace All button label tracks scope (§1):
|
|
420
|
+
// * allFiles=true → "Replace All (Alt+Ret)"
|
|
421
|
+
// * allFiles=false → "Replace All in <file> (Alt+Ret)"
|
|
422
|
+
// sourceBufferRelPath is empty for an unsaved buffer, in which
|
|
423
|
+
// case we fall back to the all-files label since restricting to
|
|
424
|
+
// a path-less buffer can't match anything anyway.
|
|
425
|
+
const replLabel = (!allFiles && panel.sourceBufferRelPath)
|
|
426
|
+
? editor.t("panel.replace_all_in_file_btn", { file: panel.sourceBufferRelPath })
|
|
427
|
+
: editor.t("panel.replace_all_btn");
|
|
349
428
|
void oFocus;
|
|
350
429
|
void optionIndex;
|
|
430
|
+
void W;
|
|
351
431
|
|
|
352
432
|
return row(
|
|
353
433
|
spacer(1),
|
|
434
|
+
toggle(allFiles, allFilesLabel, { key: "allFiles" }),
|
|
435
|
+
spacer(2),
|
|
354
436
|
toggle(caseSensitive, caseLabel, { key: "case" }),
|
|
355
437
|
spacer(2),
|
|
356
438
|
toggle(useRegex, regexLabel, { key: "regex" }),
|
|
@@ -361,6 +443,22 @@ function buildOptionsRowSpec(): WidgetSpec {
|
|
|
361
443
|
);
|
|
362
444
|
}
|
|
363
445
|
|
|
446
|
+
// Build the scope-info row shown only when allFiles=false. Tells the
|
|
447
|
+
// user which single file the search is restricted to. When allFiles=true
|
|
448
|
+
// the function returns an empty col() (the spec composer skips it).
|
|
449
|
+
function buildScopeRowSpec(): WidgetSpec {
|
|
450
|
+
if (!panel) return col();
|
|
451
|
+
if (panel.allFiles) return col();
|
|
452
|
+
const label = panel.sourceBufferRelPath
|
|
453
|
+
? editor.t("panel.scope_row_file", { file: panel.sourceBufferRelPath })
|
|
454
|
+
: editor.t("panel.scope_row_unnamed");
|
|
455
|
+
return raw([{
|
|
456
|
+
text: " " + label,
|
|
457
|
+
properties: { type: "scope-row" },
|
|
458
|
+
style: { fg: C.label, italic: true },
|
|
459
|
+
}]);
|
|
460
|
+
}
|
|
461
|
+
|
|
364
462
|
// Build the typed Row spec for line 1 (search + replace fields with
|
|
365
463
|
// trailing match-count stats). Was previously hand-rolled with two
|
|
366
464
|
// `buildFieldDisplay` calls + manual cursor overlays; now uses the
|
|
@@ -368,11 +466,52 @@ function buildOptionsRowSpec(): WidgetSpec {
|
|
|
368
466
|
// background, cursor highlight at the right byte position). The
|
|
369
467
|
// match-stats portion stays in Raw because it has bespoke
|
|
370
468
|
// truncated-warning styling (`[255, 180, 50]`) and isn't a control.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
469
|
+
// Build just the matchStats text + inline overlays. Pulled out of
|
|
470
|
+
// `buildLine1Spec` so the streaming pump can refresh it via the
|
|
471
|
+
// `setRawEntries` mutation on the keyed `matchStats` raw widget —
|
|
472
|
+
// without re-emitting the full panel spec (which forces js_to_json
|
|
473
|
+
// over the entire 5 000-node tree and blocks the JS thread).
|
|
474
|
+
function buildMatchStatsEntries(): TextPropertyEntry[] {
|
|
475
|
+
if (!panel) return [];
|
|
374
476
|
const totalMatches = panel.searchResults.length;
|
|
375
477
|
const fileCount = panel.fileGroups.length;
|
|
478
|
+
const truncated = panel.truncated;
|
|
479
|
+
const truncatedSuffix = truncated ? " " + editor.t("panel.limited") : "";
|
|
480
|
+
let matchStats = "";
|
|
481
|
+
if (totalMatches > 0) {
|
|
482
|
+
matchStats = " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) }) + truncatedSuffix;
|
|
483
|
+
} else if (panel.busy && panel.searchPattern) {
|
|
484
|
+
matchStats = " " + editor.t("panel.searching");
|
|
485
|
+
} else if (panel.searchPattern && panel.searchPerformed && !panel.busy) {
|
|
486
|
+
matchStats = " " + editor.t("panel.no_matches");
|
|
487
|
+
}
|
|
488
|
+
if (matchStats.length === 0) return [];
|
|
489
|
+
const overlays: InlineOverlay[] = [];
|
|
490
|
+
if (truncated && totalMatches > 0) {
|
|
491
|
+
const statsWithoutSuffix = " " + editor.t("panel.match_stats", {
|
|
492
|
+
count: String(totalMatches),
|
|
493
|
+
files: String(fileCount),
|
|
494
|
+
});
|
|
495
|
+
const countEnd = byteLen(statsWithoutSuffix);
|
|
496
|
+
overlays.push({ start: 0, end: countEnd, style: { fg: C.statusOk } });
|
|
497
|
+
overlays.push({
|
|
498
|
+
start: countEnd,
|
|
499
|
+
end: countEnd + byteLen(truncatedSuffix),
|
|
500
|
+
style: { fg: [255, 180, 50] as RGB, bold: true },
|
|
501
|
+
});
|
|
502
|
+
} else {
|
|
503
|
+
overlays.push({
|
|
504
|
+
start: 0,
|
|
505
|
+
end: byteLen(matchStats),
|
|
506
|
+
style: { fg: totalMatches > 0 ? C.statusOk : C.statusDim },
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
return [{ text: matchStats, inlineOverlays: overlays }];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function buildLine1Spec(): WidgetSpec {
|
|
513
|
+
if (!panel) return col();
|
|
514
|
+
const { searchPattern, replaceText, focusPanel, queryField, cursorPos } = panel;
|
|
376
515
|
const qFocusSearch = focusPanel === "query" && queryField === "search";
|
|
377
516
|
const qFocusReplace = focusPanel === "query" && queryField === "replace";
|
|
378
517
|
const searchVal = searchPattern || "";
|
|
@@ -385,39 +524,6 @@ function buildLine1Spec(): WidgetSpec {
|
|
|
385
524
|
const searchLabel = editor.t("panel.search_label");
|
|
386
525
|
const replLabel = editor.t("panel.replace_label");
|
|
387
526
|
|
|
388
|
-
const truncatedSuffix = truncated ? " " + editor.t("panel.limited") : "";
|
|
389
|
-
const matchStats = totalMatches > 0
|
|
390
|
-
? " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) }) + truncatedSuffix
|
|
391
|
-
: (searchPattern ? " " + editor.t("panel.no_matches") : "");
|
|
392
|
-
|
|
393
|
-
// Build the matchStats inline-overlay-styled Raw cell for the row.
|
|
394
|
-
// Truncated case keeps the warning-color tail; otherwise the whole
|
|
395
|
-
// stats string uses the ok/dim color depending on result presence.
|
|
396
|
-
const matchStatsEntries: TextPropertyEntry[] = [];
|
|
397
|
-
if (matchStats.length > 0) {
|
|
398
|
-
const overlays: InlineOverlay[] = [];
|
|
399
|
-
if (truncated && totalMatches > 0) {
|
|
400
|
-
const statsWithoutSuffix = " " + editor.t("panel.match_stats", {
|
|
401
|
-
count: String(totalMatches),
|
|
402
|
-
files: String(fileCount),
|
|
403
|
-
});
|
|
404
|
-
const countEnd = byteLen(statsWithoutSuffix);
|
|
405
|
-
overlays.push({ start: 0, end: countEnd, style: { fg: C.statusOk } });
|
|
406
|
-
overlays.push({
|
|
407
|
-
start: countEnd,
|
|
408
|
-
end: countEnd + byteLen(truncatedSuffix),
|
|
409
|
-
style: { fg: [255, 180, 50] as RGB, bold: true },
|
|
410
|
-
});
|
|
411
|
-
} else {
|
|
412
|
-
overlays.push({
|
|
413
|
-
start: 0,
|
|
414
|
-
end: byteLen(matchStats),
|
|
415
|
-
style: { fg: totalMatches > 0 ? C.statusOk : C.statusDim },
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
matchStatsEntries.push({ text: matchStats, inlineOverlays: overlays });
|
|
419
|
-
}
|
|
420
|
-
|
|
421
527
|
return row(
|
|
422
528
|
spacer(1),
|
|
423
529
|
textInput(searchVal, {
|
|
@@ -435,7 +541,7 @@ function buildLine1Spec(): WidgetSpec {
|
|
|
435
541
|
fieldWidth: 25,
|
|
436
542
|
key: "replaceField",
|
|
437
543
|
}),
|
|
438
|
-
raw(
|
|
544
|
+
raw(buildMatchStatsEntries(), "matchStats"),
|
|
439
545
|
);
|
|
440
546
|
}
|
|
441
547
|
|
|
@@ -490,7 +596,20 @@ function renderFlatItemEntry(item: FlatItem, W: number): TextPropertyEntry {
|
|
|
490
596
|
const group = panel.fileGroups[item.fileIndex];
|
|
491
597
|
const result = group.matches[item.matchIndex!];
|
|
492
598
|
const location = `${group.relPath}:${result.match.line}`;
|
|
493
|
-
|
|
599
|
+
// Hard-cap the context length BEFORE any per-codepoint work below.
|
|
600
|
+
// Minified CSS / JSON / single-line generated files routinely have
|
|
601
|
+
// match context strings 5 000-50 000 chars long. The downstream
|
|
602
|
+
// `truncate()` does `for (const c of s)` (per-codepoint iteration
|
|
603
|
+
// + O(N²) string concatenation in QuickJS); at 5 000 chars and 50
|
|
604
|
+
// items per flush that adds up to several hundred ms of JS work
|
|
605
|
+
// per pump iteration, blocking Tab and other queued requests.
|
|
606
|
+
// A panel viewport is at most a few hundred chars wide, so anything
|
|
607
|
+
// past ~512 chars is invisible anyway.
|
|
608
|
+
const CONTEXT_HARD_CAP = 512;
|
|
609
|
+
const rawCtx = result.match.context;
|
|
610
|
+
const context = (rawCtx.length > CONTEXT_HARD_CAP
|
|
611
|
+
? rawCtx.slice(0, CONTEXT_HARD_CAP)
|
|
612
|
+
: rawCtx).trim();
|
|
494
613
|
// Host prefix consumes:
|
|
495
614
|
// indent (depth=1) = 2
|
|
496
615
|
// leaf-alignment = 2 (in lieu of disclosure glyph)
|
|
@@ -527,6 +646,35 @@ function renderFlatItemEntry(item: FlatItem, W: number): TextPropertyEntry {
|
|
|
527
646
|
});
|
|
528
647
|
}
|
|
529
648
|
|
|
649
|
+
// Convert a slice of `FlatItem`s into the corresponding TreeNodes.
|
|
650
|
+
// Pulled out of `buildMatchListSpec` so the streaming path can use it
|
|
651
|
+
// to build deltas for `appendTreeNodes` — it must produce nodes
|
|
652
|
+
// identical to the full-spec rebuild for the same items, so
|
|
653
|
+
// auto-expand of first-seen file rows happens here.
|
|
654
|
+
function flatItemsToTreeNodes(
|
|
655
|
+
flatItems: FlatItem[],
|
|
656
|
+
itemKeys: string[],
|
|
657
|
+
W: number,
|
|
658
|
+
): TreeNode[] {
|
|
659
|
+
return flatItems.map((item, i) => {
|
|
660
|
+
const entry = renderFlatItemEntry(item, W);
|
|
661
|
+
if (item.type === "file") {
|
|
662
|
+
const k = itemKeys[i];
|
|
663
|
+
if (!panel!.knownFileKeys.has(k)) {
|
|
664
|
+
panel!.knownFileKeys.add(k);
|
|
665
|
+
panel!.expandedFileKeys.add(k);
|
|
666
|
+
}
|
|
667
|
+
// File-row checkbox derives from children: checked iff every
|
|
668
|
+
// match in this file is selected.
|
|
669
|
+
const fileChecked = panel!.fileGroups[item.fileIndex].matches.every(m => m.selected);
|
|
670
|
+
return treeNode(entry, { depth: 0, hasChildren: true, checked: fileChecked });
|
|
671
|
+
}
|
|
672
|
+
const matchSelected = panel!.fileGroups[item.fileIndex]
|
|
673
|
+
.matches[item.matchIndex!].selected;
|
|
674
|
+
return treeNode(entry, { depth: 1, hasChildren: false, checked: matchSelected });
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
530
678
|
// Build the typed spec for the matches body — either a Tree widget
|
|
531
679
|
// (when there are matches) or a Raw cell with the empty/prompt
|
|
532
680
|
// message. The Tree widget owns scroll, selection styling, click
|
|
@@ -538,52 +686,39 @@ function buildMatchListSpec(): WidgetSpec {
|
|
|
538
686
|
const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
|
|
539
687
|
const totalMatches = panel.searchResults.length;
|
|
540
688
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
689
|
+
// Empty-state branches: pristine / searching / no-results /
|
|
690
|
+
// pattern-set-but-no-search-yet. See §17 of
|
|
691
|
+
// docs/internal/search-replace-scope-replan-on-widgets.md.
|
|
692
|
+
//
|
|
693
|
+
// When the pattern is mutated while a previous search's results are
|
|
694
|
+
// still in panel.searchResults, render the stale results (fall
|
|
695
|
+
// through to the Tree branch below) until the next search
|
|
696
|
+
// completes — dropping back to "Type a search pattern above"
|
|
697
|
+
// mid-edit feels jumpy.
|
|
698
|
+
const emptyState = (key: string) =>
|
|
699
|
+
raw([{
|
|
700
|
+
text: padStr(" " + editor.t(key), W),
|
|
544
701
|
properties: { type: "empty" },
|
|
545
702
|
style: { fg: C.dim },
|
|
546
703
|
}]);
|
|
547
|
-
}
|
|
548
704
|
if (!panel.searchPattern) {
|
|
549
|
-
return
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
705
|
+
return emptyState("panel.type_pattern");
|
|
706
|
+
}
|
|
707
|
+
if (panel.busy && totalMatches === 0) {
|
|
708
|
+
return emptyState("panel.searching");
|
|
709
|
+
}
|
|
710
|
+
if (totalMatches === 0 && panel.searchPerformed && !panel.busy) {
|
|
711
|
+
return emptyState("panel.no_matches");
|
|
712
|
+
}
|
|
713
|
+
if (totalMatches === 0) {
|
|
714
|
+
// Pattern in flight but no search has run yet (and no cached
|
|
715
|
+
// results). Same friendly hint as pristine.
|
|
716
|
+
return emptyState("panel.type_pattern");
|
|
554
717
|
}
|
|
555
718
|
|
|
556
719
|
const flatItems = buildFlatItems();
|
|
557
720
|
const itemKeys = flatItems.map(flatItemKey);
|
|
558
|
-
|
|
559
|
-
// file groups are auto-added to `expandedFileKeys` (default state =
|
|
560
|
-
// expanded). Files the user has collapsed remain absent from the
|
|
561
|
-
// set; we never re-add a key that's already known but currently
|
|
562
|
-
// collapsed, since `clearedThisRender` would tag them as "first
|
|
563
|
-
// time seen". Tracking is via the per-search reset in
|
|
564
|
-
// `performSearch`: at the start of a search the set is empty, so
|
|
565
|
-
// every file is auto-added on its first appearance, then user
|
|
566
|
-
// collapse events remove them.
|
|
567
|
-
const nodes: TreeNode[] = flatItems.map((item, i) => {
|
|
568
|
-
const entry = renderFlatItemEntry(item, W);
|
|
569
|
-
if (item.type === "file") {
|
|
570
|
-
const k = itemKeys[i];
|
|
571
|
-
if (!panel!.knownFileKeys.has(k)) {
|
|
572
|
-
panel!.knownFileKeys.add(k);
|
|
573
|
-
panel!.expandedFileKeys.add(k);
|
|
574
|
-
}
|
|
575
|
-
// File-row checkbox derives from children: checked iff every
|
|
576
|
-
// match in this file is selected. Mixed (some selected, some
|
|
577
|
-
// not) renders as `[ ]` for v1 — adding a tristate `[~]`
|
|
578
|
-
// glyph is a future host-side option but not needed to wire
|
|
579
|
-
// the toggle path end-to-end.
|
|
580
|
-
const fileChecked = panel!.fileGroups[item.fileIndex].matches.every(m => m.selected);
|
|
581
|
-
return treeNode(entry, { depth: 0, hasChildren: true, checked: fileChecked });
|
|
582
|
-
}
|
|
583
|
-
const matchSelected = panel!.fileGroups[item.fileIndex]
|
|
584
|
-
.matches[item.matchIndex!].selected;
|
|
585
|
-
return treeNode(entry, { depth: 1, hasChildren: false, checked: matchSelected });
|
|
586
|
-
});
|
|
721
|
+
const nodes = flatItemsToTreeNodes(flatItems, itemKeys, W);
|
|
587
722
|
const selectedIndex = panel.focusPanel === "matches" ? panel.matchIndex : -1;
|
|
588
723
|
// Tree visible rows = panel viewport height minus the chrome
|
|
589
724
|
// (line 1 + options row + separator + footer = 4 rows) — same
|
|
@@ -796,7 +931,8 @@ function updatePanelContent(): void {
|
|
|
796
931
|
col(
|
|
797
932
|
buildLine1Spec(),
|
|
798
933
|
buildOptionsRowSpec(),
|
|
799
|
-
|
|
934
|
+
buildScopeRowSpec(),
|
|
935
|
+
raw(buildPanelEntries("postOptions"), "separator"),
|
|
800
936
|
buildMatchListSpec(),
|
|
801
937
|
hintBar(buildHelpHints()),
|
|
802
938
|
),
|
|
@@ -828,6 +964,50 @@ let activeSearchHandle: SearchHandle | null = null;
|
|
|
828
964
|
/** Pump cadence between successive `take()` drains (ms). The host writes
|
|
829
965
|
* matches at full speed; this knob bounds the UI rebuild rate. */
|
|
830
966
|
const SEARCH_PUMP_INTERVAL_MS = 50;
|
|
967
|
+
/** Number of `buildFlatItems()` entries the streaming path has already
|
|
968
|
+
* pushed to the host via `appendTreeNodes`. Zero means "no streaming
|
|
969
|
+
* append has happened for the current search"; the first batch of
|
|
970
|
+
* results will do a full `updatePanelContent()` instead so the Tree
|
|
971
|
+
* exists for subsequent appends. Reset at the start of each search
|
|
972
|
+
* and after `batch.done` (which forces a full re-emit). */
|
|
973
|
+
let lastStreamingFlatCount = 0;
|
|
974
|
+
|
|
975
|
+
/** Absolute-path → index-into-`panel.fileGroups`, maintained while a
|
|
976
|
+
* search is streaming so each new match locates its file group in
|
|
977
|
+
* O(1) instead of triggering a full `buildFileGroups(allResults)`
|
|
978
|
+
* rebuild (which is O(N) per batch and pins the JS event loop on
|
|
979
|
+
* large result sets). Cleared at the start of each search. */
|
|
980
|
+
let streamingFileIndexByPath: Map<string, number> | null = null;
|
|
981
|
+
|
|
982
|
+
/** Carryover queue: matches the host handed us in a `take()` but that
|
|
983
|
+
* we haven't processed yet because the batch was too big to drain in
|
|
984
|
+
* a single pump iteration. Drained CHUNK at a time inside the pump
|
|
985
|
+
* loop; `take()` is only re-called once this is empty so we don't
|
|
986
|
+
* flood the queue. Reset at the start of each search. */
|
|
987
|
+
let pendingMatches: GrepMatch[] = [];
|
|
988
|
+
|
|
989
|
+
/** Pending tree-append delta that hasn't been flushed to the host yet.
|
|
990
|
+
* Each pump chunk pushes its `FlatItem[]` here; the loop coalesces
|
|
991
|
+
* several chunks worth before firing one `appendTreeNodes` IPC, so
|
|
992
|
+
* the host's main thread isn't pinned servicing ~20 ms IPCs back to
|
|
993
|
+
* back during a long streaming search. */
|
|
994
|
+
let pendingTreeDeltaItems: FlatItem[] = [];
|
|
995
|
+
/** Parallel pending list of new file-row keys whose expansion state
|
|
996
|
+
* must be pushed to the host on the next flush. */
|
|
997
|
+
let pendingNewExpandedKeys: string[] = [];
|
|
998
|
+
/** Wall-clock ms of the last UI flush (the last appendTreeNodes IPC).
|
|
999
|
+
* Compared against UI_FLUSH_INTERVAL_MS to decide when to flush. */
|
|
1000
|
+
let lastUiFlush = 0;
|
|
1001
|
+
/** Don't flush more often than this. */
|
|
1002
|
+
const UI_FLUSH_INTERVAL_MS = 80;
|
|
1003
|
+
/** Hard cap on each `appendTreeNodes` flush payload. Each TreeNode in
|
|
1004
|
+
* the payload costs ~60 µs in `js_to_json` + `serde_json::from_value`
|
|
1005
|
+
* on the JS thread (measured: AppendTreeNodes(1296) = 88 ms).
|
|
1006
|
+
* Larger payloads → longer per-iteration JS block → user input
|
|
1007
|
+
* (Tab, typed char, Esc) waits in the plugin thread's request
|
|
1008
|
+
* channel. Keeping the cap at ~100 keeps each flush ≤ 10 ms so
|
|
1009
|
+
* queued Tab requests can interleave between pump iterations. */
|
|
1010
|
+
const UI_FLUSH_MAX_DELTA = 100;
|
|
831
1011
|
|
|
832
1012
|
/**
|
|
833
1013
|
* Perform a streaming search using a pull-based handle. The host writes
|
|
@@ -846,6 +1026,21 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
|
|
|
846
1026
|
// result set isn't meaningful for the new one.
|
|
847
1027
|
panel.expandedFileKeys.clear();
|
|
848
1028
|
panel.knownFileKeys.clear();
|
|
1029
|
+
// New search → reset the streaming-append checkpoint. The first
|
|
1030
|
+
// batch of results will trigger a full `updatePanelContent()`
|
|
1031
|
+
// (mounting the empty Tree); subsequent batches append deltas to
|
|
1032
|
+
// that mounted Tree.
|
|
1033
|
+
lastStreamingFlatCount = 0;
|
|
1034
|
+
streamingFileIndexByPath = new Map();
|
|
1035
|
+
pendingMatches = [];
|
|
1036
|
+
pendingTreeDeltaItems = [];
|
|
1037
|
+
pendingNewExpandedKeys = [];
|
|
1038
|
+
lastUiFlush = 0;
|
|
1039
|
+
// Reset accumulating state so a re-search (debounce from typing,
|
|
1040
|
+
// toggle flip, scope change) starts from empty rather than
|
|
1041
|
+
// appending to the previous run's results.
|
|
1042
|
+
panel.searchResults = [];
|
|
1043
|
+
panel.fileGroups = [];
|
|
849
1044
|
|
|
850
1045
|
// Cancel any in-flight search before kicking off a new one. Without
|
|
851
1046
|
// this the prior search would keep walking the project until it
|
|
@@ -878,30 +1073,168 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
|
|
|
878
1073
|
return allResults;
|
|
879
1074
|
}
|
|
880
1075
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1076
|
+
// Drain matches in chunks of at most CHUNK per pump iteration.
|
|
1077
|
+
// `pendingMatches` accumulates anything the host gave us that we
|
|
1078
|
+
// haven't processed yet. This caps the per-iteration synchronous
|
|
1079
|
+
// JS work at O(CHUNK) so the event loop yields back to the host
|
|
1080
|
+
// promptly — without this, a single batch of 3000+ matches takes
|
|
1081
|
+
// ~700ms of JS time and queues every user keypress (Tab, typed
|
|
1082
|
+
// chars, Esc) for the duration of the search.
|
|
1083
|
+
//
|
|
1084
|
+
// Only call `handle.take()` when our queue is empty, so the host
|
|
1085
|
+
// doesn't keep us flooded; the producer pauses when the take()
|
|
1086
|
+
// returns nothing left to drain.
|
|
1087
|
+
let batchDone = false;
|
|
1088
|
+
let batchTruncated = false;
|
|
1089
|
+
let batchError: string | null = null;
|
|
1090
|
+
if (pendingMatches.length === 0) {
|
|
1091
|
+
const batch = handle.take();
|
|
1092
|
+
batchDone = batch.done;
|
|
1093
|
+
batchTruncated = batch.truncated;
|
|
1094
|
+
batchError = batch.error ?? null;
|
|
1095
|
+
for (const m of batch.matches) pendingMatches.push(m);
|
|
1096
|
+
}
|
|
1097
|
+
// Hard cap on per-iteration work. Each match in the chunk turns
|
|
1098
|
+
// into a TreeNode in the `appendTreeNodes` flush, and each
|
|
1099
|
+
// TreeNode costs ~60 µs in `js_to_json` + `from_value` on the
|
|
1100
|
+
// JS thread. Keeping the chunk small means each pump iteration
|
|
1101
|
+
// stays ≤ ~10 ms — short enough that queued Tab/typed-char
|
|
1102
|
+
// requests interleave smoothly between iterations.
|
|
1103
|
+
const CHUNK = 80;
|
|
1104
|
+
const chunkSize = Math.min(CHUNK, pendingMatches.length);
|
|
1105
|
+
const chunk = pendingMatches.splice(0, chunkSize);
|
|
1106
|
+
const moreInQueue = pendingMatches.length > 0;
|
|
1107
|
+
const deltaItems: FlatItem[] = [];
|
|
1108
|
+
const newExpandedKeys: string[] = []; // file rows added this batch
|
|
1109
|
+
for (const m of chunk) {
|
|
1110
|
+
// §1 scope filter: when scope is "current file only", drop
|
|
1111
|
+
// matches from any other path. Done client-side because the
|
|
1112
|
+
// host grep API is project-wide. Empty sourceBufferPath
|
|
1113
|
+
// (unsaved buffer) filters everything out by design.
|
|
1114
|
+
if (!panel.allFiles && m.file !== panel.sourceBufferPath) continue;
|
|
1115
|
+
const result: SearchResult = { match: m, selected: true };
|
|
1116
|
+
allResults.push(result);
|
|
1117
|
+
let fileIdx = streamingFileIndexByPath?.get(m.file);
|
|
1118
|
+
if (fileIdx === undefined) {
|
|
1119
|
+
fileIdx = panel.fileGroups.length;
|
|
1120
|
+
streamingFileIndexByPath?.set(m.file, fileIdx);
|
|
1121
|
+
panel.fileGroups.push({
|
|
1122
|
+
relPath: getRelativePath(m.file),
|
|
1123
|
+
absPath: m.file,
|
|
1124
|
+
expanded: true,
|
|
1125
|
+
matches: [],
|
|
1126
|
+
});
|
|
1127
|
+
deltaItems.push({ type: "file", fileIndex: fileIdx });
|
|
1128
|
+
const fileKey = `file:${fileIdx}`;
|
|
1129
|
+
panel.expandedFileKeys.add(fileKey);
|
|
1130
|
+
panel.knownFileKeys.add(fileKey);
|
|
1131
|
+
newExpandedKeys.push(fileKey);
|
|
885
1132
|
}
|
|
886
|
-
|
|
887
|
-
panel.fileGroups
|
|
888
|
-
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1133
|
+
const matchIdx = panel.fileGroups[fileIdx].matches.length;
|
|
1134
|
+
panel.fileGroups[fileIdx].matches.push(result);
|
|
1135
|
+
deltaItems.push({ type: "match", fileIndex: fileIdx, matchIndex: matchIdx });
|
|
1136
|
+
}
|
|
1137
|
+
panel.searchResults = allResults;
|
|
1138
|
+
// Coalesce the per-chunk delta into a pending buffer. Each
|
|
1139
|
+
// `appendTreeNodes` IPC costs ~20 ms on the host (spec mutation
|
|
1140
|
+
// + Tree-visible-rows recompute + virtual-buffer repaint).
|
|
1141
|
+
// Flushing every 250-match chunk means 20+ IPCs over a 5 000-
|
|
1142
|
+
// match search — that pile of host main-thread work is exactly
|
|
1143
|
+
// when queued Tab / typed-key events sit waiting. Flush only
|
|
1144
|
+
// every UI_FLUSH_INTERVAL_MS so the user sees the result list
|
|
1145
|
+
// grow at a steady ~5 Hz while leaving the host free to dispatch
|
|
1146
|
+
// input events between flushes.
|
|
1147
|
+
for (const it of deltaItems) pendingTreeDeltaItems.push(it);
|
|
1148
|
+
for (const k of newExpandedKeys) pendingNewExpandedKeys.push(k);
|
|
1149
|
+
const nowMs = Date.now();
|
|
1150
|
+
const producerFinished = batchDone && pendingMatches.length === 0;
|
|
1151
|
+
const dueToFlush =
|
|
1152
|
+
producerFinished ||
|
|
1153
|
+
pendingTreeDeltaItems.length >= UI_FLUSH_MAX_DELTA ||
|
|
1154
|
+
nowMs - lastUiFlush >= UI_FLUSH_INTERVAL_MS;
|
|
1155
|
+
if (
|
|
1156
|
+
dueToFlush &&
|
|
1157
|
+
pendingTreeDeltaItems.length > 0 &&
|
|
1158
|
+
panel.widgetPanel &&
|
|
1159
|
+
lastStreamingFlatCount > 0
|
|
1160
|
+
) {
|
|
1161
|
+
const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
|
|
1162
|
+
const flushed = pendingTreeDeltaItems;
|
|
1163
|
+
const flushedNewExp = pendingNewExpandedKeys;
|
|
1164
|
+
pendingTreeDeltaItems = [];
|
|
1165
|
+
pendingNewExpandedKeys = [];
|
|
1166
|
+
const newItemKeys = flushed.map(flatItemKey);
|
|
1167
|
+
const newNodes = flatItemsToTreeNodes(flushed, newItemKeys, W);
|
|
1168
|
+
panel.widgetPanel.appendTreeNodes("matchTree", newNodes, newItemKeys);
|
|
1169
|
+
lastStreamingFlatCount += flushed.length;
|
|
1170
|
+
lastUiFlush = nowMs;
|
|
1171
|
+
if (flushedNewExp.length > 0) {
|
|
1172
|
+
panel.widgetPanel.setExpandedKeys(
|
|
1173
|
+
"matchTree",
|
|
1174
|
+
[...panel.expandedFileKeys],
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
} else if (lastStreamingFlatCount === 0 && panel.fileGroups.length > 0) {
|
|
1178
|
+
// First time we have any results — mount the Tree via a full
|
|
1179
|
+
// panel update. Subsequent batches use the cheap append path.
|
|
1180
|
+
// Also drain the pending buffer into the spec since
|
|
1181
|
+
// updatePanelContent rebuilds from `panel.fileGroups` directly.
|
|
1182
|
+
pendingTreeDeltaItems = [];
|
|
1183
|
+
pendingNewExpandedKeys = [];
|
|
895
1184
|
updatePanelContent();
|
|
1185
|
+
lastStreamingFlatCount = panel.fileGroups.length + panel.searchResults.length;
|
|
1186
|
+
lastUiFlush = nowMs;
|
|
1187
|
+
}
|
|
1188
|
+
if (producerFinished) {
|
|
1189
|
+
// Streaming finished. The tree is already current in the host
|
|
1190
|
+
// via the per-batch `appendTreeNodes` mutations — its nodes
|
|
1191
|
+
// don't need refreshing. The only state that drifted is the
|
|
1192
|
+
// small chrome strings: the matchStats label next to the
|
|
1193
|
+
// input fields, and the "Matches (N in M files)" header in
|
|
1194
|
+
// the separator. Update them in place via `setRawEntries`
|
|
1195
|
+
// (a few-hundred-byte mutation) instead of re-emitting the
|
|
1196
|
+
// full panel spec — the latter would force `js_to_json` over
|
|
1197
|
+
// every TreeNode (~447 bytes × 5 000 nodes = 2.2 MB) and
|
|
1198
|
+
// block the JS thread for ~1 second, exactly when user input
|
|
1199
|
+
// piles up unread in the request channel. See the
|
|
1200
|
+
// RESOLVE_CB_DONE dur_us=1095122 case in the perf trace.
|
|
1201
|
+
if (panel.widgetPanel) {
|
|
1202
|
+
if (panel.fileGroups.length === 0) {
|
|
1203
|
+
// Special case: 0 matches. The matches body is an
|
|
1204
|
+
// empty-state `raw()`, not a `tree()` — we have to swap
|
|
1205
|
+
// widget kinds, which `setRawEntries` alone can't do.
|
|
1206
|
+
// The full re-emit here is cheap because the tree is
|
|
1207
|
+
// empty (no per-node serialization cost).
|
|
1208
|
+
updatePanelContent();
|
|
1209
|
+
} else {
|
|
1210
|
+
panel.widgetPanel.setRawEntries("matchStats", buildMatchStatsEntries());
|
|
1211
|
+
panel.widgetPanel.setRawEntries("separator", buildPanelEntries("postOptions"));
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
lastStreamingFlatCount = 0;
|
|
1215
|
+
pendingTreeDeltaItems = [];
|
|
1216
|
+
pendingNewExpandedKeys = [];
|
|
1217
|
+
}
|
|
1218
|
+
// Also refresh the matchStats label on every streaming flush so
|
|
1219
|
+
// the count updates in real time as results stream in.
|
|
1220
|
+
if (!producerFinished && dueToFlush && panel.widgetPanel) {
|
|
1221
|
+
panel.widgetPanel.setRawEntries("matchStats", buildMatchStatsEntries());
|
|
1222
|
+
panel.widgetPanel.setRawEntries("separator", buildPanelEntries("postOptions"));
|
|
896
1223
|
}
|
|
897
1224
|
|
|
898
|
-
if (
|
|
899
|
-
truncated =
|
|
900
|
-
producerError =
|
|
1225
|
+
if (producerFinished) {
|
|
1226
|
+
truncated = batchTruncated;
|
|
1227
|
+
producerError = batchError;
|
|
901
1228
|
break;
|
|
902
1229
|
}
|
|
903
1230
|
|
|
904
|
-
|
|
1231
|
+
// Yield to the JS event loop between chunks. `delay(0)` is
|
|
1232
|
+
// enough — it lets queued plugin handlers (Tab, typed input,
|
|
1233
|
+
// Esc) run between our streaming work. When there's no
|
|
1234
|
+
// carryover, wait the usual pump interval so we don't hot-loop
|
|
1235
|
+
// on `handle.take()`.
|
|
1236
|
+
const yieldMs = moreInQueue ? 0 : SEARCH_PUMP_INTERVAL_MS;
|
|
1237
|
+
await editor.delay(yieldMs);
|
|
905
1238
|
}
|
|
906
1239
|
|
|
907
1240
|
if (activeSearchHandle === handle) {
|
|
@@ -939,30 +1272,40 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
|
|
|
939
1272
|
// Panel lifecycle
|
|
940
1273
|
// =============================================================================
|
|
941
1274
|
|
|
942
|
-
async function openPanel(): Promise<void> {
|
|
1275
|
+
async function openPanel(opts?: { allFiles?: boolean }): Promise<void> {
|
|
943
1276
|
// Try to pre-fill search from editor selection
|
|
944
1277
|
let prefill = "";
|
|
1278
|
+
let sourceBufferPath = "";
|
|
945
1279
|
try {
|
|
1280
|
+
const activeId = editor.getActiveBufferId();
|
|
1281
|
+
sourceBufferPath = editor.getBufferPath(activeId) || "";
|
|
946
1282
|
const cursor = editor.getPrimaryCursor();
|
|
947
1283
|
if (cursor && cursor.selection) {
|
|
948
1284
|
const start = Math.min(cursor.selection.start, cursor.selection.end);
|
|
949
1285
|
const end = Math.max(cursor.selection.start, cursor.selection.end);
|
|
950
1286
|
if (end - start > 0 && end - start < 200) {
|
|
951
|
-
const
|
|
952
|
-
const text = await editor.getBufferText(bufferId, start, end);
|
|
1287
|
+
const text = await editor.getBufferText(activeId, start, end);
|
|
953
1288
|
if (text && !text.includes("\n")) {
|
|
954
1289
|
prefill = text;
|
|
955
1290
|
}
|
|
956
1291
|
}
|
|
957
1292
|
}
|
|
958
|
-
} catch (_e) { /* no selection */ }
|
|
1293
|
+
} catch (_e) { /* no selection / no buffer */ }
|
|
1294
|
+
|
|
1295
|
+
const allFiles = opts?.allFiles ?? true;
|
|
1296
|
+
const sourceBufferRelPath = sourceBufferPath ? getRelativePath(sourceBufferPath) : "";
|
|
959
1297
|
|
|
960
1298
|
if (panel) {
|
|
961
1299
|
panel.focusPanel = "query";
|
|
962
1300
|
panel.queryField = "search";
|
|
963
1301
|
if (prefill) panel.searchPattern = prefill;
|
|
964
1302
|
panel.cursorPos = panel.searchPattern.length;
|
|
1303
|
+
// Re-opening from a different file/scope refreshes scope context.
|
|
1304
|
+
panel.allFiles = allFiles;
|
|
1305
|
+
panel.sourceBufferPath = sourceBufferPath;
|
|
1306
|
+
panel.sourceBufferRelPath = sourceBufferRelPath;
|
|
965
1307
|
updatePanelContent();
|
|
1308
|
+
if (panel.searchPattern) rerunSearchDebounced();
|
|
966
1309
|
return;
|
|
967
1310
|
}
|
|
968
1311
|
|
|
@@ -983,8 +1326,12 @@ async function openPanel(): Promise<void> {
|
|
|
983
1326
|
caseSensitive: false,
|
|
984
1327
|
useRegex: false,
|
|
985
1328
|
wholeWords: false,
|
|
1329
|
+
allFiles,
|
|
1330
|
+
sourceBufferPath,
|
|
1331
|
+
sourceBufferRelPath,
|
|
986
1332
|
viewportWidth: DEFAULT_WIDTH,
|
|
987
1333
|
busy: false,
|
|
1334
|
+
searchPerformed: false,
|
|
988
1335
|
truncated: false,
|
|
989
1336
|
cursorPos: prefill.length,
|
|
990
1337
|
scrollOffset: 0,
|
|
@@ -1075,19 +1422,42 @@ async function executeReplacements(results?: SearchResult[]): Promise<string> {
|
|
|
1075
1422
|
|
|
1076
1423
|
async function rerunSearch(): Promise<void> {
|
|
1077
1424
|
if (!panel || !panel.searchPattern) return;
|
|
1078
|
-
|
|
1425
|
+
// No `panel.busy` early-return: if a search is already running for
|
|
1426
|
+
// an older pattern (e.g. user typed "pr" then "proj"), we want the
|
|
1427
|
+
// newer search to start NOW, not after the old one finishes
|
|
1428
|
+
// walking the project. `performSearch` increments
|
|
1429
|
+
// `currentSearchGeneration` and cancels the prior handle; the older
|
|
1430
|
+
// in-flight `performSearch` sees the gen mismatch on its next
|
|
1431
|
+
// pump tick and bails out without writing to panel state.
|
|
1432
|
+
searchDebounceGeneration++;
|
|
1433
|
+
// Capture the generation this rerunSearch will own once performSearch
|
|
1434
|
+
// increments it. If a newer rerunSearch slots in while we're awaiting,
|
|
1435
|
+
// currentSearchGeneration moves past `myGen` and we know not to
|
|
1436
|
+
// finalize busy/searchPerformed for this stale invocation.
|
|
1437
|
+
const myGen = currentSearchGeneration + 1;
|
|
1079
1438
|
panel.truncated = false;
|
|
1080
1439
|
panel.busy = true;
|
|
1081
1440
|
panel.matchIndex = 0;
|
|
1082
1441
|
panel.scrollOffset = 0;
|
|
1083
|
-
|
|
1084
|
-
// performSearch
|
|
1085
|
-
//
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1442
|
+
await performSearch(panel.searchPattern);
|
|
1443
|
+
// performSearch maintains panel.searchResults / panel.fileGroups
|
|
1444
|
+
// incrementally during streaming and pushes matchStats / separator
|
|
1445
|
+
// updates via cheap targeted mutations on batch.done — no full
|
|
1446
|
+
// spec re-emit needed here. Only finalize busy + searchPerformed
|
|
1447
|
+
// if we are still the latest search; the busy flip drives the
|
|
1448
|
+
// "No matches" empty-state branch in `buildMatchListSpec`, so
|
|
1449
|
+
// when totalMatches===0 we also need to refresh the matches Raw
|
|
1450
|
+
// (a tiny mutation) so the user sees the empty-state label.
|
|
1451
|
+
if (panel && currentSearchGeneration === myGen) {
|
|
1089
1452
|
panel.busy = false;
|
|
1090
|
-
|
|
1453
|
+
panel.searchPerformed = true;
|
|
1454
|
+
// Only one tiny mutation needed: refresh matchStats since it
|
|
1455
|
+
// depends on the busy flag and searchPerformed (showing
|
|
1456
|
+
// "No matches" vs "Searching…"). The tree's nodes already
|
|
1457
|
+
// reflect the final state — no full re-emit needed.
|
|
1458
|
+
if (panel.widgetPanel) {
|
|
1459
|
+
panel.widgetPanel.setRawEntries("matchStats", buildMatchStatsEntries());
|
|
1460
|
+
}
|
|
1091
1461
|
}
|
|
1092
1462
|
}
|
|
1093
1463
|
|
|
@@ -1104,6 +1474,7 @@ function rerunSearchDebounced(): void {
|
|
|
1104
1474
|
async function rerunSearchQuiet(): Promise<void> {
|
|
1105
1475
|
if (!panel || !panel.searchPattern) return;
|
|
1106
1476
|
if (panel.busy) return;
|
|
1477
|
+
searchDebounceGeneration++;
|
|
1107
1478
|
panel.busy = true;
|
|
1108
1479
|
const results = await performSearch(panel.searchPattern, true);
|
|
1109
1480
|
if (panel) {
|
|
@@ -1112,6 +1483,7 @@ async function rerunSearchQuiet(): Promise<void> {
|
|
|
1112
1483
|
panel.matchIndex = 0;
|
|
1113
1484
|
panel.scrollOffset = 0;
|
|
1114
1485
|
panel.busy = false;
|
|
1486
|
+
panel.searchPerformed = true;
|
|
1115
1487
|
updatePanelContent();
|
|
1116
1488
|
}
|
|
1117
1489
|
}
|
|
@@ -1136,8 +1508,63 @@ registerHandler("search_replace_home", () => dispatch(widgetKey("Home")));
|
|
|
1136
1508
|
registerHandler("search_replace_end", () => dispatch(widgetKey("End")));
|
|
1137
1509
|
registerHandler("search_replace_nav_left", () => dispatch(widgetKey("Left")));
|
|
1138
1510
|
registerHandler("search_replace_nav_right", () => dispatch(widgetKey("Right")));
|
|
1139
|
-
|
|
1140
|
-
|
|
1511
|
+
/** Apply a stored history entry to the search field. Mutates the
|
|
1512
|
+
* widget's value via setValue so the host instance state stays in
|
|
1513
|
+
* sync with the plugin's panel.searchPattern, and triggers a
|
|
1514
|
+
* debounced re-search. See §11. */
|
|
1515
|
+
function applyHistoryEntry(text: string): void {
|
|
1516
|
+
if (!panel || !panel.widgetPanel) return;
|
|
1517
|
+
panel.searchPattern = text;
|
|
1518
|
+
panel.cursorPos = text.length;
|
|
1519
|
+
panel.searchPerformed = false;
|
|
1520
|
+
panel.widgetPanel.setValue("searchField", text, byteLen(text));
|
|
1521
|
+
rerunSearchDebounced();
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/** Whether Up/Down should be intercepted for history walk (instead of
|
|
1525
|
+
* being passed to the focused widget). True only when the most recent
|
|
1526
|
+
* widget_event indicated focus was on the search field. */
|
|
1527
|
+
function shouldInterceptForHistory(): boolean {
|
|
1528
|
+
return lastFocusedWidget === "searchField" || lastFocusedWidget === null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
registerHandler("search_replace_nav_up", () => {
|
|
1532
|
+
if (!panel) return;
|
|
1533
|
+
if (shouldInterceptForHistory()) {
|
|
1534
|
+
if (searchHistory.length === 0) return;
|
|
1535
|
+
if (historyIndex < 0) {
|
|
1536
|
+
// Entering history walk — snapshot what the user had typed so
|
|
1537
|
+
// a Down past the most recent entry restores it.
|
|
1538
|
+
historySavedPattern = panel.searchPattern;
|
|
1539
|
+
historyIndex = 0;
|
|
1540
|
+
} else if (historyIndex < searchHistory.length - 1) {
|
|
1541
|
+
historyIndex += 1;
|
|
1542
|
+
} else {
|
|
1543
|
+
return; // already at the oldest entry
|
|
1544
|
+
}
|
|
1545
|
+
applyHistoryEntry(searchHistory[historyIndex]);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
dispatch(widgetKey("Up"));
|
|
1549
|
+
});
|
|
1550
|
+
registerHandler("search_replace_nav_down", () => {
|
|
1551
|
+
if (!panel) return;
|
|
1552
|
+
if (shouldInterceptForHistory() && historyIndex >= 0) {
|
|
1553
|
+
if (historyIndex > 0) {
|
|
1554
|
+
historyIndex -= 1;
|
|
1555
|
+
applyHistoryEntry(searchHistory[historyIndex]);
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
// Down past the most recent entry → exit history walk and restore
|
|
1559
|
+
// whatever the user had typed before they hit Up.
|
|
1560
|
+
historyIndex = -1;
|
|
1561
|
+
const restore = historySavedPattern ?? "";
|
|
1562
|
+
historySavedPattern = null;
|
|
1563
|
+
applyHistoryEntry(restore);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
dispatch(widgetKey("Down"));
|
|
1567
|
+
});
|
|
1141
1568
|
registerHandler("search_replace_nav_page_up", () => dispatch(widgetKey("PageUp")));
|
|
1142
1569
|
registerHandler("search_replace_nav_page_down", () => dispatch(widgetKey("PageDown")));
|
|
1143
1570
|
|
|
@@ -1213,13 +1640,49 @@ registerHandler("search_replace_replace_scoped", search_replace_replace_scoped);
|
|
|
1213
1640
|
registerHandler("search_replace_enter", () => dispatch(widgetKey("Enter")));
|
|
1214
1641
|
registerHandler("search_replace_space", () => dispatch(widgetKey("Space")));
|
|
1215
1642
|
|
|
1643
|
+
/** Lock against re-entrant Replace All / Replace Scoped. Set as soon
|
|
1644
|
+
* as doReplaceAll/doReplaceScoped enters and cleared in a try/finally
|
|
1645
|
+
* around the whole flow. Without this, a user mashing Alt+Enter
|
|
1646
|
+
* during a streaming search produces N stacked confirmation prompts
|
|
1647
|
+
* once the search finishes — the host queues each keystroke and
|
|
1648
|
+
* drains them all when the JS event loop frees up; by then
|
|
1649
|
+
* `panel.busy` is false so the busy guard doesn't fire. */
|
|
1650
|
+
let replaceInProgress = false;
|
|
1651
|
+
|
|
1216
1652
|
async function doReplaceAll(): Promise<void> {
|
|
1217
|
-
if (!panel
|
|
1653
|
+
if (!panel) return;
|
|
1654
|
+
if (panel.busy) {
|
|
1655
|
+
// Search is still streaming. Don't block silently — tell the user
|
|
1656
|
+
// to wait, and don't queue the replace. (The host's event
|
|
1657
|
+
// dispatcher would otherwise hold the keystroke and run it when
|
|
1658
|
+
// the pump finishes, which feels like an unexplained delay.)
|
|
1659
|
+
editor.setStatus(editor.t("status.replace_wait_for_search"));
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
if (replaceInProgress) {
|
|
1663
|
+
// First Alt+Enter is already showing its prompt or running the
|
|
1664
|
+
// rewrites. Drop the duplicate so we don't stack prompts.
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
replaceInProgress = true;
|
|
1668
|
+
try {
|
|
1669
|
+
await doReplaceAllInner();
|
|
1670
|
+
} finally {
|
|
1671
|
+
replaceInProgress = false;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
async function doReplaceAllInner(): Promise<void> {
|
|
1676
|
+
if (!panel) return;
|
|
1218
1677
|
const selected = panel.searchResults.filter(r => r.selected);
|
|
1219
1678
|
if (selected.length === 0) {
|
|
1220
1679
|
editor.setStatus(editor.t("status.no_items_selected"));
|
|
1221
1680
|
return;
|
|
1222
1681
|
}
|
|
1682
|
+
// The user committed to this pattern by triggering Replace All —
|
|
1683
|
+
// a clear "settle" signal, so commit it to history now even if the
|
|
1684
|
+
// 2s scheduleHistoryPush hasn't fired yet.
|
|
1685
|
+
if (historyIndex < 0) historyPush(panel.searchPattern);
|
|
1223
1686
|
// Confirm before applying. Replacements write to disk immediately; Undo
|
|
1224
1687
|
// only covers files that remain open in this session (see bug #1 report).
|
|
1225
1688
|
const fileCount = new Set(selected.map(r => r.match.file)).size;
|
|
@@ -1251,7 +1714,22 @@ async function doReplaceAll(): Promise<void> {
|
|
|
1251
1714
|
}
|
|
1252
1715
|
|
|
1253
1716
|
async function doReplaceScoped(): Promise<void> {
|
|
1254
|
-
if (!panel || panel.
|
|
1717
|
+
if (!panel || panel.focusPanel !== "matches") return;
|
|
1718
|
+
if (panel.busy) {
|
|
1719
|
+
editor.setStatus(editor.t("status.replace_wait_for_search"));
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (replaceInProgress) return;
|
|
1723
|
+
replaceInProgress = true;
|
|
1724
|
+
try {
|
|
1725
|
+
await doReplaceScopedInner();
|
|
1726
|
+
} finally {
|
|
1727
|
+
replaceInProgress = false;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async function doReplaceScopedInner(): Promise<void> {
|
|
1732
|
+
if (!panel) return;
|
|
1255
1733
|
const flat = buildFlatItems();
|
|
1256
1734
|
const item = flat[panel.matchIndex];
|
|
1257
1735
|
if (!item) return;
|
|
@@ -1263,6 +1741,8 @@ async function doReplaceScoped(): Promise<void> {
|
|
|
1263
1741
|
const result = panel.fileGroups[item.fileIndex].matches[item.matchIndex!];
|
|
1264
1742
|
if (result.selected) toReplace = [result];
|
|
1265
1743
|
}
|
|
1744
|
+
// Same as doReplaceAll: explicit commit, push immediately.
|
|
1745
|
+
if (historyIndex < 0) historyPush(panel.searchPattern);
|
|
1266
1746
|
|
|
1267
1747
|
if (toReplace.length === 0) {
|
|
1268
1748
|
editor.setStatus(editor.t("status.no_selected"));
|
|
@@ -1296,12 +1776,30 @@ async function doReplaceScoped(): Promise<void> {
|
|
|
1296
1776
|
|
|
1297
1777
|
function search_replace_close(): void {
|
|
1298
1778
|
if (!panel) return;
|
|
1779
|
+
// If the user actually ran a search to completion with this
|
|
1780
|
+
// pattern (results were observed) and isn't walking history,
|
|
1781
|
+
// treat panel-close as a settle and commit to history. The
|
|
1782
|
+
// searchPerformed guard avoids capturing half-typed patterns
|
|
1783
|
+
// that never made it past the empty-state.
|
|
1784
|
+
if (
|
|
1785
|
+
historyIndex < 0
|
|
1786
|
+
&& panel.searchPattern
|
|
1787
|
+
&& panel.searchPerformed
|
|
1788
|
+
) {
|
|
1789
|
+
historyPush(panel.searchPattern);
|
|
1790
|
+
}
|
|
1791
|
+
const sourceSplitId = panel.sourceSplitId;
|
|
1299
1792
|
panel.widgetPanel?.unmount();
|
|
1300
1793
|
editor.closeBuffer(panel.resultsBufferId);
|
|
1301
1794
|
if (panel.resultsSplitId !== panel.sourceSplitId) {
|
|
1302
1795
|
editor.closeSplit(panel.resultsSplitId);
|
|
1303
1796
|
}
|
|
1304
1797
|
panel = null;
|
|
1798
|
+
// Restore focus to the split the user came from. Without this,
|
|
1799
|
+
// `getActiveBufferId()` on the next invocation can return the
|
|
1800
|
+
// utility dock's leftover buffer, and the §1 current-file scope
|
|
1801
|
+
// shows "(unsaved buffer)" instead of the real filename.
|
|
1802
|
+
editor.focusSplit(sourceSplitId);
|
|
1305
1803
|
editor.setStatus(editor.t("status.closed"));
|
|
1306
1804
|
}
|
|
1307
1805
|
registerHandler("search_replace_close", search_replace_close);
|
|
@@ -1315,6 +1813,14 @@ function start_search_replace(): void {
|
|
|
1315
1813
|
}
|
|
1316
1814
|
registerHandler("start_search_replace", start_search_replace);
|
|
1317
1815
|
|
|
1816
|
+
// §1: open the panel with scope already restricted to the active
|
|
1817
|
+
// buffer. Useful when the user wants single-file search/replace from
|
|
1818
|
+
// the keymap without flipping the toggle by hand.
|
|
1819
|
+
function start_search_replace_in_buffer(): void {
|
|
1820
|
+
openPanel({ allFiles: false });
|
|
1821
|
+
}
|
|
1822
|
+
registerHandler("start_search_replace_in_buffer", start_search_replace_in_buffer);
|
|
1823
|
+
|
|
1318
1824
|
// =============================================================================
|
|
1319
1825
|
// Event handlers (resize updates width)
|
|
1320
1826
|
// =============================================================================
|
|
@@ -1367,6 +1873,15 @@ editor.on("buffer_closed", (args) => {
|
|
|
1367
1873
|
editor.on("widget_event", (args) => {
|
|
1368
1874
|
if (!panel || args.panel_id !== panel.widgetPanel?.id()) return;
|
|
1369
1875
|
|
|
1876
|
+
// Track most-recent focused widget so Up/Down can decide whether to
|
|
1877
|
+
// walk search history (search field) or pass through to the widget
|
|
1878
|
+
// runtime (matches tree, toggles, button). The widget runtime
|
|
1879
|
+
// doesn't expose focus to the plugin directly; this best-effort
|
|
1880
|
+
// proxy is good enough for the history-walk gesture. See §11.
|
|
1881
|
+
if (typeof args.widget_key === "string" && args.widget_key.length > 0) {
|
|
1882
|
+
lastFocusedWidget = args.widget_key;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1370
1885
|
// `change` — fired for TextInput edits (Backspace, Delete,
|
|
1371
1886
|
// arrows, Home/End, mode_text_input). Payload carries the new
|
|
1372
1887
|
// value and cursor byte offset. The host already updated the
|
|
@@ -1384,9 +1899,28 @@ editor.on("widget_event", (args) => {
|
|
|
1384
1899
|
? payload.cursorByte
|
|
1385
1900
|
: payload.value.length;
|
|
1386
1901
|
if (args.widget_key === "searchField") {
|
|
1387
|
-
panel.searchPattern
|
|
1902
|
+
if (panel.searchPattern !== payload.value) {
|
|
1903
|
+
// Pattern mutated by the user; cached "no matches" / result
|
|
1904
|
+
// set no longer reflects this query. See §17.
|
|
1905
|
+
panel.searchPerformed = false;
|
|
1906
|
+
// User-driven typing exits any in-flight history walk so a
|
|
1907
|
+
// subsequent Up doesn't snap back to a history entry under
|
|
1908
|
+
// the cursor. See §11.
|
|
1909
|
+
historyIndex = -1;
|
|
1910
|
+
historySavedPattern = null;
|
|
1911
|
+
panel.searchPattern = payload.value;
|
|
1912
|
+
panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
|
|
1913
|
+
updatePanelContent();
|
|
1914
|
+
rerunSearchDebounced();
|
|
1915
|
+
scheduleHistoryPush(payload.value);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
// Cursor-only update (Left/Right arrows, Home/End, click reposition):
|
|
1919
|
+
// the search field's text is unchanged, so don't re-run the search
|
|
1920
|
+
// or perturb the history-settle timer. Just sync the plugin's
|
|
1921
|
+
// cached cursor position so the next render shows the cursor in
|
|
1922
|
+
// the right place.
|
|
1388
1923
|
panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
|
|
1389
|
-
rerunSearchDebounced();
|
|
1390
1924
|
} else if (args.widget_key === "replaceField") {
|
|
1391
1925
|
panel.replaceText = payload.value;
|
|
1392
1926
|
panel.cursorPos = byteToCharOffset(payload.value, cursorByte);
|
|
@@ -1449,6 +1983,10 @@ editor.on("widget_event", (args) => {
|
|
|
1449
1983
|
[...panel.expandedFileKeys],
|
|
1450
1984
|
);
|
|
1451
1985
|
} else {
|
|
1986
|
+
// Opening a result is a "this is the search I wanted" signal —
|
|
1987
|
+
// commit it to history immediately, regardless of how long
|
|
1988
|
+
// the pattern has been stable. See §11 follow-up.
|
|
1989
|
+
if (historyIndex < 0) historyPush(panel.searchPattern);
|
|
1452
1990
|
const group = panel.fileGroups[item.fileIndex];
|
|
1453
1991
|
const result = group.matches[item.matchIndex!];
|
|
1454
1992
|
editor.openFileInSplit(
|
|
@@ -1474,6 +2012,18 @@ editor.on("widget_event", (args) => {
|
|
|
1474
2012
|
?.checked;
|
|
1475
2013
|
if (typeof newChecked !== "boolean") return;
|
|
1476
2014
|
switch (args.widget_key) {
|
|
2015
|
+
case "allFiles":
|
|
2016
|
+
// Scope flip (§1). Push the new checked state back to the
|
|
2017
|
+
// widget instance and rebuild the whole spec so the scope
|
|
2018
|
+
// row + Replace All button label switch in lock-step. Then
|
|
2019
|
+
// re-run the search so the results pane reflects the new
|
|
2020
|
+
// scope (the search itself is project-wide; filtering
|
|
2021
|
+
// happens in performSearch).
|
|
2022
|
+
panel.allFiles = newChecked;
|
|
2023
|
+
panel.widgetPanel?.setChecked("allFiles", newChecked);
|
|
2024
|
+
updatePanelContent();
|
|
2025
|
+
rerunSearchDebounced();
|
|
2026
|
+
break;
|
|
1477
2027
|
case "case":
|
|
1478
2028
|
panel.caseSensitive = newChecked;
|
|
1479
2029
|
panel.widgetPanel?.setChecked("case", newChecked);
|
|
@@ -1564,4 +2114,11 @@ editor.registerCommand(
|
|
|
1564
2114
|
null
|
|
1565
2115
|
);
|
|
1566
2116
|
|
|
2117
|
+
editor.registerCommand(
|
|
2118
|
+
"%cmd.search_replace_in_buffer",
|
|
2119
|
+
"%cmd.search_replace_in_buffer_desc",
|
|
2120
|
+
"start_search_replace_in_buffer",
|
|
2121
|
+
null
|
|
2122
|
+
);
|
|
2123
|
+
|
|
1567
2124
|
editor.debug("Search & Replace plugin loaded");
|