@fresh-editor/fresh-editor 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,54 +1,216 @@
1
1
  /// <reference path="./lib/fresh.d.ts" />
2
2
  const editor = getEditor();
3
3
 
4
-
5
4
  /**
6
5
  * Multi-File Search & Replace Plugin
7
6
  *
8
- * Provides project-wide search and replace functionality using git grep.
9
- * Shows results in a virtual buffer split with preview and confirmation.
7
+ * Compact two-line control bar + hierarchical match tree.
8
+ * Direct inline editing of search/replace fields (no prompts).
9
+ * Navigation uses state-managed selectedIndex (like theme_editor).
10
10
  */
11
11
 
12
- // Result item structure
12
+ // =============================================================================
13
+ // Types
14
+ // =============================================================================
15
+
13
16
  interface SearchResult {
14
- file: string;
15
- line: number;
16
- column: number;
17
- content: string;
18
- selected: boolean; // Whether this result will be replaced
19
- }
20
-
21
- // Plugin state
22
- let panelOpen = false;
23
- let resultsBufferId: number | null = null;
24
- let sourceSplitId: number | null = null;
25
- let resultsSplitId: number | null = null;
26
- let searchResults: SearchResult[] = [];
27
- let searchPattern: string = "";
28
- let replaceText: string = "";
29
- let searchRegex: boolean = false;
30
-
31
- // Maximum results to display
32
- const MAX_RESULTS = 200;
33
-
34
- // Define the search-replace mode with keybindings
35
- // Inherits from "normal" for cursor navigation (Up/Down)
36
- // Simplified keybindings following UX best practices:
37
- // - Enter: Execute replace (primary action)
38
- // - Space: Toggle selection
39
- // - Escape: Close panel
40
- editor.defineMode(
41
- "search-replace-list",
42
- "normal", // Inherit from normal for cursor movement
43
- [
44
- ["Return", "search_replace_execute"],
45
- ["space", "search_replace_toggle_item"],
46
- ["Escape", "search_replace_close"],
47
- ],
48
- true // read-only
49
- );
17
+ match: GrepMatch;
18
+ selected: boolean;
19
+ }
20
+
21
+ interface FileGroup {
22
+ relPath: string;
23
+ absPath: string;
24
+ expanded: boolean;
25
+ matches: SearchResult[];
26
+ }
27
+
28
+ type FocusPanel = "query" | "options" | "matches";
29
+ type QueryField = "search" | "replace";
30
+
31
+ interface PanelState {
32
+ resultsBufferId: number;
33
+ sourceSplitId: number;
34
+ resultsSplitId: number;
35
+ searchResults: SearchResult[];
36
+ fileGroups: FileGroup[];
37
+ searchPattern: string;
38
+ replaceText: string;
39
+ // Navigation
40
+ focusPanel: FocusPanel;
41
+ queryField: QueryField;
42
+ optionIndex: number;
43
+ matchIndex: number;
44
+ // Options
45
+ caseSensitive: boolean;
46
+ useRegex: boolean;
47
+ wholeWords: boolean;
48
+ // Layout
49
+ viewportWidth: number;
50
+ // State
51
+ busy: boolean;
52
+ truncated: boolean;
53
+ // Inline editing cursor position
54
+ cursorPos: number;
55
+ // Virtual scroll offset for matches tree
56
+ scrollOffset: number;
57
+ }
58
+ let panel: PanelState | null = null;
59
+
60
+ const MAX_RESULTS = 10000;
61
+ const MIN_WIDTH = 60;
62
+ const DEFAULT_WIDTH = 100;
63
+ const SEARCH_DEBOUNCE_MS = 150;
64
+
65
+ let searchDebounceGeneration = 0;
66
+
67
+ // =============================================================================
68
+ // Colors
69
+ // =============================================================================
70
+
71
+ type RGB = [number, number, number];
72
+
73
+ const C = {
74
+ border: [80, 80, 100] as RGB,
75
+ label: [160, 160, 180] as RGB,
76
+ value: [255, 255, 255] as RGB,
77
+ inputBg: [40, 40, 55] as RGB,
78
+ statusOk: [100, 200, 100] as RGB,
79
+ statusDim: [120, 120, 140] as RGB,
80
+ toggleOn: [100, 200, 100] as RGB,
81
+ toggleOff: [100, 100, 120] as RGB,
82
+ button: [80, 140, 220] as RGB,
83
+ buttonFg: [255, 255, 255] as RGB,
84
+ filePath: [220, 160, 80] as RGB,
85
+ fileIcon: [100, 180, 220] as RGB,
86
+ lineNum: [120, 120, 140] as RGB,
87
+ matchBg: [0, 140, 160] as RGB,
88
+ matchFg: [255, 255, 255] as RGB,
89
+ selectedBg: [45, 50, 70] as RGB,
90
+ checkOn: [100, 200, 100] as RGB,
91
+ checkOff: [100, 100, 120] as RGB,
92
+ dim: [90, 90, 110] as RGB,
93
+ expandIcon: [140, 140, 160] as RGB,
94
+ separator: [60, 60, 75] as RGB,
95
+ help: [100, 100, 120] as RGB,
96
+ cursor: [255, 255, 255] as RGB,
97
+ cursorBg: [200, 200, 200] as RGB,
98
+ };
99
+
100
+ // =============================================================================
101
+ // Helpers
102
+ // =============================================================================
103
+
104
+ function byteLen(s: string): number {
105
+ return editor.utf8ByteLength(s);
106
+ }
107
+
108
+ /** Count display columns (codepoints; approximation for monospace terminal). */
109
+ function charLen(s: string): number {
110
+ let len = 0;
111
+ for (const _c of s) { len++; }
112
+ return len;
113
+ }
114
+
115
+ function padStr(s: string, width: number): string {
116
+ const len = charLen(s);
117
+ if (len >= width) return s;
118
+ return s + " ".repeat(width - len);
119
+ }
120
+
121
+ /** Truncate to at most maxLen display columns (codepoint-aware). */
122
+ function truncate(s: string, maxLen: number): string {
123
+ const sLen = charLen(s);
124
+ if (sLen <= maxLen) return s;
125
+ if (maxLen <= 3) {
126
+ // Take first maxLen codepoints
127
+ let result = "";
128
+ let count = 0;
129
+ for (const c of s) {
130
+ if (count >= maxLen) break;
131
+ result += c;
132
+ count++;
133
+ }
134
+ return result;
135
+ }
136
+ // Take first (maxLen-3) codepoints + "..."
137
+ let result = "";
138
+ let count = 0;
139
+ for (const c of s) {
140
+ if (count >= maxLen - 3) break;
141
+ result += c;
142
+ count++;
143
+ }
144
+ return result + "...";
145
+ }
146
+
147
+ // Get the active field's text
148
+ function getActiveFieldText(): string {
149
+ if (!panel) return "";
150
+ return panel.queryField === "search" ? panel.searchPattern : panel.replaceText;
151
+ }
152
+
153
+ // Set the active field's text
154
+ function setActiveFieldText(text: string): void {
155
+ if (!panel) return;
156
+ if (panel.queryField === "search") {
157
+ panel.searchPattern = text;
158
+ } else {
159
+ panel.replaceText = text;
160
+ }
161
+ }
162
+
163
+ // =============================================================================
164
+ // Mode — uses allowTextInput for inline editing (supports all keyboard layouts)
165
+ // =============================================================================
166
+
167
+ // Only explicit bindings for special keys; character input is handled via
168
+ // allowTextInput which dispatches unbound characters as mode_text_input events.
169
+ const modeBindings: [string, string][] = [
170
+ ["Return", "search_replace_enter"],
171
+ ["Space", "search_replace_space"],
172
+ ["Tab", "search_replace_tab"],
173
+ ["S-Tab", "search_replace_shift_tab"],
174
+ ["Up", "search_replace_nav_up"],
175
+ ["Down", "search_replace_nav_down"],
176
+ ["Left", "search_replace_nav_left"],
177
+ ["Right", "search_replace_nav_right"],
178
+ ["M-c", "search_replace_toggle_case"],
179
+ ["M-r", "search_replace_toggle_regex"],
180
+ ["M-w", "search_replace_toggle_whole_word"],
181
+ ["M-Return", "search_replace_replace_all"],
182
+ ["S-Return", "search_replace_replace_scoped"],
183
+ ["Escape", "search_replace_close"],
184
+ ["Backspace", "search_replace_backspace"],
185
+ ["Delete", "search_replace_delete"],
186
+ ["Home", "search_replace_home"],
187
+ ["End", "search_replace_end"],
188
+ ];
189
+
190
+ editor.defineMode("search-replace-list", modeBindings, true, true);
191
+
192
+ // Single handler for all character input (any keyboard layout, including Unicode)
193
+ function insertCharAtCursor(ch: string): void {
194
+ if (!panel || panel.focusPanel !== "query") return;
195
+ const text = getActiveFieldText();
196
+ const pos = panel.cursorPos;
197
+ setActiveFieldText(text.slice(0, pos) + ch + text.slice(pos));
198
+ panel.cursorPos = pos + ch.length;
199
+ updatePanelContent();
200
+ }
201
+
202
+ // Handler for mode_text_input events dispatched by the mode system
203
+ function mode_text_input(args: { text: string }): void {
204
+ if (args && args.text) {
205
+ insertCharAtCursor(args.text);
206
+ }
207
+ }
208
+ registerHandler("mode_text_input", mode_text_input);
209
+
210
+ // =============================================================================
211
+ // File grouping
212
+ // =============================================================================
50
213
 
