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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
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<MergeConflictBlock, string> 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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user