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:
mika kuns
2026-08-07 13:30:34 +02:00
parent 6dff72f27c
commit 9437121f3a
9 changed files with 296 additions and 192 deletions
@@ -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;
}
}