feat(merge): Rider-style 3-pane conflict editor view

Replace the Base|Ours|Theirs read-only columns + single-conflict result with a
whole-file 3-pane editor: Ours (read-only) | editable Result | Theirs (read-only),
reconstructed from the active file's segments so the panes line up on stable text.

- IBackgroundRenderer paints each conflict block (unresolved=blood, resolved=green)
  across all three panes.
- Result document edits are gated by an IReadOnlySectionProvider (stable text is
  read-only; only conflict regions, tracked via TextAnchors, are editable); edits
  flow back to the owning block.
- Between-pane gutters host inline accept controls (>/< ) positioned per conflict;
  click accepts ours/theirs into the result.
- Proportional synced vertical scroll across the panes; file switcher + change-nav
  arrows (F8 / Shift+F8); active-file 'M conflicts - K resolved' readout.
- Merge block tints + AmberBrush tokens; en/de keys for the new labels.

Seam unchanged. App builds; Ui.Tests 128, Localization.Tests 16.
This commit is contained in:
Mika Kuns
2026-06-19 10:15:12 +02:00
parent 378a92c156
commit c4d1acc75b
6 changed files with 432 additions and 97 deletions

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -100,6 +100,14 @@
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
<!-- Merge editor (3-pane conflict resolver) block tints -->
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
<SolidColorBrush x:Key="MergeConflictTintBrush" Color="#28C87060" /> <!-- unresolved conflict (blood) -->
<SolidColorBrush x:Key="MergeConflictEdgeBrush" Color="#80C87060" /> <!-- unresolved conflict gutter edge -->
<SolidColorBrush x:Key="MergeResolvedTintBrush" Color="#206FA86B" /> <!-- resolved conflict (green) -->
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#FF05070A" />

View File

@@ -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 ?? "";

View File

