@fresh-editor/fresh-editor 0.3.5 → 0.3.7
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 +147 -0
- package/README.md +9 -2
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +84 -0
- package/plugins/audit_mode.ts +139 -3
- package/plugins/config-schema.json +33 -3
- package/plugins/dashboard.ts +34 -111
- package/plugins/flash.ts +22 -4
- package/plugins/git_blame.ts +10 -6
- package/plugins/git_log.ts +705 -323
- package/plugins/git_statusbar.i18n.json +72 -0
- package/plugins/git_statusbar.ts +133 -0
- package/plugins/goto_with_selection.i18n.json +58 -0
- package/plugins/goto_with_selection.ts +17 -0
- package/plugins/lib/fresh.d.ts +911 -15
- package/plugins/lib/index.ts +34 -0
- package/plugins/lib/widgets.ts +903 -0
- package/plugins/live_diff.ts +442 -32
- package/plugins/merge_conflict.ts +89 -64
- package/plugins/orchestrator.ts +3425 -0
- package/plugins/pkg.ts +235 -54
- package/plugins/rust-lsp.ts +58 -40
- package/plugins/schemas/theme.schema.json +18 -0
- package/plugins/search_replace.i18n.json +140 -28
- package/plugins/search_replace.ts +1335 -515
- package/plugins/tab_actions.i18n.json +212 -0
- package/plugins/tab_actions.ts +76 -0
- package/plugins/theme_editor.i18n.json +112 -0
- package/plugins/theme_editor.ts +30 -5
- package/plugins/tsconfig.json +3 -0
- package/plugins/vi_mode.ts +49 -17
- package/themes/dark.json +1 -0
- package/themes/dracula.json +1 -0
- package/themes/high-contrast.json +1 -0
- package/themes/light.json +1 -0
- package/themes/nord.json +1 -0
- package/themes/nostalgia.json +1 -0
- package/themes/solarized-dark.json +1 -0
- package/themes/terminal.json +4 -0
package/plugins/live_diff.ts
CHANGED
|
@@ -36,19 +36,21 @@ const NS_OVERLAY = "live-diff-overlay";
|
|
|
36
36
|
// on the same line — but in practice users will run one or the other.
|
|
37
37
|
const PRIORITY = 9;
|
|
38
38
|
|
|
39
|
-
// Theme keys for backgrounds and
|
|
40
|
-
// resolved at render time by the editor, so the diff colors track
|
|
41
|
-
// the active theme automatically.
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
39
|
+
// Theme keys for backgrounds and "on top of bg" foregrounds. These
|
|
40
|
+
// are resolved at render time by the editor, so the diff colors track
|
|
41
|
+
// the active theme automatically. The `editor.diff_*_fg` keys are
|
|
42
|
+
// purpose-built for "text drawn on top of the matching diff bg" —
|
|
43
|
+
// they default to `ui.file_status_*_fg` so themes that haven't been
|
|
44
|
+
// updated still work, but themes whose `file_status_*_fg` collides
|
|
45
|
+
// with `diff_*_bg` (e.g. `terminal`, where both resolve to ANSI Red)
|
|
46
|
+
// override `editor.diff_*_fg` to a contrasting color.
|
|
45
47
|
const THEME = {
|
|
46
48
|
addedBg: "editor.diff_add_bg",
|
|
47
|
-
addedFg: "
|
|
49
|
+
addedFg: "editor.diff_add_fg",
|
|
48
50
|
modifiedBg: "editor.diff_modify_bg",
|
|
49
|
-
modifiedFg: "
|
|
51
|
+
modifiedFg: "editor.diff_modify_fg",
|
|
50
52
|
removedBg: "editor.diff_remove_bg",
|
|
51
|
-
removedFg: "
|
|
53
|
+
removedFg: "editor.diff_remove_fg",
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
// `setLineIndicator` only accepts RGB triples (not theme keys), so the
|
|
@@ -71,9 +73,27 @@ const DEBOUNCE_MS = 75;
|
|
|
71
73
|
|
|
72
74
|
// Skip virtual-line rendering when either side is huge — line-by-line
|
|
73
75
|
// LCS would be too slow. Gutter glyphs still render via a degraded path.
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
76
|
+
// In practice the DP only runs over the diff's *middle* (common prefix
|
|
77
|
+
// and suffix are stripped first), so this cap rarely bites for typical
|
|
78
|
+
// "small edit to a large file" cases.
|
|
79
|
+
const MAX_DIFF_LINES = 100_000;
|
|
80
|
+
// Soft cap on the LCS DP table; past this we stop computing virtual
|
|
81
|
+
// lines. Applies to the post-prefix/suffix-stripped middle, not the
|
|
82
|
+
// whole file.
|
|
83
|
+
const MAX_DP_CELLS = 16_000_000;
|
|
84
|
+
|
|
85
|
+
// Similarity (Sørensen–Dice over character LCS) above which a 1:1
|
|
86
|
+
// modified pair is rendered as "modified" (bg-only highlight on the
|
|
87
|
+
// new line, no deletion virtual line). Below this we split the pair
|
|
88
|
+
// into a `removed` (virtual deletion line) + `added` (bg-highlighted)
|
|
89
|
+
// hunk pair so the change reads as a rewrite, not an in-place edit.
|
|
90
|
+
// Tunable at runtime via `editor.getPluginApi("live-diff").setSimilarityThreshold(x)`.
|
|
91
|
+
let similarityThreshold = 0.95;
|
|
92
|
+
// Bail out of char-LCS on huge lines; cost is O(m * n).
|
|
93
|
+
const MAX_LINE_LCS_CHARS = 2000;
|
|
94
|
+
// Bail out of word-LCS when either side has more tokens than this;
|
|
95
|
+
// O(m * n) in tokens.
|
|
96
|
+
const MAX_WORD_TOKENS = 1000;
|
|
77
97
|
|
|
78
98
|
// =============================================================================
|
|
79
99
|
// Types
|
|
@@ -86,6 +106,16 @@ type DiffMode =
|
|
|
86
106
|
|
|
87
107
|
type HunkKind = "added" | "removed" | "modified";
|
|
88
108
|
|
|
109
|
+
/** Byte range inside a single new-side line, used to emphasise the
|
|
110
|
+
* word-level diff result with bold + underline overlays. Offsets are
|
|
111
|
+
* UTF-8 byte offsets relative to the start of the line, NOT the
|
|
112
|
+
* buffer — `renderHunks` adds the line's own byte offset before
|
|
113
|
+
* passing them to `addOverlay`. */
|
|
114
|
+
interface WordRange {
|
|
115
|
+
start: number;
|
|
116
|
+
end: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
89
119
|
interface Hunk {
|
|
90
120
|
kind: HunkKind;
|
|
91
121
|
/** First changed new-side line (0-indexed). */
|
|
@@ -94,6 +124,18 @@ interface Hunk {
|
|
|
94
124
|
newCount: number;
|
|
95
125
|
/** Old-side text, line by line, no trailing newline. */
|
|
96
126
|
oldLines: string[];
|
|
127
|
+
/** Word-level diff results, one entry per new-side line in this
|
|
128
|
+
* hunk. Set only on `modified` hunks above the similarity threshold
|
|
129
|
+
* — where we suppress the virtual deletion line and instead bold +
|
|
130
|
+
* underline the actually-changed words on the new line. `undefined`
|
|
131
|
+
* for unrefined hunks and for `added`/`removed` hunks. */
|
|
132
|
+
wordRanges?: WordRange[][];
|
|
133
|
+
/** Old-side word ranges, one entry per `oldLines` line. Set on
|
|
134
|
+
* `removed` hunks emitted by `refineHunks` for high-similarity
|
|
135
|
+
* pairs where words were dropped/changed; passed to addVirtualLine
|
|
136
|
+
* as `textOverlays` so the deletion virtual line bolds + underlines
|
|
137
|
+
* the actually-removed words. */
|
|
138
|
+
oldWordRanges?: WordRange[][];
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
interface BufferDiffState {
|
|
@@ -433,6 +475,286 @@ function fillOldLines(hunks: Hunk[], oldLines: string[]): void {
|
|
|
433
475
|
}
|
|
434
476
|
}
|
|
435
477
|
|
|
478
|
+
// =============================================================================
|
|
479
|
+
// Similarity + word-level diff
|
|
480
|
+
// =============================================================================
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Sørensen–Dice-style similarity ratio over a character LCS:
|
|
484
|
+
*
|
|
485
|
+
* ratio = 2 * |LCS(a, b)| / (|a| + |b|)
|
|
486
|
+
*
|
|
487
|
+
* Range `0.0..1.0`. Empty / empty is `1.0`; either-side-empty is `0.0`.
|
|
488
|
+
* Both sides are stripped of their common prefix and suffix first so
|
|
489
|
+
* "abcdef" vs "abcXYZdef" pays only for the middle DP table.
|
|
490
|
+
*/
|
|
491
|
+
function lineSimilarity(a: string, b: string): number {
|
|
492
|
+
if (a.length === 0 && b.length === 0) return 1.0;
|
|
493
|
+
if (a.length === 0 || b.length === 0) return 0.0;
|
|
494
|
+
if (a.length > MAX_LINE_LCS_CHARS || b.length > MAX_LINE_LCS_CHARS) {
|
|
495
|
+
// Quadratic char LCS is too expensive on huge lines (minified
|
|
496
|
+
// JS, base64 blobs). Treat as different so we don't stall the
|
|
497
|
+
// render; the caller falls back to "split into removed+added".
|
|
498
|
+
return 0.0;
|
|
499
|
+
}
|
|
500
|
+
let prefix = 0;
|
|
501
|
+
const minLen = Math.min(a.length, b.length);
|
|
502
|
+
while (prefix < minLen && a[prefix] === b[prefix]) prefix++;
|
|
503
|
+
let aEnd = a.length;
|
|
504
|
+
let bEnd = b.length;
|
|
505
|
+
while (aEnd > prefix && bEnd > prefix && a[aEnd - 1] === b[bEnd - 1]) {
|
|
506
|
+
aEnd--;
|
|
507
|
+
bEnd--;
|
|
508
|
+
}
|
|
509
|
+
const equal = prefix + (a.length - aEnd);
|
|
510
|
+
const m = aEnd - prefix;
|
|
511
|
+
const n = bEnd - prefix;
|
|
512
|
+
if (m === 0 || n === 0) {
|
|
513
|
+
return (2 * equal) / (a.length + b.length);
|
|
514
|
+
}
|
|
515
|
+
const stride = n + 1;
|
|
516
|
+
const dp: number[] = new Array((m + 1) * stride).fill(0);
|
|
517
|
+
for (let i = 1; i <= m; i++) {
|
|
518
|
+
const ai = a[prefix + i - 1];
|
|
519
|
+
for (let j = 1; j <= n; j++) {
|
|
520
|
+
if (ai === b[prefix + j - 1]) {
|
|
521
|
+
dp[i * stride + j] = dp[(i - 1) * stride + (j - 1)] + 1;
|
|
522
|
+
} else {
|
|
523
|
+
const x = dp[(i - 1) * stride + j];
|
|
524
|
+
const y = dp[i * stride + (j - 1)];
|
|
525
|
+
dp[i * stride + j] = x >= y ? x : y;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const middleLcs = dp[m * stride + n];
|
|
530
|
+
return (2 * (equal + middleLcs)) / (a.length + b.length);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** A run of word, whitespace, or punctuation characters, with the
|
|
534
|
+
* UTF-8 byte offsets it occupies inside its source string. */
|
|
535
|
+
interface Token {
|
|
536
|
+
text: string;
|
|
537
|
+
byteStart: number;
|
|
538
|
+
byteEnd: number;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const WORD_CHAR = /[A-Za-z0-9_]/;
|
|
542
|
+
const WHITESPACE_CHAR = /\s/;
|
|
543
|
+
|
|
544
|
+
/** Tokenize into word runs (`\w+`), whitespace runs (`\s+`), and
|
|
545
|
+
* single non-word non-whitespace characters. Byte offsets are
|
|
546
|
+
* computed once per run via `editor.utf8ByteLength` so downstream
|
|
547
|
+
* overlays can index without re-scanning the string. */
|
|
548
|
+
function tokenize(s: string): Token[] {
|
|
549
|
+
const tokens: Token[] = [];
|
|
550
|
+
let i = 0;
|
|
551
|
+
let bytePos = 0;
|
|
552
|
+
while (i < s.length) {
|
|
553
|
+
let j = i;
|
|
554
|
+
const c = s[i];
|
|
555
|
+
if (WHITESPACE_CHAR.test(c)) {
|
|
556
|
+
while (j < s.length && WHITESPACE_CHAR.test(s[j])) j++;
|
|
557
|
+
} else if (WORD_CHAR.test(c)) {
|
|
558
|
+
while (j < s.length && WORD_CHAR.test(s[j])) j++;
|
|
559
|
+
} else {
|
|
560
|
+
j = i + 1;
|
|
561
|
+
}
|
|
562
|
+
const text = s.slice(i, j);
|
|
563
|
+
const byteLen = editor.utf8ByteLength(text);
|
|
564
|
+
tokens.push({ text, byteStart: bytePos, byteEnd: bytePos + byteLen });
|
|
565
|
+
bytePos += byteLen;
|
|
566
|
+
i = j;
|
|
567
|
+
}
|
|
568
|
+
return tokens;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Word-level diff result: byte ranges on each side that aren't part
|
|
572
|
+
* of the longest common token subsequence. `newRanges` drives the
|
|
573
|
+
* bold + underline overlay on the new (modified) line; `oldRanges`
|
|
574
|
+
* drives the same overlay on the deletion virtual line so removed
|
|
575
|
+
* words are visually called out, not just present in the gutter. */
|
|
576
|
+
interface WordDiff {
|
|
577
|
+
newRanges: WordRange[];
|
|
578
|
+
oldRanges: WordRange[];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Token-level LCS over the two lines. Returns the byte ranges of
|
|
583
|
+
* non-whitespace tokens on each side that aren't part of the LCS.
|
|
584
|
+
* Whitespace-only tokens are never highlighted (whitespace changes
|
|
585
|
+
* mid-word look like noise; whole-line whitespace edits are handled
|
|
586
|
+
* by the line-level diff). Adjacent unmatched non-whitespace tokens
|
|
587
|
+
* are coalesced into a single range so a renamed `foo.bar.baz`
|
|
588
|
+
* becomes one underline, not three.
|
|
589
|
+
*/
|
|
590
|
+
function computeWordDiff(oldS: string, newS: string): WordDiff {
|
|
591
|
+
const oldTokens = tokenize(oldS);
|
|
592
|
+
const newTokens = tokenize(newS);
|
|
593
|
+
const m = oldTokens.length;
|
|
594
|
+
const n = newTokens.length;
|
|
595
|
+
if (m === 0 && n === 0) return { newRanges: [], oldRanges: [] };
|
|
596
|
+
if (m > MAX_WORD_TOKENS || n > MAX_WORD_TOKENS) {
|
|
597
|
+
// Token DP would dwarf the line-level pass; degrade to "every
|
|
598
|
+
// non-whitespace token changed" on whichever side has tokens.
|
|
599
|
+
return {
|
|
600
|
+
newRanges: collapseRanges(
|
|
601
|
+
newTokens.filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
|
|
602
|
+
),
|
|
603
|
+
oldRanges: collapseRanges(
|
|
604
|
+
oldTokens.filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
|
|
605
|
+
),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
if (m === 0) {
|
|
609
|
+
return {
|
|
610
|
+
newRanges: collapseRanges(
|
|
611
|
+
newTokens.filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
|
|
612
|
+
),
|
|
613
|
+
oldRanges: [],
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (n === 0) {
|
|
617
|
+
return {
|
|
618
|
+
newRanges: [],
|
|
619
|
+
oldRanges: collapseRanges(
|
|
620
|
+
oldTokens.filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
|
|
621
|
+
),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
const stride = n + 1;
|
|
625
|
+
const dp: number[] = new Array((m + 1) * stride).fill(0);
|
|
626
|
+
for (let i = 1; i <= m; i++) {
|
|
627
|
+
const ot = oldTokens[i - 1].text;
|
|
628
|
+
for (let j = 1; j <= n; j++) {
|
|
629
|
+
if (ot === newTokens[j - 1].text) {
|
|
630
|
+
dp[i * stride + j] = dp[(i - 1) * stride + (j - 1)] + 1;
|
|
631
|
+
} else {
|
|
632
|
+
const x = dp[(i - 1) * stride + j];
|
|
633
|
+
const y = dp[i * stride + (j - 1)];
|
|
634
|
+
dp[i * stride + j] = x >= y ? x : y;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Backtrack to find which old/new tokens participate in the LCS.
|
|
639
|
+
const matchedOld: boolean[] = new Array(m).fill(false);
|
|
640
|
+
const matchedNew: boolean[] = new Array(n).fill(false);
|
|
641
|
+
let i = m;
|
|
642
|
+
let j = n;
|
|
643
|
+
while (i > 0 && j > 0) {
|
|
644
|
+
if (oldTokens[i - 1].text === newTokens[j - 1].text) {
|
|
645
|
+
matchedOld[i - 1] = true;
|
|
646
|
+
matchedNew[j - 1] = true;
|
|
647
|
+
i--;
|
|
648
|
+
j--;
|
|
649
|
+
} else if (dp[(i - 1) * stride + j] >= dp[i * stride + (j - 1)]) {
|
|
650
|
+
i--;
|
|
651
|
+
} else {
|
|
652
|
+
j--;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const unmatchedOf = (
|
|
656
|
+
tokens: Token[],
|
|
657
|
+
matched: boolean[],
|
|
658
|
+
): WordRange[] => {
|
|
659
|
+
const out: Token[] = [];
|
|
660
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
661
|
+
if (matched[k]) continue;
|
|
662
|
+
const t = tokens[k];
|
|
663
|
+
if (WHITESPACE_CHAR.test(t.text[0] ?? "")) continue;
|
|
664
|
+
out.push(t);
|
|
665
|
+
}
|
|
666
|
+
return collapseRanges(out);
|
|
667
|
+
};
|
|
668
|
+
return {
|
|
669
|
+
newRanges: unmatchedOf(newTokens, matchedNew),
|
|
670
|
+
oldRanges: unmatchedOf(oldTokens, matchedOld),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Merge adjacent or touching token ranges into a single range so
|
|
675
|
+
* downstream overlay creation costs are O(runs), not O(tokens). */
|
|
676
|
+
function collapseRanges(tokens: Token[]): WordRange[] {
|
|
677
|
+
const ranges: WordRange[] = [];
|
|
678
|
+
for (const t of tokens) {
|
|
679
|
+
const last = ranges[ranges.length - 1];
|
|
680
|
+
if (last && last.end === t.byteStart) {
|
|
681
|
+
last.end = t.byteEnd;
|
|
682
|
+
} else {
|
|
683
|
+
ranges.push({ start: t.byteStart, end: t.byteEnd });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return ranges;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Post-process `opsToHunks` output: split low-similarity 1:1
|
|
691
|
+
* `modified` hunks into separate `removed` (virtual deletion line) +
|
|
692
|
+
* `added` (bg-highlighted) hunks. High-similarity pairs stay as
|
|
693
|
+
* `modified` and gain a `wordRanges` entry that drives the bold +
|
|
694
|
+
* underline word-level overlay on the new line. When a high-similarity
|
|
695
|
+
* pair also has *removed* (or otherwise unmatched) old-side words,
|
|
696
|
+
* we additionally emit a `removed` hunk carrying the old line plus
|
|
697
|
+
* `oldWordRanges` so the deletion virtual line shows the user which
|
|
698
|
+
* words went away — not just that *something* did.
|
|
699
|
+
*
|
|
700
|
+
* Hunks that don't have a 1:1 mapping (e.g. 3 old lines becoming 2
|
|
701
|
+
* new lines) keep their original shape — the pairing is ambiguous,
|
|
702
|
+
* and forcing a rewrite-style split would just create misleading
|
|
703
|
+
* "removed" lines.
|
|
704
|
+
*/
|
|
705
|
+
function refineHunks(hunks: Hunk[], newLines: string[]): Hunk[] {
|
|
706
|
+
const out: Hunk[] = [];
|
|
707
|
+
for (const h of hunks) {
|
|
708
|
+
if (h.kind !== "modified" || h.oldLines.length !== h.newCount) {
|
|
709
|
+
out.push(h);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
for (let i = 0; i < h.newCount; i++) {
|
|
713
|
+
const oldLine = h.oldLines[i];
|
|
714
|
+
const newLine = newLines[h.newStart + i] ?? "";
|
|
715
|
+
const sim = lineSimilarity(oldLine, newLine);
|
|
716
|
+
if (sim >= similarityThreshold) {
|
|
717
|
+
const wd = computeWordDiff(oldLine, newLine);
|
|
718
|
+
// Always emit the in-place modified hunk (drives the new-line
|
|
719
|
+
// bg highlight + new-side word-diff). When old-side has
|
|
720
|
+
// unmatched non-WS tokens, also emit a deletion line above
|
|
721
|
+
// it carrying old-side word ranges so the user can see which
|
|
722
|
+
// words were removed/replaced.
|
|
723
|
+
if (wd.oldRanges.length > 0) {
|
|
724
|
+
out.push({
|
|
725
|
+
kind: "removed",
|
|
726
|
+
newStart: h.newStart + i,
|
|
727
|
+
newCount: 0,
|
|
728
|
+
oldLines: [oldLine],
|
|
729
|
+
oldWordRanges: [wd.oldRanges],
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
out.push({
|
|
733
|
+
kind: "modified",
|
|
734
|
+
newStart: h.newStart + i,
|
|
735
|
+
newCount: 1,
|
|
736
|
+
oldLines: [],
|
|
737
|
+
wordRanges: [wd.newRanges],
|
|
738
|
+
});
|
|
739
|
+
} else {
|
|
740
|
+
out.push({
|
|
741
|
+
kind: "removed",
|
|
742
|
+
newStart: h.newStart + i,
|
|
743
|
+
newCount: 0,
|
|
744
|
+
oldLines: [oldLine],
|
|
745
|
+
});
|
|
746
|
+
out.push({
|
|
747
|
+
kind: "added",
|
|
748
|
+
newStart: h.newStart + i,
|
|
749
|
+
newCount: 1,
|
|
750
|
+
oldLines: [],
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
|
|
436
758
|
// =============================================================================
|
|
437
759
|
// Rendering
|
|
438
760
|
// =============================================================================
|
|
@@ -490,20 +812,18 @@ function renderHunks(state: BufferDiffState, newLines: string[]): void {
|
|
|
490
812
|
const lineCount = lineStarts.length;
|
|
491
813
|
|
|
492
814
|
// Group new-side lines per kind for batched setLineIndicators.
|
|
815
|
+
// `removed` hunks have no new-side line they belong on — their
|
|
816
|
+
// indicator rides directly on the virtual deletion line itself
|
|
817
|
+
// via `addVirtualLine`'s `gutterGlyph`, so it sits next to the
|
|
818
|
+
// deleted content instead of on the source line that happens to
|
|
819
|
+
// follow it.
|
|
493
820
|
const addedLines: number[] = [];
|
|
494
821
|
const modifiedLines: number[] = [];
|
|
495
|
-
const removedAnchors: number[] = [];
|
|
496
822
|
|
|
497
823
|
for (const h of state.hunks) {
|
|
498
|
-
if (h.kind === "
|
|
499
|
-
// Anchor on the line that took the deletion's place. If newStart
|
|
500
|
-
// is past EOF, step back to the last real line.
|
|
501
|
-
let anchor = h.newStart;
|
|
502
|
-
if (anchor >= lineCount) anchor = Math.max(0, lineCount - 1);
|
|
503
|
-
removedAnchors.push(anchor);
|
|
504
|
-
} else if (h.kind === "added") {
|
|
824
|
+
if (h.kind === "added") {
|
|
505
825
|
for (let i = 0; i < h.newCount; i++) addedLines.push(h.newStart + i);
|
|
506
|
-
} else {
|
|
826
|
+
} else if (h.kind === "modified") {
|
|
507
827
|
for (let i = 0; i < h.newCount; i++) modifiedLines.push(h.newStart + i);
|
|
508
828
|
}
|
|
509
829
|
}
|
|
@@ -520,17 +840,20 @@ function renderHunks(state: BufferDiffState, newLines: string[]): void {
|
|
|
520
840
|
GUTTER_COLORS.modified[0], GUTTER_COLORS.modified[1], GUTTER_COLORS.modified[2], PRIORITY,
|
|
521
841
|
);
|
|
522
842
|
}
|
|
523
|
-
if (removedAnchors.length > 0) {
|
|
524
|
-
editor.setLineIndicators(
|
|
525
|
-
bid, removedAnchors, NS_GUTTER, SYMBOLS.removed,
|
|
526
|
-
GUTTER_COLORS.removed[0], GUTTER_COLORS.removed[1], GUTTER_COLORS.removed[2], PRIORITY,
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
843
|
|
|
530
844
|
// Background highlights and virtual lines, all sync now.
|
|
531
845
|
for (const h of state.hunks) {
|
|
532
846
|
if (h.kind === "added" || h.kind === "modified") {
|
|
533
847
|
const bg = h.kind === "added" ? THEME.addedBg : THEME.modifiedBg;
|
|
848
|
+
// Passing `fg` as a theme key lets each theme decide whether to
|
|
849
|
+
// override the cell's existing fg: themes that DEFINE
|
|
850
|
+
// `editor.diff_*_fg` (e.g. `terminal`, where the ANSI bg would
|
|
851
|
+
// otherwise collide with same-named syntax colors) get a
|
|
852
|
+
// contrasting fg painted on; themes that don't define the key
|
|
853
|
+
// resolve to `None` in `OverlayFace::ThemedStyle`, so the
|
|
854
|
+
// overlay leaves the cell's fg alone and syntax highlighting
|
|
855
|
+
// shows through unchanged.
|
|
856
|
+
const fg = h.kind === "added" ? THEME.addedFg : THEME.modifiedFg;
|
|
534
857
|
for (let i = 0; i < h.newCount; i++) {
|
|
535
858
|
const line = h.newStart + i;
|
|
536
859
|
if (line >= lineCount) break;
|
|
@@ -547,6 +870,7 @@ function renderHunks(state: BufferDiffState, newLines: string[]): void {
|
|
|
547
870
|
if (end <= start) end = start + 1;
|
|
548
871
|
editor.addOverlay(bid, NS_OVERLAY, start, end, {
|
|
549
872
|
bg,
|
|
873
|
+
fg,
|
|
550
874
|
underline: false,
|
|
551
875
|
bold: false,
|
|
552
876
|
italic: false,
|
|
@@ -554,6 +878,36 @@ function renderHunks(state: BufferDiffState, newLines: string[]): void {
|
|
|
554
878
|
extendToLineEnd: true,
|
|
555
879
|
});
|
|
556
880
|
}
|
|
881
|
+
|
|
882
|
+
// Word-level diff: bold + underline the changed words on the
|
|
883
|
+
// new-side line of a refined high-similarity modified hunk.
|
|
884
|
+
// `wordRanges` is set only by `refineHunks` and uses byte
|
|
885
|
+
// offsets relative to each new-side line's start, so we add the
|
|
886
|
+
// line's own start byte before passing to `addOverlay`.
|
|
887
|
+
if (h.wordRanges) {
|
|
888
|
+
for (let i = 0; i < h.newCount; i++) {
|
|
889
|
+
const line = h.newStart + i;
|
|
890
|
+
if (line >= lineCount) break;
|
|
891
|
+
const lineByteStart = lineStarts[line];
|
|
892
|
+
const ranges = h.wordRanges[i];
|
|
893
|
+
if (!ranges) continue;
|
|
894
|
+
for (const r of ranges) {
|
|
895
|
+
editor.addOverlay(
|
|
896
|
+
bid,
|
|
897
|
+
NS_OVERLAY,
|
|
898
|
+
lineByteStart + r.start,
|
|
899
|
+
lineByteStart + r.end,
|
|
900
|
+
{
|
|
901
|
+
bold: true,
|
|
902
|
+
underline: true,
|
|
903
|
+
italic: false,
|
|
904
|
+
strikethrough: false,
|
|
905
|
+
extendToLineEnd: false,
|
|
906
|
+
},
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
557
911
|
}
|
|
558
912
|
|
|
559
913
|
if (h.oldLines.length === 0) continue;
|
|
@@ -569,9 +923,21 @@ function renderHunks(state: BufferDiffState, newLines: string[]): void {
|
|
|
569
923
|
const anchor = lineStarts[anchorLine];
|
|
570
924
|
|
|
571
925
|
for (let i = 0; i < h.oldLines.length; i++) {
|
|
572
|
-
// No "- " prefix
|
|
573
|
-
//
|
|
574
|
-
//
|
|
926
|
+
// No "- " prefix in the line text — the indicator goes in the
|
|
927
|
+
// gutter via `gutterGlyph` so it sits next to the deletion
|
|
928
|
+
// line itself, not on the source line that follows it.
|
|
929
|
+
// When `oldWordRanges` is set (from a high-similarity refined
|
|
930
|
+
// pair), bold + underline the actually-removed words so the
|
|
931
|
+
// user can see *what* changed, not just that something did.
|
|
932
|
+
const wordRanges = h.oldWordRanges?.[i];
|
|
933
|
+
const textOverlays = wordRanges
|
|
934
|
+
? wordRanges.map((r) => ({
|
|
935
|
+
start: r.start,
|
|
936
|
+
end: r.end,
|
|
937
|
+
bold: true,
|
|
938
|
+
underline: true,
|
|
939
|
+
}))
|
|
940
|
+
: [];
|
|
575
941
|
editor.addVirtualLine(
|
|
576
942
|
bid,
|
|
577
943
|
anchor,
|
|
@@ -579,6 +945,9 @@ function renderHunks(state: BufferDiffState, newLines: string[]): void {
|
|
|
579
945
|
{
|
|
580
946
|
fg: THEME.removedFg,
|
|
581
947
|
bg: THEME.removedBg,
|
|
948
|
+
gutterGlyph: SYMBOLS.removed,
|
|
949
|
+
gutterColor: GUTTER_COLORS.removed,
|
|
950
|
+
textOverlays,
|
|
582
951
|
},
|
|
583
952
|
above,
|
|
584
953
|
NS_VLINE,
|
|
@@ -650,8 +1019,13 @@ async function recompute(bufferId: number): Promise<void> {
|
|
|
650
1019
|
return;
|
|
651
1020
|
}
|
|
652
1021
|
|
|
653
|
-
const
|
|
654
|
-
fillOldLines(
|
|
1022
|
+
const rawHunks = opsToHunks(ops);
|
|
1023
|
+
fillOldLines(rawHunks, state.oldLines);
|
|
1024
|
+
// Decide per-line whether each `modified` pair is a similar
|
|
1025
|
+
// in-place edit (keep as `modified`, drop the virtual deletion
|
|
1026
|
+
// line, mark changed words) or a low-similarity rewrite (split
|
|
1027
|
+
// into separate `removed` + `added` hunks).
|
|
1028
|
+
const hunks = refineHunks(rawHunks, newLines);
|
|
655
1029
|
|
|
656
1030
|
// Skip 2: same hunks as last render. The user can edit inside an
|
|
657
1031
|
// already-flagged region without changing line counts (e.g., typing
|
|
@@ -931,6 +1305,42 @@ editor.registerCommand("%cmd.vs_default_branch", "%cmd.vs_default_branch_desc",
|
|
|
931
1305
|
editor.registerCommand("%cmd.refresh", "%cmd.refresh_desc", "live_diff_refresh", null);
|
|
932
1306
|
editor.registerCommand("%cmd.set_default", "%cmd.set_default_desc", "live_diff_set_default", null);
|
|
933
1307
|
|
|
1308
|
+
// =============================================================================
|
|
1309
|
+
// Plugin API
|
|
1310
|
+
// =============================================================================
|
|
1311
|
+
|
|
1312
|
+
export type LiveDiffApi = {
|
|
1313
|
+
/** Lines whose Sørensen–Dice similarity ratio is at least this value
|
|
1314
|
+
* render as in-place "modified" (bg highlight + word-level diff on the
|
|
1315
|
+
* new line). Below this they split into a removed + added pair so the
|
|
1316
|
+
* change reads as a rewrite. Range 0..1; clamped. */
|
|
1317
|
+
setSimilarityThreshold(value: number): void;
|
|
1318
|
+
getSimilarityThreshold(): number;
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
declare global {
|
|
1322
|
+
interface FreshPluginRegistry {
|
|
1323
|
+
"live-diff": LiveDiffApi;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
editor.exportPluginApi("live-diff", {
|
|
1328
|
+
setSimilarityThreshold(value: number): void {
|
|
1329
|
+
const clamped = Math.max(0, Math.min(1, value));
|
|
1330
|
+
if (clamped === similarityThreshold) return;
|
|
1331
|
+
similarityThreshold = clamped;
|
|
1332
|
+
// Invalidate cached hunks so the next recompute repaints with the
|
|
1333
|
+
// new threshold instead of short-circuiting on the same hunksKey.
|
|
1334
|
+
for (const state of states.values()) {
|
|
1335
|
+
state.lastHunksKey = "";
|
|
1336
|
+
scheduleRecompute(state.bufferId).catch((e) =>
|
|
1337
|
+
editor.error(`live-diff: ${e}`),
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
getSimilarityThreshold: () => similarityThreshold,
|
|
1342
|
+
} satisfies LiveDiffApi);
|
|
1343
|
+
|
|
934
1344
|
// =============================================================================
|
|
935
1345
|
// Initialization
|
|
936
1346
|
// =============================================================================
|