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
@@ -0,0 +1,170 @@
using System;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
using TextMateSharp.Grammars;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// The AvaloniaEdit host plumbing shared by the two diff surfaces: the read-only side-by-side
/// <see cref="DiffTextView"/> and the 3-pane conflict resolver.
/// </summary>
/// <remarks>
/// Only the boilerplate lives here. The two surfaces are not variants of one control — one is a
/// read-only two-way renderer over aligned diff rows (with filler rows the other has no concept
/// of), the other a three-way editor over a writable result document with anchored regions. Their
/// documents, margins and background renderers therefore stay with their own views.
/// </remarks>
internal static class DiffEditorSetup
{
/// Grammars and themes are process-wide; loading a registry per view would be wasteful.
public static readonly RegistryOptions Registry = new(ThemeName.DarkPlus);
public static TextMate.Installation InstallHighlighting(TextEditor editor) =>
editor.InstallTextMate(Registry);
/// <summary>
/// Points every installation at the grammar for <paramref name="path"/>'s extension. No path,
/// no extension or no matching language leaves the editors as plain text.
/// </summary>
/// <remarks>
/// Both surfaces feed fragments (diff hunks / conflict regions) rather than 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.
/// </remarks>
public static void ApplyGrammar(string? path, params TextMate.Installation?[] installations)
{
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);
foreach (var installation in installations) installation?.SetGrammar(scope);
}
/// <summary>Resolves a themed brush, falling back to a literal when the lookup fails.</summary>
/// <remarks>
/// Two traps, both silent. It must be <c>TryFindResource</c> (the extension that walks up to
/// Application) and not the <c>TryGetResource</c> instance method, which only sees the
/// control's own Resources. And it must run while <paramref name="owner"/> is attached —
/// a detached control has no resource parent to walk, so resolving in a constructor freezes
/// every brush on its fallback for good.
/// </remarks>
public static IBrush Brush(Control owner, string key, Color fallback) =>
owner.TryFindResource(key, out var value) && value is IBrush brush
? brush
: new SolidColorBrush(fallback);
/// <summary>A minimal <see cref="ISegment"/> for geometry and read-only queries.</summary>
public 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;
}
}
/// <summary>
/// Keeps a set of editors scrolled to the same vertical position. Construction wires the panes;
/// there is nothing to dispose, and nothing to keep a reference to — the event subscriptions on
/// the text views own the instance.
/// </summary>
/// <remarks>
/// <para>
/// Reading is driven off <see cref="TextView.ScrollOffsetChanged"/> rather than the editors'
/// templated ScrollViewers. A TextView exists from construction, whereas the ScrollViewer only
/// materialises once the editor has been measured — so subscribing to the ScrollViewer at load
/// time found nothing whenever a pane started out collapsed, and the sync then stayed dead for
/// the lifetime of the window.
/// </para>
/// <para>
/// Writing still goes through the ScrollViewer, resolved lazily on first use (by which point the
/// panes are on screen). <see cref="TextEditor.ScrollToVerticalOffset"/> looks like the obvious
/// call and is <b>silently a no-op</b> in AvaloniaEdit 12 even with the editor templated and
/// <c>TextEditor.ScrollViewer</c> non-null — verified against 12.0.0. Assigning
/// <c>ILogicalScrollable.Offset</c> on the TextView is no good either: it moves the text but
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
/// </para>
/// </remarks>
internal sealed class VerticalScrollSync
{
private readonly TextEditor[] _editors;
private readonly ScrollViewer?[] _viewers;
private readonly Func<bool> _isActive;
private readonly Func<bool> _anchorByLine;
private readonly Action? _afterSync;
private bool _syncing;
/// <param name="isActive">Gate for layouts where the panes are not side by side.</param>
/// <param name="anchorByLine">True while lines wrap — see <see cref="TargetOffset"/>.</param>
/// <param name="afterSync">Runs once per user scroll, not per echo.</param>
public VerticalScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
Func<bool>? anchorByLine = null, Action? afterSync = null)
{
_editors = editors;
_viewers = new ScrollViewer?[editors.Length];
_isActive = isActive ?? (() => true);
_anchorByLine = anchorByLine ?? (() => false);
_afterSync = afterSync;
for (var i = 0; i < editors.Length; i++)
{
var index = i;
editors[i].TextArea.TextView.ScrollOffsetChanged += (_, _) => Sync(index);
}
}
/// Resolved on demand and cached: at construction the editor may still be collapsed and
/// therefore untemplated, but by the time anything scrolls it is on screen.
private ScrollViewer? Viewer(int index) =>
_viewers[index] ??= _editors[index].GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
private void Sync(int sourceIndex)
{
if (_syncing || !_isActive()) return;
_syncing = true;
try
{
for (var i = 0; i < _editors.Length; i++)
{
if (i == sourceIndex) continue;
if (Viewer(i) is not { } viewer) continue;
if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue;
// The echo from the target's own ScrollOffsetChanged lands after _syncing is
// cleared again, so this comparison — not the flag — is what ends the loop.
if (Math.Abs(viewer.Offset.Y - y) > 0.5)
viewer.Offset = new Vector(viewer.Offset.X, y);
}
}
finally { _syncing = false; }
_afterSync?.Invoke();
}
/// Where <paramref name="target"/> has to sit to show the same content as
/// <paramref name="source"/>, or null when the two cannot be related right now.
private double? TargetOffset(TextEditor source, TextEditor target)
{
if (!_anchorByLine()) return source.TextArea.TextView.ScrollOffset.Y;
// Line heights differ once lines wrap, so pixel offsets no longer correspond between the
// panes — anchor on the top visible line instead and top-align it by computing the
// target's own vertical offset for that document line. ScrollToLine would not do: it is a
// "bring into view" primitive that parks the line near mid-viewport past a threshold.
if (source.TextArea.TextView.VisualLines is not { Count: > 0 } visualLines) return null;
var line = visualLines[0].FirstDocumentLine.LineNumber;
if (line < 1 || line > target.Document.LineCount) return null;
return target.TextArea.TextView.GetVisualTopByDocumentLine(line);
}
}