diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml new file mode 100644 index 00000000..425f9860 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs new file mode 100644 index 00000000..e706bca8 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs @@ -0,0 +1,150 @@ +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; + +/// +/// Read-only diff renderer. 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 +/// . Syntax highlighting comes from TextMate, keyed off the file's +/// extension — the same mechanism the 3-pane conflict resolver uses. +/// +public partial class DiffTextView : UserControl +{ + public static readonly StyledProperty FileProperty = + AvaloniaProperty.Register(nameof(File)); + + public static readonly StyledProperty IsSplitProperty = + AvaloniaProperty.Register(nameof(IsSplit)); + + public static readonly StyledProperty WrapLinesProperty = + AvaloniaProperty.Register(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(); + private RowInfo?[] _rightRows = Array.Empty(); + + 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(); + } + + 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 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); + } + + /// What one document line represents, for the margin and the background renderers. + internal sealed record RowInfo( + AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList Spans); +}