Files
ClaudeDo/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
T

407 lines
16 KiB
C#

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;
/// <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;
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<RowInfo?>();
}
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<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);
}
/// 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<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 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; }
}
/// <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);
/// <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),
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));
}
}
/// <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;
}
}