51
- // Get relative path for display
52
214
  function getRelativePath(filePath: string): string {
53
215
  const cwd = editor.getCwd();
54
216
  if (filePath.startsWith(cwd)) {
@@ -57,434 +219,1029 @@ function getRelativePath(filePath: string): string {
57
219
  return filePath;
58
220
  }
59
221
 
60
- // Parse git grep output
61
- function parseGitGrepLine(line: string): SearchResult | null {
62
- const match = line.match(/^([^:]+):(\d+):(\d+):(.*)$/);
63
- if (match) {
64
- return {
65
- file: match[1],
66
- line: parseInt(match[2], 10),
67
- column: parseInt(match[3], 10),
68
- content: match[4],
69
- selected: true, // Selected by default
70
- };
222
+ function getFileExtBadge(path: string): string {
223
+ const dot = path.lastIndexOf(".");
224
+ if (dot < 0) return " ";
225
+ const ext = path.slice(dot + 1).toUpperCase();
226
+ if (ext.length <= 2) return ext.padEnd(2);
227
+ return ext.slice(0, 2);
228
+ }
229
+
230
+ function buildFileGroups(results: SearchResult[]): FileGroup[] {
231
+ const map = new Map<string, SearchResult[]>();
232
+ const order: string[] = [];
233
+ for (const r of results) {
234
+ const key = r.match.file;
235
+ if (!map.has(key)) {
236
+ map.set(key, []);
237
+ order.push(key);
238
+ }
239
+ map.get(key)!.push(r);
71
240
  }
72
- return null;
241
+ return order.map(absPath => ({
242
+ relPath: getRelativePath(absPath),
243
+ absPath,
244
+ expanded: true,
245
+ matches: map.get(absPath)!,
246
+ }));
73
247
  }
74
248
 
75
- // Format a result for display
76
- function formatResult(item: SearchResult, index: number): string {
77
- const checkbox = item.selected ? "[x]" : "[ ]";
78
- const displayPath = getRelativePath(item.file);
79
- const location = `${displayPath}:${item.line}`;
249
+ interface FlatItem {
250
+ type: "file" | "match";
251
+ fileIndex: number;
252
+ matchIndex?: number;
253
+ }
80
254
 
81
- // Truncate for display
82
- const maxLocationLen = 40;
83
- const truncatedLocation = location.length > maxLocationLen
84
- ? "..." + location.slice(-(maxLocationLen - 3))
85
- : location.padEnd(maxLocationLen);
255
+ function buildFlatItems(): FlatItem[] {
256
+ if (!panel) return [];
257
+ const items: FlatItem[] = [];
258
+ for (let fi = 0; fi < panel.fileGroups.length; fi++) {
259
+ const group = panel.fileGroups[fi];
260
+ items.push({ type: "file", fileIndex: fi });
261
+ if (group.expanded) {
262
+ for (let mi = 0; mi < group.matches.length; mi++) {
263
+ items.push({ type: "match", fileIndex: fi, matchIndex: mi });
264
+ }
265
+ }
266
+ }
267
+ return items;
268
+ }
86
269
 
87
- const trimmedContent = item.content.trim();
88
- const maxContentLen = 50;
89
- const displayContent = trimmedContent.length > maxContentLen
90
- ? trimmedContent.slice(0, maxContentLen - 3) + "..."
91
- : trimmedContent;
270
+ // =============================================================================
271
+ // Get actual viewport width
272
+ // =============================================================================
273
+
274
+ function getViewportWidth(): number {
275
+ const vp = editor.getViewport();
276
+ if (vp && vp.width > 0) return vp.width;
277
+ return DEFAULT_WIDTH;
278
+ }
92
279
 
93
- return `${checkbox} ${truncatedLocation} ${displayContent}\n`;
280
+ function getViewportHeight(): number {
281
+ const vp = editor.getViewport();
282
+ if (vp && vp.height > 0) return vp.height;
283
+ return 30;
94
284
  }
95
285
 
96
- // Build panel entries
286
+ // =============================================================================
287
+ // Panel content builder — compact two-line control bar + match tree
288
+ // =============================================================================
289
+
97
290
  function buildPanelEntries(): TextPropertyEntry[] {
291
+ if (!panel) return [];
292
+ const { searchPattern, replaceText, searchResults, fileGroups, focusPanel, queryField,
293
+ optionIndex, caseSensitive, useRegex, wholeWords, cursorPos } = panel;
294
+
295
+ const W = Math.max(MIN_WIDTH, panel.viewportWidth - 2);
98
296
  const entries: TextPropertyEntry[] = [];
99
297
 
100
- // Header
101
- const selectedCount = searchResults.filter(r => r.selected).length;
298
+ const totalMatches = searchResults.length;
299
+ const fileCount = fileGroups.length;
300
+
301
+ // ── Line 1: Query fields + match count ──
302
+ const qFocusSearch = focusPanel === "query" && queryField === "search";
303
+ const qFocusReplace = focusPanel === "query" && queryField === "replace";
304
+
305
+ // Build search field display with cursor
306
+ const searchVal = searchPattern || "";
307
+ const replaceVal = replaceText || "";
308
+ const searchCursorPos = qFocusSearch ? cursorPos : -1;
309
+ const replaceCursorPos = qFocusReplace ? cursorPos : -1;
310
+
311
+ const searchDisp = buildFieldDisplay(searchVal, searchCursorPos, 25);
312
+ const replDisp = buildFieldDisplay(replaceVal, replaceCursorPos, 25);
313
+
314
+ const searchLabel = " " + editor.t("panel.search_label") + " ";
315
+ const replSep = " " + editor.t("panel.replace_label") + " ";
316
+ const truncatedSuffix = panel.truncated ? " " + editor.t("panel.limited") : "";
317
+ const matchStats = totalMatches > 0
318
+ ? " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) }) + truncatedSuffix
319
+ : (searchPattern ? " " + editor.t("panel.no_matches") : "");
320
+
321
+ const line1Text = searchLabel + searchDisp + replSep + replDisp + matchStats;
322
+ const line1 = padStr(line1Text, W);
323
+
324
+ const line1Overlays: InlineOverlay[] = [];
325
+ // Search label
326
+ line1Overlays.push({ start: byteLen(" "), end: byteLen(searchLabel), style: { fg: C.label } });
327
+ // Search value
328
+ const svStart = byteLen(searchLabel);
329
+ const svEnd = svStart + byteLen(searchDisp);
330
+ line1Overlays.push({ start: svStart, end: svEnd, style: { fg: C.value, bg: qFocusSearch ? C.inputBg : undefined } });
331
+ // Cursor highlight in search field
332
+ if (qFocusSearch) {
333
+ addCursorOverlay(searchVal, searchCursorPos, svStart + byteLen("["), line1Overlays);
334
+ }
335
+ // Replace label
336
+ const rlStart = svEnd;
337
+ const rlEnd = rlStart + byteLen(replSep);
338
+ line1Overlays.push({ start: rlStart, end: rlEnd, style: { fg: C.label } });
339
+ // Replace value
340
+ const rvStart = rlEnd;
341
+ const rvEnd = rvStart + byteLen(replDisp);
342
+ line1Overlays.push({ start: rvStart, end: rvEnd, style: { fg: C.value, bg: qFocusReplace ? C.inputBg : undefined } });
343
+ // Cursor highlight in replace field
344
+ if (qFocusReplace) {
345
+ addCursorOverlay(replaceVal, replaceCursorPos, rvStart + byteLen("["), line1Overlays);
346
+ }
347
+ // Stats
348
+ if (matchStats) {
349
+ const msStart = rvEnd;
350
+ if (panel.truncated && totalMatches > 0) {
351
+ // Color the count part normally, then the truncated suffix in warning color
352
+ const statsWithoutSuffix = " " + editor.t("panel.match_stats", { count: String(totalMatches), files: String(fileCount) });
353
+ const countEnd = msStart + byteLen(statsWithoutSuffix);
354
+ line1Overlays.push({ start: msStart, end: countEnd, style: { fg: C.statusOk } });
355
+ const suffixEnd = countEnd + byteLen(truncatedSuffix);
356
+ line1Overlays.push({ start: countEnd, end: suffixEnd, style: { fg: [255, 180, 50] as RGB, bold: true } });
357
+ } else {
358
+ const msEnd = msStart + byteLen(matchStats);
359
+ line1Overlays.push({ start: msStart, end: msEnd, style: { fg: totalMatches > 0 ? C.statusOk : C.statusDim } });
360
+ }
361
+ }
362
+
102
363
  entries.push({
103
- text: `═══ ${editor.t("panel.header")} ═══\n`,
104
- properties: { type: "header" },
364
+ text: line1 + "\n",
365
+ properties: { type: "query-line" },
366
+ inlineOverlays: line1Overlays,
105
367
  });
106
- entries.push({
107
- text: `${editor.t("panel.search_label")} "${searchPattern}"${searchRegex ? " " + editor.t("panel.regex") : ""}\n`,
108
- properties: { type: "info" },
368
+
369
+ // ── Line 2: Options toggles + Replace All button ──
370
+ const optCase = (caseSensitive ? "[v]" : "[ ]") + " " + editor.t("panel.case_toggle");
371
+ const optRegex = (useRegex ? "[v]" : "[ ]") + " " + editor.t("panel.regex_toggle");
372
+ const optWhole = (wholeWords ? "[v]" : "[ ]") + " " + editor.t("panel.whole_toggle");
373
+ const replBtn = "[" + editor.t("panel.replace_all_btn") + "]";
374
+
375
+ const line2Text = " " + optCase + " " + optRegex + " " + optWhole + " " + replBtn;
376
+ const line2 = padStr(line2Text, W);
377
+
378
+ const line2Overlays: InlineOverlay[] = [];
379
+ const oFocus = focusPanel === "options";
380
+
381
+ let pos = byteLen(" ");
382
+ function addToggleOverlay(text: string, idx: number): void {
383
+ const isOn = text.startsWith("[v]");
384
+ const isFoc = oFocus && optionIndex === idx;
385
+ const checkEnd = pos + byteLen(text.substring(0, 3));
386
+ line2Overlays.push({ start: pos, end: checkEnd, style: { fg: isOn ? C.toggleOn : C.toggleOff, bold: isFoc } });
387
+ const labelEnd = pos + byteLen(text);
388
+ line2Overlays.push({ start: checkEnd, end: labelEnd, style: { fg: C.label, bg: isFoc ? C.selectedBg : undefined, bold: isFoc } });
389
+ pos = labelEnd + byteLen(" ");
390
+ }
391
+
392
+ addToggleOverlay(optCase, 0);
393
+ addToggleOverlay(optRegex, 1);
394
+ addToggleOverlay(optWhole, 2);
395
+
396
+ // Replace All button
397
+ pos = byteLen(line2Text) - byteLen(replBtn);
398
+ const btnFoc = oFocus && optionIndex === 3;
399
+ line2Overlays.push({
400
+ start: pos, end: pos + byteLen(replBtn),
401
+ style: { fg: btnFoc ? C.buttonFg : C.button, bg: btnFoc ? C.button : undefined, bold: btnFoc },
109
402
  });
403
+
110
404
  entries.push({
111
- text: `${editor.t("panel.replace_label")} "${replaceText}"\n`,
112
- properties: { type: "info" },
405
+ text: line2 + "\n",
406
+ properties: { type: "options-line" },
407
+ inlineOverlays: line2Overlays,
113
408
  });
409
+
410
+ // ── Separator ──
411
+ const sepChar = "─";
412
+ const matchesLabel = totalMatches > 0
413
+ ? " " + editor.t("panel.matches_count", { count: String(totalMatches), files: String(fileCount) }) + (panel.truncated ? " " + editor.t("panel.limited") : "") + " "
414
+ : " " + editor.t("panel.matches_title") + " ";
415
+ const sepRemaining = W - charLen(matchesLabel);
416
+ const sepLeft = Math.floor(sepRemaining / 2);
417
+ const sepRight = sepRemaining - sepLeft;
418
+ const sepLine = (sepLeft > 0 ? sepChar.repeat(sepLeft) : "") + matchesLabel + (sepRight > 0 ? sepChar.repeat(sepRight) : "");
114
419
  entries.push({
115
- text: `\n`,
116
- properties: { type: "spacer" },
420
+ text: sepLine + "\n",
421
+ properties: { type: "separator" },
422
+ style: { fg: C.separator },
423
+ inlineOverlays: [{
424
+ start: byteLen(sepChar.repeat(sepLeft)),
425
+ end: byteLen(sepChar.repeat(sepLeft) + matchesLabel),
426
+ style: { fg: C.label, bold: true },
427
+ }],
117
428
  });
118
429
 
119
- if (searchResults.length === 0) {
120
- entries.push({
121
- text: " " + editor.t("panel.no_matches") + "\n",
430
+ // ── Matches tree (virtual-scrolled) ──
431
+ // Build all tree lines first, then show only a viewport-sized slice.
432
+ // The control bar above (query + options + separator) is always visible.
433
+ const allTreeLines: TextPropertyEntry[] = [];
434
+ let selectedLineIdx = -1;
435
+
436
+ if (searchPattern && totalMatches === 0) {
437
+ allTreeLines.push({
438
+ text: padStr(" " + editor.t("panel.no_matches"), W) + "\n",
122
439
  properties: { type: "empty" },
440
+ style: { fg: C.dim },
123
441
  });
124
- } else {
125
- // Results header
126
- const limitNote = searchResults.length >= MAX_RESULTS ? " " + editor.t("panel.limited", { max: String(MAX_RESULTS) }) : "";
127
- entries.push({
128
- text: `${editor.t("panel.results", { count: String(searchResults.length) })}${limitNote} ${editor.t("panel.selected", { selected: String(selectedCount) })}\n`,
129
- properties: { type: "count" },
130
- });
131
- entries.push({
132
- text: `\n`,
133
- properties: { type: "spacer" },
442
+ } else if (!searchPattern) {
443
+ allTreeLines.push({
444
+ text: padStr(" " + editor.t("panel.type_pattern"), W) + "\n",
445
+ properties: { type: "empty" },
446
+ style: { fg: C.dim },
134
447
  });
135
-
136
- // Add each result
137
- for (let i = 0; i < searchResults.length; i++) {
138
- const result = searchResults[i];
139
- entries.push({
140
- text: formatResult(result, i),
141
- properties: {
142
- type: "result",
143
- index: i,
144
- location: {
145
- file: result.file,
146
- line: result.line,
147
- column: result.column,
148
- },
149
- },
448
+ } else {
449
+ let flatIdx = 0;
450
+ for (let fi = 0; fi < fileGroups.length; fi++) {
451
+ const group = fileGroups[fi];
452
+ const isFileSelected = focusPanel === "matches" && panel.matchIndex === flatIdx;
453
+ if (isFileSelected) selectedLineIdx = allTreeLines.length;
454
+ const expandIcon = group.expanded ? "v" : ">";
455
+ const badge = getFileExtBadge(group.relPath);
456
+ const matchCount = group.matches.length;
457
+ const selectedInFile = group.matches.filter(m => m.selected).length;
458
+ const fileLineText = ` ${expandIcon} ${badge} ${group.relPath} (${selectedInFile}/${matchCount})`;
459
+
460
+ const fileOverlays: InlineOverlay[] = [];
461
+ const eiStart = byteLen(" ");
462
+ const eiEnd = eiStart + byteLen(expandIcon);
463
+ fileOverlays.push({ start: eiStart, end: eiEnd, style: { fg: C.expandIcon } });
464
+ const bgStart = eiEnd + byteLen(" ");
465
+ const bgEnd = bgStart + byteLen(badge);
466
+ fileOverlays.push({ start: bgStart, end: bgEnd, style: { fg: C.fileIcon, bold: true } });
467
+ const fpStart = bgEnd + byteLen(" ");
468
+ const fpEnd = fpStart + byteLen(group.relPath);
469
+ fileOverlays.push({ start: fpStart, end: fpEnd, style: { fg: C.filePath } });
470
+
471
+ allTreeLines.push({
472
+ text: padStr(fileLineText, W) + "\n",
473
+ properties: { type: "file-row", fileIndex: fi },
474
+ style: isFileSelected ? { bg: C.selectedBg } : undefined,
475
+ inlineOverlays: fileOverlays,
150
476
  });
477
+ flatIdx++;
478
+
479
+ if (group.expanded) {
480
+ for (let mi = 0; mi < group.matches.length; mi++) {
481
+ const result = group.matches[mi];
482
+ const isMatchSelected = focusPanel === "matches" && panel.matchIndex === flatIdx;
483
+ if (isMatchSelected) selectedLineIdx = allTreeLines.length;
484
+ const checkbox = result.selected ? "[v]" : "[ ]";
485
+ const location = `${group.relPath}:${result.match.line}`;
486
+ const context = result.match.context.trim();
487
+ const prefixText = ` ${isMatchSelected ? ">" : " "} ${checkbox} `;
488
+ const maxCtx = W - charLen(prefixText) - charLen(location) - 3;
489
+ const displayCtx = truncate(context, Math.max(10, maxCtx));
490
+ const matchLineText = `${prefixText}${location} - ${displayCtx}`;
491
+
492
+ const inlines: InlineOverlay[] = [];
493
+ const cbStart = byteLen(` ${isMatchSelected ? ">" : " "} `);
494
+ const cbEnd = cbStart + byteLen(checkbox);
495
+ inlines.push({ start: cbStart, end: cbEnd, style: { fg: result.selected ? C.checkOn : C.checkOff } });
496
+ const locStart = cbEnd + byteLen(" ");
497
+ const locEnd = locStart + byteLen(location);
498
+ inlines.push({ start: locStart, end: locEnd, style: { fg: C.lineNum } });
499
+
500
+ if (panel.searchPattern) {
501
+ const ctxStart = locEnd + byteLen(" - ");
502
+ highlightMatches(displayCtx, panel.searchPattern, ctxStart, panel.useRegex, panel.caseSensitive, inlines);
503
+ }
504
+
505
+ allTreeLines.push({
506
+ text: padStr(matchLineText, W) + "\n",
507
+ properties: { type: "match-row", fileIndex: fi, matchIndex: mi },
508
+ style: isMatchSelected ? { bg: C.selectedBg } : undefined,
509
+ inlineOverlays: inlines.length > 0 ? inlines : undefined,
510
+ });
511
+ flatIdx++;
512
+ }
513
+ }
151
514
  }
152
515
  }
153
516
 
154
- // Footer
155
- entries.push({
156
- text: `───────────────────────────────────────────────────────────────────────────────\n`,
157
- properties: { type: "separator" },
158
- });
517
+ // Virtual scroll: fixed rows = query(1) + options(1) + separator(1) + help(1) + tab bar(1) = 5
518
+ const fixedRows = 5;
519
+ const treeVisibleRows = Math.max(3, getViewportHeight() - fixedRows);
520
+
521
+ // Adjust scroll offset to keep selected line visible
522
+ if (selectedLineIdx >= 0) {
523
+ if (selectedLineIdx < panel.scrollOffset) {
524
+ panel.scrollOffset = selectedLineIdx;
525
+ }
526
+ if (selectedLineIdx >= panel.scrollOffset + treeVisibleRows) {
527
+ panel.scrollOffset = selectedLineIdx - treeVisibleRows + 1;
528
+ }
529
+ }
530
+ const maxOffset = Math.max(0, allTreeLines.length - treeVisibleRows);
531
+ if (panel.scrollOffset > maxOffset) panel.scrollOffset = maxOffset;
532
+ if (panel.scrollOffset < 0) panel.scrollOffset = 0;
533
+
534
+ const visibleLines = allTreeLines.slice(panel.scrollOffset, panel.scrollOffset + treeVisibleRows);
535
+ for (const line of visibleLines) entries.push(line);
536
+
537
+ // Scroll indicators
538
+ const canScrollUp = panel.scrollOffset > 0;
539
+ const canScrollDown = panel.scrollOffset + treeVisibleRows < allTreeLines.length;
540
+ const scrollHint = canScrollUp || canScrollDown
541
+ ? " " + (canScrollUp ? "↑" : " ") + (canScrollDown ? "↓" : " ")
542
+ : "";
543
+
544
+ // ── Help bar ──
545
+ const helpText = " " + editor.t("panel.help") + scrollHint;
159
546
  entries.push({
160
- text: editor.t("panel.help") + "\n",
547
+ text: truncate(helpText, W) + "\n",
161
548
  properties: { type: "help" },
549
+ style: { fg: C.help },
162
550
  });
163
551
 
164
552
  return entries;
165
553
  }
166
554
 
167
- // Update panel content
168
- function updatePanelContent(): void {
169
- if (resultsBufferId !== null) {
170
- const entries = buildPanelEntries();
171
- editor.setVirtualBufferContent(resultsBufferId, entries);
555
+ // Build field display string: [value] with cursor
556
+ function buildFieldDisplay(value: string, cursorPos: number, maxLen: number): string {
557
+ const display = value.length > maxLen ? value.slice(0, maxLen - 1) + "…" : value;
558
+ if (cursorPos >= 0) {
559
+ // Show cursor as underscore or pipe at position
560
+ return "[" + display + "]";
172
561
  }
562
+ return "[" + display + "]";
173
563
  }
174
564
 
175
- // Perform the search
176
- async function performSearch(pattern: string, replace: string, isRegex: boolean): Promise<void> {
177
- searchPattern = pattern;
178
- replaceText = replace;
179
- searchRegex = isRegex;
565
+ // Add cursor overlay at the right byte position within a field
566
+ function addCursorOverlay(value: string, cursorPos: number, fieldByteStart: number, overlays: InlineOverlay[]): void {
567
+ if (cursorPos < 0) return;
568
+ const beforeCursor = value.substring(0, cursorPos);
569
+ const cursorBytePos = fieldByteStart + byteLen(beforeCursor);
570
+ // Highlight the character at cursor position (or the closing bracket if at end)
571
+ const charAtCursor = cursorPos < value.length ? value.charAt(cursorPos) : "]";
572
+ const cursorByteEnd = cursorBytePos + byteLen(charAtCursor);
573
+ overlays.push({ start: cursorBytePos, end: cursorByteEnd, style: { fg: [0, 0, 0], bg: C.cursorBg } });
574
+ }
180
575
 
181
- // Build git grep args
182
- const args = ["grep", "-n", "--column", "-I"];
183
- if (isRegex) {
184
- args.push("-E"); // Extended regex
185
- } else {
186
- args.push("-F"); // Fixed string
576
+ // Highlight search pattern occurrences in a display string
577
+ function highlightMatches(text: string, pattern: string, baseByteOffset: number, isRegex: boolean, caseSensitive: boolean, overlays: InlineOverlay[]): void {
578
+ if (!pattern) return;
579
+ try {
580
+ if (!isRegex) {
581
+ let searchText = text;
582
+ let searchPat = pattern;
583
+ if (!caseSensitive) {
584
+ searchText = text.toLowerCase();
585
+ searchPat = pattern.toLowerCase();
586
+ }
587
+ let pos = 0;
588
+ while (pos < searchText.length) {
589
+ const idx = searchText.indexOf(searchPat, pos);
590
+ if (idx < 0) break;
591
+ const startByte = baseByteOffset + byteLen(text.substring(0, idx));
592
+ const endByte = startByte + byteLen(text.substring(idx, idx + pattern.length));
593
+ overlays.push({ start: startByte, end: endByte, style: { bg: C.matchBg, fg: C.matchFg } });
594
+ pos = idx + pattern.length;
595
+ }
596
+ } else {
597
+ const flags = caseSensitive ? "g" : "gi";
598
+ const re = new RegExp(pattern, flags);
599
+ let m;
600
+ while ((m = re.exec(text)) !== null) {
601
+ if (m[0].length === 0) { re.lastIndex++; continue; }
602
+ const startByte = baseByteOffset + byteLen(text.substring(0, m.index));
603
+ const endByte = startByte + byteLen(m[0]);
604
+ overlays.push({ start: startByte, end: endByte, style: { bg: C.matchBg, fg: C.matchFg } });
605
+ }
606
+ }
607
+ } catch (_e) { /* invalid regex */ }
608
+ }
609
+
610
+ // =============================================================================
611
+ // Panel update
612
+ // =============================================================================
613
+
614
+ function updatePanelContent(): void {
615
+ if (panel) {
616
+ // Refresh viewport width each time
617
+ panel.viewportWidth = getViewportWidth();
618
+ editor.setVirtualBufferContent(panel.resultsBufferId, buildPanelEntries());
187
619
  }
188
- args.push("--", pattern);
620
+ }
621
+
622
+ // =============================================================================
623
+ // Search
624
+ // =============================================================================
625
+
626
+ /** Current search generation — incremented on each new search to discard stale results. */
627
+ let currentSearchGeneration = 0;
628
+
629
+ /**
630
+ * Perform a streaming search. Results arrive incrementally per-file via the
631
+ * progress callback and are merged into the panel state as they arrive.
632
+ * Returns the final complete list of results.
633
+ */
634
+ async function performSearch(pattern: string, silent?: boolean): Promise<SearchResult[]> {
635
+ if (!panel) return [];
636
+
637
+ const generation = ++currentSearchGeneration;
189
638
 
190
639
  try {
191
- const cwd = editor.getCwd();
192
- const result = await editor.spawnProcess("git", args, cwd);
193
-
194
- searchResults = [];
195
-
196
- if (result.exit_code === 0) {
197
- for (const line of result.stdout.split("\n")) {
198
- if (!line.trim()) continue;
199
- const match = parseGitGrepLine(line);
200
- if (match) {
201
- searchResults.push(match);
202
- if (searchResults.length >= MAX_RESULTS) break;
640
+ const fixedString = !panel.useRegex;
641
+ let allResults: SearchResult[] = [];
642
+
643
+ // Whole-word filtering is done Rust-side so maxResults is respected correctly
644
+ const result = await editor.grepProjectStreaming(
645
+ pattern,
646
+ {
647
+ fixedString,
648
+ caseSensitive: panel.caseSensitive,
649
+ maxResults: MAX_RESULTS,
650
+ wholeWords: panel.wholeWords,
651
+ },
652
+ (matches: GrepMatch[], _done: boolean) => {
653
+ // Discard if a newer search has started
654
+ if (generation !== currentSearchGeneration || !panel) return;
655
+
656
+ const newResults: SearchResult[] = matches.map(m => ({ match: m, selected: true }));
657
+
658
+ if (newResults.length > 0) {
659
+ allResults = allResults.concat(newResults);
660
+ panel.searchResults = allResults;
661
+ panel.fileGroups = buildFileGroups(allResults);
662
+ updatePanelContent();
203
663
  }
204
664
  }
205
- }
665
+ );
206
666
 
207
- if (searchResults.length === 0) {
208
- editor.setStatus(editor.t("status.no_matches", { pattern }));
209
- } else {
210
- editor.setStatus(editor.t("status.found_matches", { count: String(searchResults.length) }));
667
+ // Final state
668
+ if (generation !== currentSearchGeneration || !panel) return allResults;
669
+
670
+ panel.truncated = !!(result && (result as any).truncated);
671
+
672
+ if (!silent) {
673
+ if (allResults.length === 0) {
674
+ editor.setStatus(editor.t("status.no_matches", { pattern }));
675
+ } else if (panel.truncated) {
676
+ editor.setStatus(editor.t("status.found_matches", { count: String(allResults.length) }) + " " + editor.t("panel.limited"));
677
+ } else {
678
+ editor.setStatus(editor.t("status.found_matches", { count: String(allResults.length) }));
679
+ }
211
680
  }
681
+ return allResults;
212
682
  } catch (e) {
213
- editor.setStatus(editor.t("status.search_error", { error: String(e) }));
214
- searchResults = [];
683
+ if (!silent) {
684
+ editor.setStatus(editor.t("status.search_error", { error: String(e) }));
685
+ }
686
+ return [];
215
687
  }
216
688
  }
217
689
 
218
- // Show the search results panel
219
- async function showResultsPanel(): Promise<void> {
220
- if (panelOpen && resultsBufferId !== null) {
690
+ // =============================================================================
691
+ // Panel lifecycle
692
+ // =============================================================================
693
+
694
+ async function openPanel(): Promise<void> {
695
+ // Try to pre-fill search from editor selection
696
+ let prefill = "";
697
+ try {
698
+ const cursor = editor.getPrimaryCursor();
699
+ if (cursor && cursor.selection) {
700
+ const start = Math.min(cursor.selection.start, cursor.selection.end);
701
+ const end = Math.max(cursor.selection.start, cursor.selection.end);
702
+ if (end - start > 0 && end - start < 200) {
703
+ const bufferId = editor.getActiveBufferId();
704
+ const text = await editor.getBufferText(bufferId, start, end);
705
+ if (text && !text.includes("\n")) {
706
+ prefill = text;
707
+ }
708
+ }
709
+ }
710
+ } catch (_e) { /* no selection */ }
711
+
712
+ if (panel) {
713
+ panel.focusPanel = "query";
714
+ panel.queryField = "search";
715
+ if (prefill) panel.searchPattern = prefill;
716
+ panel.cursorPos = panel.searchPattern.length;
221
717
  updatePanelContent();
222
718
  return;
223
719
  }
224
720
 
225
- sourceSplitId = editor.getActiveSplitId();
226
- const entries = buildPanelEntries();
721
+ const sourceSplitId = editor.getActiveSplitId();
722
+
723
+ panel = {
724
+ resultsBufferId: 0,
725
+ sourceSplitId,
726
+ resultsSplitId: 0,
727
+ searchResults: [],
728
+ fileGroups: [],
729
+ searchPattern: prefill,
730
+ replaceText: "",
731
+ focusPanel: "query",
732
+ queryField: "search",
733
+ optionIndex: 0,
734
+ matchIndex: 0,
735
+ caseSensitive: false,
736
+ useRegex: false,
737
+ wholeWords: false,
738
+ viewportWidth: DEFAULT_WIDTH,
739
+ busy: false,
740
+ truncated: false,
741
+ cursorPos: prefill.length,
742
+ scrollOffset: 0,
743
+ };
227
744
 
228
745
  try {
229
746
  const result = await editor.createVirtualBufferInSplit({
230
747
  name: "*Search/Replace*",
231
748
  mode: "search-replace-list",
232
749
  readOnly: true,
233
- entries: entries,
234
- ratio: 0.6, // 60/40 split
750
+ entries: buildPanelEntries(),
751
+ ratio: 0.6,
235
752
  panelId: "search-replace-panel",
236
753
  showLineNumbers: false,
237
- showCursors: true,
754
+ showCursors: false,
755
+ editingDisabled: true,
238
756
  });
239
- resultsBufferId = result.bufferId;
240
- resultsSplitId = result.splitId ?? editor.getActiveSplitId();
757
+ panel.resultsBufferId = result.bufferId;
758
+ panel.resultsSplitId = result.splitId ?? editor.getActiveSplitId();
759
+ editor.debug(`Search/Replace: panel opened, bufferId=${result.bufferId}, splitId=${result.splitId}`);
241
760
 
242
- panelOpen = true;
243
- editor.debug(`Search/Replace panel opened with buffer ID ${resultsBufferId}`);
761
+ // Now we have the split, refresh width
762
+ panel.viewportWidth = getViewportWidth();
763
+ updatePanelContent();
244
764
  } catch (error) {
245
765
  const errorMessage = error instanceof Error ? error.message : String(error);
246
766
  editor.setStatus(editor.t("status.failed_open_panel"));
247
767
  editor.debug(`ERROR: createVirtualBufferInSplit failed: ${errorMessage}`);
768
+ panel = null;
248
769
  }
249
770
  }
250
771
 
251
- // Execute replacements
252
- async function executeReplacements(): Promise<void> {
253
- const selectedResults = searchResults.filter(r => r.selected);
772
+ // =============================================================================
773
+ // Replacements
774
+ // =============================================================================
254
775
 
255
- if (selectedResults.length === 0) {
256
- editor.setStatus(editor.t("status.no_selected"));
257
- return;
776
+ async function executeReplacements(results?: SearchResult[]): Promise<string> {
777
+ if (!panel) return "";
778
+ const toReplace = results || panel.searchResults.filter(r => r.selected);
779
+ if (toReplace.length === 0) {
780
+ return editor.t("status.no_selected");
258
781
  }
259
782
 
260
- // Group by file
261
- const fileGroups: Map<string, SearchResult[]> = new Map();
262
- for (const result of selectedResults) {
263
- if (!fileGroups.has(result.file)) {
264
- fileGroups.set(result.file, []);
783
+ const fileGroups: Map<string, Array<[number, number]>> = new Map();
784
+ for (const result of toReplace) {
785
+ const file = result.match.file;
786
+ if (!fileGroups.has(file)) {
787
+ fileGroups.set(file, []);
265
788
  }
266
- fileGroups.get(result.file)!.push(result);
789
+ fileGroups.get(file)!.push([result.match.byteOffset, result.match.length]);
267
790
  }
268
791
 
269
792
  let filesModified = 0;
270
793
  let replacementsCount = 0;
271
794
  const errors: string[] = [];
272
795
 
273
- for (const [filePath, results] of fileGroups) {
796
+ const keys: string[] = [];
797
+ fileGroups.forEach((_v, k) => keys.push(k));
798
+ for (const filePath of keys) {
799
+ const matches = fileGroups.get(filePath)!;
274
800
  try {
275
- // Read file
276
- const content = await editor.readFile(filePath);
277
- if (!content) continue;
278
- const lines = content.split("\n");
279
-
280
- // Sort results by line (descending) to avoid offset issues
281
- const sortedResults = [...results].sort((a, b) => {
282
- if (a.line !== b.line) return b.line - a.line;
283
- return b.column - a.column;
284
- });
285
-
286
- // Apply replacements
287
- for (const result of sortedResults) {
288
- const lineIndex = result.line - 1;
289
- if (lineIndex >= 0 && lineIndex < lines.length) {
290
- let line = lines[lineIndex];
291
-
292
- if (searchRegex) {
293
- // Regex replacement
294
- const regex = new RegExp(searchPattern, "g");
295
- lines[lineIndex] = line.replace(regex, replaceText);
296
- } else {
297
- // Simple string replacement (all occurrences in line)
298
- lines[lineIndex] = line.split(searchPattern).join(replaceText);
299
- }
300
- replacementsCount++;
301
- }
302
- }
303
-
304
- // Write back
305
- const newContent = lines.join("\n");
306
- await editor.writeFile(filePath, newContent);
307
- filesModified++;
308
-
801
+ const result = await editor.replaceInFile(filePath, matches, panel.replaceText);
802
+ replacementsCount += result.replacements;
803
+ if (result.replacements > 0) filesModified++;
309
804
  } catch (e) {
310
- const errorMessage = e instanceof Error ? e.message : String(e);
311
- errors.push(`${filePath}: ${errorMessage}`);
805
+ errors.push(`${filePath}: ${e instanceof Error ? e.message : String(e)}`);
312
806
  }
313
807
  }
314
808
 
315
- // Report results
316
809
  if (errors.length > 0) {
317
- editor.setStatus(editor.t("status.replaced_with_errors", { files: String(filesModified), errors: String(errors.length) }));
318
810
  editor.debug(`Replacement errors: ${errors.join(", ")}`);
319
- } else {
320
- editor.setStatus(editor.t("status.replaced", { count: String(replacementsCount), files: String(filesModified) }));
811
+ return editor.t("status.replaced_with_errors", { files: String(filesModified), errors: String(errors.length) });
321
812
  }
322
-
323
- // Close panel after replacement
324
- search_replace_close();
813
+ return editor.t("status.replaced", { count: String(replacementsCount), files: String(filesModified) });
325
814
  }
326
815
 
327
- // Start search/replace workflow
328
- function start_search_replace() : void {
329
- searchResults = [];
330
- searchPattern = "";
331
- replaceText = "";
332
-
333
- editor.startPrompt(editor.t("prompt.search"), "search-replace-search");
334
- editor.setStatus(editor.t("status.enter_pattern"));
816
+ // =============================================================================
817
+ // Re-search
818
+ // =============================================================================
819
+
820
+ async function rerunSearch(): Promise<void> {
821
+ if (!panel || !panel.searchPattern) return;
822
+ if (panel.busy) return; // guard against re-entrant search
823
+ panel.truncated = false;
824
+ panel.busy = true;
825
+ panel.matchIndex = 0;
826
+ panel.scrollOffset = 0;
827
+ const results = await performSearch(panel.searchPattern);
828
+ // performSearch already updates panel.searchResults/fileGroups incrementally;
829
+ // just ensure final state is consistent
830
+ if (panel) {
831
+ panel.searchResults = results;
832
+ panel.fileGroups = buildFileGroups(results);
833
+ panel.busy = false;
834
+ updatePanelContent();
835
+ }
335
836
  }
336
- registerHandler("start_search_replace", start_search_replace);
337
837
 
338
- // Handle search prompt confirmation
339
- function onSearchReplaceSearchConfirmed(args: {
340
- prompt_type: string;
341
- selected_index: number | null;
342
- input: string;
343
- }): boolean {
344
- if (args.prompt_type !== "search-replace-search") {
345
- return true;
346
- }
838
+ function rerunSearchDebounced(): void {
839
+ const gen = ++searchDebounceGeneration;
840
+ editor.delay(SEARCH_DEBOUNCE_MS).then(() => {
841
+ if (gen === searchDebounceGeneration) {
842
+ rerunSearch();
843
+ }
844
+ });
845
+ }
347
846
 
348
- const pattern = args.input.trim();
349
- if (!pattern) {
350
- editor.setStatus(editor.t("status.cancelled_empty"));
351
- return true;
847
+ // Same as rerunSearch but doesn't update status bar (preserves replacement message)
848
+ async function rerunSearchQuiet(): Promise<void> {
849
+ if (!panel || !panel.searchPattern) return;
850
+ if (panel.busy) return;
851
+ panel.busy = true;
852
+ const results = await performSearch(panel.searchPattern, true);
853
+ if (panel) {
854
+ panel.searchResults = results;
855
+ panel.fileGroups = buildFileGroups(results);
856
+ panel.matchIndex = 0;
857
+ panel.scrollOffset = 0;
858
+ panel.busy = false;
859
+ updatePanelContent();
352
860
  }
861
+ }
353
862
 
354
- searchPattern = pattern;
863
+ // =============================================================================
864
+ // Text editing handlers (inline editing of query fields)
865
+ // =============================================================================
866
+
867
+ function search_replace_backspace(): void {
868
+ if (!panel || panel.focusPanel !== "query") return;
869
+ const text = getActiveFieldText();
870
+ const pos = panel.cursorPos;
871
+ if (pos <= 0) return;
872
+ setActiveFieldText(text.slice(0, pos - 1) + text.slice(pos));
873
+ panel.cursorPos = pos - 1;
874
+ updatePanelContent();
875
+ }
876
+ registerHandler("search_replace_backspace", search_replace_backspace);
877
+
878
+ function search_replace_delete(): void {
879
+ if (!panel || panel.focusPanel !== "query") return;
880
+ const text = getActiveFieldText();
881
+ const pos = panel.cursorPos;
882
+ if (pos >= text.length) return;
883
+ setActiveFieldText(text.slice(0, pos) + text.slice(pos + 1));
884
+ updatePanelContent();
885
+ }
886
+ registerHandler("search_replace_delete", search_replace_delete);
355
887
 
356
- // Ask for replacement text
357
- editor.startPrompt(editor.t("prompt.replace"), "search-replace-replace");
358
- return true;
888
+ function search_replace_home(): void {
889
+ if (!panel || panel.focusPanel !== "query") return;
890
+ panel.cursorPos = 0;
891
+ updatePanelContent();
359
892
  }
360
- registerHandler("onSearchReplaceSearchConfirmed", onSearchReplaceSearchConfirmed);
893
+ registerHandler("search_replace_home", search_replace_home);
361
894
 
362
- // Handle replace prompt confirmation
363
- async function onSearchReplaceReplaceConfirmed(args: {
364
- prompt_type: string;
365
- selected_index: number | null;
366
- input: string;
367
- }): Promise<boolean> {
368
- if (args.prompt_type !== "search-replace-replace") {
369
- return true;
895
+ function search_replace_end(): void {
896
+ if (!panel || panel.focusPanel !== "query") return;
897
+ panel.cursorPos = getActiveFieldText().length;
898
+ updatePanelContent();
899
+ }
900
+ registerHandler("search_replace_end", search_replace_end);
901
+
902
+ // =============================================================================
903
+ // Navigation handlers
904
+ // =============================================================================
905
+
906
+ function search_replace_nav_down(): void {
907
+ if (!panel) return;
908
+ if (panel.focusPanel === "query") {
909
+ if (panel.queryField === "search") {
910
+ panel.queryField = "replace";
911
+ panel.cursorPos = panel.replaceText.length;
912
+ }
913
+ updatePanelContent();
914
+ } else if (panel.focusPanel === "options") {
915
+ if (panel.optionIndex < 3) { panel.optionIndex++; updatePanelContent(); }
916
+ } else {
917
+ const flat = buildFlatItems();
918
+ if (panel.matchIndex < flat.length - 1) { panel.matchIndex++; updatePanelContent(); }
370
919
  }
920
+ }
921
+ registerHandler("search_replace_nav_down", search_replace_nav_down);
922
+
923
+ function search_replace_nav_up(): void {
924
+ if (!panel) return;
925
+ if (panel.focusPanel === "query") {
926
+ if (panel.queryField === "replace") {
927
+ panel.queryField = "search";
928
+ panel.cursorPos = panel.searchPattern.length;
929
+ }
930
+ updatePanelContent();
931
+ } else if (panel.focusPanel === "options") {
932
+ if (panel.optionIndex > 0) { panel.optionIndex--; updatePanelContent(); }
933
+ } else {
934
+ if (panel.matchIndex > 0) { panel.matchIndex--; updatePanelContent(); }
935
+ }
936
+ }
937
+ registerHandler("search_replace_nav_up", search_replace_nav_up);
938
+
939
+ function search_replace_tab(): void {
940
+ editor.debug("search_replace_tab CALLED, panel=" + (panel ? "yes" : "null"));
941
+ if (!panel) return;
942
+ if (panel.focusPanel === "query") {
943
+ if (panel.queryField === "search") {
944
+ // Search → Replace
945
+ panel.queryField = "replace";
946
+ panel.cursorPos = panel.replaceText.length;
947
+ updatePanelContent();
948
+ return;
949
+ } else {
950
+ // Replace → Options
951
+ panel.focusPanel = "options";
952
+ }
953
+ } else if (panel.focusPanel === "options") {
954
+ panel.focusPanel = "matches";
955
+ } else {
956
+ // Matches → Query/Search
957
+ panel.focusPanel = "query";
958
+ panel.queryField = "search";
959
+ panel.cursorPos = panel.searchPattern.length;
960
+ }
961
+ updatePanelContent();
962
+ }
963
+ registerHandler("search_replace_tab", search_replace_tab);
964
+
965
+ function search_replace_shift_tab(): void {
966
+ if (!panel) return;
967
+ if (panel.focusPanel === "matches") {
968
+ panel.focusPanel = "options";
969
+ } else if (panel.focusPanel === "options") {
970
+ panel.focusPanel = "query";
971
+ panel.queryField = "replace";
972
+ panel.cursorPos = panel.replaceText.length;
973
+ } else {
974
+ if (panel.queryField === "replace") {
975
+ panel.queryField = "search";
976
+ panel.cursorPos = panel.searchPattern.length;
977
+ } else {
978
+ panel.focusPanel = "matches";
979
+ }
980
+ }
981
+ updatePanelContent();
982
+ }
983
+ registerHandler("search_replace_shift_tab", search_replace_shift_tab);
984
+
985
+ function search_replace_nav_left(): void {
986
+ if (!panel) return;
987
+ // When in query panel, move cursor left
988
+ if (panel.focusPanel === "query") {
989
+ if (panel.cursorPos > 0) {
990
+ panel.cursorPos--;
991
+ updatePanelContent();
992
+ }
993
+ return;
994
+ }
995
+ if (panel.focusPanel !== "matches") return;
996
+ const flat = buildFlatItems();
997
+ const item = flat[panel.matchIndex];
998
+ if (!item) return;
999
+ if (item.type === "file") {
1000
+ if (panel.fileGroups[item.fileIndex].expanded) {
1001
+ panel.fileGroups[item.fileIndex].expanded = false;
1002
+ updatePanelContent();
1003
+ }
1004
+ } else {
1005
+ for (let i = panel.matchIndex - 1; i >= 0; i--) {
1006
+ if (flat[i].type === "file" && flat[i].fileIndex === item.fileIndex) {
1007
+ panel.matchIndex = i;
1008
+ updatePanelContent();
1009
+ break;
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+ registerHandler("search_replace_nav_left", search_replace_nav_left);
1015
+
1016
+ function search_replace_nav_right(): void {
1017
+ if (!panel) return;
1018
+ // When in query panel, move cursor right
1019
+ if (panel.focusPanel === "query") {
1020
+ const text = getActiveFieldText();
1021
+ if (panel.cursorPos < text.length) {
1022
+ panel.cursorPos++;
1023
+ updatePanelContent();
1024
+ }
1025
+ return;
1026
+ }
1027
+ if (panel.focusPanel !== "matches") return;
1028
+ const flat = buildFlatItems();
1029
+ const item = flat[panel.matchIndex];
1030
+ if (!item) return;
1031
+ if (item.type === "file" && !panel.fileGroups[item.fileIndex].expanded) {
1032
+ panel.fileGroups[item.fileIndex].expanded = true;
1033
+ updatePanelContent();
1034
+ }
1035
+ }
1036
+ registerHandler("search_replace_nav_right", search_replace_nav_right);
371
1037
 
372
- replaceText = args.input; // Can be empty for deletion
373
-
374
- // Perform search and show results
375
- await performSearch(searchPattern, replaceText, false);
376
- await showResultsPanel();
377
-
378
- return true;
1038
+ // Global option toggles (Alt+C, Alt+R, Alt+W)
1039
+ function search_replace_toggle_case(): void {
1040
+ if (!panel) return;
1041
+ panel.caseSensitive = !panel.caseSensitive;
1042
+ updatePanelContent();
1043
+ rerunSearchDebounced();
379
1044
  }
380
- registerHandler("onSearchReplaceReplaceConfirmed", onSearchReplaceReplaceConfirmed);
1045
+ registerHandler("search_replace_toggle_case", search_replace_toggle_case);
381
1046
 
382
- // Handle prompt cancellation
383
- function onSearchReplacePromptCancelled(args: {
384
- prompt_type: string;
385
- }): boolean {
386
- if (args.prompt_type !== "search-replace-search" &&
387
- args.prompt_type !== "search-replace-replace") {
388
- return true;
389
- }
1047
+ function search_replace_toggle_regex(): void {
1048
+ if (!panel) return;
1049
+ panel.useRegex = !panel.useRegex;
1050
+ updatePanelContent();
1051
+ rerunSearchDebounced();
1052
+ }
1053
+ registerHandler("search_replace_toggle_regex", search_replace_toggle_regex);
390
1054
 
391
- editor.setStatus(editor.t("status.cancelled"));
392
- return true;
1055
+ function search_replace_toggle_whole_word(): void {
1056
+ if (!panel) return;
1057
+ panel.wholeWords = !panel.wholeWords;
1058
+ updatePanelContent();
1059
+ rerunSearchDebounced();
393
1060
  }
394
- registerHandler("onSearchReplacePromptCancelled", onSearchReplacePromptCancelled);
1061
+ registerHandler("search_replace_toggle_whole_word", search_replace_toggle_whole_word);
395
1062
 
396
- // Toggle selection of current item
397
- function search_replace_toggle_item() : void {
398
- if (resultsBufferId === null || searchResults.length === 0) return;
1063
+ function search_replace_replace_all(): void {
1064
+ doReplaceAll();
1065
+ }
1066
+ registerHandler("search_replace_replace_all", search_replace_replace_all);
399
1067
 
400
- const props = editor.getTextPropertiesAtCursor(resultsBufferId);
401
- if (props.length > 0 && typeof props[0].index === "number") {
402
- const index = props[0].index as number;
403
- if (index >= 0 && index < searchResults.length) {
404
- searchResults[index].selected = !searchResults[index].selected;
1068
+ function search_replace_replace_scoped(): void {
1069
+ doReplaceScoped();
1070
+ }
1071
+ registerHandler("search_replace_replace_scoped", search_replace_replace_scoped);
1072
+
1073
+ // =============================================================================
1074
+ // Action handlers
1075
+ // =============================================================================
1076
+
1077
+ function search_replace_enter(): void {
1078
+ editor.debug("search_replace_enter CALLED, panel=" + (panel ? "yes" : "null"));
1079
+ if (!panel) return;
1080
+ if (panel.focusPanel === "query") {
1081
+ // Enter in query field = confirm and run search
1082
+ if (panel.queryField === "search") {
1083
+ // Move to replace field
1084
+ panel.queryField = "replace";
1085
+ panel.cursorPos = panel.replaceText.length;
405
1086
  updatePanelContent();
406
- const selected = searchResults.filter(r => r.selected).length;
407
- editor.setStatus(editor.t("status.selected_count", { selected: String(selected), total: String(searchResults.length) }));
1087
+ } else {
1088
+ // Confirm replace field and run search
1089
+ if (panel.searchPattern) {
1090
+ rerunSearch().then(() => {
1091
+ if (panel) {
1092
+ panel.focusPanel = "matches";
1093
+ panel.matchIndex = 0;
1094
+ panel.scrollOffset = 0;
1095
+ updatePanelContent();
1096
+ }
1097
+ });
1098
+ }
1099
+ }
1100
+ } else if (panel.focusPanel === "options") {
1101
+ if (panel.optionIndex === 3) {
1102
+ doReplaceAll();
1103
+ } else {
1104
+ search_replace_space();
1105
+ }
1106
+ } else {
1107
+ const flat = buildFlatItems();
1108
+ const item = flat[panel.matchIndex];
1109
+ if (!item) return;
1110
+ if (item.type === "file") {
1111
+ panel.fileGroups[item.fileIndex].expanded = !panel.fileGroups[item.fileIndex].expanded;
1112
+ updatePanelContent();
1113
+ } else {
1114
+ const group = panel.fileGroups[item.fileIndex];
1115
+ const result = group.matches[item.matchIndex!];
1116
+ editor.openFileInSplit(panel.sourceSplitId, result.match.file, result.match.line, result.match.column);
408
1117
  }
409
1118
  }
410
1119
  }
411
- registerHandler("search_replace_toggle_item", search_replace_toggle_item);
1120
+ registerHandler("search_replace_enter", search_replace_enter);
412
1121
 
413
- // Select all items
414
- function search_replace_select_all() : void {
415
- for (const result of searchResults) {
416
- result.selected = true;
1122
+ function search_replace_space(): void {
1123
+ if (!panel) return;
1124
+ if (panel.focusPanel === "query") {
1125
+ // Space in query field = insert space character
1126
+ insertCharAtCursor(" ");
1127
+ return;
1128
+ }
1129
+ if (panel.focusPanel === "options") {
1130
+ if (panel.optionIndex === 0) { panel.caseSensitive = !panel.caseSensitive; updatePanelContent(); rerunSearchDebounced(); }
1131
+ else if (panel.optionIndex === 1) { panel.useRegex = !panel.useRegex; updatePanelContent(); rerunSearchDebounced(); }
1132
+ else if (panel.optionIndex === 2) { panel.wholeWords = !panel.wholeWords; updatePanelContent(); rerunSearchDebounced(); }
1133
+ else if (panel.optionIndex === 3) { doReplaceAll(); }
1134
+ return;
1135
+ }
1136
+ if (panel.focusPanel === "matches") {
1137
+ const flat = buildFlatItems();
1138
+ const item = flat[panel.matchIndex];
1139
+ if (!item) return;
1140
+ if (item.type === "file") {
1141
+ const group = panel.fileGroups[item.fileIndex];
1142
+ const allSelected = group.matches.every(m => m.selected);
1143
+ for (const m of group.matches) m.selected = !allSelected;
1144
+ } else {
1145
+ const group = panel.fileGroups[item.fileIndex];
1146
+ group.matches[item.matchIndex!].selected = !group.matches[item.matchIndex!].selected;
1147
+ }
1148
+ updatePanelContent();
417
1149
  }
418
- updatePanelContent();
419
- editor.setStatus(editor.t("status.selected_count", { selected: String(searchResults.length), total: String(searchResults.length) }));
420
1150
  }
421
- registerHandler("search_replace_select_all", search_replace_select_all);
1151
+ registerHandler("search_replace_space", search_replace_space);
422
1152
 
423
- // Select no items
424
- function search_replace_select_none() : void {
425
- for (const result of searchResults) {
426
- result.selected = false;
1153
+ async function doReplaceAll(): Promise<void> {
1154
+ if (!panel || panel.busy) return;
1155
+ const selected = panel.searchResults.filter(r => r.selected);
1156
+ if (selected.length === 0) {
1157
+ editor.setStatus(editor.t("status.no_items_selected"));
1158
+ return;
427
1159
  }
1160
+ panel.busy = true;
1161
+ editor.setStatus(editor.t("status.replacing", { count: String(selected.length) }));
1162
+ const statusMsg = await executeReplacements(selected);
1163
+ editor.setStatus(statusMsg);
1164
+ await rerunSearchQuiet();
1165
+ panel.busy = false;
428
1166
  updatePanelContent();
429
- editor.setStatus(editor.t("status.selected_count", { selected: "0", total: String(searchResults.length) }));
430
1167
  }
431
- registerHandler("search_replace_select_none", search_replace_select_none);
432
1168
 
433
- // Execute replacement
434
- function search_replace_execute() : void {
435
- const selected = searchResults.filter(r => r.selected).length;
436
- if (selected === 0) {
437
- editor.setStatus(editor.t("status.no_items_selected"));
1169
+ async function doReplaceScoped(): Promise<void> {
1170
+ if (!panel || panel.busy || panel.focusPanel !== "matches") return;
1171
+ const flat = buildFlatItems();
1172
+ const item = flat[panel.matchIndex];
1173
+ if (!item) return;
1174
+
1175
+ let toReplace: SearchResult[] = [];
1176
+ if (item.type === "file") {
1177
+ toReplace = panel.fileGroups[item.fileIndex].matches.filter(m => m.selected);
1178
+ } else {
1179
+ const result = panel.fileGroups[item.fileIndex].matches[item.matchIndex!];
1180
+ if (result.selected) toReplace = [result];
1181
+ }
1182
+
1183
+ if (toReplace.length === 0) {
1184
+ editor.setStatus(editor.t("status.no_selected"));
438
1185
  return;
439
1186
  }
440
1187
 
441
- editor.setStatus(editor.t("status.replacing", { count: String(selected) }));
442
- executeReplacements();
1188
+ panel.busy = true;
1189
+ editor.setStatus(editor.t("status.replacing", { count: String(toReplace.length) }));
1190
+ const statusMsg = await executeReplacements(toReplace);
1191
+ editor.setStatus(statusMsg);
1192
+ await rerunSearchQuiet();
1193
+ panel.busy = false;
1194
+ updatePanelContent();
443
1195
  }
444
- registerHandler("search_replace_execute", search_replace_execute);
445
1196
 
446
- // Preview current item (jump to location)
447
- function search_replace_preview() : void {
448
- if (sourceSplitId === null || resultsBufferId === null) return;
449
-
450
- const props = editor.getTextPropertiesAtCursor(resultsBufferId);
451
- if (props.length > 0) {
452
- const location = props[0].location as { file: string; line: number; column: number } | undefined;
453
- if (location) {
454
- editor.openFileInSplit(sourceSplitId, location.file, location.line, location.column);
455
- editor.setStatus(editor.t("status.preview", { file: getRelativePath(location.file), line: String(location.line) }));
456
- }
1197
+ function search_replace_close(): void {
1198
+ if (!panel) return;
1199
+ editor.closeBuffer(panel.resultsBufferId);
1200
+ if (panel.resultsSplitId !== panel.sourceSplitId) {
1201
+ editor.closeSplit(panel.resultsSplitId);
457
1202
  }
1203
+ panel = null;
1204
+ editor.setStatus(editor.t("status.closed"));
458
1205
  }
459
- registerHandler("search_replace_preview", search_replace_preview);
1206
+ registerHandler("search_replace_close", search_replace_close);
460
1207
 
461
- // Close the panel
462
- function search_replace_close() : void {
463
- if (!panelOpen) return;
1208
+ // =============================================================================
1209
+ // Command entry point
1210
+ // =============================================================================
464
1211
 
465
- if (resultsBufferId !== null) {
466
- editor.closeBuffer(resultsBufferId);
467
- }
1212
+ function start_search_replace(): void {
1213
+ openPanel();
1214
+ }
1215
+ registerHandler("start_search_replace", start_search_replace);
468
1216
 
469
- if (resultsSplitId !== null && resultsSplitId !== sourceSplitId) {
470
- editor.closeSplit(resultsSplitId);
471
- }
1217
+ // =============================================================================
1218
+ // Event handlers (resize updates width)
1219
+ // =============================================================================
472
1220
 
473
- panelOpen = false;
474
- resultsBufferId = null;
475
- sourceSplitId = null;
476
- resultsSplitId = null;
477
- searchResults = [];
478
- editor.setStatus(editor.t("status.closed"));
1221
+ function onSearchReplaceResize(data: { width: number; height: number }): void {
1222
+ if (!panel) return;
1223
+ // Try viewport first (gives actual split width), fall back to terminal width estimate
1224
+ const vp = editor.getViewport();
1225
+ if (vp && vp.width > 0) {
1226
+ panel.viewportWidth = vp.width;
1227
+ } else {
1228
+ // Approximate: panel split is ~40% of terminal (ratio=0.6 means source gets 60%)
1229
+ panel.viewportWidth = Math.floor(data.width * 0.4);
1230
+ }
1231
+ updatePanelContent();
479
1232
  }
480
- registerHandler("search_replace_close", search_replace_close);
1233
+ registerHandler("onSearchReplaceResize", onSearchReplaceResize);
1234
+
1235
+ editor.on("resize", "onSearchReplaceResize");
481
1236
 
482
- // Register event handlers
483
- editor.on("prompt_confirmed", "onSearchReplaceSearchConfirmed");
484
- editor.on("prompt_confirmed", "onSearchReplaceReplaceConfirmed");
1237
+ // Prompt handlers (in case prompts are opened externally for this panel - gracefully handle)
1238
+ function onSearchReplacePromptCancelled(args: { prompt_type: string }): boolean {
1239
+ if (!args.prompt_type.startsWith("search-replace-")) return true;
1240
+ return true;
1241
+ }
1242
+ registerHandler("onSearchReplacePromptCancelled", onSearchReplacePromptCancelled);
485
1243
  editor.on("prompt_cancelled", "onSearchReplacePromptCancelled");
486
1244
 
487
- // Register command
488
1245
  editor.registerCommand(
489
1246
  "%cmd.search_replace",
490
1247
  "%cmd.search_replace_desc",
@@ -492,5 +1249,4 @@ editor.registerCommand(
492
1249
  null
493
1250
  );
494
1251
 
495
- // Plugin initialization
496
1252
  editor.debug("Search & Replace plugin loaded");