@fresh-editor/fresh-editor 0.2.22 → 0.2.24

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.
@@ -36,7 +36,9 @@
36
36
  "panel.replace_all_btn": "Replace All (Alt+Ret)",
37
37
  "panel.type_pattern": "Type a search pattern above",
38
38
  "panel.matches_count": "Matches (%{count} in %{files} files)",
39
- "panel.matches_title": "Matches"
39
+ "panel.matches_title": "Matches",
40
+ "prompt.confirm_replace": "Replace %{count} match(es) in %{files} file(s)? Undo only covers files still open in this session. Press Enter to confirm, Esc to cancel.",
41
+ "status.replace_cancelled": "Replacement cancelled"
40
42
  },
41
43
  "cs": {
42
44
  "cmd.search_replace": "Hledat a nahradit v projektu",
@@ -75,7 +77,9 @@
75
77
  "panel.replace_all_btn": "Nahradit vše (Alt+Ret)",
76
78
  "panel.type_pattern": "Zadejte vyhledávací vzor",
77
79
  "panel.matches_count": "Shody (%{count} v %{files} souborech)",
78
- "panel.matches_title": "Shody"
80
+ "panel.matches_title": "Shody",
81
+ "prompt.confirm_replace": "Nahradit %{count} výskyt(ů) v %{files} souboru(ech)? Vrátit zpět lze jen soubory otevřené v této relaci. Enter potvrdí, Esc zruší.",
82
+ "status.replace_cancelled": "Nahrazení zrušeno"
79
83
  },
80
84
  "de": {
81
85
  "cmd.search_replace": "Suchen und Ersetzen im Projekt",
@@ -114,7 +118,9 @@
114
118
  "panel.replace_all_btn": "Alle ersetzen (Alt+Ret)",
115
119
  "panel.type_pattern": "Suchmuster oben eingeben",
116
120
  "panel.matches_count": "Treffer (%{count} in %{files} Dateien)",
117
- "panel.matches_title": "Treffer"
121
+ "panel.matches_title": "Treffer",
122
+ "prompt.confirm_replace": "%{count} Treffer in %{files} Datei(en) ersetzen? Rückgängig wirkt nur auf Dateien, die in dieser Sitzung geöffnet sind. Enter bestätigt, Esc bricht ab.",
123
+ "status.replace_cancelled": "Ersetzen abgebrochen"
118
124
  },
119
125
  "es": {
120
126
  "cmd.search_replace": "Buscar y Reemplazar en Proyecto",
@@ -153,7 +159,9 @@
153
159
  "panel.replace_all_btn": "Reemplazar todo (Alt+Ret)",
154
160
  "panel.type_pattern": "Escriba un patrón de búsqueda",
155
161
  "panel.matches_count": "Coincidencias (%{count} en %{files} archivos)",
156
- "panel.matches_title": "Coincidencias"
162
+ "panel.matches_title": "Coincidencias",
163
+ "prompt.confirm_replace": "¿Reemplazar %{count} coincidencia(s) en %{files} archivo(s)? Deshacer solo cubre los archivos abiertos en esta sesión. Intro confirma, Esc cancela.",
164
+ "status.replace_cancelled": "Reemplazo cancelado"
157
165
  },
158
166
  "fr": {
159
167
  "cmd.search_replace": "Rechercher et Remplacer dans le Projet",
@@ -192,7 +200,9 @@
192
200
  "panel.replace_all_btn": "Tout remplacer (Alt+Ret)",
193
201
  "panel.type_pattern": "Saisissez un motif de recherche",
194
202
  "panel.matches_count": "Correspondances (%{count} dans %{files} fichiers)",
195
- "panel.matches_title": "Correspondances"
203
+ "panel.matches_title": "Correspondances",
204
+ "prompt.confirm_replace": "Remplacer %{count} correspondance(s) dans %{files} fichier(s) ? L'annulation ne concerne que les fichiers ouverts dans cette session. Entrée confirme, Échap annule.",
205
+ "status.replace_cancelled": "Remplacement annulé"
196
206
  },
