From 9437121f3a2a759a4645a8b37321307a1b29ef77 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 13:30:34 +0200 Subject: [PATCH] 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. --- src/ClaudeDo.Localization/locales/de.json | 5 +- src/ClaudeDo.Localization/locales/en.json | 5 +- src/ClaudeDo.Ui/Design/IslandStyles.axaml | 53 +++++- .../ViewModels/Modals/DiffViewerViewModel.cs | 5 + .../Conflicts/ConflictResolverView.axaml.cs | 101 ++--------- .../Views/Controls/DiffEditorSetup.cs | 170 ++++++++++++++++++ .../Views/Controls/DiffTextView.axaml | 25 ++- .../Views/Controls/DiffTextView.axaml.cs | 104 +---------- .../Views/Modals/DiffViewerView.axaml | 20 ++- 9 files changed, 296 insertions(+), 192 deletions(-) create mode 100644 src/ClaudeDo.Ui/Views/Controls/DiffEditorSetup.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 5aa6b39c..41797703 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 41213be1..c8f18116 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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", diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index 41d37329..c6870765 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -108,6 +108,9 @@ 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 + + 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 + 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 @@ -323,8 +326,9 @@ - - - + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs index 21838697..b6670ebc 100644 --- a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs @@ -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(); - _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. - // 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; } - } - /// What one document line represents, for the margin and the background renderers. internal sealed record RowInfo( AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList Spans); @@ -428,13 +349,4 @@ public partial class DiffTextView : UserControl } } } - - /// A minimal for geometry queries. - 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; - } } diff --git a/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml b/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml index 003cdaf2..89fbd4e1 100644 --- a/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml @@ -30,10 +30,24 @@ - + - - + + +