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 _hookedBlocks = new(); private ScrollViewer?[] _scrollViewers = Array.Empty(); 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(); _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 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()) .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) ─ /// A minimal for geometry/read-only queries. 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; } /// 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); } } } }