197
207
  "it": {
198
208
  "cmd.search_replace": "Cerca e sostituisci nel progetto",
@@ -231,7 +241,9 @@
231
241
  "panel.replace_all_btn": "Sostituisci tutto (Alt+Ret)",
232
242
  "panel.type_pattern": "Digita un modello di ricerca",
233
243
  "panel.matches_count": "Corrispondenze (%{count} in %{files} file)",
234
- "panel.matches_title": "Corrispondenze"
244
+ "panel.matches_title": "Corrispondenze",
245
+ "prompt.confirm_replace": "Sostituire %{count} corrispondenza/e in %{files} file? L'annulla copre solo i file aperti in questa sessione. Invio conferma, Esc annulla.",
246
+ "status.replace_cancelled": "Sostituzione annullata"
235
247
  },
236
248
  "ja": {
237
249
  "cmd.search_replace": "プロジェクト内で検索と置換",
@@ -270,7 +282,9 @@
270
282
  "panel.replace_all_btn": "すべて置換 (Alt+Ret)",
271
283
  "panel.type_pattern": "検索パターンを入力してください",
272
284
  "panel.matches_count": "一致 (%{files}ファイル中%{count}件)",
273
- "panel.matches_title": "一致"
285
+ "panel.matches_title": "一致",
286
+ "prompt.confirm_replace": "%{files} 個のファイルで %{count} 件の一致を置換しますか?元に戻せるのはこのセッションで開いているファイルのみです。Enterで確認、Escでキャンセル。",
287
+ "status.replace_cancelled": "置換がキャンセルされました"
274
288
  },
275
289
  "ko": {
276
290
  "cmd.search_replace": "프로젝트에서 검색 및 바꾸기",
@@ -309,7 +323,9 @@
309
323
  "panel.replace_all_btn": "모두 바꾸기 (Alt+Ret)",
310
324
  "panel.type_pattern": "검색 패턴을 입력하세요",
311
325
  "panel.matches_count": "일치 (%{files}개 파일에서 %{count}개)",
312
- "panel.matches_title": "일치"
326
+ "panel.matches_title": "일치",
327
+ "prompt.confirm_replace": "%{files}개 파일에서 %{count}개 일치를 바꾸시겠습니까? 실행 취소는 이 세션에서 열려 있는 파일에만 적용됩니다. Enter 확인, Esc 취소.",
328
+ "status.replace_cancelled": "바꾸기가 취소됨"
313
329
  },
314
330
  "pt-BR": {
315
331
  "cmd.search_replace": "Pesquisar e Substituir no Projeto",
@@ -348,7 +364,9 @@
348
364
  "panel.replace_all_btn": "Substituir tudo (Alt+Ret)",
349
365
  "panel.type_pattern": "Digite um padrão de pesquisa",
350
366
  "panel.matches_count": "Correspondências (%{count} em %{files} arquivos)",
351
- "panel.matches_title": "Correspondências"
367
+ "panel.matches_title": "Correspondências",
368
+ "prompt.confirm_replace": "Substituir %{count} ocorrência(s) em %{files} arquivo(s)? Desfazer só cobre arquivos abertos nesta sessão. Enter confirma, Esc cancela.",
369
+ "status.replace_cancelled": "Substituição cancelada"
352
370
  },
353
371
  "ru": {
354
372
  "cmd.search_replace": "Поиск и замена в проекте",
@@ -387,7 +405,9 @@
387
405
  "panel.replace_all_btn": "Заменить все (Alt+Ret)",
388
406
  "panel.type_pattern": "Введите шаблон поиска",
389
407
  "panel.matches_count": "Совпадения (%{count} в %{files} файлах)",
390
- "panel.matches_title": "Совпадения"
408
+ "panel.matches_title": "Совпадения",
409
+ "prompt.confirm_replace": "Заменить %{count} совпадений в %{files} файлах? Отмена применяется только к файлам, открытым в этом сеансе. Enter — подтвердить, Esc — отменить.",
410
+ "status.replace_cancelled": "Замена отменена"
391
411
  },
