feat(diff): align parsed diff lines into side-by-side rows

This commit is contained in:
mika kuns
2026-08-07 09:13:59 +02:00
parent 6e9a7cea87
commit 3cd0f0879e
2 changed files with 299 additions and 0 deletions
@@ -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);
}