66 KiB
Diff Side-by-Side Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give the diff viewer a persistent Unified/Side-by-Side toggle with TextMate syntax highlighting, intra-line word highlighting, and an optional line-wrap toggle.
Architecture: A new pure helper (DiffAlignment) turns the parsed DiffLineViewModel stream into aligned left/right rows plus word-diff spans. A new AvaloniaEdit-based control (DiffTextView) renders those rows — one editor in unified mode, two synced editors in split mode — with TextMate highlighting, a custom line-number margin and two background renderers. DiffLinesView is deleted once both call sites migrate. The toggle state lives in ui.config.json.
Tech Stack: .NET 8, Avalonia 12, AvaloniaEdit + AvaloniaEdit.TextMate + TextMateSharp.Grammars (all already referenced in ClaudeDo.Ui.csproj), CommunityToolkit.Mvvm, xUnit.
Spec: docs/superpowers/specs/2026-08-07-diff-side-by-side-design.md
Deviations from the spec, decided while planning:
- The control's layout property is
IsSplit(bool), notMode(enum) — drops an enum and a converter for zero benefit. - A third word-diff guard
MaxWordDiffTokens = 400is added. The LCS table is O(n·m); 2000 chars can tokenize to ~2000 tokens, which would allocate a 16 MB table. AppSettings's config path is a hardcoded private static. Task 9 makes the ViewModel callSave()whenever a toggle flips, andDiffViewerViewModelTestsconstructs that ViewModel eight times — so without a change, a test that touches a toggle would overwrite the developer's real~/.todo-app/ui.config.json. Task 3 therefore makes the path an instance property so both the round-trip test and the ViewModel tests stay in a temp directory.
Build/test commands (.slnx needs .NET 9 — build individual csproj; a running Worker locks Debug, so always -c Release):
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
File Structure
| File | Responsibility |
|---|---|
Create src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs |
Pure: parsed lines → aligned rows, gap rows, word-diff spans. No Avalonia types. |
Create src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml + .axaml.cs |
The renderer: 1–2 TextEditors, TextMate, margin, renderers, scroll sync. Single-consumer helper types (RowInfo, Seg, margin, renderers) live in the .axaml.cs per repo convention. |
Modify src/ClaudeDo.Ui/AppSettings.cs |
Two new persisted view preferences. |
Modify src/ClaudeDo.Ui/Design/Tokens.axaml |
Filler/gap/word-diff brushes. |
Modify src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs |
Toggle state + persistence; DiffLines → PlanningFiles. |
Modify src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml |
Toolbar toggles; both DiffLinesView call sites → DiffTextView. |
Modify src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs |
Remove Flatten (last consumer goes away). |
Delete src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml + .axaml.cs |
Replaced. |
Modify src/ClaudeDo.Localization/locales/{en,de}.json |
modals.diff.splitView, modals.diff.wrapLines. |
Create tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs |
All alignment + word-diff behaviour. |
Create tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs |
JSON contract for the two new properties. |
Task 1: DiffAlignment types and context/change-block pairing
Files:
- Create:
src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs - Test:
tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs
Background: DiffLineViewModel (src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs:12) has Kind (Add/Del/Ctx/File), OldNo, NewNo, Text. UnifiedDiffParser emits Del runs before Add runs inside a change block, and drops @@ headers — so a skipped region shows up only as a jump in the line numbers.
- Step 1: Write the failing tests
Create tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs:
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);
}
}
- Step 2: Run the tests to verify they fail
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests
Expected: compile error — DiffAlignment, AlignedSide do not exist.
- Step 3: Write the implementation
Create src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs:
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);
}
- Step 4: Run the tests to verify they pass
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests
Expected: PASS, 10 tests.
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs
git commit -m "feat(diff): align parsed diff lines into side-by-side rows" -- src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs
Repo rule: the main checkout is shared with concurrent sessions. Always commit with an explicit
-- <paths>list; nevergit add -Aand never a baregit commit.
Task 2: Word diff inside paired lines
Files:
-
Modify:
src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs(replace theWordDiffplaceholder) -
Test:
tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs(append) -
Step 1: Write the failing tests
Append these methods inside the existing DiffAlignmentTests class:
[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);
}
- Step 2: Run the tests to verify they fail
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests
Expected: the 7 new tests fail (MaxWordDiffChars / MaxWordDiffTokens do not compile; the span assertions fail because WordDiff returns nothing).
- Step 3: Write the implementation
In src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs, replace the WordDiff placeholder method with the following, and add the constants directly under 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;
/// 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<TextSpan> Left, IReadOnlyList<TextSpan> Right) WordDiff(
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<Token> Tokenize(string s)
{
var tokens = new List<Token>();
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<Token> a, List<Token> 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<TextSpan> SpansForUnmatched(List<Token> tokens, bool[] keep)
{
var spans = new List<TextSpan>();
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);
- Step 4: Run the tests to verify they pass
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests
Expected: PASS, 17 tests.
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs
git commit -m "feat(diff): highlight changed words inside paired diff lines" -- src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs
Task 3: Persist the view preferences
Files:
- Modify:
src/ClaudeDo.Ui/AppSettings.cs - Test:
tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs(create)
Background: AppSettings (src/ClaudeDo.Ui/AppSettings.cs) already holds UI-only preferences (Language, AccentPreset) in ~/.todo-app/ui.config.json and is registered as a singleton in src/ClaudeDo.App/Program.cs:86. Load() deserializes with PropertyNameCaseInsensitive = true.
The config path is currently a hardcoded private static readonly string ConfigPath. This task makes it an instance property so tests can redirect it — necessary because Task 9 saves on every toggle flip and the existing DiffViewerViewModelTests would otherwise write to the developer's real config file.
- Step 1: Write the failing test
Create tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs:
using System.Text.Json;
using ClaudeDo.Ui;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class AppSettingsTests
{
private static string TempConfigPath() =>
Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json");
[Fact]
public void DiffPreferences_DefaultToUnifiedAndNoWrap()
{
var settings = new AppSettings();
Assert.Equal("unified", settings.DiffViewMode);
Assert.False(settings.DiffWrapLines);
}
[Fact]
public void DiffPreferences_SurviveSaveAndLoad()
{
var path = TempConfigPath();
try
{
new AppSettings { ConfigPath = path, DiffViewMode = "split", DiffWrapLines = true }.Save();
var restored = AppSettings.Load(path);
Assert.Equal("split", restored.DiffViewMode);
Assert.True(restored.DiffWrapLines);
Assert.Equal(path, restored.ConfigPath);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void ConfigPath_IsNotWrittenIntoTheConfigFile()
{
var path = TempConfigPath();
try
{
new AppSettings { ConfigPath = path }.Save();
Assert.DoesNotContain("ConfigPath", File.ReadAllText(path), StringComparison.OrdinalIgnoreCase);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void DiffPreferences_ReadFromCamelCasedConfig()
{
const string json = """{"diffViewMode":"split","diffWrapLines":true}""";
var restored = JsonSerializer.Deserialize<AppSettings>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
Assert.Equal("split", restored.DiffViewMode);
Assert.True(restored.DiffWrapLines);
}
}
- Step 2: Run the test to verify it fails
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter AppSettingsTests
Expected: compile error — AppSettings has no DiffViewMode and no settable ConfigPath.
- Step 3: Write the implementation
In src/ClaudeDo.Ui/AppSettings.cs, add both preferences after AccentPreset:
/// Diff viewer layout: "unified" or "split". A view preference, so it lives here in
/// ui.config.json rather than in the worker-owned AppSettingsEntity.
public string DiffViewMode { get; set; } = "unified";
public bool DiffWrapLines { get; set; }
Replace the ConfigPath field with a redirectable instance property:
private static readonly string DefaultConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
/// Where this instance persists. Instance-level (not static) so tests can redirect it —
/// the diff-viewer toggles call Save() on every flip.
[JsonIgnore]
public string ConfigPath { get; set; } = DefaultConfigPath;
Change Load to accept an optional path and stamp it onto the result:
public static AppSettings Load(string? configPath = null)
{
var path = configPath ?? DefaultConfigPath;
try
{
if (File.Exists(path))
{
var json = File.ReadAllText(path);
var loaded = JsonSerializer.Deserialize<AppSettings>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (loaded is not null)
{
loaded.ConfigPath = path;
return loaded;
}
}
}
catch
{
// Fall through to defaults
}
return new AppSettings { ConfigPath = path };
}
and change Save() to use the instance path — replace the two ConfigPath reads in the existing body with the property (Path.GetDirectoryName(ConfigPath) and File.WriteAllText(ConfigPath, json) now resolve to the instance property, so the body itself is unchanged).
Add using System.Text.Json.Serialization; for [JsonIgnore].
Program.cs:81 calls AppSettings.Load() with no argument and keeps working.
- Step 4: Run the test to verify it passes
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter AppSettingsTests
Expected: PASS, 4 tests.
- Step 5: Commit
git add src/ClaudeDo.Ui/AppSettings.cs tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs
git commit -m "feat(diff): persist diff view mode and wrap preference in ui config" -- src/ClaudeDo.Ui/AppSettings.cs tests/ClaudeDo.Ui.Tests/AppSettingsTests.cs
Task 4: Diff brushes
Files:
- Modify:
src/ClaudeDo.Ui/Design/Tokens.axaml
Background: the merge-editor tints sit at Tokens.axaml:106-110. Status tints (RunningTintBrush #1F7C9166, ErrorTintBrush #1FC87060) are already low-alpha, so syntax-highlighted foreground text stays legible on top of them; the diff view reuses those for whole-line add/delete tints and needs three new brushes of its own.
- Step 1: Add the brushes
In src/ClaudeDo.Ui/Design/Tokens.axaml, directly after the MergeResolvedEdgeBrush line that closes the merge block, add:
<!-- Diff viewer: filler/gap rows and intra-line (word) highlighting -->
<SolidColorBrush x:Key="DiffFillerBrush" Color="#0AFFFFFF" /> <!-- "no line on this side" -->
<SolidColorBrush x:Key="DiffGapBrush" Color="#14FFFFFF" /> <!-- skipped region between hunks -->
<SolidColorBrush x:Key="DiffWordAddBrush" Color="#556FA86B" /> <!-- changed words, new side -->
<SolidColorBrush x:Key="DiffWordDelBrush" Color="#55C87060" /> <!-- changed words, old side -->
- Step 2: Verify the build
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: build succeeded. (An unparseable Tokens.axaml fails the Avalonia XAML compiler, so a clean build is the check here.)
- Step 3: Commit
git add src/ClaudeDo.Ui/Design/Tokens.axaml
git commit -m "feat(diff): add filler, gap and word-diff brushes" -- src/ClaudeDo.Ui/Design/Tokens.axaml
Task 5: DiffTextView — editors, documents, TextMate, wrap
Files:
- Create:
src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml - Create:
src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
This task builds the control and gets text into it. The margin (Task 6), the background renderers (Task 7) and scroll sync (Task 8) are layered on afterwards. Nothing consumes the control yet, so the app must still build and run unchanged.
Reference implementation for TextMate setup: src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:77-98 (install) and :393-404 (grammar by extension). IsReadOnly="True" blocks user input only — assigning editor.Text still works, as that view does.
- Step 1: Create the view
Create src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml:
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ae="using:AvaloniaEdit"
x:Class="ClaudeDo.Ui.Views.Controls.DiffTextView">
<UserControl.Styles>
<Style Selector="ae|TextEditor">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Padding" Value="0,2" />
</Style>
</UserControl.Styles>
<Grid x:Name="PaneGrid" ColumnDefinitions="*,1,*">
<ae:TextEditor Grid.Column="0" x:Name="LeftEditor" IsReadOnly="True" ShowLineNumbers="False"/>
<Border Grid.Column="1" x:Name="PaneDivider" Background="{DynamicResource LineBrush}"/>
<ae:TextEditor Grid.Column="2" x:Name="RightEditor" IsReadOnly="True" ShowLineNumbers="False"/>
</Grid>
</UserControl>
- Step 2: Create the code-behind
Create src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs:
using System;
using System.Collections.Generic;
using System.IO;
using Avalonia;
using Avalonia.Controls;
using AvaloniaEdit;
using AvaloniaEdit.TextMate;
using ClaudeDo.Ui.ViewModels.Modals;
using TextMateSharp.Grammars;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Read-only diff renderer. <see cref="IsSplit"/> false shows one editor with the unified
/// stream; true shows the old state on the left and the new state on the right, aligned by
/// <see cref="DiffAlignment"/>. Syntax highlighting comes from TextMate, keyed off the file's
/// extension — the same mechanism the 3-pane conflict resolver uses.
/// </summary>
public partial class DiffTextView : UserControl
{
public static readonly StyledProperty<DiffFileViewModel?> FileProperty =
AvaloniaProperty.Register<DiffTextView, DiffFileViewModel?>(nameof(File));
public static readonly StyledProperty<bool> IsSplitProperty =
AvaloniaProperty.Register<DiffTextView, bool>(nameof(IsSplit));
public static readonly StyledProperty<bool> WrapLinesProperty =
AvaloniaProperty.Register<DiffTextView, bool>(nameof(WrapLines));
public DiffFileViewModel? File
{
get => GetValue(FileProperty);
set => SetValue(FileProperty, value);
}
public bool IsSplit
{
get => GetValue(IsSplitProperty);
set => SetValue(IsSplitProperty, value);
}
public bool WrapLines
{
get => GetValue(WrapLinesProperty);
set => SetValue(WrapLinesProperty, value);
}
// Grammars and themes are process-wide; loading the registry per control would be wasteful.
private static readonly RegistryOptions Registry = new(ThemeName.DarkPlus);
private TextMate.Installation? _leftTm, _rightTm;
// Row lookup per editor, indexed by document line number (1-based). Populated on rebuild
// and consumed by the margin and background renderers added in later tasks.
private RowInfo?[] _leftRows = Array.Empty<RowInfo?>();
private RowInfo?[] _rightRows = Array.Empty<RowInfo?>();
private AlignedDiff _aligned = AlignedDiff.Empty;
public DiffTextView()
{
InitializeComponent();
_leftTm = LeftEditor.InstallTextMate(Registry);
_rightTm = RightEditor.InstallTextMate(Registry);
Rebuild();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == FileProperty || change.Property == IsSplitProperty)
Rebuild();
else if (change.Property == WrapLinesProperty)
ApplyWrap();
}
private void Rebuild()
{
_aligned = DiffAlignment.Build(File?.Lines);
var split = IsSplit;
RightEditor.IsVisible = split;
PaneDivider.IsVisible = split;
Grid.SetColumnSpan(LeftEditor, split ? 1 : 3);
if (split)
{
LeftEditor.Text = _aligned.LeftText;
RightEditor.Text = _aligned.RightText;
_leftRows = BuildRows(_aligned.SplitRows.Count,
i => new RowInfo(_aligned.SplitRows[i].LeftKind, _aligned.SplitRows[i].OldNo, null,
_aligned.SplitRows[i].LeftSpans));
_rightRows = BuildRows(_aligned.SplitRows.Count,
i => new RowInfo(_aligned.SplitRows[i].RightKind, null, _aligned.SplitRows[i].NewNo,
_aligned.SplitRows[i].RightSpans));
}
else
{
LeftEditor.Text = _aligned.UnifiedText;
RightEditor.Text = "";
_leftRows = BuildRows(_aligned.UnifiedRows.Count,
i => new RowInfo(_aligned.UnifiedRows[i].Kind, _aligned.UnifiedRows[i].OldNo,
_aligned.UnifiedRows[i].NewNo, _aligned.UnifiedRows[i].Spans));
_rightRows = Array.Empty<RowInfo?>();
}
ApplyWrap();
ApplyGrammar(File?.Path);
}
/// Row index i is document line i + 1, so slot 0 stays null and lookups can pass the
/// line number straight through.
private static RowInfo?[] BuildRows(int count, Func<int, RowInfo> project)
{
var rows = new RowInfo?[count + 1];
for (var i = 0; i < count; i++) rows[i + 1] = project(i);
return rows;
}
private RowInfo? LeftRow(int line) =>
line > 0 && line < _leftRows.Length ? _leftRows[line] : null;
private RowInfo? RightRow(int line) =>
line > 0 && line < _rightRows.Length ? _rightRows[line] : null;
private void ApplyWrap()
{
LeftEditor.WordWrap = WrapLines;
RightEditor.WordWrap = WrapLines;
}
/// Only hunks are in the document, not whole files, so TextMate's line-by-line state can
/// be wrong at a fragment boundary (a line inside a block comment may highlight as code).
/// Accepted — every fragment-based diff viewer has this.
private void ApplyGrammar(string? path)
{
if (string.IsNullOrEmpty(path)) return;
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext)) return;
var language = Registry.GetLanguageByExtension(ext);
if (language is null) return;
var scope = Registry.GetScopeByLanguageId(language.Id);
_leftTm?.SetGrammar(scope);
_rightTm?.SetGrammar(scope);
}
/// <summary>What one document line represents, for the margin and the background renderers.</summary>
internal sealed record RowInfo(
AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList<TextSpan> Spans);
}
- Step 3: Verify the build
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: build succeeded, no warnings about DiffTextView.
- Step 4: Commit
git add src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
git commit -m "feat(diff): add AvaloniaEdit-based diff control with TextMate highlighting" -- src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
Task 6: Line-number margin
Files:
- Modify:
src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
The margin draws old numbers in the left editor and new numbers in the right editor when split; in unified mode the left editor's margin draws both columns. Filler and Gap rows have no number and draw nothing.
- Step 1: Add the margin type
In src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs, add these usings:
using Avalonia.Media;
using AvaloniaEdit.Editing;
using AvaloniaEdit.Rendering;
and add the margin as a nested type next to RowInfo:
/// <summary>Draws one or two gutter columns of diff line numbers from the row lookup.</summary>
private sealed class DiffLineNumberMargin : AbstractMargin
{
private const double ColumnWidth = 44;
private const double Gap = 6;
private readonly Func<int, RowInfo?> _rows;
private readonly bool _showOld;
private readonly bool _showNew;
private readonly IBrush _foreground;
private readonly Typeface _typeface;
private readonly double _fontSize;
public DiffLineNumberMargin(Func<int, RowInfo?> rows, bool showOld, bool showNew,
IBrush foreground, Typeface typeface, double fontSize)
{
_rows = rows;
_showOld = showOld;
_showNew = showNew;
_foreground = foreground;
_typeface = typeface;
_fontSize = fontSize;
}
private double Columns => (_showOld ? 1 : 0) + (_showNew ? 1 : 0);
protected override Size MeasureOverride(Size availableSize) =>
new(Columns * ColumnWidth + Gap, 0);
protected override void OnTextViewChanged(TextView? oldTextView, TextView? newTextView)
{
if (oldTextView is not null) oldTextView.VisualLinesChanged -= OnVisualLinesChanged;
base.OnTextViewChanged(oldTextView, newTextView);
if (newTextView is not null) newTextView.VisualLinesChanged += OnVisualLinesChanged;
InvalidateVisual();
}
private void OnVisualLinesChanged(object? sender, EventArgs e) => InvalidateVisual();
public override void Render(DrawingContext context)
{
var textView = TextView;
if (textView is null || !textView.VisualLinesValid) return;
foreach (var visualLine in textView.VisualLines)
{
var lineNumber = visualLine.FirstDocumentLine.LineNumber;
if (_rows(lineNumber) is not { } row) continue;
var y = visualLine.VisualTop - textView.ScrollOffset.Y;
var column = 0;
if (_showOld) DrawNumber(context, row.OldNo, column++, y);
if (_showNew) DrawNumber(context, row.NewNo, column, y);
}
}
private void DrawNumber(DrawingContext context, int? value, int column, double y)
{
if (value is null) return;
var text = new FormattedText(value.Value.ToString(), CultureInfo.InvariantCulture,
FlowDirection.LeftToRight, _typeface, _fontSize, _foreground);
// Right-align inside the column so the digits line up across rows.
var x = (column + 1) * ColumnWidth - text.Width - Gap;
context.DrawText(text, new Point(x, y));
}
}
Add using System.Globalization; for CultureInfo.
- Step 2: Attach the margins
In DiffTextView, add fields and a wiring method, and call it from the constructor after InstallTextMate:
private DiffLineNumberMargin? _leftMargin, _rightMargin;
/// The margin's column layout depends on split vs unified, so it is rebuilt rather than
/// reconfigured whenever the layout changes.
private void RebuildMargins()
{
if (_leftMargin is not null) LeftEditor.TextArea.LeftMargins.Remove(_leftMargin);
if (_rightMargin is not null) RightEditor.TextArea.LeftMargins.Remove(_rightMargin);
var foreground = Brush("TextFaintBrush", Color.Parse("#80FFFFFF"));
var typeface = new Typeface(LeftEditor.FontFamily);
_leftMargin = new DiffLineNumberMargin(LeftRow, showOld: true, showNew: !IsSplit,
foreground, typeface, LeftEditor.FontSize);
LeftEditor.TextArea.LeftMargins.Insert(0, _leftMargin);
_rightMargin = new DiffLineNumberMargin(RightRow, showOld: false, showNew: true,
foreground, typeface, RightEditor.FontSize);
RightEditor.TextArea.LeftMargins.Insert(0, _rightMargin);
}
private IBrush Brush(string key, Color fallback) =>
this.TryGetResource(key, ActualThemeVariant, out var value) && value is IBrush brush
? brush
: new SolidColorBrush(fallback);
Call RebuildMargins() at the end of Rebuild(), before ApplyWrap().
- Step 3: Verify the build
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: build succeeded.
- Step 4: Commit
git add src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
git commit -m "feat(diff): draw old and new line numbers in a custom margin" -- src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
Task 7: Line and word background renderers
Files:
- Modify:
src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
Reference: MergeBlockRenderer in src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:432-468 shows the IBackgroundRenderer + BackgroundGeometryBuilder pattern used here.
- Step 1: Add the renderer types
In src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs, add these nested types:
/// <summary>Tints whole rows by their diff role.</summary>
private sealed class DiffLineRenderer : IBackgroundRenderer
{
private readonly Func<int, RowInfo?> _rows;
private readonly IBrush _add, _del, _filler, _gap;
public DiffLineRenderer(Func<int, RowInfo?> rows, IBrush add, IBrush del, IBrush filler, IBrush gap)
{
_rows = rows; _add = add; _del = del; _filler = filler; _gap = gap;
}
public KnownLayer Layer => KnownLayer.Background;
public void Draw(TextView textView, DrawingContext drawingContext)
{
if (!textView.VisualLinesValid) return;
foreach (var visualLine in textView.VisualLines)
{
var row = _rows(visualLine.FirstDocumentLine.LineNumber);
var brush = row?.Kind switch
{
AlignedSide.Add => _add,
AlignedSide.Del => _del,
AlignedSide.Filler => _filler,
AlignedSide.Gap => _gap,
_ => null,
};
if (brush is null) continue;
var top = visualLine.VisualTop - textView.ScrollOffset.Y;
drawingContext.FillRectangle(brush, new Rect(0, top, textView.Bounds.Width, visualLine.Height));
}
}
}
/// <summary>Tints the changed character ranges inside a row, on top of the row tint.</summary>
private sealed class WordDiffRenderer : IBackgroundRenderer
{
private readonly Func<int, RowInfo?> _rows;
private readonly IBrush _add, _del;
public WordDiffRenderer(Func<int, RowInfo?> rows, IBrush add, IBrush del)
{
_rows = rows; _add = add; _del = del;
}
// Above the line tint but still behind the text.
public KnownLayer Layer => KnownLayer.Selection;
public void Draw(TextView textView, DrawingContext drawingContext)
{
if (!textView.VisualLinesValid) return;
foreach (var visualLine in textView.VisualLines)
{
var documentLine = visualLine.FirstDocumentLine;
var row = _rows(documentLine.LineNumber);
if (row is null || row.Spans.Count == 0) continue;
var brush = row.Kind == AlignedSide.Add ? _add : _del;
foreach (var span in row.Spans)
{
var offset = documentLine.Offset + span.Start;
// Spans are computed against the row text; clamp in case the document
// and the row lookup ever disagree rather than drawing past the line.
if (offset < documentLine.Offset || offset + span.Length > documentLine.EndOffset) continue;
var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 };
builder.AddSegment(textView, new Seg(offset, span.Length));
if (builder.CreateGeometry() is { } geometry)
drawingContext.DrawGeometry(brush, null, geometry);
}
}
}
}
/// <summary>A minimal <see cref="ISegment"/> for geometry queries.</summary>
private readonly struct Seg : ISegment
{
public Seg(int offset, int length) { Offset = offset; Length = length; }
public int Offset { get; }
public int Length { get; }
public int EndOffset => Offset + Length;
}
Add using AvaloniaEdit.Document; for ISegment.
- Step 2: Install the renderers
Renderers are added once per editor in the constructor — they read through the LeftRow/RightRow delegates, so a rebuild only needs an invalidate. Add to DiffTextView:
private void InstallRenderers()
{
var add = Brush("RunningTintBrush", Color.Parse("#1F7C9166"));
var del = Brush("ErrorTintBrush", Color.Parse("#1FC87060"));
var filler = Brush("DiffFillerBrush", Color.Parse("#0AFFFFFF"));
var gap = Brush("DiffGapBrush", Color.Parse("#14FFFFFF"));
var wordAdd = Brush("DiffWordAddBrush", Color.Parse("#556FA86B"));
var wordDel = Brush("DiffWordDelBrush", Color.Parse("#55C87060"));
LeftEditor.TextArea.TextView.BackgroundRenderers.Add(new DiffLineRenderer(LeftRow, add, del, filler, gap));
LeftEditor.TextArea.TextView.BackgroundRenderers.Add(new WordDiffRenderer(LeftRow, wordAdd, wordDel));
RightEditor.TextArea.TextView.BackgroundRenderers.Add(new DiffLineRenderer(RightRow, add, del, filler, gap));
RightEditor.TextArea.TextView.BackgroundRenderers.Add(new WordDiffRenderer(RightRow, wordAdd, wordDel));
}
private void InvalidateRenderers()
{
LeftEditor.TextArea.TextView.InvalidateVisual();
RightEditor.TextArea.TextView.InvalidateVisual();
}
Call InstallRenderers() in the constructor after InstallTextMate, and InvalidateRenderers() at the end of Rebuild().
- Step 3: Verify the build
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: build succeeded.
- Step 4: Commit
git add src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
git commit -m "feat(diff): tint diff rows and changed words via background renderers" -- src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
Task 8: Synced scrolling between the panes
Files:
- Modify:
src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
With wrap off both panes have identical line heights, so syncing the pixel offset keeps rows exactly aligned. With wrap on the heights diverge; syncing the first visible document line instead keeps the top of the viewport aligned and lets rows drift downward, which is the accepted limitation from the spec.
Reference: HookScrollSync / OnPaneScroll in src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:353-374.
- Step 1: Add scroll sync
In src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs add these usings:
using Avalonia.Threading;
using Avalonia.VisualTree;
and add to DiffTextView:
private ScrollViewer? _leftScroll, _rightScroll;
private bool _scrollHooked;
private bool _syncing;
/// The editors' ScrollViewers only exist once the template has been applied.
private void HookScrollSync()
{
if (_scrollHooked) return;
_scrollHooked = true;
Dispatcher.UIThread.Post(() =>
{
_leftScroll = LeftEditor.FindDescendantOfType<ScrollViewer>();
_rightScroll = RightEditor.FindDescendantOfType<ScrollViewer>();
if (_leftScroll is not null) _leftScroll.ScrollChanged += OnPaneScroll;
if (_rightScroll is not null) _rightScroll.ScrollChanged += OnPaneScroll;
}, DispatcherPriority.Loaded);
}
private void OnPaneScroll(object? sender, ScrollChangedEventArgs e)
{
if (_syncing || !IsSplit || _leftScroll is null || _rightScroll is null) return;
var fromLeft = ReferenceEquals(sender, _leftScroll);
var source = fromLeft ? _leftScroll : _rightScroll;
var target = fromLeft ? _rightScroll : _leftScroll;
var targetEditor = fromLeft ? RightEditor : LeftEditor;
var sourceEditor = fromLeft ? LeftEditor : RightEditor;
_syncing = true;
try
{
if (WrapLines)
{
// Line heights differ once lines wrap, so anchor on the top visible line.
var line = sourceEditor.TextArea.TextView.GetDocumentLineByVisualTop(
sourceEditor.TextArea.TextView.ScrollOffset.Y)?.LineNumber;
if (line is { } l && l >= 1 && l <= targetEditor.Document.LineCount)
targetEditor.ScrollToLine(l);
}
else if (Math.Abs(target.Offset.Y - source.Offset.Y) > 0.5)
{
target.Offset = new Vector(target.Offset.X, source.Offset.Y);
}
}
finally { _syncing = false; }
}
Call HookScrollSync() at the end of the constructor.
- Step 2: Verify the build
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: build succeeded. If GetDocumentLineByVisualTop is not present on this AvaloniaEdit version, substitute sourceEditor.TextArea.TextView.VisualLines is { Count: > 0 } lines ? lines[0].FirstDocumentLine.LineNumber : (int?)null.
- Step 3: Commit
git add src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
git commit -m "feat(diff): sync vertical scrolling across the split panes" -- src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
Task 9: Wire the toggles and the Files-mode pane
Files:
- Modify:
src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs - Modify:
src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml - Modify:
src/ClaudeDo.Localization/locales/en.json,src/ClaudeDo.Localization/locales/de.json
DiffViewerViewModel is registered transient in src/ClaudeDo.App/Program.cs:132, and AppSettings is a singleton (:86), so the new constructor parameter resolves without any DI change.
- Step 1: Add the toggle state to the ViewModel
In src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs, add the field and extend the constructor:
private readonly AppSettings _settings;
public DiffViewerViewModel(GitService git, IWorkerClient worker, AppSettings settings)
{
_git = git;
_worker = worker;
_settings = settings;
_isSplitView = string.Equals(settings.DiffViewMode, "split", StringComparison.OrdinalIgnoreCase);
_wrapLines = settings.DiffWrapLines;
}
Add the observable properties next to the other right-pane state:
// ── View toggles (persisted to ui.config.json) ──────────────────────────
[ObservableProperty] private bool _isSplitView;
[ObservableProperty] private bool _wrapLines;
partial void OnIsSplitViewChanged(bool value)
{
_settings.DiffViewMode = value ? "split" : "unified";
PersistViewPreferences();
}
partial void OnWrapLinesChanged(bool value)
{
_settings.DiffWrapLines = value;
PersistViewPreferences();
}
/// A failed preference write must never take the diff viewer down with it; the toggle
/// still works for this session, it just won't survive a restart.
private void PersistViewPreferences()
{
try { _settings.Save(); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
Add using ClaudeDo.Ui; if the namespace is not already in scope (AppSettings lives in ClaudeDo.Ui).
- Step 2: Add the toolbar and swap the Files pane
In src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml, insert a new top-docked toolbar immediately after the opening <DockPanel> (line 31), before the planning toolbar:
<!-- View toolbar: layout + wrap, both persisted -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,8,16,0">
<ToggleButton Content="{loc:Tr modals.diff.splitView}" IsChecked="{Binding IsSplitView}"/>
<ToggleButton Content="{loc:Tr modals.diff.wrapLines}" IsChecked="{Binding WrapLines}"/>
</StackPanel>
Replace the Files-mode diff pane (lines 152-155) — the outer ScrollViewer goes away because the editor scrolls itself:
<ctl:DiffTextView IsVisible="{Binding SelectedFile.HasLines}"
File="{Binding SelectedFile}"
IsSplit="{Binding IsSplitView}"
WrapLines="{Binding WrapLines}"/>
- Step 3: Add the locale keys
In src/ClaudeDo.Localization/locales/en.json, extend the modals.diff object (line 354):
"splitView": "Side by side",
"wrapLines": "Wrap lines"
In src/ClaudeDo.Localization/locales/de.json, the same keys in the matching modals.diff object:
"splitView": "Nebeneinander",
"wrapLines": "Zeilenumbruch"
- Step 4: Verify the build and the locale parity test
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
Expected: build succeeded; all tests pass.
tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs constructs the ViewModel at lines 39, 52, 107, 127, 144, 165, 187 and 208 — all eight break on the new parameter. Add a helper to that class and use it at every site, so no test can ever write to the real config:
// A throwaway config path — the view toggles call Save(), and a test must never
// overwrite the developer's real ~/.todo-app/ui.config.json.
private static AppSettings TestSettings() => new()
{
ConfigPath = Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json"),
};
e.g. var vm = new DiffViewerViewModel(null!, new FakePlanningWorker(), TestSettings());
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs
git commit -m "feat(diff): add persisted side-by-side and wrap toggles to the diff viewer" -- src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs
Task 10: Planning mode, remove the old renderer, update docs
Files:
- Modify:
src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs - Modify:
src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml - Modify:
src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs - Delete:
src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml,src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs - Modify:
src/ClaudeDo.Ui/CLAUDE.md,docs/explore-notes/review-merge.md
Planning mode currently flattens every file into one line stream. One editor can carry only one TextMate grammar, so highlighting requires one editor per file. Verified before planning: DiffLines and UnifiedDiffParser.Flatten have no other consumer (DiffViewerViewModel.cs:55,221-223 only).
- Step 1: Replace
DiffLineswithPlanningFiles
In src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs, replace the DiffLines collection (line 55):
// Planning mode: one entry per file so each gets its own editor and grammar.
public ObservableCollection<DiffFileViewModel> PlanningFiles { get; } = new();
and replace OnDisplayedDiffChanged (lines 219-224):
partial void OnDisplayedDiffChanged(string value)
{
PlanningFiles.Clear();
foreach (var file in UnifiedDiffParser.Parse(value))
PlanningFiles.Add(file);
}
- Step 2: Replace the planning pane
In src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml, add x:Name="DiffRoot" to the root <Window> element, then replace the planning pane (lines 159-164):
<!-- Planning mode: one editor per file so each gets its own grammar -->
<Grid Background="{DynamicResource VoidBrush}" IsVisible="{Binding IsPlanning}">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding PlanningFiles}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DiffFileViewModel">
<StackPanel Margin="0,0,0,12">
<Border Classes="island-header">
<TextBlock Classes="path-mono" Text="{Binding HeaderPath}"
TextTrimming="PrefixCharacterEllipsis"/>
</Border>
<ctl:DiffTextView File="{Binding}"
IsSplit="{Binding #DiffRoot.((vm:DiffViewerViewModel)DataContext).IsSplitView}"
WrapLines="{Binding #DiffRoot.((vm:DiffViewerViewModel)DataContext).WrapLines}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
Known risk — verify this one by running the app. A TextEditor inside an ItemsControl inside a ScrollViewer is measured with unbounded height. If it collapses to zero height or shows its own scrollbar instead of growing, the fallback is to give DiffTextView an explicit height: add a MeasureOverride that returns rowCount * LeftEditor.TextArea.TextView.DefaultLineHeight + Padding, using the row count from _aligned. Do not paper over it with a fixed pixel height.
- Step 3: Remove
Flattenand deleteDiffLinesView
Delete the Flatten method from src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs (lines 135-148), then:
git rm src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs
DiffLineKind.File stays on the model — DiffAlignment still skips it defensively and removing an enum member is unrelated churn.
- Step 4: Update the docs
In src/ClaudeDo.Ui/CLAUDE.md:
Views/Controls/list (line 34): replaceDiffLinesViewwithDiffTextView.- "Diff & Conflicts" section (line 71): replace
`DiffLinesView`with a sentence naming the new stack —DiffAlignment(pure row alignment + word diff) andDiffTextView(AvaloniaEdit, TextMate highlighting, unified/split, wrap), and note that both toggles persist toui.config.json.
In docs/explore-notes/review-merge.md: update the diff-stack description to the same effect and bump its "verified against" commit to the commit produced by this task.
- Step 5: Verify the full build and test suite
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
Expected: both builds succeed; both test projects pass.
- Step 6: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/review-merge.md
git commit -m "refactor(diff): render planning mode per file and retire DiffLinesView" -- src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/review-merge.md
Manual verification (cannot be automated — the user runs these)
Nothing below is covered by a test. Do not claim the feature works until the user confirms.
- Files mode, unified: syntax colours visible, add/delete tints legible on top of them, old+new line numbers aligned.
- Files mode, split: left pane is the old state, right pane the new state; fillers and the
⋯gap rows read correctly. - Word diff: a one-token change highlights only that token on both sides.
- Wrap off, split: the panes stay pixel-aligned while scrolling either one.
- Wrap on, split: long lines wrap; the top of the viewport stays aligned.
- Planning mode with a multi-file subtask: every file renders, each with its own highlighting, and the editors size to their content (see the Task 10 risk note).
- A file type with no TextMate grammar (e.g.
.slnx) renders as plain text without throwing. - Toggle both switches, close the app, reopen: the choices survived.