392
412
  "th": {
393
413
  "cmd.search_replace": "ค้นหาและแทนที่ในโปรเจกต์",
@@ -426,7 +446,9 @@
426
446
  "panel.replace_all_btn": "แทนที่ทั้งหมด (Alt+Ret)",
427
447
  "panel.type_pattern": "พิมพ์รูปแบบการค้นหา",
428
448
  "panel.matches_count": "รายการที่ตรงกัน (%{count} ใน %{files} ไฟล์)",
429
- "panel.matches_title": "รายการที่ตรงกัน"
449
+ "panel.matches_title": "รายการที่ตรงกัน",
450
+ "prompt.confirm_replace": "แทนที่ %{count} รายการใน %{files} ไฟล์? เลิกทำครอบคลุมเฉพาะไฟล์ที่เปิดอยู่ในเซสชันนี้เท่านั้น กด Enter เพื่อยืนยัน, Esc เพื่อยกเลิก",
451
+ "status.replace_cancelled": "ยกเลิกการแทนที่"
430
452
  },
431
453
  "uk": {
432
454
  "cmd.search_replace": "Пошук та заміна в проекті",
@@ -465,7 +487,9 @@
465
487
  "panel.replace_all_btn": "Замінити все (Alt+Ret)",
466
488
  "panel.type_pattern": "Введіть шаблон пошуку",
467
489
  "panel.matches_count": "Збіги (%{count} у %{files} файлах)",
468
- "panel.matches_title": "Збіги"
490
+ "panel.matches_title": "Збіги",
491
+ "prompt.confirm_replace": "Замінити %{count} збіг(ів) у %{files} файл(ах)? Скасування охоплює лише файли, відкриті в цьому сеансі. Enter — підтвердити, Esc — скасувати.",
492
+ "status.replace_cancelled": "Заміну скасовано"
469
493
  },
470
494
  "vi": {
471
495
  "cmd.search_replace": "Tìm và Thay thế trong Dự án",
@@ -504,7 +528,9 @@
504
528
  "panel.replace_all_btn": "Thay thế tất cả (Alt+Ret)",
505
529
  "panel.type_pattern": "Nhập mẫu tìm kiếm",
506
530
  "panel.matches_count": "Kết quả (%{count} trong %{files} tệp)",
507
- "panel.matches_title": "Kết quả"
531
+ "panel.matches_title": "Kết quả",
532
+ "prompt.confirm_replace": "Thay thế %{count} kết quả trong %{files} tệp? Hoàn tác chỉ áp dụng cho các tệp đang mở trong phiên này. Enter để xác nhận, Esc để hủy.",
533
+ "status.replace_cancelled": "Đã hủy thay thế"
508
534
  },
509
535
  "zh-CN": {
510
536
  "cmd.search_replace": "在项目中搜索和替换",
@@ -543,6 +569,8 @@
543
569
  "panel.replace_all_btn": "全部替换 (Alt+Ret)",
544
570
  "panel.type_pattern": "请输入搜索模式",
545
571
  "panel.matches_count": "匹配 (%{files} 个文件中 %{count} 个)",
546
- "panel.matches_title": "匹配"
572
+ "panel.matches_title": "匹配",
573
+ "prompt.confirm_replace": "在 %{files} 个文件中替换 %{count} 处匹配?撤销仅适用于本次会话中打开的文件。按 Enter 确认,Esc 取消。",
574
+ "status.replace_cancelled": "替换已取消"
547
575
  }
548
576
  }
@@ -428,115 +428,106 @@ function buildPanelEntries(): TextPropertyEntry[] {
428
428
  });
429
429
 
430
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;
431
+ const flatItems = buildFlatItems();
432
+ const fixedRows = 5;
433
+ const treeVisibleRows = Math.max(3, getViewportHeight() - fixedRows);
435
434
 
