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).
424 lines
17 KiB
C#
424 lines
17 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.ComponentModel;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using Avalonia;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Controls.Shapes;
|
||
using Avalonia.Media;
|
||
using Avalonia.Threading;
|
||
using AvaloniaEdit.Document;
|
||
using AvaloniaEdit.Editing;
|
||
using AvaloniaEdit.Rendering;
|
||
using AvaloniaEdit.TextMate;
|
||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||
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 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();
|
||
private List<(int Offset, int Length, MergeConflictBlock Block)> _theirsSpans = new();
|
||
|
||
// Live, edit-tracked conflict regions in the editable result document.
|
||
private readonly List<ResultRegion> _resultRegions = new();
|
||
private readonly List<MergeConflictBlock> _hookedBlocks = new();
|
||
|
||
private bool _rebuilding;
|
||
private bool _applyingAccept;
|
||
private bool _gutterPending;
|
||
private int _gutterRetries;
|
||
|
||
public ConflictResolverView()
|
||
{
|
||
InitializeComponent();
|
||
}
|
||
|
||
protected override void OnDataContextChanged(EventArgs e)
|
||
{
|
||
base.OnDataContextChanged(e);
|
||
|
||
if (_vm is not null)
|
||
{
|
||
_vm.ActiveFileChanged -= Rebuild;
|
||
_vm.CurrentChanged -= ScrollToCurrent;
|
||
}
|
||
_vm = DataContext as ConflictResolverViewModel;
|
||
if (_vm is null) return;
|
||
|
||
_vm.CloseRequested = Close;
|
||
EnsureEditors();
|
||
_vm.ActiveFileChanged += Rebuild;
|
||
_vm.CurrentChanged += ScrollToCurrent;
|
||
Rebuild();
|
||
}
|
||
|
||
// ── One-time editor setup ────────────────────────────────────────────────
|
||
|
||
private void EnsureEditors()
|
||
{
|
||
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)));
|
||
|
||
// 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 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"));
|
||
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(
|
||
() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset - r.Start.Offset, r.Block.IsResolved)), conflict, resolved));
|
||
TheirsEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||
() => _theirsSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
|
||
}
|
||
|
||
// ── Rebuild the three documents for the active file ───────────────────────
|
||
|
||
private void Rebuild()
|
||
{
|
||
if (_vm is null) return;
|
||
_rebuilding = true;
|
||
_gutterRetries = 0; // fresh retry budget for this file's gutter layout
|
||
try
|
||
{
|
||
ClearGutters();
|
||
UnhookBlocks();
|
||
_resultRegions.Clear();
|
||
|
||
var file = _vm.ActiveFile;
|
||
if (file is null || file.IsBinary)
|
||
{
|
||
OursEditor.Text = TheirsEditor.Text = "";
|
||
if (ResultEditor.Document is { } d0) d0.Text = "";
|
||
_oursSpans = new(); _theirsSpans = new();
|
||
InvalidateRenderers();
|
||
return;
|
||
}
|
||
|
||
var (oursText, oursSpans) = BuildSide(file, b => b.Ours);
|
||
var (theirsText, theirsSpans) = BuildSide(file, b => b.Theirs);
|
||
// Unresolved conflicts start EMPTY — the user builds the result by appending sides.
|
||
var (resultText, resultSpans) = BuildSide(file, b => b.Resolution ?? "");
|
||
_oursSpans = oursSpans;
|
||
_theirsSpans = theirsSpans;
|
||
|
||
OursEditor.Text = oursText;
|
||
TheirsEditor.Text = theirsText;
|
||
ResultEditor.Document ??= new TextDocument();
|
||
ResultEditor.Document.Text = resultText;
|
||
|
||
var doc = ResultEditor.Document;
|
||
foreach (var (offset, length, block) in resultSpans)
|
||
{
|
||
var start = doc.CreateAnchor(offset);
|
||
start.MovementType = AnchorMovementType.BeforeInsertion;
|
||
var end = doc.CreateAnchor(offset + length);
|
||
end.MovementType = AnchorMovementType.AfterInsertion;
|
||
_resultRegions.Add(new ResultRegion(block, start, end));
|
||
block.PropertyChanged += OnBlockChanged;
|
||
_hookedBlocks.Add(block);
|
||
}
|
||
|
||
DiffEditorSetup.ApplyGrammar(file.Path, _oursTm, _resultTm, _theirsTm);
|
||
InvalidateRenderers();
|
||
}
|
||
finally { _rebuilding = false; }
|
||
|
||
QueueGutters();
|
||
}
|
||
|
||
private static (string Text, List<(int Offset, int Length, MergeConflictBlock Block)> Spans) BuildSide(
|
||
MergeFile file, Func<MergeConflictBlock, string> pick)
|
||
{
|
||
var sb = new StringBuilder();
|
||
var spans = new List<(int, int, MergeConflictBlock)>();
|
||
foreach (var seg in file.Segments)
|
||
{
|
||
if (seg.IsConflict)
|
||
{
|
||
var text = pick(seg.Conflict!);
|
||
spans.Add((sb.Length, text.Length, seg.Conflict!));
|
||
sb.Append(text);
|
||
}
|
||
else
|
||
{
|
||
sb.Append(seg.StableText);
|
||
}
|
||
}
|
||
return (sb.ToString(), spans);
|
||
}
|
||
|
||
private void UnhookBlocks()
|
||
{
|
||
foreach (var b in _hookedBlocks) b.PropertyChanged -= OnBlockChanged;
|
||
_hookedBlocks.Clear();
|
||
}
|
||
|
||
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
|
||
{
|
||
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
|
||
{
|
||
InvalidateRenderers();
|
||
QueueGutters();
|
||
}
|
||
}
|
||
|
||
// ── User edits in the result document flow back to the owning conflict ────
|
||
|
||
private void OnResultDocumentChanged(object? sender, DocumentChangeEventArgs e)
|
||
{
|
||
if (_rebuilding || _applyingAccept) return;
|
||
foreach (var r in _resultRegions)
|
||
{
|
||
if (e.Offset >= r.Start.Offset && e.Offset <= r.End.Offset)
|
||
{
|
||
r.Block.Resolution = ResultEditor.Document.GetText(r.Start.Offset, Math.Max(0, r.End.Offset - r.Start.Offset));
|
||
break;
|
||
}
|
||
}
|
||
QueueGutters();
|
||
}
|
||
|
||
// ── Toggle a side in/out of the result region ────────────────────────────
|
||
|
||
// Each side can be included at most once. Clicking adds it (in click order, first on
|
||
// top); clicking again removes it. The region content is rebuilt from the included set.
|
||
private void ToggleSide(ResultRegion region, char side)
|
||
{
|
||
if (region.Order.Contains(side)) region.Order.Remove(side);
|
||
else region.Order.Add(side);
|
||
|
||
var text = string.Concat(region.Order.Select(c => c == 'o' ? region.Block.Ours : region.Block.Theirs));
|
||
_applyingAccept = true;
|
||
try { ResultEditor.Document.Replace(region.Start.Offset, region.End.Offset - region.Start.Offset, text); }
|
||
finally { _applyingAccept = false; }
|
||
|
||
region.Block.Resolution = region.Order.Count == 0 ? null : text;
|
||
InvalidateRenderers();
|
||
PositionGutters();
|
||
}
|
||
|
||
// ── Inline accept controls in the between-pane gutters ────────────────────
|
||
|
||
private void ClearGutters()
|
||
{
|
||
LeftGutter.Children.Clear();
|
||
RightGutter.Children.Clear();
|
||
}
|
||
|
||
// Coalesce gutter re-layouts so repeated change/scroll events can't flood the dispatcher.
|
||
private void QueueGutters()
|
||
{
|
||
if (_gutterPending) return;
|
||
_gutterPending = true;
|
||
Dispatcher.UIThread.Post(() => { _gutterPending = false; PositionGutters(); }, DispatcherPriority.Background);
|
||
}
|
||
|
||
private void PositionGutters()
|
||
{
|
||
ClearGutters();
|
||
PopulateConflictMap();
|
||
if (_vm?.ActiveFile is null) return;
|
||
var tv = ResultEditor.TextArea.TextView;
|
||
if (!tv.VisualLinesValid)
|
||
{
|
||
// Retry until the editor is laid out, but bounded so a never-laid-out editor
|
||
// (e.g. minimized window) can't busy-loop the dispatcher.
|
||
if (_gutterRetries++ < 40) QueueGutters();
|
||
return;
|
||
}
|
||
_gutterRetries = 0;
|
||
|
||
var doc = ResultEditor.Document;
|
||
foreach (var region in _resultRegions)
|
||
{
|
||
// Controls stay visible whether or not a side is included, so either can be toggled.
|
||
var len = region.End.Offset - region.Start.Offset;
|
||
ISegment probe = len > 0
|
||
? new Seg(region.Start.Offset, len)
|
||
: new Seg(region.Start.Offset, region.Start.Offset < doc.TextLength ? 1 : 0);
|
||
var rects = BackgroundGeometryBuilder.GetRectsForSegment(tv, probe).ToList();
|
||
if (rects.Count == 0) continue;
|
||
var y = rects[0].Top;
|
||
|
||
var r = region;
|
||
var oursIn = region.Order.Contains('o');
|
||
var theirsIn = region.Order.Contains('t');
|
||
|
||
if (tv.TranslatePoint(new Point(0, y), LeftGutter) is { } pl &&
|
||
pl.Y > -24 && pl.Y < LeftGutter.Bounds.Height + 24)
|
||
AddAcceptButton(LeftGutter, pl.Y, oursIn ? "−" : "›", () => ToggleSide(r, 'o'),
|
||
Tr(oursIn ? "conflictResolver.removeOurs" : "conflictResolver.acceptOurs"));
|
||
|
||
if (tv.TranslatePoint(new Point(0, y), RightGutter) is { } pr &&
|
||
pr.Y > -24 && pr.Y < RightGutter.Bounds.Height + 24)
|
||
AddAcceptButton(RightGutter, pr.Y, theirsIn ? "−" : "‹", () => ToggleSide(r, 't'),
|
||
Tr(theirsIn ? "conflictResolver.removeTheirs" : "conflictResolver.acceptTheirs"));
|
||
}
|
||
}
|
||
|
||
private void AddAcceptButton(Canvas canvas, double y, string glyph, Action onClick, string tip)
|
||
{
|
||
var b = new Button { Content = glyph };
|
||
b.Classes.Add("accept-gutter");
|
||
ToolTip.SetTip(b, tip);
|
||
b.Click += (_, _) => onClick();
|
||
Canvas.SetLeft(b, 1);
|
||
Canvas.SetTop(b, Math.Max(0, y));
|
||
canvas.Children.Add(b);
|
||
}
|
||
|
||
// ── Conflict overview ruler (right of the result pane) ───────────────────
|
||
|
||
// A proportional map of every conflict in the active file so they're findable in
|
||
// long files without scrolling; ticks recolor by resolved state and jump on click.
|
||
private void PopulateConflictMap()
|
||
{
|
||
ConflictMap.Children.Clear();
|
||
if (_vm?.ActiveFile is null || _resultRegions.Count == 0) return;
|
||
var h = ConflictMap.Bounds.Height;
|
||
if (h <= 1) return;
|
||
var doc = ResultEditor.Document;
|
||
var totalLines = Math.Max(1, doc.LineCount);
|
||
var unresolved = DiffEditorSetup.Brush(this, "MergeConflictEdgeBrush", Color.Parse("#80C87060"));
|
||
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedEdgeBrush", Color.Parse("#806FA86B"));
|
||
|
||
foreach (var region in _resultRegions)
|
||
{
|
||
var line = doc.GetLineByOffset(region.Start.Offset).LineNumber;
|
||
var y = (line - 1) / (double)totalLines * h;
|
||
var tick = new Rectangle
|
||
{
|
||
Width = 9,
|
||
Height = 4,
|
||
Fill = region.Block.IsResolved ? resolved : unresolved,
|
||
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
|
||
};
|
||
Canvas.SetLeft(tick, 2);
|
||
Canvas.SetTop(tick, Math.Min(h - 4, Math.Max(0, y)));
|
||
var r = region;
|
||
tick.PointerPressed += (_, _) => JumpToRegion(r);
|
||
ConflictMap.Children.Add(tick);
|
||
}
|
||
}
|
||
|
||
private void JumpToRegion(ResultRegion region)
|
||
{
|
||
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
|
||
ResultEditor.ScrollToLine(line);
|
||
QueueGutters();
|
||
}
|
||
|
||
private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key);
|
||
|
||
private void ScrollToCurrent()
|
||
{
|
||
if (_vm?.Current is not { } block) return;
|
||
var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block));
|
||
if (region is null) return;
|
||
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
|
||
ResultEditor.ScrollToLine(line);
|
||
QueueGutters();
|
||
}
|
||
|
||
private void InvalidateRenderers()
|
||
{
|
||
OursEditor.TextArea.TextView.InvalidateVisual();
|
||
ResultEditor.TextArea.TextView.InvalidateVisual();
|
||
TheirsEditor.TextArea.TextView.InvalidateVisual();
|
||
}
|
||
|
||
// ── Helper types (single-consumer; live with their consumer per repo style) ─
|
||
|
||
/// <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
|
||
{
|
||
public ResultRegion(MergeConflictBlock block, TextAnchor start, TextAnchor end)
|
||
{
|
||
Block = block; Start = start; End = end;
|
||
}
|
||
public MergeConflictBlock Block { get; }
|
||
public TextAnchor Start { get; }
|
||
public TextAnchor End { get; }
|
||
public List<char> Order { get; } = new();
|
||
}
|
||
|
||
/// <summary>Paints each conflict block with the unresolved/resolved tint across a pane.</summary>
|
||
private sealed class MergeBlockRenderer : IBackgroundRenderer
|
||
{
|
||
private readonly Func<IEnumerable<(int Offset, int Length, bool Resolved)>> _spans;
|
||
private readonly IBrush _conflict;
|
||
private readonly IBrush _resolved;
|
||
|
||
public MergeBlockRenderer(Func<IEnumerable<(int, int, bool)>> spans, IBrush conflict, IBrush resolved)
|
||
{
|
||
_spans = spans; _conflict = conflict; _resolved = resolved;
|
||
}
|
||
|
||
public KnownLayer Layer => KnownLayer.Background;
|
||
|
||
public void Draw(TextView textView, DrawingContext drawingContext)
|
||
{
|
||
if (!textView.VisualLinesValid) return;
|
||
foreach (var (offset, length, resolved) in _spans())
|
||
{
|
||
var brush = resolved ? _resolved : _conflict;
|
||
if (length > 0)
|
||
{
|
||
var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 };
|
||
builder.AddSegment(textView, new Seg(offset, length));
|
||
var geo = builder.CreateGeometry();
|
||
if (geo is not null) drawingContext.DrawGeometry(brush, null, geo);
|
||
}
|
||
else
|
||
{
|
||
// Empty region (nothing accepted yet): a thin marker bar marks the spot.
|
||
var at = offset < textView.Document.TextLength ? offset : Math.Max(0, offset - 1);
|
||
var rects = BackgroundGeometryBuilder.GetRectsForSegment(textView, new Seg(at, 1)).ToList();
|
||
if (rects.Count > 0)
|
||
drawingContext.FillRectangle(brush, new Rect(0, rects[0].Top, textView.Bounds.Width, 3));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Makes everything read-only except the live conflict regions in the result document.</summary>
|
||
private sealed class ConflictReadOnlyProvider : IReadOnlySectionProvider
|
||
{
|
||
private readonly Func<IEnumerable<(int Start, int End)>> _regions;
|
||
public ConflictReadOnlyProvider(Func<IEnumerable<(int, int)>> regions) => _regions = regions;
|
||
|
||
public bool CanInsert(int offset) => _regions().Any(r => offset >= r.Start && offset <= r.End);
|
||
|
||
public IEnumerable<ISegment> GetDeletableSegments(ISegment segment)
|
||
{
|
||
foreach (var (start, end) in _regions())
|
||
{
|
||
var s = Math.Max(segment.Offset, start);
|
||
var e = Math.Min(segment.EndOffset, end);
|
||
if (e > s) yield return new Seg(s, e - s);
|
||
}
|
||
}
|
||
}
|
||
}
|