@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +9 -2
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.i18n.json +84 -0
  5. package/plugins/audit_mode.ts +139 -3
  6. package/plugins/config-schema.json +33 -3
  7. package/plugins/dashboard.ts +34 -111
  8. package/plugins/flash.ts +22 -4
  9. package/plugins/git_blame.ts +10 -6
  10. package/plugins/git_log.ts +705 -323
  11. package/plugins/git_statusbar.i18n.json +72 -0
  12. package/plugins/git_statusbar.ts +133 -0
  13. package/plugins/goto_with_selection.i18n.json +58 -0
  14. package/plugins/goto_with_selection.ts +17 -0
  15. package/plugins/lib/fresh.d.ts +911 -15
  16. package/plugins/lib/index.ts +34 -0
  17. package/plugins/lib/widgets.ts +903 -0
  18. package/plugins/live_diff.ts +442 -32
  19. package/plugins/merge_conflict.ts +89 -64
  20. package/plugins/orchestrator.ts +3425 -0
  21. package/plugins/pkg.ts +235 -54
  22. package/plugins/rust-lsp.ts +58 -40
  23. package/plugins/schemas/theme.schema.json +18 -0
  24. package/plugins/search_replace.i18n.json +140 -28
  25. package/plugins/search_replace.ts +1335 -515
  26. package/plugins/tab_actions.i18n.json +212 -0
  27. package/plugins/tab_actions.ts +76 -0
  28. package/plugins/theme_editor.i18n.json +112 -0
  29. package/plugins/theme_editor.ts +30 -5
  30. package/plugins/tsconfig.json +3 -0
  31. package/plugins/vi_mode.ts +49 -17
  32. package/themes/dark.json +1 -0
  33. package/themes/dracula.json +1 -0
  34. package/themes/high-contrast.json +1 -0
  35. package/themes/light.json +1 -0
  36. package/themes/nord.json +1 -0
  37. package/themes/nostalgia.json +1 -0
  38. package/themes/solarized-dark.json +1 -0
  39. package/themes/terminal.json +4 -0
@@ -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 virtual-line foregrounds. These are
40
- // resolved at render time by the editor, so the diff colors track
41
- // the active theme automatically. All bundled themes provide
42
- // `editor.diff_*_bg` (defaulted via serde) and `ui.file_status_*_fg`
43
- // (falls through to `diagnostic.{info,warning,error}_fg` when the
44
- // theme doesn't override).
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: "ui.file_status_added_fg",
49
+ addedFg: "editor.diff_add_fg",
48
50
  modifiedBg: "editor.diff_modify_bg",
49
- modifiedFg: "ui.file_status_modified_fg",
51
+ modifiedFg: "editor.diff_modify_fg",
50
52
  removedBg: "editor.diff_remove_bg",
51
- removedFg: "ui.file_status_deleted_fg",
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
- const MAX_DIFF_LINES = 20_000;
75
- // Soft cap on the LCS DP table; past this we stop computing virtual lines.
76
- const MAX_DP_CELLS = 4_000_000;
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 === "removed") {
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 the red bg/fg is the visual signal, and the user
573
- // prefers any "-" indicator to live in the gutter rather than
574
- // inside the buffer content.
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 hunks = opsToHunks(ops);
654
- fillOldLines(hunks, state.oldLines);
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
  // =============================================================================