@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.
- package/CHANGELOG.md +49 -0
- package/package.json +1 -1
- package/plugins/audit_mode.ts +2 -2
- package/plugins/config-schema.json +7 -0
- package/plugins/diagnostics_panel.ts +2 -1
- package/plugins/examples/README.md +3 -3
- package/plugins/examples/hello_world.ts +59 -0
- package/plugins/examples/virtual_buffer_demo.ts +0 -1
- package/plugins/git_blame.ts +0 -1
- package/plugins/git_log.ts +0 -3
- package/plugins/lib/finder.ts +1 -3
- package/plugins/lib/fresh.d.ts +83 -1
- package/plugins/lib/search-utils.ts +2 -2
- package/plugins/markdown_compose.i18n.json +84 -28
- package/plugins/markdown_compose.ts +46 -1
- package/plugins/markdown_source.ts +1 -1
- package/plugins/merge_conflict.ts +0 -2
- package/plugins/pkg.ts +4 -2
- package/plugins/search_replace.i18n.json +140 -28
- package/plugins/search_replace.ts +1094 -338
- package/plugins/theme_editor.ts +30 -1
- package/plugins/vi_mode.ts +8 -8
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
//
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Types
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
13
16
|
interface SearchResult {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
249
|
+
interface FlatItem {
|
|
250
|
+
type: "file" | "match";
|
|
251
|
+
fileIndex: number;
|
|
252
|
+
matchIndex?: number;
|
|
253
|
+
}
|
|
80
254
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
101
|
-
const
|
|
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:
|
|
104
|
-
properties: { type: "
|
|
364
|
+
text: line1 + "\n",
|
|
365
|
+
properties: { type: "query-line" },
|
|
366
|
+
inlineOverlays: line1Overlays,
|
|
105
367
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
112
|
-
properties: { type: "
|
|
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:
|
|
116
|
-
properties: { type: "
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
137
|
-
for (let
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
|
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
|
-
//
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
683
|
+
if (!silent) {
|
|
684
|
+
editor.setStatus(editor.t("status.search_error", { error: String(e) }));
|
|
685
|
+
}
|
|
686
|
+
return [];
|
|
215
687
|
}
|
|
216
688
|
}
|
|
217
689
|
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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:
|
|
234
|
-
ratio: 0.6,
|
|
750
|
+
entries: buildPanelEntries(),
|
|
751
|
+
ratio: 0.6,
|
|
235
752
|
panelId: "search-replace-panel",
|
|
236
753
|
showLineNumbers: false,
|
|
237
|
-
showCursors:
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
772
|
+
// =============================================================================
|
|
773
|
+
// Replacements
|
|
774
|
+
// =============================================================================
|
|
254
775
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
if (!fileGroups.has(
|
|
264
|
-
fileGroups.set(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
888
|
+
function search_replace_home(): void {
|
|
889
|
+
if (!panel || panel.focusPanel !== "query") return;
|
|
890
|
+
panel.cursorPos = 0;
|
|
891
|
+
updatePanelContent();
|
|
359
892
|
}
|
|
360
|
-
registerHandler("
|
|
893
|
+
registerHandler("search_replace_home", search_replace_home);
|
|
361
894
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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("
|
|
1045
|
+
registerHandler("search_replace_toggle_case", search_replace_toggle_case);
|
|
381
1046
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
392
|
-
return
|
|
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("
|
|
1061
|
+
registerHandler("search_replace_toggle_whole_word", search_replace_toggle_whole_word);
|
|
395
1062
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1063
|
+
function search_replace_replace_all(): void {
|
|
1064
|
+
doReplaceAll();
|
|
1065
|
+
}
|
|
1066
|
+
registerHandler("search_replace_replace_all", search_replace_replace_all);
|
|
399
1067
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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("
|
|
1120
|
+
registerHandler("search_replace_enter", search_replace_enter);
|
|
412
1121
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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("
|
|
1151
|
+
registerHandler("search_replace_space", search_replace_space);
|
|
422
1152
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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("
|
|
1206
|
+
registerHandler("search_replace_close", search_replace_close);
|
|
460
1207
|
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
1208
|
+
// =============================================================================
|
|
1209
|
+
// Command entry point
|
|
1210
|
+
// =============================================================================
|
|
464
1211
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
1212
|
+
function start_search_replace(): void {
|
|
1213
|
+
openPanel();
|
|
1214
|
+
}
|
|
1215
|
+
registerHandler("start_search_replace", start_search_replace);
|
|
468
1216
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1217
|
+
// =============================================================================
|
|
1218
|
+
// Event handlers (resize updates width)
|
|
1219
|
+
// =============================================================================
|
|
472
1220
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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("
|
|
1233
|
+
registerHandler("onSearchReplaceResize", onSearchReplaceResize);
|
|
1234
|
+
|
|
1235
|
+
editor.on("resize", "onSearchReplaceResize");
|
|
481
1236
|
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
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");
|