using System; using System.Collections.Generic; using System.Globalization; using System.IO; using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; using AvaloniaEdit; using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; 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; private DiffLineNumberMargin? _leftMargin, _rightMargin; private ScrollViewer? _leftScroll, _rightScroll; private bool _scrollHooked; private bool _syncing; public DiffTextView() { InitializeComponent(); _leftTm = LeftEditor.InstallTextMate(Registry); _rightTm = RightEditor.InstallTextMate(Registry); InstallRenderers(); Rebuild(); HookScrollSync(); } 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(); } RebuildMargins(); ApplyWrap(); ApplyGrammar(File?.Path); InvalidateRenderers(); } /// 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); } /// 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); 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(); } /// 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(); _rightScroll = RightEditor.FindDescendantOfType(); 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 topLine = sourceEditor.TextArea.TextView.VisualLines is { Count: > 0 } visualLines ? visualLines[0].FirstDocumentLine.LineNumber : (int?)null; if (topLine is { } line && line >= 1 && line <= targetEditor.Document.LineCount) targetEditor.ScrollToLine(line); } 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; } } /// What one document line represents, for the margin and the background renderers. internal sealed record RowInfo( AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList Spans); /// Draws one or two gutter columns of diff line numbers from the row lookup. private sealed class DiffLineNumberMargin : AbstractMargin { private const double ColumnWidth = 44; private const double Gap = 6; private readonly Func _rows; private readonly bool _showOld; private readonly bool _showNew; private readonly IBrush _foreground; private readonly Typeface _typeface; private readonly double _fontSize; public DiffLineNumberMargin(Func 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), 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)); } } /// Tints whole rows by their diff role. private sealed class DiffLineRenderer : IBackgroundRenderer { private readonly Func _rows; private readonly IBrush _add, _del, _filler, _gap; public DiffLineRenderer(Func 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)); } } } /// Tints the changed character ranges inside a row, on top of the row tint. private sealed class WordDiffRenderer : IBackgroundRenderer { private readonly Func _rows; private readonly IBrush _add, _del; public WordDiffRenderer(Func 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); } } } } /// A minimal for geometry queries. 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; } }