diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs index f896c5a3..9149201c 100644 --- a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs @@ -5,6 +5,8 @@ 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; @@ -64,6 +66,10 @@ public partial class DiffTextView : UserControl private DiffLineNumberMargin? _leftMargin, _rightMargin; + private ScrollViewer? _leftScroll, _rightScroll; + private bool _scrollHooked; + private bool _syncing; + public DiffTextView() { InitializeComponent(); @@ -71,6 +77,7 @@ public partial class DiffTextView : UserControl _rightTm = RightEditor.InstallTextMate(Registry); InstallRenderers(); Rebuild(); + HookScrollSync(); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -199,6 +206,49 @@ public partial class DiffTextView : UserControl 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);