diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 1ddde29..36d1339 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -400,13 +400,14 @@
"windowTitle": "Merge-Konflikte lösen",
"modalTitle": "KONFLIKTE LÖSEN",
"loading": "Konflikte werden geladen…",
- "current": "Aktuell (unsere)",
- "incoming": "Eingehend (ihre)",
- "mergedResult": "Zusammengeführtes Ergebnis",
- "acceptCurrent": "Aktuelle übernehmen",
- "acceptIncoming": "Eingehende übernehmen",
- "acceptBoth": "Beide übernehmen",
- "editManually": "Manuell bearbeiten",
+ "ours": "OURS · aktuell (Ziel-Branch)",
+ "result": "ERGEBNIS",
+ "theirs": "THEIRS · eingehend (Task)",
+ "binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
+ "prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
+ "nextConflict": "Nächster Konflikt (F8)",
+ "acceptOurs": "Ours ins Ergebnis übernehmen",
+ "acceptTheirs": "Theirs ins Ergebnis übernehmen",
"continue": "Lösen & fortfahren",
"abort": "Merge abbrechen"
},
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index e3b9d1e..0317a6b 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -400,13 +400,14 @@
"windowTitle": "Resolve merge conflicts",
"modalTitle": "RESOLVE CONFLICTS",
"loading": "Loading conflicts…",
- "current": "Current (ours)",
- "incoming": "Incoming (theirs)",
- "mergedResult": "Merged result",
- "acceptCurrent": "Accept Current",
- "acceptIncoming": "Accept Incoming",
- "acceptBoth": "Accept Both",
- "editManually": "Edit manually",
+ "ours": "OURS · current (merge target)",
+ "result": "RESULT",
+ "theirs": "THEIRS · incoming (task)",
+ "binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
+ "prevConflict": "Previous conflict (Shift+F8)",
+ "nextConflict": "Next conflict (F8)",
+ "acceptOurs": "Accept ours into result",
+ "acceptTheirs": "Accept theirs into result",
"continue": "Resolve & continue",
"abort": "Abort merge"
},
diff --git a/src/ClaudeDo.Ui/Design/Tokens.axaml b/src/ClaudeDo.Ui/Design/Tokens.axaml
index 8bafa08..d291080 100644
--- a/src/ClaudeDo.Ui/Design/Tokens.axaml
+++ b/src/ClaudeDo.Ui/Design/Tokens.axaml
@@ -100,6 +100,14 @@
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
index 06548e8..732fcd4 100644
--- a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
@@ -57,6 +57,12 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
OnPropertyChanged(nameof(ActiveTheirsText));
OnPropertyChanged(nameof(ActiveResultText));
OnPropertyChanged(nameof(PositionText));
+ // Keep the focused conflict inside the active file (e.g. when switched via the file picker).
+ if (value is not null && (Current is null || !value.Conflicts.Contains(Current)))
+ {
+ var idx = _flat.FindIndex(x => x.File == value);
+ if (idx >= 0) MoveTo(idx);
+ }
}
public string ActiveOursText => ActiveFile?.OursText ?? "";
diff --git a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
index 0b9d8d6..7a023eb 100644
--- a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
+++ b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
@@ -7,7 +7,7 @@
x:DataType="vm:ConflictResolverViewModel"
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
Title="{loc:Tr conflictResolver.windowTitle}"
- Width="1120" Height="760" MinWidth="840" MinHeight="540"
+ Width="1280" Height="820" MinWidth="960" MinHeight="560"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
@@ -17,6 +17,8 @@
+
+
@@ -24,13 +26,38 @@
-
-
+
+
+
+
+
+
@@ -49,7 +76,7 @@
-
+
+ Text="{loc:Tr conflictResolver.binaryHint}"/>
@@ -76,69 +103,64 @@
-
-
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
-
+
-
+
-
+
+
+
+
-
+
-
+
-
+
+
+
+
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs
index 3a21505..b1c366f 100644
--- a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs
@@ -1,8 +1,18 @@
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;
@@ -13,9 +23,21 @@ public partial class ConflictResolverView : Window
{
private ConflictResolverViewModel? _vm;
private RegistryOptions? _registry;
- private TextMate.Installation? _baseTm, _oursTm, _theirsTm, _resultTm;
- private MergeConflictBlock? _hooked;
- private bool _reloading;
+ 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;
public ConflictResolverView()
{
@@ -26,62 +48,280 @@ public partial class ConflictResolverView : Window
{
base.OnDataContextChanged(e);
- if (_vm is not null) _vm.CurrentChanged -= ReloadEditors;
+ if (_vm is not null)
+ {
+ _vm.ActiveFileChanged -= Rebuild;
+ _vm.CurrentChanged -= ScrollToCurrent;
+ }
_vm = DataContext as ConflictResolverViewModel;
if (_vm is null) return;
_vm.CloseRequested = Close;
- EnsureTextMate();
- _vm.CurrentChanged += ReloadEditors;
- ResultEditor.TextChanged += OnResultEditorChanged;
- ReloadEditors();
+ EnsureEditors();
+ _vm.ActiveFileChanged += Rebuild;
+ _vm.CurrentChanged += ScrollToCurrent;
+ Rebuild();
}
- private void EnsureTextMate()
+ // ── One-time editor setup ────────────────────────────────────────────────
+
+ private void EnsureEditors()
{
if (_registry is not null) return;
_registry = new RegistryOptions(ThemeName.DarkPlus);
- _baseTm = BaseEditor.InstallTextMate(_registry);
_oursTm = OursEditor.InstallTextMate(_registry);
- _theirsTm = TheirsEditor.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 void ReloadEditors()
+ 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;
- _reloading = true;
+ _rebuilding = true;
try
{
- if (_hooked is not null) _hooked.PropertyChanged -= OnCurrentResolutionChanged;
- _hooked = _vm.Current;
- if (_hooked is not null) _hooked.PropertyChanged += OnCurrentResolutionChanged;
+ ClearGutters();
+ UnhookBlocks();
+ _resultRegions.Clear();
- BaseEditor.Text = _vm.Current?.Base ?? "";
- OursEditor.Text = _vm.Current?.Ours ?? "";
- TheirsEditor.Text = _vm.Current?.Theirs ?? "";
- ResultEditor.Text = _vm.Current?.Resolution ?? "";
- ApplyGrammar(_vm.CurrentPath);
+ 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);
+ var (resultText, resultSpans) = BuildSide(file, b => b.Resolution ?? b.Ours);
+ _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 { _reloading = false; }
+ finally { _rebuilding = false; }
+
+ if (!_wired)
+ {
+ _wired = true;
+ Dispatcher.UIThread.Post(HookScrollSync, DispatcherPriority.Loaded);
+ }
+ Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Loaded);
}
- // User edits in the result editor flow back to the current conflict's resolution.
- private void OnResultEditorChanged(object? sender, EventArgs e)
+ private static (string Text, List<(int Offset, int Length, MergeConflictBlock Block)> Spans) BuildSide(
+ MergeFile file, Func pick)
{
- if (_reloading || _vm?.Current is null) return;
- _vm.Current.Resolution = ResultEditor.Text;
+ 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);
}
- // Accept-buttons set Resolution on the VM; mirror that into the result editor.
- private void OnCurrentResolutionChanged(object? sender, PropertyChangedEventArgs e)
+ private void UnhookBlocks()
{
- if (_reloading || e.PropertyName != nameof(MergeConflictBlock.Resolution)) return;
- var resolved = _vm?.Current?.Resolution ?? "";
- if (ResultEditor.Text == resolved) return;
- _reloading = true;
- try { ResultEditor.Text = resolved; }
- finally { _reloading = false; }
+ 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();
+ Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Background);
+ }
+ }
+
+ // ── 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;
+ }
+ }
+ Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Background);
+ }
+
+ // ── Accept a side into the result ────────────────────────────────────────
+
+ private void AcceptOurs(MergeConflictBlock block) => AcceptInto(block, block.Ours);
+ private void AcceptTheirs(MergeConflictBlock block) => AcceptInto(block, block.Theirs);
+
+ private void AcceptInto(MergeConflictBlock block, string text)
+ {
+ 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, text);
+ }
+ finally { _applyingAccept = false; }
+ block.Resolution = text;
+ InvalidateRenderers();
+ PositionGutters();
+ }
+
+ // ── Inline accept controls in the between-pane gutters ────────────────────
+
+ private void ClearGutters()
+ {
+ LeftGutter.Children.Clear();
+ RightGutter.Children.Clear();
+ }
+
+ private void PositionGutters()
+ {
+ ClearGutters();
+ if (_vm?.ActiveFile is null) return;
+ var tv = ResultEditor.TextArea.TextView;
+ if (!tv.VisualLinesValid)
+ {
+ Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Background);
+ return;
+ }
+
+ var doc = ResultEditor.Document;
+ foreach (var (block, start, end) in _resultRegions)
+ {
+ if (block.IsResolved) continue;
+ 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, "›", () => AcceptOurs(capturedBlock),
+ Tr("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, "‹", () => AcceptTheirs(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);
+ Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Background);
+ }
+
+ private void InvalidateRenderers()
+ {
+ OursEditor.TextArea.TextView.InvalidateVisual();
+ ResultEditor.TextArea.TextView.InvalidateVisual();
+ TheirsEditor.TextArea.TextView.InvalidateVisual();
}
private void ApplyGrammar(string? path)
@@ -89,14 +329,71 @@ public partial class ConflictResolverView : Window
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);
- _baseTm?.SetGrammar(scope);
_oursTm?.SetGrammar(scope);
- _theirsTm?.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())
+ {
+ ISegment seg = new Seg(offset, Math.Max(length, 0));
+ var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 };
+ builder.AddSegment(textView, seg);
+ var geo = builder.CreateGeometry();
+ if (geo is not null)
+ drawingContext.DrawGeometry(resolved ? _resolved : _conflict, null, geo);
+ }
+ }
+ }
+
+ /// 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);
+ else if (e == s && segment.Length == 0 && s >= start && s <= end) yield return new Seg(s, 0);
+ }
+ }
}
}