From 3cd0f0879efb9cea63e435f39f869352acdfcbc2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:13:59 +0200 Subject: [PATCH] feat(diff): align parsed diff lines into side-by-side rows --- .../ViewModels/Modals/DiffAlignment.cs | 132 ++++++++++++++ .../ViewModels/DiffAlignmentTests.cs | 167 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs new file mode 100644 index 00000000..dad73081 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs @@ -0,0 +1,132 @@ +namespace ClaudeDo.Ui.ViewModels.Modals; + +/// Which role a row plays on one side of the split view. +public enum AlignedSide { Ctx, Del, Add, Filler, Gap } + +/// A character range inside a row's text, used for intra-line (word) highlighting. +public readonly record struct TextSpan(int Start, int Length); + +/// One rendered row of the side-by-side view. Left is the old state, right the new. +public sealed record SplitRow( + AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList LeftSpans, + AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList RightSpans); + +/// One rendered row of the single-pane view. +public sealed record UnifiedRow( + AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList Spans); + +/// Render-ready diff. Row index i is document line i + 1 in the matching text — +/// that mapping is what the line-number margin and both background renderers rely on. +public sealed record AlignedDiff( + IReadOnlyList SplitRows, string LeftText, string RightText, + IReadOnlyList UnifiedRows, string UnifiedText) +{ + public static readonly AlignedDiff Empty = new( + Array.Empty(), "", "", Array.Empty(), ""); +} + +/// Turns a parsed unified-diff line stream into aligned rows. Pure — no Avalonia types, +/// so all of the interesting behaviour is unit-testable. +public static class DiffAlignment +{ + /// Marker text for a skipped region between two hunks. + public const string GapText = "⋯"; + + internal static readonly IReadOnlyList NoSpans = Array.Empty(); + + public static AlignedDiff Build(IReadOnlyList? lines) + { + if (lines is null || lines.Count == 0) return AlignedDiff.Empty; + + var split = new List(); + var unified = new List(); + int? prevOld = null, prevNew = null; + + var i = 0; + while (i < lines.Count) + { + var line = lines[i]; + + // File-header rows only appear in the legacy flattened stream; alignment is per file. + if (line.Kind == DiffLineKind.File) { i++; continue; } + + if (IsGap(prevOld, prevNew, line)) + { + split.Add(new SplitRow(AlignedSide.Gap, null, GapText, NoSpans, + AlignedSide.Gap, null, GapText, NoSpans)); + unified.Add(new UnifiedRow(AlignedSide.Gap, null, null, GapText, NoSpans)); + } + + if (line.Kind == DiffLineKind.Ctx) + { + split.Add(new SplitRow(AlignedSide.Ctx, line.OldNo, line.Text, NoSpans, + AlignedSide.Ctx, line.NewNo, line.Text, NoSpans)); + unified.Add(new UnifiedRow(AlignedSide.Ctx, line.OldNo, line.NewNo, line.Text, NoSpans)); + prevOld = line.OldNo; + prevNew = line.NewNo; + i++; + continue; + } + + // A change block: the parser always emits the deletions before the additions. + var dels = new List(); + while (i < lines.Count && lines[i].Kind == DiffLineKind.Del) dels.Add(lines[i++]); + var adds = new List(); + while (i < lines.Count && lines[i].Kind == DiffLineKind.Add) adds.Add(lines[i++]); + + EmitChangeBlock(dels, adds, split, unified); + + if (dels.Count > 0) prevOld = dels[^1].OldNo; + if (adds.Count > 0) prevNew = adds[^1].NewNo; + } + + return new AlignedDiff( + split, + string.Join('\n', split.Select(r => r.LeftText)), + string.Join('\n', split.Select(r => r.RightText)), + unified, + string.Join('\n', unified.Select(r => r.Text))); + } + + /// The parser drops "@@" headers, so a skipped region is visible only as a jump in the + /// line numbers. Nothing precedes the first row, so it can never open with a gap. + private static bool IsGap(int? prevOld, int? prevNew, DiffLineViewModel next) + { + if (prevOld is { } po && next.OldNo is { } no && no > po + 1) return true; + if (prevNew is { } pn && next.NewNo is { } nn && nn > pn + 1) return true; + return false; + } + + private static void EmitChangeBlock( + List dels, List adds, + List split, List unified) + { + var paired = Math.Min(dels.Count, adds.Count); + + // Word diff is only meaningful for rows that stand 1:1 opposite each other. + var spans = new (IReadOnlyList Left, IReadOnlyList Right)[paired]; + for (var k = 0; k < paired; k++) + spans[k] = WordDiff(dels[k].Text, adds[k].Text); + + for (var k = 0; k < paired; k++) + split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, spans[k].Left, + AlignedSide.Add, adds[k].NewNo, adds[k].Text, spans[k].Right)); + for (var k = paired; k < dels.Count; k++) + split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, NoSpans, + AlignedSide.Filler, null, "", NoSpans)); + for (var k = paired; k < adds.Count; k++) + split.Add(new SplitRow(AlignedSide.Filler, null, "", NoSpans, + AlignedSide.Add, adds[k].NewNo, adds[k].Text, NoSpans)); + + for (var k = 0; k < dels.Count; k++) + unified.Add(new UnifiedRow(AlignedSide.Del, dels[k].OldNo, null, dels[k].Text, + k < paired ? spans[k].Left : NoSpans)); + for (var k = 0; k < adds.Count; k++) + unified.Add(new UnifiedRow(AlignedSide.Add, null, adds[k].NewNo, adds[k].Text, + k < paired ? spans[k].Right : NoSpans)); + } + + /// Placeholder until Task 2 fills in the token LCS. + internal static (IReadOnlyList Left, IReadOnlyList Right) WordDiff( + string left, string right) => (NoSpans, NoSpans); +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs new file mode 100644 index 00000000..31985f14 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs @@ -0,0 +1,167 @@ +using ClaudeDo.Ui.ViewModels.Modals; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class DiffAlignmentTests +{ + private static DiffLineViewModel Ctx(int oldNo, int newNo, string text) => + new() { Kind = DiffLineKind.Ctx, OldNo = oldNo, NewNo = newNo, Text = text }; + + private static DiffLineViewModel Del(int oldNo, string text) => + new() { Kind = DiffLineKind.Del, OldNo = oldNo, Text = text }; + + private static DiffLineViewModel Add(int newNo, string text) => + new() { Kind = DiffLineKind.Add, NewNo = newNo, Text = text }; + + [Fact] + public void NullOrEmpty_YieldsEmptyDiff() + { + Assert.Empty(DiffAlignment.Build(null).SplitRows); + Assert.Empty(DiffAlignment.Build(Array.Empty()).SplitRows); + } + + [Fact] + public void ContextOnly_MirrorsBothSides_WithNoFillers() + { + var result = DiffAlignment.Build(new[] { Ctx(1, 1, "a"), Ctx(2, 2, "b") }); + + Assert.Equal(2, result.SplitRows.Count); + Assert.All(result.SplitRows, r => + { + Assert.Equal(AlignedSide.Ctx, r.LeftKind); + Assert.Equal(AlignedSide.Ctx, r.RightKind); + Assert.Equal(r.LeftText, r.RightText); + }); + Assert.Equal("a\nb", result.LeftText); + Assert.Equal("a\nb", result.RightText); + } + + [Fact] + public void EqualSizedChangeBlock_PairsOneToOne() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "old one"), Del(2, "old two"), + Add(1, "new one"), Add(2, "new two"), + }); + + Assert.Equal(2, result.SplitRows.Count); + Assert.Equal(AlignedSide.Del, result.SplitRows[0].LeftKind); + Assert.Equal(AlignedSide.Add, result.SplitRows[0].RightKind); + Assert.Equal("old one", result.SplitRows[0].LeftText); + Assert.Equal("new one", result.SplitRows[0].RightText); + Assert.Equal(1, result.SplitRows[0].OldNo!.Value); + Assert.Equal(1, result.SplitRows[0].NewNo!.Value); + } + + [Fact] + public void MoreAddsThanDels_PadsTheLeftSideWithFillers() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "d1"), + Add(1, "a1"), Add(2, "a2"), Add(3, "a3"), + }); + + Assert.Equal(3, result.SplitRows.Count); + Assert.Equal(AlignedSide.Del, result.SplitRows[0].LeftKind); + Assert.Equal(AlignedSide.Filler, result.SplitRows[1].LeftKind); + Assert.Equal(AlignedSide.Filler, result.SplitRows[2].LeftKind); + Assert.Equal("", result.SplitRows[1].LeftText); + Assert.Null(result.SplitRows[1].OldNo); + Assert.All(result.SplitRows, r => Assert.Equal(AlignedSide.Add, r.RightKind)); + } + + [Fact] + public void MoreDelsThanAdds_PadsTheRightSideWithFillers() + { + var result = DiffAlignment.Build(new[] + { + Del(1, "d1"), Del(2, "d2"), Del(3, "d3"), + Add(1, "a1"), + }); + + Assert.Equal(3, result.SplitRows.Count); + Assert.Equal(AlignedSide.Add, result.SplitRows[0].RightKind); + Assert.Equal(AlignedSide.Filler, result.SplitRows[1].RightKind); + Assert.Equal(AlignedSide.Filler, result.SplitRows[2].RightKind); + Assert.All(result.SplitRows, r => Assert.Equal(AlignedSide.Del, r.LeftKind)); + } + + [Fact] + public void NonContiguousLineNumbers_InsertOneGapRow() + { + var result = DiffAlignment.Build(new[] { Ctx(1, 1, "a"), Ctx(40, 40, "b") }); + + Assert.Equal(3, result.SplitRows.Count); + Assert.Equal(AlignedSide.Gap, result.SplitRows[1].LeftKind); + Assert.Equal(AlignedSide.Gap, result.SplitRows[1].RightKind); + Assert.Null(result.SplitRows[1].OldNo); + Assert.Null(result.SplitRows[1].NewNo); + } + + [Fact] + public void FirstLineNeverProducesALeadingGap() + { + var result = DiffAlignment.Build(new[] { Ctx(120, 118, "a") }); + + Assert.Single(result.SplitRows); + Assert.Equal(AlignedSide.Ctx, result.SplitRows[0].LeftKind); + } + + [Fact] + public void UnifiedRows_KeepGitOrder_DeletionsThenAdditions() + { + var result = DiffAlignment.Build(new[] + { + Ctx(1, 1, "keep"), + Del(2, "d1"), Del(3, "d2"), + Add(2, "a1"), + }); + + Assert.Equal(4, result.UnifiedRows.Count); + Assert.Equal(AlignedSide.Ctx, result.UnifiedRows[0].Kind); + Assert.Equal(AlignedSide.Del, result.UnifiedRows[1].Kind); + Assert.Equal(AlignedSide.Del, result.UnifiedRows[2].Kind); + Assert.Equal(AlignedSide.Add, result.UnifiedRows[3].Kind); + Assert.Equal("keep\nd1\nd2\na1", result.UnifiedText); + } + + [Fact] + public void RowIndex_MapsToDocumentLine_OnBothSides() + { + var result = DiffAlignment.Build(new[] + { + Ctx(1, 1, "a"), + Del(2, "d"), Add(2, "x"), Add(3, "y"), + Ctx(3, 4, "b"), + }); + + var leftLines = result.LeftText.Split('\n'); + var rightLines = result.RightText.Split('\n'); + Assert.Equal(result.SplitRows.Count, leftLines.Length); + Assert.Equal(result.SplitRows.Count, rightLines.Length); + for (var i = 0; i < result.SplitRows.Count; i++) + { + Assert.Equal(result.SplitRows[i].LeftText, leftLines[i]); + Assert.Equal(result.SplitRows[i].RightText, rightLines[i]); + } + + var unifiedLines = result.UnifiedText.Split('\n'); + Assert.Equal(result.UnifiedRows.Count, unifiedLines.Length); + } + + [Fact] + public void FileHeaderRows_AreIgnored() + { + var result = DiffAlignment.Build(new[] + { + new DiffLineViewModel { Kind = DiffLineKind.File, Text = "src/Foo.cs" }, + Ctx(1, 1, "a"), + }); + + Assert.Single(result.SplitRows); + Assert.Equal("a", result.SplitRows[0].LeftText); + } +}