fix(ui): Diff-Viewer scrollt jetzt auch horizontal synchron

VerticalScrollSync mirrort nur noch Y, X blieb pro Pane unabhaengig. Beide
Panes nutzen dieselbe Schrift/Breite, also X 1:1 mitspiegeln statt wie bei
Y ueber TargetOffset umzurechnen. Die Echo-Abbruchbedingung prueft jetzt
beide Achsen (vorher nur Y), sonst wuerde der neue X-Schreibvorgang seine
eigene Echo-Bedingung nie erfuellen und endlos weiterlaufen.

Klasse in ScrollSync umbenannt (DiffTextView + ConflictResolverView
mitgezogen), da sie nicht mehr nur vertikal synchronisiert.

Kein Headless-Test hinzugefuegt: DiffDispatcherGuardTests.cs dokumentiert
bereits, dass ein bootstrapptes Avalonia Application/Dispatcher in
ClaudeDo.Ui.Tests nach drei eskalierenden Versuchen als Sackgasse verifiziert
wurde (Dispatcher-Thread-Affinitaet kollidiert mit xUnits Worker-Pool ohne
Thread-Garantie; Avalonia.Headless.XUnit kollidiert mit xunit 2.9.3). Als
Finding avaloniaedit-ui-test-dead-end festgehalten. Sichtpruefung bleibt
offen (Side-by-side + Merge-Editor mit ueberbreiten Zeilen).
This commit is contained in:
Mika Kuns
2026-09-16 15:43:37 +02:00
committed by mika kuns
parent 5cdfb14ff1
commit 4ff5383b35
4 changed files with 19 additions and 14 deletions
+1 -1
View File
@@ -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 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 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`, `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. new editor boilerplate there rather than copying it a third time.
## Services ## Services
@@ -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 // 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. // gutter buttons only have to be repositioned once per scroll.
new VerticalScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor }, new ScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
afterSync: PositionGutters); afterSync: PositionGutters);
var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060")); var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060"));
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedTintBrush", Color.Parse("#206FA86B")); var resolved = DiffEditorSetup.Brush(this, "MergeResolvedTintBrush", Color.Parse("#206FA86B"));
@@ -75,9 +75,11 @@ internal static class DiffEditorSetup
} }
/// <summary> /// <summary>
/// Keeps a set of editors scrolled to the same vertical position. Construction wires the panes; /// Keeps a set of editors scrolled to the same position, vertically always and horizontally
/// there is nothing to dispose, and nothing to keep a reference to — the event subscriptions on /// whenever the panes share the same content width (no per-pane offset conversion, unlike
/// the text views own the instance. /// <see cref="TargetOffset"/> 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.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -96,7 +98,7 @@ internal static class DiffEditorSetup
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind. /// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal sealed class VerticalScrollSync internal sealed class ScrollSync
{ {
private readonly TextEditor[] _editors; private readonly TextEditor[] _editors;
private readonly ScrollViewer?[] _viewers; private readonly ScrollViewer?[] _viewers;
@@ -108,8 +110,8 @@ internal sealed class VerticalScrollSync
/// <param name="isActive">Gate for layouts where the panes are not side by side.</param> /// <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="anchorByLine">True while lines wrap — see <see cref="TargetOffset"/>.</param>
/// <param name="afterSync">Runs once per user scroll, not per echo.</param> /// <param name="afterSync">Runs once per user scroll, not per echo.</param>
public VerticalScrollSync(TextEditor[] editors, Func<bool>? isActive = null, public ScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
Func<bool>? anchorByLine = null, Action? afterSync = null) Func<bool>? anchorByLine = null, Action? afterSync = null)
{ {
_editors = editors; _editors = editors;
_viewers = new ScrollViewer?[editors.Length]; _viewers = new ScrollViewer?[editors.Length];
@@ -136,15 +138,18 @@ internal sealed class VerticalScrollSync
_syncing = true; _syncing = true;
try try
{ {
var x = _editors[sourceIndex].TextArea.TextView.ScrollOffset.X;
for (var i = 0; i < _editors.Length; i++) for (var i = 0; i < _editors.Length; i++)
{ {
if (i == sourceIndex) continue; if (i == sourceIndex) continue;
if (Viewer(i) is not { } viewer) continue; if (Viewer(i) is not { } viewer) continue;
if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue; if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue;
// The echo from the target's own ScrollOffsetChanged lands after _syncing is // 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. // cleared again, so this comparison — not the flag — is what ends the loop. Both
if (Math.Abs(viewer.Offset.Y - y) > 0.5) // axes must be checked: writing only when Y moved left X's own echo unguarded,
viewer.Offset = new Vector(viewer.Offset.X, y); // 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; } finally { _syncing = false; }
@@ -85,8 +85,8 @@ public partial class DiffTextView : UserControl
_leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor); _leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor);
_rightTm = DiffEditorSetup.InstallHighlighting(RightEditor); _rightTm = DiffEditorSetup.InstallHighlighting(RightEditor);
ReloadFile(); ReloadFile();
new VerticalScrollSync(new[] { LeftEditor, RightEditor }, new ScrollSync(new[] { LeftEditor, RightEditor },
isActive: () => IsSplit, anchorByLine: () => WrapLines); isActive: () => IsSplit, anchorByLine: () => WrapLines);
} }
/// Brushes and the mono typeface only resolve once the control is in the visual tree — /// Brushes and the mono typeface only resolve once the control is in the visual tree —