feat(diff): add AvaloniaEdit-based diff control with TextMate highlighting

This commit is contained in:
mika kuns
2026-08-07 09:27:34 +02:00
parent e2d987e976
commit ffc60c4101
2 changed files with 170 additions and 0 deletions
@@ -0,0 +1,20 @@
<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>
@@ -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;
/// <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);
}