1659 lines
66 KiB
Markdown
1659 lines
66 KiB
Markdown
# 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), not `Mode` (enum) — drops an enum and a converter for zero benefit.
|
||
- A third word-diff guard `MaxWordDiffTokens = 400` is 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 call `Save()` whenever a toggle flips, and `DiffViewerViewModelTests` constructs 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`):
|
||
|
||
```bash
|
||
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 `TextEditor`s, 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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests
|
||
```
|
||
|
||
Expected: PASS, 10 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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; never `git add -A` and never a bare `git commit`.
|
||
|
||
---
|
||
|
||
## Task 2: Word diff inside paired lines
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs` (replace the `WordDiff` placeholder)
|
||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DiffAlignmentTests.cs` (append)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append these methods inside the existing `DiffAlignmentTests` class:
|
||
|
||
```csharp
|
||
[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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
/// 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;
|
||
```
|
||
|
||
```csharp
|
||
/// 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**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DiffAlignmentTests
|
||
```
|
||
|
||
Expected: PASS, 17 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
/// 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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter AppSettingsTests
|
||
```
|
||
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```xml
|
||
<!-- 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```xml
|
||
<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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||
```
|
||
|
||
Expected: build succeeded, no warnings about `DiffTextView`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```csharp
|
||
using Avalonia.Media;
|
||
using AvaloniaEdit.Editing;
|
||
using AvaloniaEdit.Rendering;
|
||
```
|
||
|
||
and add the margin as a nested type next to `RowInfo`:
|
||
|
||
```csharp
|
||
/// <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`:
|
||
|
||
```csharp
|
||
private DiffLineNumberMargin? _leftMargin, _rightMargin;
|
||
```
|
||
|
||
```csharp
|
||
/// 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**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||
```
|
||
|
||
Expected: build succeeded.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```csharp
|
||
/// <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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||
```
|
||
|
||
Expected: build succeeded.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```csharp
|
||
using Avalonia.Threading;
|
||
using Avalonia.VisualTree;
|
||
```
|
||
|
||
and add to `DiffTextView`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```csharp
|
||
private readonly AppSettings _settings;
|
||
```
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
// ── 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:
|
||
|
||
```xml
|
||
<!-- 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:
|
||
|
||
```xml
|
||
<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):
|
||
|
||
```json
|
||
"splitView": "Side by side",
|
||
"wrapLines": "Wrap lines"
|
||
```
|
||
|
||
In `src/ClaudeDo.Localization/locales/de.json`, the same keys in the matching `modals.diff` object:
|
||
|
||
```json
|
||
"splitView": "Nebeneinander",
|
||
"wrapLines": "Zeilenumbruch"
|
||
```
|
||
|
||
- [ ] **Step 4: Verify the build and the locale parity test**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```csharp
|
||
// 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**
|
||
|
||
```bash
|
||
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 `DiffLines` with `PlanningFiles`**
|
||
|
||
In `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs`, replace the `DiffLines` collection (line 55):
|
||
|
||
```csharp
|
||
// 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):
|
||
|
||
```csharp
|
||
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):
|
||
|
||
```xml
|
||
<!-- 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 `Flatten` and delete `DiffLinesView`**
|
||
|
||
Delete the `Flatten` method from `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs` (lines 135-148), then:
|
||
|
||
```bash
|
||
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): replace `DiffLinesView` with `DiffTextView`.
|
||
- "Diff & Conflicts" section (line 71): replace `` `DiffLinesView` `` with a sentence naming the new stack — `DiffAlignment` (pure row alignment + word diff) and `DiffTextView` (AvaloniaEdit, TextMate highlighting, unified/split, wrap), and note that both toggles persist to `ui.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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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.
|