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",
|
"windowTitle": "Merge-Konflikte lösen",
|
||||||
"modalTitle": "KONFLIKTE LÖSEN",
|
"modalTitle": "KONFLIKTE LÖSEN",
|
||||||
"loading": "Konflikte werden geladen…",
|
"loading": "Konflikte werden geladen…",
|
||||||
"current": "Aktuell (unsere)",
|
"ours": "OURS · aktuell (Ziel-Branch)",
|
||||||
"incoming": "Eingehend (ihre)",
|
"result": "ERGEBNIS",
|
||||||
"mergedResult": "Zusammengeführtes Ergebnis",
|
"theirs": "THEIRS · eingehend (Task)",
|
||||||
"acceptCurrent": "Aktuelle übernehmen",
|
"binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
|
||||||
"acceptIncoming": "Eingehende übernehmen",
|
"prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
|
||||||
"acceptBoth": "Beide übernehmen",
|
"nextConflict": "Nächster Konflikt (F8)",
|
||||||
"editManually": "Manuell bearbeiten",
|
"acceptOurs": "Ours ins Ergebnis übernehmen",
|
||||||
|
"acceptTheirs": "Theirs ins Ergebnis übernehmen",
|
||||||
"continue": "Lösen & fortfahren",
|
"continue": "Lösen & fortfahren",
|
||||||
"abort": "Merge abbrechen"
|
"abort": "Merge abbrechen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -400,13 +400,14 @@
|
|||||||
"windowTitle": "Resolve merge conflicts",
|
"windowTitle": "Resolve merge conflicts",
|
||||||
"modalTitle": "RESOLVE CONFLICTS",
|
"modalTitle": "RESOLVE CONFLICTS",
|
||||||
"loading": "Loading conflicts…",
|
"loading": "Loading conflicts…",
|
||||||
"current": "Current (ours)",
|
"ours": "OURS · current (merge target)",
|
||||||
"incoming": "Incoming (theirs)",
|
"result": "RESULT",
|
||||||
"mergedResult": "Merged result",
|
"theirs": "THEIRS · incoming (task)",
|
||||||
"acceptCurrent": "Accept Current",
|
"binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
|
||||||
"acceptIncoming": "Accept Incoming",
|
"prevConflict": "Previous conflict (Shift+F8)",
|
||||||
"acceptBoth": "Accept Both",
|
"nextConflict": "Next conflict (F8)",
|
||||||
"editManually": "Edit manually",
|
"acceptOurs": "Accept ours into result",
|
||||||
|
"acceptTheirs": "Accept theirs into result",
|
||||||
"continue": "Resolve & continue",
|
"continue": "Resolve & continue",
|
||||||
"abort": "Abort merge"
|
"abort": "Abort merge"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,6 +100,14 @@
|
|||||||
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
|
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
|
||||||
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
|
<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) -->
|
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||||
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||||
<GradientStop Offset="0" Color="#FF05070A" />
|
<GradientStop Offset="0" Color="#FF05070A" />
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(ActiveTheirsText));
|
OnPropertyChanged(nameof(ActiveTheirsText));
|
||||||
OnPropertyChanged(nameof(ActiveResultText));
|
OnPropertyChanged(nameof(ActiveResultText));
|
||||||
OnPropertyChanged(nameof(PositionText));
|
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 ?? "";
|
public string ActiveOursText => ActiveFile?.OursText ?? "";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:DataType="vm:ConflictResolverViewModel"
|
x:DataType="vm:ConflictResolverViewModel"
|
||||||
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||||
Title="{loc:Tr conflictResolver.windowTitle}"
|
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||||
Width="1120" Height="760" MinWidth="840" MinHeight="540"
|
Width="1280" Height="820" MinWidth="960" MinHeight="560"
|
||||||
CanResize="True"
|
CanResize="True"
|
||||||
WindowDecorations="BorderOnly"
|
WindowDecorations="BorderOnly"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||||
|
<KeyBinding Gesture="F8" Command="{Binding NextCommand}"/>
|
||||||
|
<KeyBinding Gesture="Shift+F8" Command="{Binding PreviousCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
|
|
||||||
<Window.Styles>
|
<Window.Styles>
|
||||||
@@ -24,13 +26,38 @@
|
|||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
||||||
<Setter Property="Padding" Value="6,4" />
|
<Setter Property="Padding" Value="4,2" />
|
||||||
<Setter Property="WordWrap" Value="True" />
|
<Setter Property="WordWrap" Value="False" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.col-head">
|
<Style Selector="Border.col-head">
|
||||||
<Setter Property="Padding" Value="8,4" />
|
<Setter Property="Padding" Value="8,4" />
|
||||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
|
<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>
|
</Style>
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
@@ -49,7 +76,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</ctl:ModalShell.Footer>
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*,Auto,Auto,*">
|
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*">
|
||||||
|
|
||||||
<!-- Busy / error -->
|
<!-- Busy / error -->
|
||||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
|
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
|
||||||
@@ -65,7 +92,7 @@
|
|||||||
IsVisible="{Binding HasBinaryFiles}">
|
IsVisible="{Binding HasBinaryFiles}">
|
||||||
<StackPanel Spacing="3">
|
<StackPanel Spacing="3">
|
||||||
<TextBlock Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
<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 ItemsSource="{Binding BinaryFilePaths}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="x:String">
|
<DataTemplate x:DataType="x:String">
|
||||||
@@ -76,69 +103,64 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Navigation header -->
|
<!-- Toolbar: change nav · file switcher · readout -->
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,8"
|
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,Auto,*,Auto" Margin="0,0,0,8"
|
||||||
IsVisible="{Binding HasCurrent}">
|
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}"/>
|
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}"/>
|
Command="{Binding NextCommand}"/>
|
||||||
<TextBlock Grid.Column="2" Classes="path-mono" VerticalAlignment="Center"
|
<ComboBox Grid.Column="2" MinWidth="240" MaxWidth="520"
|
||||||
Margin="14,0" TextTrimming="CharacterEllipsis"
|
ItemsSource="{Binding Files}"
|
||||||
Text="{Binding CurrentPath}"/>
|
SelectedItem="{Binding ActiveFile, Mode=TwoWay}">
|
||||||
<TextBlock Grid.Column="3" Classes="meta" VerticalAlignment="Center"
|
<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}"
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
Text="{Binding PositionText}"/>
|
Text="{Binding PositionText}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Three-way columns: Base | Ours | Theirs -->
|
<!-- Three panes: Ours | (gutter) | Result | (gutter) | Theirs -->
|
||||||
<Grid Grid.Row="3" ColumnDefinitions="*,*,*" IsVisible="{Binding HasCurrent}">
|
<Grid Grid.Row="3" ColumnDefinitions="*,26,*,26,*" IsVisible="{Binding HasCurrent}">
|
||||||
<Border Grid.Column="0" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="0,0,4,0">
|
<Border Grid.Column="0" Classes="pane">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||||
<TextBlock Classes="eyebrow" Text="BASE"/>
|
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.ours}"
|
||||||
|
Foreground="{DynamicResource MossBrush}"/>
|
||||||
</Border>
|
</Border>
|
||||||
<ae:TextEditor Name="BaseEditor" IsReadOnly="True"/>
|
<ae:TextEditor Name="OursEditor" IsReadOnly="True" ShowLineNumbers="True"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</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>
|
<DockPanel>
|
||||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
<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>
|
</Border>
|
||||||
<ae:TextEditor Name="OursEditor" IsReadOnly="True"/>
|
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</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>
|
<DockPanel>
|
||||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
<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>
|
</Border>
|
||||||
<ae:TextEditor Name="TheirsEditor" IsReadOnly="True"/>
|
<ae:TextEditor Name="TheirsEditor" IsReadOnly="True" ShowLineNumbers="True"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</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>
|
</Grid>
|
||||||
</ctl:ModalShell>
|
</ctl:ModalShell>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using AvaloniaEdit;
|
using AvaloniaEdit;
|
||||||
|
using AvaloniaEdit.Document;
|
||||||
|
using AvaloniaEdit.Editing;
|
||||||
|
using AvaloniaEdit.Rendering;
|
||||||
using AvaloniaEdit.TextMate;
|
using AvaloniaEdit.TextMate;
|
||||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
using TextMateSharp.Grammars;
|
using TextMateSharp.Grammars;
|
||||||
@@ -13,9 +23,21 @@ public partial class ConflictResolverView : Window
|
|||||||
{
|
{
|
||||||
private ConflictResolverViewModel? _vm;
|
private ConflictResolverViewModel? _vm;
|
||||||
private RegistryOptions? _registry;
|
private RegistryOptions? _registry;
|
||||||
private TextMate.Installation? _baseTm, _oursTm, _theirsTm, _resultTm;
|
private TextMate.Installation? _oursTm, _resultTm, _theirsTm;
|
||||||
private MergeConflictBlock? _hooked;
|
|
||||||
private bool _reloading;
|
// 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()
|
public ConflictResolverView()
|
||||||
{
|
{
|
||||||
@@ -26,62 +48,280 @@ public partial class ConflictResolverView : Window
|
|||||||
{
|
{
|
||||||
base.OnDataContextChanged(e);
|
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;
|
_vm = DataContext as ConflictResolverViewModel;
|
||||||
if (_vm is null) return;
|
if (_vm is null) return;
|
||||||
|
|
||||||
_vm.CloseRequested = Close;
|
_vm.CloseRequested = Close;
|
||||||
EnsureTextMate();
|
EnsureEditors();
|
||||||
_vm.CurrentChanged += ReloadEditors;
|
_vm.ActiveFileChanged += Rebuild;
|
||||||
ResultEditor.TextChanged += OnResultEditorChanged;
|
_vm.CurrentChanged += ScrollToCurrent;
|
||||||
ReloadEditors();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureTextMate()
|
// ── One-time editor setup ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void EnsureEditors()
|
||||||
{
|
{
|
||||||
if (_registry is not null) return;
|
if (_registry is not null) return;
|
||||||
_registry = new RegistryOptions(ThemeName.DarkPlus);
|
_registry = new RegistryOptions(ThemeName.DarkPlus);
|
||||||
_baseTm = BaseEditor.InstallTextMate(_registry);
|
|
||||||
_oursTm = OursEditor.InstallTextMate(_registry);
|
_oursTm = OursEditor.InstallTextMate(_registry);
|
||||||
_theirsTm = TheirsEditor.InstallTextMate(_registry);
|
|
||||||
_resultTm = ResultEditor.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;
|
if (_vm is null) return;
|
||||||
_reloading = true;
|
_rebuilding = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_hooked is not null) _hooked.PropertyChanged -= OnCurrentResolutionChanged;
|
ClearGutters();
|
||||||
_hooked = _vm.Current;
|
UnhookBlocks();
|
||||||
if (_hooked is not null) _hooked.PropertyChanged += OnCurrentResolutionChanged;
|
_resultRegions.Clear();
|
||||||
|
|
||||||
BaseEditor.Text = _vm.Current?.Base ?? "";
|
var file = _vm.ActiveFile;
|
||||||
OursEditor.Text = _vm.Current?.Ours ?? "";
|
if (file is null || file.IsBinary)
|
||||||
TheirsEditor.Text = _vm.Current?.Theirs ?? "";
|
{
|
||||||
ResultEditor.Text = _vm.Current?.Resolution ?? "";
|
OursEditor.Text = TheirsEditor.Text = "";
|
||||||
ApplyGrammar(_vm.CurrentPath);
|
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 static (string Text, List<(int Offset, int Length, MergeConflictBlock Block)> Spans) BuildSide(
|
||||||
private void OnResultEditorChanged(object? sender, EventArgs e)
|
MergeFile file, Func<MergeConflictBlock, string> pick)
|
||||||
{
|
{
|
||||||
if (_reloading || _vm?.Current is null) return;
|
var sb = new StringBuilder();
|
||||||
_vm.Current.Resolution = ResultEditor.Text;
|
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 UnhookBlocks()
|
||||||
private void OnCurrentResolutionChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
{
|
||||||
if (_reloading || e.PropertyName != nameof(MergeConflictBlock.Resolution)) return;
|
foreach (var b in _hookedBlocks) b.PropertyChanged -= OnBlockChanged;
|
||||||
var resolved = _vm?.Current?.Resolution ?? "";
|
_hookedBlocks.Clear();
|
||||||
if (ResultEditor.Text == resolved) return;
|
}
|
||||||
_reloading = true;
|
|
||||||
try { ResultEditor.Text = resolved; }
|
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
finally { _reloading = false; }
|
{
|
||||||
|
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)
|
private void ApplyGrammar(string? path)
|
||||||
@@ -89,14 +329,71 @@ public partial class ConflictResolverView : Window
|
|||||||
if (_registry is null || string.IsNullOrEmpty(path)) return;
|
if (_registry is null || string.IsNullOrEmpty(path)) return;
|
||||||
var ext = Path.GetExtension(path);
|
var ext = Path.GetExtension(path);
|
||||||
if (string.IsNullOrEmpty(ext)) return;
|
if (string.IsNullOrEmpty(ext)) return;
|
||||||
|
|
||||||
var language = _registry.GetLanguageByExtension(ext);
|
var language = _registry.GetLanguageByExtension(ext);
|
||||||
if (language is null) return;
|
if (language is null) return;
|
||||||
|
|
||||||
var scope = _registry.GetScopeByLanguageId(language.Id);
|
var scope = _registry.GetScopeByLanguageId(language.Id);
|
||||||
_baseTm?.SetGrammar(scope);
|
|
||||||
_oursTm?.SetGrammar(scope);
|
_oursTm?.SetGrammar(scope);
|
||||||
_theirsTm?.SetGrammar(scope);
|
|
||||||
_resultTm?.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