feat(diff): align parsed diff lines into side-by-side rows
This commit is contained in:
@@ -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<TextSpan> LeftSpans,
|
||||
AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);
|
||||
|
||||
/// One rendered row of the single-pane view.
|
||||
public sealed record UnifiedRow(
|
||||
AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);
|
||||
|
||||
/// Render-ready diff. Row index <c>i</c> is document line <c>i + 1</c> in the matching text —
|
||||
/// that mapping is what the line-number margin and both background renderers rely on.
|
||||
public sealed record AlignedDiff(
|
||||
IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
|
||||
IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText)
|
||||
{
|
||||
public static readonly AlignedDiff Empty = new(
|
||||
Array.Empty<SplitRow>(), "", "", Array.Empty<UnifiedRow>(), "");
|
||||
}
|
||||
|
||||
/// 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<TextSpan> NoSpans = Array.Empty<TextSpan>();
|
||||
|
||||
public static AlignedDiff Build(IReadOnlyList<DiffLineViewModel>? lines)
|
||||
{
|
||||
if (lines is null || lines.Count == 0) return AlignedDiff.Empty;
|
||||
|
||||
var split = new List<SplitRow>();
|
||||
var unified = new List<UnifiedRow>();
|
||||
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<DiffLineViewModel>();
|
||||
while (i < lines.Count && lines[i].Kind == DiffLineKind.Del) dels.Add(lines[i++]);
|
||||
var adds = new List<DiffLineViewModel>();
|
||||
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<DiffLineViewModel> dels, List<DiffLineViewModel> adds,
|
||||
List<SplitRow> split, List<UnifiedRow> 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<TextSpan> Left, IReadOnlyList<TextSpan> 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<TextSpan> Left, IReadOnlyList<TextSpan> Right) WordDiff(
|
||||
string left, string right) => (NoSpans, NoSpans);
|
||||
}
|
||||
@@ -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<DiffLineViewModel>()).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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user