@fresh-editor/fresh-editor 0.2.23 → 0.2.25

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.
@@ -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",
@@ -556,10 +556,15 @@ function vi_yank_line() : void {
556
556
  editor.executeActions([{ action: "select_line", count }]);
557
557
  }
558
558
  editor.executeAction("copy");
559
- // Move back to original line using synchronous actions
560
- // (setBufferCursor is async and doesn't take effect in time)
561
- editor.executeAction("move_up");
559
+ // Move back to original line. After #1566 the plain arrow keys collapse
560
+ // an active selection to its edge, so we can't rely on `move_up` from
561
+ // the end of a `select_line` selection to land on the original line —
562
+ // it would collapse to the top edge (line N) and then move up to N-1.
563
+ // Clear the selection first by issuing `move_line_start` (which drops
564
+ // the anchor without moving the cursor horizontally), then `move_up`
565
+ // steps cleanly to the original line.
562
566
  editor.executeAction("move_line_start");
567
+ editor.executeAction("move_up");
563
568
  state.lastYankWasLinewise = true;
564
569
  editor.setStatus(editor.t("status.yanked_lines", { count: String(count) }));
565
570
  switchMode("normal");