feat(diff): add AvaloniaEdit-based diff control with TextMate highlighting
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user