436
435
  if (searchPattern && totalMatches === 0) {
437
- allTreeLines.push({
436
+ entries.push({
438
437
  text: padStr(" " + editor.t("panel.no_matches"), W) + "\n",
439
438
  properties: { type: "empty" },
440
439
  style: { fg: C.dim },
441
440
  });
442
441
  } else if (!searchPattern) {
443
- allTreeLines.push({
442
+ entries.push({
444
443
  text: padStr(" " + editor.t("panel.type_pattern"), W) + "\n",
445
444
  properties: { type: "empty" },
446
445
  style: { fg: C.dim },
447
446
  });
448
447
  } 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,
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
- }
448
+ let selectedLineIdx = focusPanel === "matches" ? panel.matchIndex : -1;
504
449
 
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
- }
450
+ // Adjust scroll offset to keep selected line visible
451
+ if (selectedLineIdx >= 0) {
452
+ if (selectedLineIdx < panel.scrollOffset) {
453
+ panel.scrollOffset = selectedLineIdx;
454
+ }
455
+ if (selectedLineIdx >= panel.scrollOffset + treeVisibleRows) {
456
+ panel.scrollOffset = selectedLineIdx - treeVisibleRows + 1;
513
457
  }
514
458
  }
515
- }
516
-
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);
459
+ const maxOffset = Math.max(0, flatItems.length - treeVisibleRows);
460
+ if (panel.scrollOffset > maxOffset) panel.scrollOffset = maxOffset;
461
+ if (panel.scrollOffset < 0) panel.scrollOffset = 0;
462
+
463
+ // ONLY loop through the items that are literally on the screen right now
464
+ for (let i = panel.scrollOffset; i < panel.scrollOffset + treeVisibleRows; i++) {
465
+ if (i >= flatItems.length) break;
466
+ const item = flatItems[i];
467
+ const isSelected = focusPanel === "matches" && panel.matchIndex === i;
468
+
469
+ if (item.type === "file") {
470
+ const group = fileGroups[item.fileIndex];
471
+ const expandIcon = group.expanded ? "v" : ">";
472
+ const badge = getFileExtBadge(group.relPath);
473
+ const matchCount = group.matches.length;
474
+ const selectedInFile = group.matches.filter(m => m.selected).length;
475
+ const fileLineText = ` ${expandIcon} ${badge} ${group.relPath} (${selectedInFile}/${matchCount})`;
476
+
477
+ const fileOverlays: InlineOverlay[] = [];
478
+ const eiStart = byteLen(" ");
479
+ const eiEnd = eiStart + byteLen(expandIcon);
480
+ fileOverlays.push({ start: eiStart, end: eiEnd, style: { fg: C.expandIcon } });
481
+ const bgStart = eiEnd + byteLen(" ");
482
+ const bgEnd = bgStart + byteLen(badge);
483
+ fileOverlays.push({ start: bgStart, end: bgEnd, style: { fg: C.fileIcon, bold: true } });
484
+ const fpStart = bgEnd + byteLen(" ");
485
+ const fpEnd = fpStart + byteLen(group.relPath);
486
+ fileOverlays.push({ start: fpStart, end: fpEnd, style: { fg: C.filePath } });
487
+
488
+ entries.push({
489
+ text: padStr(fileLineText, W) + "\n",
490
+ properties: { type: "file-row", fileIndex: item.fileIndex },
491
+ style: isSelected ? { bg: C.selectedBg } : undefined,
492
+ inlineOverlays: fileOverlays,
493
+ });
494
+ } else {
495
+ const group = fileGroups[item.fileIndex];
496
+ const result = group.matches[item.matchIndex!];
497
+ const checkbox = result.selected ? "[v]" : "[ ]";
498
+ const location = `${group.relPath}:${result.match.line}`;
499
+ const context = result.match.context.trim();
500
+ const prefixText = ` ${isSelected ? ">" : " "} ${checkbox} `;
501
+ const maxCtx = W - charLen(prefixText) - charLen(location) - 3;
502
+ const displayCtx = truncate(context, Math.max(10, maxCtx));
503
+ const matchLineText = `${prefixText}${location} - ${displayCtx}`;
504
+
505
+ const inlines: InlineOverlay[] = [];
506
+ const cbStart = byteLen(` ${isSelected ? ">" : " "} `);
507
+ const cbEnd = cbStart + byteLen(checkbox);
508
+ inlines.push({ start: cbStart, end: cbEnd, style: { fg: result.selected ? C.checkOn : C.checkOff } });
509
+ const locStart = cbEnd + byteLen(" ");
510
+ const locEnd = locStart + byteLen(location);
511
+ inlines.push({ start: locStart, end: locEnd, style: { fg: C.lineNum } });
512
+
513
+ if (panel.searchPattern) {
514
+ const ctxStart = locEnd + byteLen(" - ");
515
+ highlightMatches(displayCtx, panel.searchPattern, ctxStart, panel.useRegex, panel.caseSensitive, inlines);
516
+ }
520
517
 
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;
518
+ entries.push({
519
+ text: padStr(matchLineText, W) + "\n",
520
+ properties: { type: "match-row", fileIndex: item.fileIndex, matchIndex: item.matchIndex },
521
+ style: isSelected ? { bg: C.selectedBg } : undefined,
522
+ inlineOverlays: inlines.length > 0 ? inlines : undefined,
523
+ });
524
+ }
528
525
  }