@@ -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 @@
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
<KeyBinding Gesture="F8" Command="{Binding NextCommand}"/>
<KeyBinding Gesture="Shift+F8" Command="{Binding PreviousCommand}"/>
</Window.KeyBindings>
<Window.Styles>
@@ -24,13 +26,38 @@
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
<Setter Property="Padding" Value="6,4" />
<Setter Property="WordWrap" Value="True" />
<Setter Property="Padding" Value="4,2" />
<Setter Property="WordWrap" Value="False" />
</Style>
<Style Selector="Border.col-head">
<Setter Property="Padding" Value="8,4" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
</Style>
<Style Selector="Border.pane">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<!-- Inline accept controls in the between-pane gutters -->
<Style Selector="Button.accept-gutter">
<Setter Property="Width" Value="22" />
<Setter Property="Height" Value="20" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{DynamicResource MergeConflictEdgeBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
<Style Selector="Button.accept-gutter:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource MergeConflictTintBrush}" />
</Style>
</Window.Styles>
@@ -49,7 +76,7 @@
</Grid>
</ctl:ModalShell.Footer>
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*,Auto,Auto,*">
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*">
<!-- Busy / error -->
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
@@ -65,7 +92,7 @@
IsVisible="{Binding HasBinaryFiles}">
<StackPanel Spacing="3">
<TextBlock Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="Binary files can't be merged here — abort and resolve them in your editor:"/>
Text="{loc:Tr conflictResolver.binaryHint}"/>
<ItemsControl ItemsSource="{Binding BinaryFilePaths}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
@@ -76,69 +103,64 @@
</StackPanel>
</Border>
<!-- Navigation header -->
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,8"
<!-- Toolbar: change nav · file switcher · readout -->
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,Auto,*,Auto" Margin="0,0,0,8"
IsVisible="{Binding HasCurrent}">
<Button Grid.Column="0" Classes="btn" Content="◀ Prev" Margin="0,0,6,0"
<Button Grid.Column="0" Classes="btn" Content="" Margin="0,0,4,0" Padding="10,4"
ToolTip.Tip="{loc:Tr conflictResolver.prevConflict}"
Command="{Binding PreviousCommand}"/>
<Button Grid.Column="1" Classes="btn" Content="Next ▶"
<Button Grid.Column="1" Classes="btn" Content="↓" Margin="0,0,12,0" Padding="10,4"
ToolTip.Tip="{loc:Tr conflictResolver.nextConflict}"
Command="{Binding NextCommand}"/>
<TextBlock Grid.Column="2" Classes="path-mono" VerticalAlignment="Center"
Margin="14,0" TextTrimming="CharacterEllipsis"
Text="{Binding CurrentPath}"/>
<TextBlock Grid.Column="3" Classes="meta" VerticalAlignment="Center"
<ComboBox Grid.Column="2" MinWidth="240" MaxWidth="520"
ItemsSource="{Binding Files}"
SelectedItem="{Binding ActiveFile, Mode=TwoWay}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:MergeFile">
<TextBlock Classes="path-mono" Text="{Binding Path}" TextTrimming="CharacterEllipsis"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Column="4" Classes="meta" VerticalAlignment="Center"
Foreground="{DynamicResource TextDimBrush}"
Text="{Binding PositionText}"/>
</Grid>
<!-- Three-way columns: Base | Ours | Theirs -->
<Grid Grid.Row="3" ColumnDefinitions="*,*,*" IsVisible="{Binding HasCurrent}">
<Border Grid.Column="0" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="0,0,4,0">
<!-- Three panes: Ours | (gutter) | Result | (gutter) | Theirs -->
<Grid Grid.Row="3" ColumnDefinitions="*,26,*,26,*" IsVisible="{Binding HasCurrent}">
<Border Grid.Column="0" Classes="pane">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="BASE"/>
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.ours}"
Foreground="{DynamicResource MossBrush}"/>
</Border>
<ae:TextEditor Name="BaseEditor" IsReadOnly="True"/>
<ae:TextEditor Name="OursEditor" IsReadOnly="True" ShowLineNumbers="True"/>
</DockPanel>
</Border>
<Border Grid.Column="1" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="4,0">
<Canvas Grid.Column="1" Name="LeftGutter" Background="Transparent"/>
<Border Grid.Column="2" Classes="pane">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="OURS · current (merge target)" Foreground="{DynamicResource MossBrush}"/>
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.result}"/>
</Border>
<ae:TextEditor Name="OursEditor" IsReadOnly="True"/>
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
</DockPanel>
</Border>
<Border Grid.Column="2" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="4,0,0,0">
<Canvas Grid.Column="3" Name="RightGutter" Background="Transparent"/>
<Border Grid.Column="4" Classes="pane">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="THEIRS · incoming (task)" Foreground="{DynamicResource AccentBrush}"/>
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.theirs}"
Foreground="{DynamicResource AmberBrush}"/>
</Border>
<ae:TextEditor Name="TheirsEditor" IsReadOnly="True"/>
<ae:TextEditor Name="TheirsEditor" IsReadOnly="True" ShowLineNumbers="True"/>
</DockPanel>
</Border>
</Grid>
<!-- Accept actions -->
<WrapPanel Grid.Row="4" Orientation="Horizontal" Margin="0,8" IsVisible="{Binding HasCurrent}">
<Button Classes="btn" Content="Accept Ours" Margin="0,0,8,0"
Command="{Binding Current.AcceptOursCommand}"/>
<Button Classes="btn" Content="Accept Base" Margin="0,0,8,0"
IsEnabled="{Binding Current.HasBase}"
Command="{Binding Current.AcceptBaseCommand}"/>
<Button Classes="btn" Content="Accept Theirs" Margin="0,0,8,0"
Command="{Binding Current.AcceptTheirsCommand}"/>
<Button Classes="btn" Content="Accept Both" Margin="0,0,8,0"
Command="{Binding Current.AcceptBothCommand}"/>
</WrapPanel>
<!-- Merged result -->
<TextBlock Grid.Row="5" Classes="eyebrow" Text="MERGED RESULT" Margin="0,4,0,4"
IsVisible="{Binding HasCurrent}"/>
<Border Grid.Row="6" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6"
IsVisible="{Binding HasCurrent}">
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
</Border>
</Grid>
</ctl:ModalShell>
</Window>

View File

@@ -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<MergeConflictBlock> _hookedBlocks = new();
private ScrollViewer?[] _scrollViewers = Array.Empty<ScrollViewer?>();
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);
}
finally { _reloading = false; }
}
// User edits in the result editor flow back to the current conflict's resolution.
private void OnResultEditorChanged(object? sender, EventArgs e)
var file = _vm.ActiveFile;
if (file is null || file.IsBinary)
{
if (_reloading || _vm?.Current is null) return;
_vm.Current.Resolution = ResultEditor.Text;
OursEditor.Text = TheirsEditor.Text = "";
if (ResultEditor.Document is { } d0) d0.Text = "";
_oursSpans = new(); _theirsSpans = new();
InvalidateRenderers();
return;
}
// Accept-buttons set Resolution on the VM; mirror that into the result editor.
private void OnCurrentResolutionChanged(object? sender, PropertyChangedEventArgs e)
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)
{
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; }
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);
}
Dispatcher.UIThread.Post(PositionGutters, DispatcherPriority.Loaded);
}
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();
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<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);
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) ─
/// <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())
{
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);
}
}
}
/// <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);
else if (e == s && segment.Length == 0 && s >= start && s <= end) yield return new Seg(s, 0);
}
}
}
}