fix(diff): label split panes, unify the mode switch, sync split scrolling
Three things were wrong with the fresh side-by-side viewer: - The panes scrolled independently. The sync hunted the editors' templated ScrollViewers at load time, but the viewer starts collapsed (no file selected), so nothing was ever measured, no template existed, and the hook silently no-opped for the lifetime of the window. Reading now goes through TextView.ScrollOffsetChanged, which exists from construction; writing goes through a lazily resolved ScrollViewer.Offset. TextEditor.ScrollToVerticalOffset is NOT usable here - it is a silent no-op in AvaloniaEdit 12.0.0 even with the editor templated (verified headlessly). - Neither pane said what it showed. Added a BASE | WORKTREE header row inside DiffTextView, sharing the pane grid's columns so each label sits over its editor. - The layout picker was two lookalike ToggleButtons. It is now a segmented switch (Border.segmented + Button.segment.active), with wrap demoted to an icon toggle since it is orthogonal to the layout mode. The scroll sync, TextMate setup, grammar switching, brush fallback and segment struct were duplicated between this control and the 3-pane conflict resolver, which the design doc had used as a copy-paste template. They now live in DiffEditorSetup; both surfaces migrated. They stay separate controls on purpose - a read-only two-way renderer over aligned rows with filler lines is not a variant of a three-way editor over a writable document. The resolver thereby also picks up the fixed scroll sync and the TryFindResource brush lookup (its TryGetResource never resolved anything). All four merge tokens happen to equal their hardcoded fallbacks, so nothing changes on screen.
This commit is contained in:
@@ -1,19 +1,14 @@
|
||||
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;
|
||||
using Seg = ClaudeDo.Ui.Views.Controls.DiffEditorSetup.Seg;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Controls;
|
||||
|
||||
@@ -52,9 +47,6 @@ public partial class DiffTextView : UserControl
|
||||
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
|
||||
@@ -66,18 +58,16 @@ public partial class DiffTextView : UserControl
|
||||
|
||||
private DiffLineNumberMargin? _leftMargin, _rightMargin;
|
||||
|
||||
private ScrollViewer? _leftScroll, _rightScroll;
|
||||
private bool _scrollHooked;
|
||||
private bool _syncing;
|
||||
private bool _renderersInstalled;
|
||||
|
||||
public DiffTextView()
|
||||
{
|
||||
InitializeComponent();
|
||||
_leftTm = LeftEditor.InstallTextMate(Registry);
|
||||
_rightTm = RightEditor.InstallTextMate(Registry);
|
||||
_leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor);
|
||||
_rightTm = DiffEditorSetup.InstallHighlighting(RightEditor);
|
||||
ReloadFile();
|
||||
HookScrollSync();
|
||||
new VerticalScrollSync(new[] { LeftEditor, RightEditor },
|
||||
isActive: () => IsSplit, anchorByLine: () => WrapLines);
|
||||
}
|
||||
|
||||
/// Brushes and the mono typeface only resolve once the control is in the visual tree —
|
||||
@@ -121,6 +111,7 @@ public partial class DiffTextView : UserControl
|
||||
var split = IsSplit;
|
||||
RightEditor.IsVisible = split;
|
||||
PaneDivider.IsVisible = split;
|
||||
PaneHeader.IsVisible = split;
|
||||
Grid.SetColumnSpan(LeftEditor, split ? 1 : 3);
|
||||
|
||||
if (split)
|
||||
@@ -146,7 +137,7 @@ public partial class DiffTextView : UserControl
|
||||
|
||||
RebuildMargins();
|
||||
ApplyWrap();
|
||||
ApplyGrammar(File?.Path);
|
||||
DiffEditorSetup.ApplyGrammar(File?.Path, _leftTm, _rightTm);
|
||||
InvalidateRenderers();
|
||||
}
|
||||
|
||||
@@ -171,21 +162,6 @@ public partial class DiffTextView : UserControl
|
||||
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()
|
||||
@@ -205,10 +181,7 @@ public partial class DiffTextView : UserControl
|
||||
RightEditor.TextArea.LeftMargins.Insert(0, _rightMargin);
|
||||
}
|
||||
|
||||
private IBrush Brush(string key, Color fallback) =>
|
||||
this.TryFindResource(key, out var value) && value is IBrush brush
|
||||
? brush
|
||||
: new SolidColorBrush(fallback);
|
||||
private IBrush Brush(string key, Color fallback) => DiffEditorSetup.Brush(this, key, fallback);
|
||||
|
||||
private void InstallRenderers()
|
||||
{
|
||||
@@ -231,58 +204,6 @@ 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<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.
|
||||
// ScrollToLine/ScrollTo are "bring into view" primitives (they park the line
|
||||
// near mid-viewport past a hysteresis threshold) — top-align by computing the
|
||||
// target's own vertical offset for that document line and assigning it
|
||||
// directly, the same way the non-wrap branch assigns Offset below. That is
|
||||
// synchronous, so the same offset-comparison guard makes the echo self-terminate.
|
||||
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)
|
||||
{
|
||||
var targetTop = targetEditor.TextArea.TextView.GetVisualTopByDocumentLine(line);
|
||||
if (Math.Abs(target.Offset.Y - targetTop) > 0.5)
|
||||
target.Offset = new Vector(target.Offset.X, targetTop);
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -428,13 +349,4 @@ public partial class DiffTextView : UserControl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user