529
526
  }
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
527
 
537
528
  // Scroll indicators
538
529
  const canScrollUp = panel.scrollOffset > 0;
539
- const canScrollDown = panel.scrollOffset + treeVisibleRows < allTreeLines.length;
530
+ const canScrollDown = panel.scrollOffset + treeVisibleRows < flatItems.length;
540
531
  const scrollHint = canScrollUp || canScrollDown
541
532
  ? " " + (canScrollUp ? "↑" : " ") + (canScrollDown ? "↓" : " ")
542
533
  : "";
@@ -635,6 +626,8 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
635
626
  if (!panel) return [];
636
627
 
637
628
  const generation = ++currentSearchGeneration;
629
+ let lastUiUpdate = Date.now();
630
+ const UI_UPDATE_INTERVAL_MS = 100; // Force maximum 10 UI updates per second
638
631
 
639
632
  try {
640
633
  const fixedString = !panel.useRegex;
@@ -649,17 +642,24 @@ async function performSearch(pattern: string, silent?: boolean): Promise<SearchR
649
642
  maxResults: MAX_RESULTS,
650
643
  wholeWords: panel.wholeWords,
651
644
  },
652
- (matches: GrepMatch[], _done: boolean) => {
645
+ (matches: GrepMatch[], done: boolean) => {
653
646
  // Discard if a newer search has started
654
647
  if (generation !== currentSearchGeneration || !panel) return;
655
648
 
656
- const newResults: SearchResult[] = matches.map(m => ({ match: m, selected: true }));
657
-
658
- if (newResults.length > 0) {
659
- allResults = allResults.concat(newResults);
649
+ if (matches.length > 0) {
650
+ // Use push loop instead of allResults.concat() to save massive memory allocations
651
+ for (const m of matches) {
652
+ allResults.push({ match: m, selected: true });
653
+ }
660
654
  panel.searchResults = allResults;
655
+ }
656
+
657
+ const now = Date.now();
658
+ // Only trigger the expensive UI rebuild if enough time passed or stream finished
659
+ if (done || now - lastUiUpdate > UI_UPDATE_INTERVAL_MS) {
661
660
  panel.fileGroups = buildFileGroups(allResults);
662
661
  updatePanelContent();
662
+ lastUiUpdate = now;
663
663
  }
664
664
  }
665
665
  );
@@ -1091,7 +1091,7 @@ function search_replace_enter(): void {
1091
1091
  if (panel) {
1092
1092
  panel.focusPanel = "matches";
1093
1093
  panel.matchIndex = 0;
1094
- panel.scrollOffset = 0;
1094
+ panel.scrollOffset = 0;
1095
1095
  updatePanelContent();
1096
1096
  }
1097
1097
  });
@@ -1157,12 +1157,33 @@ async function doReplaceAll(): Promise<void> {
1157
1157
  editor.setStatus(editor.t("status.no_items_selected"));
1158
1158
  return;
1159
1159
  }
