feat(merge): in-app 3-way merge editor (chunk 2b)

Replace the whole-file conflict resolver with a real 3-way merge editor
built on the line-level hunk pipeline.

- ConflictModels: MergeFile/MergeFileSegment/MergeConflictBlock with
  Compose() that reassembles stable text + chosen resolutions
- ConflictResolverViewModel (same seam contract): loads conflict
  documents, flattens conflicts for one-at-a-time navigation, per-block
  Accept Ours/Base/Theirs/Both + editable result, binary files block continue
- ConflictResolverView: 3-column Base|Ours|Theirs + editable result via
  AvaloniaEdit with TextMate syntax highlighting by file extension;
  editors synced in code-behind
- add Avalonia.AvaloniaEdit + AvaloniaEdit.TextMate + TextMateSharp.Grammars;
  AvaloniaEdit theme StyleInclude in App.axaml
- rewrite ConflictResolverViewModel tests (load/gating/compose/nav/binary/abort)
This commit is contained in:
Mika Kuns
2026-06-18 16:46:43 +02:00
parent e779e13654
commit 92767c646e
9 changed files with 416 additions and 106 deletions

View File

@@ -2,11 +2,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:ae="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:ConflictResolverViewModel"
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
Title="{loc:Tr conflictResolver.windowTitle}"
Width="760" Height="640" MinWidth="560" MinHeight="420"
Width="1120" Height="760" MinWidth="840" MinHeight="540"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
@@ -18,65 +19,126 @@
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="ae|TextEditor">
<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" />
</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}" />
</Style>
</Window.Styles>
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
Foreground="{DynamicResource BloodBrush}"
Text="{Binding ContinueHint}"
IsVisible="{Binding HasBinaryFiles}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Classes="btn accent" Content="{loc:Tr conflictResolver.continue}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
</StackPanel>
</Grid>
</ctl:ModalShell.Footer>
<Grid RowDefinitions="Auto,*" Margin="16,12">
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
Text="{loc:Tr conflictResolver.loading}"
IsVisible="{Binding IsBusy}"/>
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*,Auto,Auto,*">
<!-- Busy / error -->
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
Text="{loc:Tr conflictResolver.loading}" IsVisible="{Binding IsBusy}"/>
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="{Binding Error}" TextWrapping="Wrap"
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictFile">
<StackPanel Spacing="8" Margin="0,0,0,16">
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
<ItemsControl ItemsSource="{Binding Hunks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictHunk">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
CornerRadius="6" Padding="10" Margin="0,0,0,8">
<StackPanel Spacing="6">
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" MaxHeight="120"/>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" MaxHeight="120"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
Command="{Binding AcceptCurrentCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
Command="{Binding AcceptIncomingCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
Command="{Binding AcceptBothCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
Command="{Binding EditManuallyCommand}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Binary-conflict banner -->
<Border Grid.Row="1" Margin="0,0,0,8" Padding="10,7" CornerRadius="6"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
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:"/>
<ItemsControl ItemsSource="{Binding BinaryFilePaths}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="path-mono" Text="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Navigation header -->
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,8"
IsVisible="{Binding HasCurrent}">
<Button Grid.Column="0" Classes="btn" Content="◀ Prev" Margin="0,0,6,0"
Command="{Binding PreviousCommand}"/>
<Button Grid.Column="1" Classes="btn" Content="Next ▶"
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"
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">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="BASE"/>
</Border>
<ae:TextEditor Name="BaseEditor" IsReadOnly="True"/>
</DockPanel>
</Border>
<Border Grid.Column="1" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="4,0">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="OURS · current (merge target)" Foreground="{DynamicResource MossBrush}"/>
</Border>
<ae:TextEditor Name="OursEditor" IsReadOnly="True"/>
</DockPanel>
</Border>
<Border Grid.Column="2" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="4,0,0,0">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="THEIRS · incoming (task)" Foreground="{DynamicResource AccentBrush}"/>
</Border>
<ae:TextEditor Name="TheirsEditor" IsReadOnly="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,19 +1,102 @@
using System;
using System.ComponentModel;
using System.IO;
using Avalonia.Controls;
using AvaloniaEdit;
using AvaloniaEdit.TextMate;
using ClaudeDo.Ui.ViewModels.Conflicts;
using TextMateSharp.Grammars;
namespace ClaudeDo.Ui.Views.Conflicts;
public partial class ConflictResolverView : Window
{
private ConflictResolverViewModel? _vm;
private RegistryOptions? _registry;
private TextMate.Installation? _baseTm, _oursTm, _theirsTm, _resultTm;
private MergeConflictBlock? _hooked;
private bool _reloading;
public ConflictResolverView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(System.EventArgs e)
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ConflictResolverViewModel vm)
vm.CloseRequested = Close;
if (_vm is not null) _vm.CurrentChanged -= ReloadEditors;
_vm = DataContext as ConflictResolverViewModel;
if (_vm is null) return;
_vm.CloseRequested = Close;
EnsureTextMate();
_vm.CurrentChanged += ReloadEditors;
ResultEditor.TextChanged += OnResultEditorChanged;
ReloadEditors();
}
private void EnsureTextMate()
{
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);
}
private void ReloadEditors()
{
if (_vm is null) return;
_reloading = true;
try
{
if (_hooked is not null) _hooked.PropertyChanged -= OnCurrentResolutionChanged;
_hooked = _vm.Current;
if (_hooked is not null) _hooked.PropertyChanged += OnCurrentResolutionChanged;
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)
{
if (_reloading || _vm?.Current is null) return;
_vm.Current.Resolution = ResultEditor.Text;
}
// Accept-buttons set Resolution on the VM; mirror that into the result editor.
private void OnCurrentResolutionChanged(object? sender, PropertyChangedEventArgs e)
{
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; }
}
private void ApplyGrammar(string? path)
{
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);
}
}