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