diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index c98961f5..2baddd38 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -92,7 +92,7 @@ single-task and planning unit-merge conflicts. Full detail → The two surfaces are **not** variants of one control (read-only 2-way renderer over aligned rows vs. 3-way editor over a writable document), but they share their AvaloniaEdit host plumbing via `Views/Controls/DiffEditorSetup.cs`: the process-wide TextMate `Registry`, `InstallHighlighting`, -`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `VerticalScrollSync`. Put +`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `ScrollSync`. Put new editor boilerplate there rather than copying it a third time. ## Services diff --git a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs index 47f57c50..47160d69 100644 --- a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs @@ -78,8 +78,8 @@ public partial class ConflictResolverView : Window // 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); + new ScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor }, + afterSync: PositionGutters); var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060")); var resolved = DiffEditorSetup.Brush(this, "MergeResolvedTintBrush", Color.Parse("#206FA86B")); diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffEditorSetup.cs b/src/ClaudeDo.Ui/Views/Controls/DiffEditorSetup.cs index d562cf99..bb6a5b02 100644 --- a/src/ClaudeDo.Ui/Views/Controls/DiffEditorSetup.cs +++ b/src/ClaudeDo.Ui/Views/Controls/DiffEditorSetup.cs @@ -75,9 +75,11 @@ internal static class DiffEditorSetup } /// -/// 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. +/// Keeps a set of editors scrolled to the same position, vertically always and horizontally +/// whenever the panes share the same content width (no per-pane offset conversion, unlike +/// for Y). 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. /// /// /// @@ -96,7 +98,7 @@ internal static class DiffEditorSetup /// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind. /// /// -internal sealed class VerticalScrollSync +internal sealed class ScrollSync { private readonly TextEditor[] _editors; private readonly ScrollViewer?[] _viewers; @@ -108,8 +110,8 @@ internal sealed class VerticalScrollSync /// Gate for layouts where the panes are not side by side. /// True while lines wrap — see . /// Runs once per user scroll, not per echo. - public VerticalScrollSync(TextEditor[] editors, Func? isActive = null, - Func? anchorByLine = null, Action? afterSync = null) + public ScrollSync(TextEditor[] editors, Func? isActive = null, + Func? anchorByLine = null, Action? afterSync = null) { _editors = editors; _viewers = new ScrollViewer?[editors.Length]; @@ -136,15 +138,18 @@ internal sealed class VerticalScrollSync _syncing = true; try { + var x = _editors[sourceIndex].TextArea.TextView.ScrollOffset.X; 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); + // cleared again, so this comparison — not the flag — is what ends the loop. Both + // axes must be checked: writing only when Y moved left X's own echo unguarded, + // which is how it looped forever once X was written below. + if (Math.Abs(viewer.Offset.X - x) > 0.5 || Math.Abs(viewer.Offset.Y - y) > 0.5) + viewer.Offset = new Vector(x, y); } } finally { _syncing = false; } diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs index 79786c49..daa35e6b 100644 --- a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs @@ -85,8 +85,8 @@ public partial class DiffTextView : UserControl _leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor); _rightTm = DiffEditorSetup.InstallHighlighting(RightEditor); ReloadFile(); - new VerticalScrollSync(new[] { LeftEditor, RightEditor }, - isActive: () => IsSplit, anchorByLine: () => WrapLines); + new ScrollSync(new[] { LeftEditor, RightEditor }, + isActive: () => IsSplit, anchorByLine: () => WrapLines); } /// Brushes and the mono typeface only resolve once the control is in the visual tree —