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 —