1160
+ // Confirm before applying. Replacements write to disk immediately; Undo
1161
+ // only covers files that remain open in this session (see bug #1 report).
1162
+ const fileCount = new Set(selected.map(r => r.match.file)).size;
1163
+ const confirmed = await editor.prompt(
1164
+ editor.t("prompt.confirm_replace", {
1165
+ count: String(selected.length),
1166
+ files: String(fileCount),
1167
+ }),
1168
+ "",
1169
+ );
1170
+ if (confirmed === null) {
1171
+ editor.setStatus(editor.t("status.replace_cancelled"));
1172
+ return;
1173
+ }
1160
1174
  panel.busy = true;
1161
1175
  editor.setStatus(editor.t("status.replacing", { count: String(selected.length) }));
1162
1176
  const statusMsg = await executeReplacements(selected);
1163
1177
  editor.setStatus(statusMsg);
1164
- await rerunSearchQuiet();
1178
+ // Clear stale results before re-searching: the byte offsets in
1179
+ // `panel.searchResults` now point at positions in the pre-replacement
1180
+ // file and must never be re-used (see bug #4 — a second Alt+Enter would
1181
+ // otherwise corrupt files by writing into moved offsets). We also drop
1182
+ // `busy` so rerunSearchQuiet doesn't bail out on its own guard.
1183
+ panel.searchResults = [];
1184
+ panel.fileGroups = [];
1165
1185
  panel.busy = false;
1186
+ await rerunSearchQuiet();
1166
1187
  updatePanelContent();
1167
1188
  }
1168
1189
 
@@ -1185,12 +1206,28 @@ async function doReplaceScoped(): Promise<void> {
1185
1206
  return;
1186
1207
  }
1187
1208
 
1209
+ const fileCount = new Set(toReplace.map(r => r.match.file)).size;
1210
+ const confirmed = await editor.prompt(
1211
+ editor.t("prompt.confirm_replace", {
1212
+ count: String(toReplace.length),
1213
+ files: String(fileCount),
1214
+ }),
1215
+ "",
1216
+ );
1217
+ if (confirmed === null) {
1218
+ editor.setStatus(editor.t("status.replace_cancelled"));
1219
+ return;
1220
+ }
1221
+
1188
1222
  panel.busy = true;
1189
1223
  editor.setStatus(editor.t("status.replacing", { count: String(toReplace.length) }));
1190
1224
  const statusMsg = await executeReplacements(toReplace);
1191
1225
  editor.setStatus(statusMsg);
1192
- await rerunSearchQuiet();
1226
+ // See doReplaceAll — clear stale offsets and drop busy before re-searching.
1227
+ panel.searchResults = [];
1228
+ panel.fileGroups = [];
1193
1229
  panel.busy = false;
1230
+ await rerunSearchQuiet();
1194
1231
  updatePanelContent();
1195
1232
  }
1196
1233
 
@@ -1242,6 +1279,19 @@ function onSearchReplacePromptCancelled(args: { prompt_type: string }): boolean
1242
1279
  registerHandler("onSearchReplacePromptCancelled", onSearchReplacePromptCancelled);
1243
1280
  editor.on("prompt_cancelled", "onSearchReplacePromptCancelled");
1244
1281
 
1282
+ // If the panel's virtual buffer is closed externally (via the × button,
1283
+ // the Close Buffer/Close Tab commands, or anything else), reset the
1284
+ // plugin's internal state so the next invocation of `openPanel` creates
1285
+ // a fresh buffer/split instead of trying to update a buffer that no
1286
+ // longer exists (which silently no-ops and leaves the user with no UI).
1287
+ function onSearchReplaceBufferClosed(args: { buffer_id: number }): void {
1288
+ if (panel && args.buffer_id === panel.resultsBufferId) {
1289
+ panel = null;
1290
+ }
1291
+ }
1292
+ registerHandler("onSearchReplaceBufferClosed", onSearchReplaceBufferClosed);
1293
+ editor.on("buffer_closed", "onSearchReplaceBufferClosed");
1294
+
1245
1295
  editor.registerCommand(
1246
1296
  "%cmd.search_replace",
1247
1297
  "%cmd.search_replace_desc",