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:
@@ -358,8 +358,11 @@
|
||||
"filesHeader": "Dateien",
|
||||
"binary": "Binärdatei — kein Text-Diff",
|
||||
"empty": "Kein Inhalt",
|
||||
"unifiedView": "Zusammen",
|
||||
"splitView": "Nebeneinander",
|
||||
"wrapLines": "Zeilenumbruch"
|
||||
"wrapLines": "Zeilenumbruch",
|
||||
"paneBase": "BASIS",
|
||||
"paneWorktree": "WORKTREE"
|
||||
},
|
||||
"worktreesOverview": {
|
||||
"refresh": "Aktualisieren",
|
||||
|
||||
@@ -358,8 +358,11 @@
|
||||
"filesHeader": "Files",
|
||||
"binary": "Binary file — no text diff",
|
||||
"empty": "No content",
|
||||
"unifiedView": "Unified",
|
||||
"splitView": "Side by side",
|
||||
"wrapLines": "Wrap lines"
|
||||
"wrapLines": "Wrap lines",
|
||||
"paneBase": "BASE",
|
||||
"paneWorktree": "WORKTREE"
|
||||
},
|
||||
"worktreesOverview": {
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
<!-- Icon.Settings (gear) -->
|
||||
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Wrap — a full-width line above a line that wraps back with an arrowhead -->
|
||||
<StreamGeometry x:Key="Icon.Wrap">M3 5 H21 V7 H3 Z M3 11 H17 A4 4 0 0 1 17 19 H12 V21 L7 18 L12 15 V17 H17 A2 2 0 0 0 17 13 H3 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
|
||||
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
|
||||
|
||||
@@ -323,8 +326,9 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Icon button: 24×24 square with hover surface -->
|
||||
<Style Selector="Button.icon-btn">
|
||||
<!-- Icon button: 24×24 square with hover surface. The ToggleButton variant is the same
|
||||
chrome for a persisted on/off control (its "on" state is the :checked rule below). -->
|
||||
<Style Selector="Button.icon-btn, ToggleButton.icon-btn">
|
||||
<Setter Property="Width" Value="24" />
|
||||
<Setter Property="Height" Value="24" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
@@ -335,10 +339,53 @@
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
|
||||
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter, ToggleButton.icon-btn:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.icon-btn:checked /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
<!-- PathIcon fills with its own Foreground and does not pick up TextElement.Foreground,
|
||||
so the icon's three states are set on the icon itself. -->
|
||||
<Style Selector="ToggleButton.icon-btn PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.icon-btn:pointerover PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.icon-btn:checked PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Segmented switch: one control, mutually exclusive segments, active one highlighted.
|
||||
Use for picking a mode (not for independent on/off flags — those are icon toggles). -->
|
||||
<Style Selector="Border.segmented">
|
||||
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="{StaticResource ButtonCornerRadius}" />
|
||||
<Setter Property="Padding" Value="2" />
|
||||
</Style>
|
||||
<Style Selector="Border.segmented Button.segment">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="10,4" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.segmented Button.segment:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<!-- Declared after :pointerover so hovering the active segment keeps the active look. -->
|
||||
<Style Selector="Border.segmented Button.segment.active /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Stroke-rendered icon (for line-art geometries that PathIcon would fill away) -->
|
||||
<Style Selector="Button.icon-btn Path.plan-icon">
|
||||
|
||||
@@ -65,6 +65,11 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isSplitView;
|
||||
[ObservableProperty] private bool _wrapLines;
|
||||
|
||||
// The layout picker is a segmented switch, so each segment sets its own mode rather than
|
||||
// flipping a shared flag — clicking the already-active segment must be a no-op.
|
||||
[RelayCommand] private void ShowUnified() => IsSplitView = false;
|
||||
[RelayCommand] private void ShowSplit() => IsSplitView = true;
|
||||
|
||||
partial void OnIsSplitViewChanged(bool value)
|
||||
{
|
||||
_settings.DiffViewMode = value ? "split" : "unified";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
@@ -9,22 +8,21 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
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.Conflicts;
|
||||
using TextMateSharp.Grammars;
|
||||
using ClaudeDo.Ui.Views.Controls;
|
||||
using Seg = ClaudeDo.Ui.Views.Controls.DiffEditorSetup.Seg;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||
|
||||
public partial class ConflictResolverView : Window
|
||||
{
|
||||
private ConflictResolverViewModel? _vm;
|
||||
private RegistryOptions? _registry;
|
||||
private TextMate.Installation? _oursTm, _resultTm, _theirsTm;
|
||||
private bool _editorsReady;
|
||||
|
||||
// Fixed conflict spans for the read-only side panes (recomputed each rebuild).
|
||||
private List<(int Offset, int Length, MergeConflictBlock Block)> _oursSpans = new();
|
||||
@@ -34,11 +32,8 @@ public partial class ConflictResolverView : Window
|
||||
private readonly List<ResultRegion> _resultRegions = new();
|
||||
private readonly List<MergeConflictBlock> _hookedBlocks = new();
|
||||
|
||||
private ScrollViewer?[] _scrollViewers = Array.Empty<ScrollViewer?>();
|
||||
private bool _wired;
|
||||
private bool _rebuilding;
|
||||
private bool _applyingAccept;
|
||||
private bool _syncing;
|
||||
private bool _gutterPending;
|
||||
private int _gutterRetries;
|
||||
|
||||
@@ -56,12 +51,6 @@ public partial class ConflictResolverView : Window
|
||||
_vm.ActiveFileChanged -= Rebuild;
|
||||
_vm.CurrentChanged -= ScrollToCurrent;
|
||||
}
|
||||
// The editors persist across a DataContext swap, so drop stale scroll-sync hooks first.
|
||||
foreach (var sv in _scrollViewers)
|
||||
if (sv is not null) sv.ScrollChanged -= OnPaneScroll;
|
||||
_scrollViewers = Array.Empty<ScrollViewer?>();
|
||||
_wired = false;
|
||||
|
||||
_vm = DataContext as ConflictResolverViewModel;
|
||||
if (_vm is null) return;
|
||||
|
||||
@@ -76,19 +65,24 @@ public partial class ConflictResolverView : Window
|
||||
|
||||
private void EnsureEditors()
|
||||
{
|
||||
if (_registry is not null) return;
|
||||
_registry = new RegistryOptions(ThemeName.DarkPlus);
|
||||
_oursTm = OursEditor.InstallTextMate(_registry);
|
||||
_resultTm = ResultEditor.InstallTextMate(_registry);
|
||||
_theirsTm = TheirsEditor.InstallTextMate(_registry);
|
||||
if (_editorsReady) return;
|
||||
_editorsReady = true;
|
||||
_oursTm = DiffEditorSetup.InstallHighlighting(OursEditor);
|
||||
_resultTm = DiffEditorSetup.InstallHighlighting(ResultEditor);
|
||||
_theirsTm = DiffEditorSetup.InstallHighlighting(TheirsEditor);
|
||||
|
||||
ResultEditor.Document ??= new TextDocument();
|
||||
ResultEditor.Document.Changed += OnResultDocumentChanged;
|
||||
ResultEditor.TextArea.ReadOnlySectionProvider =
|
||||
new ConflictReadOnlyProvider(() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset)));
|
||||
|
||||
var conflict = BrushRes("MergeConflictTintBrush", Color.Parse("#28C87060"));
|
||||
var resolved = BrushRes("MergeResolvedTintBrush", Color.Parse("#206FA86B"));
|
||||
// Panes are whole files here, not aligned rows, so raw pixel offsets line up and the
|
||||
// gutter buttons only have to be repositioned once per scroll.
|
||||
new VerticalScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
|
||||
afterSync: PositionGutters);
|
||||
|
||||
var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060"));
|
||||
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedTintBrush", Color.Parse("#206FA86B"));
|
||||
OursEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||||
() => _oursSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
|
||||
ResultEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||||
@@ -97,13 +91,6 @@ public partial class ConflictResolverView : Window
|
||||
() => _theirsSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
|
||||
}
|
||||
|
||||
private IBrush BrushRes(string key, Color fallback)
|
||||
{
|
||||
if (this.TryGetResource(key, null, out var v) && v is IBrush b)
|
||||
return b;
|
||||
return new SolidColorBrush(fallback);
|
||||
}
|
||||
|
||||
// ── Rebuild the three documents for the active file ───────────────────────
|
||||
|
||||
private void Rebuild()
|
||||
@@ -151,16 +138,11 @@ public partial class ConflictResolverView : Window
|
||||
_hookedBlocks.Add(block);
|
||||
}
|
||||
|
||||
ApplyGrammar(file.Path);
|
||||
DiffEditorSetup.ApplyGrammar(file.Path, _oursTm, _resultTm, _theirsTm);
|
||||
InvalidateRenderers();
|
||||
}
|
||||
finally { _rebuilding = false; }
|
||||
|
||||
if (!_wired)
|
||||
{
|
||||
_wired = true;
|
||||
Dispatcher.UIThread.Post(HookScrollSync, DispatcherPriority.Loaded);
|
||||
}
|
||||
QueueGutters();
|
||||
}
|
||||
|
||||
@@ -317,8 +299,8 @@ public partial class ConflictResolverView : Window
|
||||
if (h <= 1) return;
|
||||
var doc = ResultEditor.Document;
|
||||
var totalLines = Math.Max(1, doc.LineCount);
|
||||
var unresolved = BrushRes("MergeConflictEdgeBrush", Color.Parse("#80C87060"));
|
||||
var resolved = BrushRes("MergeResolvedEdgeBrush", Color.Parse("#806FA86B"));
|
||||
var unresolved = DiffEditorSetup.Brush(this, "MergeConflictEdgeBrush", Color.Parse("#80C87060"));
|
||||
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedEdgeBrush", Color.Parse("#806FA86B"));
|
||||
|
||||
foreach (var region in _resultRegions)
|
||||
{
|
||||
@@ -348,31 +330,6 @@ public partial class ConflictResolverView : Window
|
||||
|
||||
private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key);
|
||||
|
||||
// ── Synced vertical scroll across the three panes ─────────────────────────
|
||||
|
||||
private void HookScrollSync()
|
||||
{
|
||||
_scrollViewers = new[] { OursEditor, ResultEditor, TheirsEditor }
|
||||
.Select(ed => ed.FindDescendantOfType<ScrollViewer>())
|
||||
.ToArray();
|
||||
foreach (var sv in _scrollViewers)
|
||||
if (sv is not null) sv.ScrollChanged += OnPaneScroll;
|
||||
}
|
||||
|
||||
private void OnPaneScroll(object? sender, ScrollChangedEventArgs e)
|
||||
{
|
||||
if (_syncing || sender is not ScrollViewer src) return;
|
||||
_syncing = true;
|
||||
try
|
||||
{
|
||||
foreach (var sv in _scrollViewers)
|
||||
if (sv is not null && !ReferenceEquals(sv, src) && Math.Abs(sv.Offset.Y - src.Offset.Y) > 0.5)
|
||||
sv.Offset = new Vector(sv.Offset.X, src.Offset.Y);
|
||||
}
|
||||
finally { _syncing = false; }
|
||||
PositionGutters();
|
||||
}
|
||||
|
||||
private void ScrollToCurrent()
|
||||
{
|
||||
if (_vm?.Current is not { } block) return;
|
||||
@@ -390,30 +347,8 @@ public partial class ConflictResolverView : Window
|
||||
TheirsEditor.TextArea.TextView.InvalidateVisual();
|
||||
}
|
||||
|
||||
private void ApplyGrammar(string? path)
|
||||
{
|
||||
if (_registry is null || string.IsNullOrEmpty(path)) return;
|
||||
var ext = System.IO.Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext)) return;
|
||||
var language = _registry.GetLanguageByExtension(ext);
|
||||
if (language is null) return;
|
||||
var scope = _registry.GetScopeByLanguageId(language.Id);
|
||||
_oursTm?.SetGrammar(scope);
|
||||
_resultTm?.SetGrammar(scope);
|
||||
_theirsTm?.SetGrammar(scope);
|
||||
}
|
||||
|
||||
// ── Helper types (single-consumer; live with their consumer per repo style) ─
|
||||
|
||||
/// <summary>A minimal <see cref="ISegment"/> for geometry/read-only 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;
|
||||
}
|
||||
|
||||
/// <summary>An editable conflict region in the result document, tracking which sides are
|
||||
/// currently included (in click order — <c>'o'</c> = ours/main, <c>'t'</c> = theirs/incoming).</summary>
|
||||
private sealed class ResultRegion
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ae="using:AvaloniaEdit"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Controls.DiffTextView">
|
||||
|
||||
<UserControl.Styles>
|
||||
@@ -12,9 +13,23 @@
|
||||
</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>
|
||||
<DockPanel>
|
||||
<!-- Split-only: labels which side is which. Same column definitions as the pane grid
|
||||
so each label sits over its own editor. Hidden in unified layout (see RefreshLayout). -->
|
||||
<Grid x:Name="PaneHeader" DockPanel.Dock="Top" ColumnDefinitions="*,1,*" IsVisible="False">
|
||||
<Border Grid.Column="0" Classes="island-header">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.paneBase}"/>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Background="{DynamicResource LineBrush}"/>
|
||||
<Border Grid.Column="2" Classes="island-header">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.paneWorktree}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<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>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,24 @@
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- View toolbar: layout + wrap, both persisted -->
|
||||
<!-- View toolbar: layout mode (segmented) + wrap (icon toggle), both persisted.
|
||||
Wrap is orthogonal to the layout mode, so it stays a separate control. -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,8,16,0">
|
||||
<ToggleButton Content="{loc:Tr modals.diff.splitView}" IsChecked="{Binding IsSplitView}"/>
|
||||
<ToggleButton Content="{loc:Tr modals.diff.wrapLines}" IsChecked="{Binding WrapLines}"/>
|
||||
<Border Classes="segmented">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="segment" Classes.active="{Binding !IsSplitView}"
|
||||
Content="{loc:Tr modals.diff.unifiedView}"
|
||||
Command="{Binding ShowUnifiedCommand}"/>
|
||||
<Button Classes="segment" Classes.active="{Binding IsSplitView}"
|
||||
Content="{loc:Tr modals.diff.splitView}"
|
||||
Command="{Binding ShowSplitCommand}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ToggleButton Classes="icon-btn" IsChecked="{Binding WrapLines}"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{loc:Tr modals.diff.wrapLines}">
|
||||
<PathIcon Data="{StaticResource Icon.Wrap}" Width="14" Height="14"/>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Planning toolbar: combined-mode toggle + warning/loading -->
|
||||
|
||||
Reference in New Issue
Block a user