From ebbee6005dde144ba391d0a91e88c72549ae33f2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:17:26 +0200 Subject: [PATCH] feat(diff): highlight changed words inside paired diff lines --- .../ViewModels/Modals/DiffAlignment.cs | 103 +++++++++++++++++- .../ViewModels/DiffAlignmentTests.cs | 91 ++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs index dad73081..f61adde1 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs @@ -32,6 +32,12 @@ public static class DiffAlignment /// Marker text for a skipped region between two hunks. public const string GapText = "⋯"; + /// Word diff is O(n·m) in tokens and is noise on lines that were rewritten wholesale, + /// so it is skipped on very long lines, on token-heavy lines, and on dissimilar pairs. + public const int MaxWordDiffChars = 2000; + public const int MaxWordDiffTokens = 400; + public const double MinWordDiffSimilarity = 0.5; + internal static readonly IReadOnlyList NoSpans = Array.Empty(); public static AlignedDiff Build(IReadOnlyList? lines) @@ -126,7 +132,100 @@ public static class DiffAlignment k < paired ? spans[k].Right : NoSpans)); } - /// Placeholder until Task 2 fills in the token LCS. + /// Changed character ranges on each side of a 1:1 line pair, or no spans when the pair + /// is too long, too token-heavy or too dissimilar for per-word highlighting to help. internal static (IReadOnlyList Left, IReadOnlyList Right) WordDiff( - string left, string right) => (NoSpans, NoSpans); + string left, string right) + { + if (left.Length == 0 || right.Length == 0 || string.Equals(left, right, StringComparison.Ordinal)) + return (NoSpans, NoSpans); + if (left.Length > MaxWordDiffChars || right.Length > MaxWordDiffChars) + return (NoSpans, NoSpans); + + var a = Tokenize(left); + var b = Tokenize(right); + if (a.Count > MaxWordDiffTokens || b.Count > MaxWordDiffTokens) + return (NoSpans, NoSpans); + + var (keepA, keepB, common) = LongestCommonSubsequence(a, b); + var similarity = common / (double)Math.Max(a.Count, b.Count); + if (similarity < MinWordDiffSimilarity) return (NoSpans, NoSpans); + + return (SpansForUnmatched(a, keepA), SpansForUnmatched(b, keepB)); + } + + /// Splits a line into runs of word characters, runs of whitespace, and single + /// punctuation characters — the granularity that makes an identifier rename read as + /// one changed token rather than a string of changed characters. + private static List Tokenize(string s) + { + var tokens = new List(); + var i = 0; + while (i < s.Length) + { + var start = i; + if (IsWordChar(s[i])) + while (i < s.Length && IsWordChar(s[i])) i++; + else if (char.IsWhiteSpace(s[i])) + while (i < s.Length && char.IsWhiteSpace(s[i])) i++; + else + i++; + tokens.Add(new Token(start, s[start..i])); + } + return tokens; + } + + private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c == '_'; + + private static (bool[] KeepA, bool[] KeepB, int Common) LongestCommonSubsequence( + List a, List b) + { + var n = a.Count; + var m = b.Count; + var dp = new int[n + 1, m + 1]; + for (var i = n - 1; i >= 0; i--) + for (var j = m - 1; j >= 0; j--) + dp[i, j] = string.Equals(a[i].Text, b[j].Text, StringComparison.Ordinal) + ? dp[i + 1, j + 1] + 1 + : Math.Max(dp[i + 1, j], dp[i, j + 1]); + + var keepA = new bool[n]; + var keepB = new bool[m]; + int x = 0, y = 0; + while (x < n && y < m) + { + if (string.Equals(a[x].Text, b[y].Text, StringComparison.Ordinal)) + { + keepA[x] = keepB[y] = true; + x++; y++; + } + else if (dp[x + 1, y] >= dp[x, y + 1]) x++; + else y++; + } + return (keepA, keepB, dp[0, 0]); + } + + /// Merges runs of consecutive unmatched tokens so "zz" is one span, not two. + private static IReadOnlyList SpansForUnmatched(List tokens, bool[] keep) + { + var spans = new List(); + var i = 0; + while (i < tokens.Count) + { + if (keep[i]) { i++; continue; } + var start = tokens[i].Start; + var end = start + tokens[i].Text.Length; + i++; + while (i < tokens.Count && !keep[i]) + { + end = tokens[i].Start + tokens[i].Text.Length; + i++; + } + spans.Add(new TextSpan(start, end - start)); + } + return spans.Count == 0 ? NoSpans : spans; + } + + /// A tokenized slice of a line, carrying its offset so spans map back to characters. + private readonly record struct Token(int Start, string Text); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs index 31985f14..8997d44e 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs @@ -164,4 +164,95 @@ public class DiffAlignmentTests Assert.Single(result.SplitRows); Assert.Equal("a", result.SplitRows[0].LeftText); } + + [Fact] + public void WordDiff_HighlightsOnlyTheChangedToken() + { + var result = DiffAlignment.Build(new[] + { + Del(1, " var x = 1;"), + Add(1, " var x = 2;"), + }); + + var row = Assert.Single(result.SplitRows); + var left = Assert.Single(row.LeftSpans); + var right = Assert.Single(row.RightSpans); + Assert.Equal(new TextSpan(12, 1), left); + Assert.Equal(new TextSpan(12, 1), right); + Assert.Equal("1", row.LeftText.Substring(left.Start, left.Length)); + Assert.Equal("2", row.RightText.Substring(right.Start, right.Length)); + } + + [Fact] + public void WordDiff_MergesAdjacentChangedTokensIntoOneSpan() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "call(a, b);"), + Add(1, "call(zz, b);"), + }); + + var row = Assert.Single(result.SplitRows); + var right = Assert.Single(row.RightSpans); + Assert.Equal("zz", row.RightText.Substring(right.Start, right.Length)); + } + + [Fact] + public void WordDiff_SkippedWhenTheLinesAreUnrelated() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "public void Alpha()"), + Add(1, "return 42;"), + }); + + var row = Assert.Single(result.SplitRows); + Assert.Empty(row.LeftSpans); + Assert.Empty(row.RightSpans); + } + + [Fact] + public void WordDiff_SkippedOnVeryLongLines() + { + var left = new string('a', DiffAlignment.MaxWordDiffChars + 1); + var result = DiffAlignment.Build(new[] { Del(1, left), Add(1, left + "b") }); + + var row = Assert.Single(result.SplitRows); + Assert.Empty(row.LeftSpans); + Assert.Empty(row.RightSpans); + } + + [Fact] + public void WordDiff_SkippedWhenTokenCountExceedsTheCap() + { + var many = string.Join(" ", Enumerable.Range(0, DiffAlignment.MaxWordDiffTokens + 10).Select(n => $"t{n}")); + var result = DiffAlignment.Build(new[] { Del(1, many), Add(1, many + " x") }); + + var row = Assert.Single(result.SplitRows); + Assert.Empty(row.LeftSpans); + Assert.Empty(row.RightSpans); + } + + [Fact] + public void WordDiff_IdenticalTextYieldsNoSpans() + { + var result = DiffAlignment.Build(new[] { Del(1, "same"), Add(1, "same") }); + + var row = Assert.Single(result.SplitRows); + Assert.Empty(row.LeftSpans); + Assert.Empty(row.RightSpans); + } + + [Fact] + public void WordDiff_UnpairedOverhangRowsHaveNoSpans() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "var x = 1;"), + Add(1, "var x = 2;"), Add(2, "var y = 3;"), + }); + + Assert.NotEmpty(result.SplitRows[0].RightSpans); + Assert.Empty(result.SplitRows[1].RightSpans); + } }