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 _resultRegions = new(); private readonly List _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 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) ─ /// An editable conflict region in the result document, tracking which sides are /// currently included (in click order — 'o' = ours/main, 't' = theirs/incoming). 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 Order { get; } = new(); } /// Paints each conflict block with the unresolved/resolved tint across a pane. private sealed class MergeBlockRenderer : IBackgroundRenderer { private readonly Func> _spans; private readonly IBrush _conflict; private readonly IBrush _resolved; public MergeBlockRenderer(Func> 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)); } } } } /// Makes everything read-only except the live conflict regions in the result document. private sealed class ConflictReadOnlyProvider : IReadOnlySectionProvider { private readonly Func> _regions; public ConflictReadOnlyProvider(Func> regions) => _regions = regions; public bool CanInsert(int offset) => _regions().Any(r => offset >= r.Start && offset <= r.End); public IEnumerable 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); } } } }