Replace the single-side replace (and the short-lived accept-both button) with additive accepts: each result conflict region starts EMPTY (thin marker bar), and the gutter controls append a side in click order — > adds ours, < adds theirs (first pick on top, next below), x clears. Controls stay visible after the first pick so both sides can be stacked; empty/unresolved regions render a marker so they stay visible. en/de keys updated; Ui 128 + Localization 16 green.
442 lines
17 KiB
C#
442 lines
17 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.ComponentModel;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using Avalonia;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Media;
|
||
using Avalonia.Threading;
|
||
using Avalonia.VisualTree;
|
||
using AvaloniaEdit;
|
||
using AvaloniaEdit.Document;
|
||
using AvaloniaEdit.Editing;
|
||
using AvaloniaEdit.Rendering;
|
||
using AvaloniaEdit.TextMate;
|
||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||
using TextMateSharp.Grammars;
|
||
|
||
namespace ClaudeDo.Ui.Views.Conflicts;
|
||
|
||
public partial class ConflictResolverView : Window
|
||
{
|
||
private ConflictResolverViewModel? _vm;
|
||
private RegistryOptions? _registry;
|
||
private TextMate.Installation? _oursTm, _resultTm, _theirsTm;
|
||
|
||
// 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<(MergeConflictBlock Block, TextAnchor Start, TextAnchor End)> _resultRegions = new();
|
||
private readonly List<MergeConflictBlock> _hookedBlocks = new();
|
||
|
||
private ScrollViewer?[] _scrollViewers = Array.Empty<ScrollViewer?>();
|
||
private bool _wired;
|
||
private bool _rebuilding;
|
||
private bool _applyingAccept;
|
||
private bool _syncing;
|
||
private bool _gutterPending;
|
||
|
||
public ConflictResolverView()
|
||
{
|
||
InitializeComponent();
|
||
}
|
||
|
||
protected override void OnDataContextChanged(EventArgs e)
|
||
{
|
||
base.OnDataContextChanged(e);
|
||
|
||
if (_vm is not null)
|
||
{
|
||
_vm.ActiveFileChanged -= Rebuild;
|
||
_vm.CurrentChanged -= ScrollToCurrent;
|
||
}
|
||
// The editors persist across a DataContext swap, so drop stale scroll-sync hooks first.
|
||
foreach (var sv in _scrollViewers)
|
||
if (sv is not null) sv.ScrollChanged -= OnPaneScroll;
|
||
_scrollViewers = Array.Empty<ScrollViewer?>();
|
||
_wired = false;
|
||
|
||
_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 (_registry is not null) return;
|
||
_registry = new RegistryOptions(ThemeName.DarkPlus);
|
||
_oursTm = OursEditor.InstallTextMate(_registry);
|
||
_resultTm = ResultEditor.InstallTextMate(_registry);
|
||
_theirsTm = TheirsEditor.InstallTextMate(_registry);
|
||
|
||
ResultEditor.Document ??= new TextDocument();
|
||
ResultEditor.Document.Changed += OnResultDocumentChanged;
|
||
ResultEditor.TextArea.ReadOnlySectionProvider =
|
||
new ConflictReadOnlyProvider(() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset)));
|
||
|
||
var conflict = BrushRes("MergeConflictTintBrush", Color.Parse("#28C87060"));
|
||
var resolved = BrushRes("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));
|
||
}
|
||
|
||
private IBrush BrushRes(string key, Color fallback)
|
||
{
|
||
if (this.TryGetResource(key, null, out var v) && v is IBrush b)
|
||
return b;
|
||
return new SolidColorBrush(fallback);
|
||
}
|
||
|
||
// ── Rebuild the three documents for the active file ───────────────────────
|
||
|
||
private void Rebuild()
|
||
{
|
||
if (_vm is null) return;
|
||
_rebuilding = true;
|
||
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((block, start, end));
|
||
block.PropertyChanged += OnBlockChanged;
|
||
_hookedBlocks.Add(block);
|
||
}
|
||
|
||
ApplyGrammar(file.Path);
|
||
InvalidateRenderers();
|
||
}
|
||
finally { _rebuilding = false; }
|
||
|
||
if (!_wired)
|
||
{
|
||
_wired = true;
|
||
Dispatcher.UIThread.Post(HookScrollSync, DispatcherPriority.Loaded);
|
||
}
|
||
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 (block, start, end) in _resultRegions)
|
||
{
|
||
if (e.Offset >= start.Offset && e.Offset <= end.Offset)
|
||
{
|
||
block.Resolution = ResultEditor.Document.GetText(start.Offset, Math.Max(0, end.Offset - start.Offset));
|
||
break;
|
||
}
|
||
}
|
||
QueueGutters();
|
||
}
|
||
|
||
// ── Accept a side into the result ────────────────────────────────────────
|
||
|
||
private void AppendOurs(MergeConflictBlock block) => AppendSide(block, block.Ours);
|
||
private void AppendTheirs(MergeConflictBlock block) => AppendSide(block, block.Theirs);
|
||
|
||
// Accept APPENDS a side to the result region in click order (first pick on top, the
|
||
// next below), so a conflict can take ours, theirs, or both — and stay editable.
|
||
private void AppendSide(MergeConflictBlock block, string text)
|
||
{
|
||
var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block));
|
||
if (region.Block is null) return;
|
||
_applyingAccept = true;
|
||
try { ResultEditor.Document.Insert(region.End.Offset, text); }
|
||
finally { _applyingAccept = false; }
|
||
block.Resolution = ResultEditor.Document.GetText(region.Start.Offset, Math.Max(0, region.End.Offset - region.Start.Offset));
|
||
InvalidateRenderers();
|
||
PositionGutters();
|
||
}
|
||
|
||
// Reset a conflict back to empty/unresolved (start over).
|
||
private void ClearRegion(MergeConflictBlock block)
|
||
{
|
||
var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block));
|
||
if (region.Block is null) return;
|
||
_applyingAccept = true;
|
||
try { ResultEditor.Document.Replace(region.Start.Offset, region.End.Offset - region.Start.Offset, ""); }
|
||
finally { _applyingAccept = false; }
|
||
block.Resolution = null;
|
||
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();
|
||
if (_vm?.ActiveFile is null) return;
|
||
var tv = ResultEditor.TextArea.TextView;
|
||
if (!tv.VisualLinesValid)
|
||
{
|
||
QueueGutters();
|
||
return;
|
||
}
|
||
|
||
var doc = ResultEditor.Document;
|
||
foreach (var (block, start, end) in _resultRegions)
|
||
{
|
||
// Controls stay visible even once resolved, so you can append the other side too.
|
||
var len = end.Offset - start.Offset;
|
||
ISegment probe = len > 0
|
||
? new Seg(start.Offset, len)
|
||
: new Seg(start.Offset, start.Offset < doc.TextLength ? 1 : 0);
|
||
var rects = BackgroundGeometryBuilder.GetRectsForSegment(tv, probe).ToList();
|
||
if (rects.Count == 0) continue;
|
||
var y = rects[0].Top;
|
||
|
||
var capturedBlock = block;
|
||
if (tv.TranslatePoint(new Point(0, y), LeftGutter) is { } pl &&
|
||
pl.Y > -24 && pl.Y < LeftGutter.Bounds.Height + 24)
|
||
{
|
||
AddAcceptButton(LeftGutter, pl.Y, "›", () => AppendOurs(capturedBlock),
|
||
Tr("conflictResolver.acceptOurs"));
|
||
// ✕ resets the conflict to empty so you can start the stack over.
|
||
AddAcceptButton(LeftGutter, pl.Y + 21, "✕", () => ClearRegion(capturedBlock),
|
||
Tr("conflictResolver.clearConflict"));
|
||
}
|
||
|
||
if (tv.TranslatePoint(new Point(0, y), RightGutter) is { } pr &&
|
||
pr.Y > -24 && pr.Y < RightGutter.Bounds.Height + 24)
|
||
AddAcceptButton(RightGutter, pr.Y, "‹", () => AppendTheirs(capturedBlock),
|
||
Tr("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);
|
||
}
|
||
|
||
private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key);
|
||
|
||
// ── Synced vertical scroll across the three panes ─────────────────────────
|
||
|
||
private void HookScrollSync()
|
||
{
|
||
_scrollViewers = new[] { OursEditor, ResultEditor, TheirsEditor }
|
||
.Select(ed => ed.FindDescendantOfType<ScrollViewer>())
|
||
.ToArray();
|
||
foreach (var sv in _scrollViewers)
|
||
if (sv is not null) sv.ScrollChanged += OnPaneScroll;
|
||
}
|
||
|
||
private void OnPaneScroll(object? sender, ScrollChangedEventArgs e)
|
||
{
|
||
if (_syncing || sender is not ScrollViewer src) return;
|
||
_syncing = true;
|
||
try
|
||
{
|
||
foreach (var sv in _scrollViewers)
|
||
if (sv is not null && !ReferenceEquals(sv, src) && Math.Abs(sv.Offset.Y - src.Offset.Y) > 0.5)
|
||
sv.Offset = new Vector(sv.Offset.X, src.Offset.Y);
|
||
}
|
||
finally { _syncing = false; }
|
||
PositionGutters();
|
||
}
|
||
|
||
private void ScrollToCurrent()
|
||
{
|
||
if (_vm?.Current is not { } block) return;
|
||
var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block));
|
||
if (region.Block 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();
|
||
}
|
||
|
||
private void ApplyGrammar(string? path)
|
||
{
|
||
if (_registry is null || string.IsNullOrEmpty(path)) return;
|
||
var ext = Path.GetExtension(path);
|
||
if (string.IsNullOrEmpty(ext)) return;
|
||
var language = _registry.GetLanguageByExtension(ext);
|
||
if (language is null) return;
|
||
var scope = _registry.GetScopeByLanguageId(language.Id);
|
||
_oursTm?.SetGrammar(scope);
|
||
_resultTm?.SetGrammar(scope);
|
||
_theirsTm?.SetGrammar(scope);
|
||
}
|
||
|
||
// ── Helper types (single-consumer; live with their consumer per repo style) ─
|
||
|
||
/// <summary>A minimal <see cref="ISegment"/> for geometry/read-only queries.</summary>
|
||
private readonly struct Seg : ISegment
|
||
{
|
||
public Seg(int offset, int length) { Offset = offset; Length = length; }
|
||
public int Offset { get; }
|
||
public int Length { get; }
|
||
public int EndOffset => Offset + Length;
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|
||
}
|
||
}
|