@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.
- package/CHANGELOG.md +76 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +497 -119
- package/plugins/audit_mode.ts +2568 -551
- package/plugins/config-schema.json +7 -1
- package/plugins/git_blame.ts +1 -6
- package/plugins/git_log.ts +616 -1025
- package/plugins/lib/fresh.d.ts +76 -4
- package/plugins/lib/git_history.ts +596 -0
- package/plugins/markdown_compose.ts +183 -7
- package/plugins/search_replace.i18n.json +42 -14
- package/plugins/search_replace.ts +146 -96
- package/plugins/vi_mode.ts +8 -3
|
@@ -428,115 +428,106 @@ function buildPanelEntries(): TextPropertyEntry[] {
|
|
|
428
428
|
});
|
|
429
429
|
|
|
430
430
|
// ── Matches tree (virtual-scrolled) ──
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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 <
|
|
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[],
|
|
645
|
+
(matches: GrepMatch[], done: boolean) => {
|
|
653
646
|
// Discard if a newer search has started
|
|
654
647
|
if (generation !== currentSearchGeneration || !panel) return;
|
|
655
648
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
package/plugins/vi_mode.ts
CHANGED
|
@@ -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
|
|
560
|
-
//
|
|
561
|
-
|
|
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");
|