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

@@ -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>