Files
ClaudeDo/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs
Mika Kuns d5eec75bea feat(merge): additive conflict accept — stack ours/theirs in click order
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.
2026-06-19 10:50:57 +02:00

442 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}
}