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.
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 VerticalScrollSync(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);
|
||
}
|
||
}
|
||
}
|
||
}
|