Files
ClaudeDo/docs/superpowers/specs/2026-08-07-diff-side-by-side-design.md

226 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Diff Viewer: Side-by-Side, Syntax Highlighting, Word Diff
Date: 2026-08-07
Status: approved (design), not implemented
## Problem
The diff viewer renders every change as a flat unified stream. Reading what actually changed
inside a modified line means mentally aligning a `` row with a `+` row several rows below it.
The user reads diffs faster side by side.
Three gaps, all in the same surface:
1. No side-by-side mode.
2. No syntax highlighting — the merge editor (`ConflictResolverView`) already has it via
TextMate, the diff viewer does not.
3. No intra-line (word) highlighting, so a one-character change looks like a whole-line rewrite.
## Current state
| Concern | Where |
|---|---|
| Parsing | `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs``DiffFileViewModel.Lines` |
| Models | `src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs` (`DiffLineViewModel{Kind,OldNo,NewNo,Text}`, `DiffLineKind{Add,Del,Ctx,File}`) |
| Rendering | `src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml` — non-virtualized `ItemsControl`, one `Border`+`Grid`+4 `TextBlock`s per line, `TextWrapping="NoWrap"` |
| Host | `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml` — Files mode (line 154, `SelectedFile.Lines`) and Planning mode (line 162, flattened `DiffLines` across all files) |
| Highlighting reference | `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:80-83``RegistryOptions(ThemeName.DarkPlus)` + `InstallTextMate` + `SetGrammar` by file extension |
`Avalonia.AvaloniaEdit`, `AvaloniaEdit.TextMate` and `TextMateSharp.Grammars` are already
referenced in `ClaudeDo.Ui.csproj`.
## Decision
Replace `DiffLinesView` with an AvaloniaEdit-based control. TextMate highlighting is bound to
the `TextEditor` control; it cannot be lifted into `TextBlock` inlines without reimplementing
the scope→brush layer that `AvaloniaEdit.TextMate` already provides. Building split/wrap/word
diff on the `TextBlock` model first and swapping the renderer later would be throwaway work.
Side effect worth having: AvaloniaEdit virtualizes, which removes the current non-virtualized
`ItemsControl` as a scaling limit on large diffs.
Rejected: keeping the `ItemsControl` and hand-rolling highlighting from `TextMateSharp`
tokenization — same output, materially more code, and a second highlighting path to maintain
alongside the merge editor's.
## Layout semantics
Left pane is the **old** state, right pane is the **new** state — each side carries the complete
version of the hunk, not "removals here, additions there".
```
LEFT (old) RIGHT (new)
12 public void Save() 12 public void Save()
13 var x = 1; 13 var x = 2; ← word diff on `1` / `2`
14 Log("old"); · (filler)
· (filler) 14 Log("new");
15 } 15 }
```
## Components
### 1. `DiffAlignment` (new, pure)
`src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs`
Turns `IReadOnlyList<DiffLineViewModel>` into a render-ready `AlignedDiff`. No Avalonia types,
fully unit-testable.
```csharp
public enum AlignedSide { Ctx, Del, Add, Filler, Gap }
public readonly record struct TextSpan(int Start, int Length);
public sealed record SplitRow(
AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList<TextSpan> LeftSpans,
AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);
public sealed record UnifiedRow(
AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);
public sealed record AlignedDiff(
IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText);
```
Row index `i` maps to document line `i + 1` in the corresponding text. That mapping is the
contract the margin and both renderers depend on.
**Pairing.** Walk the lines. A `Ctx` run emits rows with the same text on both sides. A change
block (a `Del` run followed by an `Add` run) pairs index-wise up to `min(delCount, addCount)`;
the overhang gets `Filler` rows on the opposite side.
**Gaps.** The parser drops `@@` headers, so a skipped region shows up as a jump in `OldNo`/`NewNo`
between consecutive lines. `DiffAlignment` detects that jump and inserts a `Gap` row on both
sides. The parser is not touched.
**Word diff.** Only for `(Del, Add)` rows that are paired 1:1. Tokenize each side into runs of
word characters / whitespace / single punctuation, run an LCS over the tokens, and emit the
changed token runs as character spans per side.
Two guards, both `const` and both covered by tests:
- Skip when either side exceeds `MaxWordDiffChars = 2000` — LCS cost, and such lines are
unreadable as word diffs anyway.
- Skip when token similarity is below `MinWordDiffSimilarity = 0.5` (common tokens / max token
count). Below that the two lines are unrelated rewrites and per-word tinting is noise.
### 2. `DiffTextView` (new control, replaces `DiffLinesView`)
`src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml` + `.axaml.cs`
Styled properties:
| Property | Type | Meaning |
|---|---|---|
| `File` | `DiffFileViewModel?` | Source; the control aligns it and caches the `AlignedDiff` per file instance |
| `Mode` | `DiffViewMode` (`Unified`\|`Split`) | Layout |
| `WrapLines` | `bool` | Bound to each editor's `WordWrap` |
Two `TextEditor`s in a two-column grid, both `IsReadOnly=true`, `ShowLineNumbers=false`.
`Unified` mode collapses the right editor and spans the left one across both columns, feeding
it `UnifiedText`. `Split` mode shows both, fed `LeftText` / `RightText`.
Per editor:
- **TextMate**: one shared `RegistryOptions(ThemeName.DarkPlus)`; grammar resolved from
`File.Path`'s extension via `GetLanguageByExtension``GetScopeByLanguageId``SetGrammar`,
exactly as `ConflictResolverView.ApplyGrammar` does. No extension match → no grammar, plain text.
- **`DiffLineNumberMargin : AbstractMargin`** — draws line numbers from the row list. Split: old
numbers left, new numbers right. Unified: two number columns in one margin. `Filler` and `Gap`
rows draw nothing.
- **`DiffLineBackgroundRenderer : IBackgroundRenderer`** — full-width tint per visual line by
row kind: add / del / filler / gap / ctx.
- **`WordDiffRenderer : IBackgroundRenderer`** — stronger tint over the changed spans, via
`BackgroundGeometryBuilder` at `lineStartOffset + span.Start`.
Both renderers resolve rows through a single `Func<int, RowInfo?>` keyed by document line.
**Highlighting on fragments.** 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 be
highlighted as code). Accepted — the same is true of every fragment-based diff viewer.
**Colors.** Line tints stay the existing low-alpha `RunningTintBrush` / `ErrorTintBrush` so
syntax foregrounds remain legible. The per-line foreground recolor from `DiffLinesView`
(green/red text) is dropped — syntax colors take over. `Filler` gets a new dim token brush,
`Gap` renders as a dim `⋯` separator row.
**Scroll sync** (split only), modeled on `ConflictResolverView.HookScrollSync` — find each
editor's descendant `ScrollViewer`, guard re-entry with a `_syncing` flag:
- `WrapLines = false`: sync `Offset.Y` directly. Line heights match, so alignment is exact.
- `WrapLines = true`: line heights diverge. Sync on the first visible document line instead
(`ScrollToLine`), which keeps the top of the viewport aligned and lets rows drift downward.
### 3. Host changes
`DiffViewerView.axaml`:
- Header gains a segmented Unified/Split toggle and a wrap toggle.
- Files mode: `DiffLinesView Lines="{Binding SelectedFile.Lines}"``DiffTextView File="{Binding SelectedFile}"`.
- Planning mode: the flattened single-stream `DiffLines` view is replaced by an `ItemsControl`
over the subtask's parsed files, each item a file header plus its own `DiffTextView`. One
editor can only carry one grammar, so per-file editors are required for highlighting to work
at all here. `DiffViewerViewModel` exposes the parsed per-file list for the selected subtask;
`DiffLines` and `UnifiedDiffParser.Flatten` lose their last consumer and are removed.
`DiffLinesView.axaml` + `.axaml.cs` are deleted once both usages are migrated.
### 4. Persistence
`src/ClaudeDo.Ui/AppSettings.cs` (`~/.todo-app/ui.config.json`) already holds UI-only
preferences (`Language`, `AccentPreset`) with plain `Load()`/`Save()`. Two properties are added
there:
```csharp
public string DiffViewMode { get; set; } = "unified"; // "unified" | "split"
public bool DiffWrapLines { get; set; }
```
`DiffViewerViewModel` takes the injected `AppSettings`, seeds its toggles on open and calls
`Save()` when either changes. No database column, no EF migration, no hub method — a view
preference does not belong in `AppSettingsEntity`.
### 5. Localization
New keys in both `locales/en.json` and `locales/de.json` (Localization.Tests enforces parity):
`diff.view.unified`, `diff.view.split`, `diff.view.wrap`.
## Testing
`tests/ClaudeDo.Ui.Tests``DiffAlignment` is pure and carries the logic worth testing:
- Context-only diff → identical rows on both sides, no fillers.
- Equal-size change block → 1:1 pairing, no fillers.
- Unequal change block (3 del / 5 add) → 3 paired rows + 2 right-side rows with left fillers.
- Add-only and delete-only blocks → fillers on the opposite side throughout.
- Non-contiguous line numbers → exactly one `Gap` row inserted.
- Word diff: single-token change yields one span per side at the right offsets.
- Word diff skipped above `MaxWordDiffChars` and below `MinWordDiffSimilarity`.
- Row index ↔ document line mapping holds for both `SplitRows` and `UnifiedRows`.
- Binary file and empty-content file → empty `AlignedDiff`, no crash.
`AppSettings` round-trip: persisted mode and wrap survive `Save()`/`Load()`.
Rendering (margin, both renderers, scroll sync, TextMate colors) is not unit-testable here and
is an explicit manual visual pass — see Open items.
## Known limitations
1. **Wrap + split drift.** With wrap on, the two panes align at the top of the viewport but rows
drift apart further down. Per-line vertical alignment as VS Code does it is out of scope.
2. **Fragment highlighting.** See above — highlighting state can be wrong at hunk boundaries.
3. **Editors per file in Planning mode.** A subtask touching many files instantiates one editor
per file. Same cost as viewing those files individually in Files mode; not capped. If it
proves slow, the fix is lazy instantiation on expand, not a silent truncation.
## Open items (manual verification)
- Visual pass on both modes: tints legible over DarkPlus syntax colors; line numbers aligned;
filler and gap rows readable.
- Scroll sync with wrap off (exact) and wrap on (top-anchored).
- Planning mode with a multi-file subtask.
- Toggle state survives an app restart.
## Docs to update on completion
- `src/ClaudeDo.Ui/CLAUDE.md` — Views/Controls list and the "Diff & Conflicts" section still
name `DiffLinesView`.
- `docs/explore-notes/review-merge.md` — diff stack description + "verified against" commit.