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

@@ -31,6 +31,7 @@
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
<!-- Global defaults: every Window inherits Inter Tight + body size.
Controls that need mono opt in via their own class/style. -->

View File

@@ -18,6 +18,8 @@
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<!-- Direct ref so the App.axaml AvaloniaEdit theme (avares://AvaloniaEdit/...) resolves at runtime. -->
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>

View File

@@ -23,7 +23,7 @@ ViewModels/
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
WorktreesOverview, UnifiedDiffParser
Planning/ — PlanningDiffViewModel, ConflictResolutionViewModel
Conflicts/ — ConflictResolverViewModel
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge
@@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolutionViewModel` (unit-merge conflict dialog: conflicted files, open in VS Code, continue/abort planning merge), `ConflictResolverViewModel` (in-app hunk-by-hunk resolver for single-task merges: start conflict merge, resolve hunks, write resolution, continue/abort).
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolutionViewModel` (unit-merge conflict dialog: conflicted files, open in VS Code, continue/abort planning merge), `ConflictResolverViewModel` (in-app **3-way merge editor** for single-task merges: starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`, flattens conflicts for one-at-a-time navigation, per-block Accept Ours/Base/Theirs/Both + editable result, composes each file and writes via `WriteConflictResolution`, continue/abort). The view (`Views/Conflicts/ConflictResolverView`) renders a 3-column Base|Ours|Theirs + result using **AvaloniaEdit** with TextMate syntax highlighting by file extension (theme `StyleInclude` in `App.axaml`); editors are synced in code-behind since AvaloniaEdit's `Text` isn't cleanly bindable.
## Services

View File

@@ -8,6 +8,9 @@
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />

View File

@@ -5,45 +5,74 @@ using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictHunk : ObservableObject
/// <summary>
/// One conflict region in a file: the two competing versions (and the merge base when the
/// merge used diff3 style), plus the chosen <see cref="Resolution"/> (null until resolved).
/// </summary>
public sealed partial class MergeConflictBlock : ObservableObject
{
public string Ours { get; }
public string Theirs { get; }
public string? Base { get; }
public string Theirs { get; }
[ObservableProperty] private string? _resolution;
public bool IsResolved => Resolution is not null;
public bool HasBase => Base is not null;
public ConflictHunk(string ours, string theirs, string? @base)
public MergeConflictBlock(string ours, string? @base, string theirs)
{
Ours = ours;
Theirs = theirs;
Base = @base;
Theirs = theirs;
}
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
[RelayCommand] private void AcceptOurs() => Resolution = Ours;
[RelayCommand] private void AcceptTheirs() => Resolution = Theirs;
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
[RelayCommand] private void EditManually() => Resolution ??= Ours;
[RelayCommand] private void AcceptBase() => Resolution = Base ?? "";
}
public sealed class ConflictFile
/// <summary>An ordered piece of a conflicted file: either stable common text or a conflict block.</summary>
public sealed class MergeFileSegment
{
public bool IsConflict { get; }
public string StableText { get; }
public MergeConflictBlock? Conflict { get; }
private MergeFileSegment(bool isConflict, string stableText, MergeConflictBlock? conflict)
{
IsConflict = isConflict;
StableText = stableText;
Conflict = conflict;
}
public static MergeFileSegment Stable(string text) => new(false, text, null);
public static MergeFileSegment FromConflict(MergeConflictBlock block) => new(true, "", block);
}
/// <summary>A conflicted file: its ordered segments (for reassembly) and just its conflict blocks.</summary>
public sealed class MergeFile
{
public string Path { get; }
public IReadOnlyList<ConflictHunk> Hunks { get; }
public bool IsBinary { get; }
public IReadOnlyList<MergeFileSegment> Segments { get; }
public IReadOnlyList<MergeConflictBlock> Conflicts { get; }
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
public MergeFile(string path, bool isBinary, IReadOnlyList<MergeFileSegment> segments)
{
Path = path;
Hunks = hunks;
IsBinary = isBinary;
Segments = segments;
Conflicts = segments.Where(s => s.IsConflict).Select(s => s.Conflict!).ToList();
}
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
/// <summary>A binary file can't be resolved in-app; a text file is done once every block is resolved.</summary>
public bool AllResolved => !IsBinary && Conflicts.All(c => c.IsResolved);
/// <summary>Merged file content: concatenation of each hunk's resolution
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution.</summary>
public string Compose() => string.Concat(
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? s.Conflict.Ours) : s.StableText));
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
@@ -14,23 +15,61 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
private readonly IWorkerClient _worker;
private readonly string _taskId;
public ObservableCollection<ConflictFile> Files { get; } = new();
public ObservableCollection<MergeFile> Files { get; } = new();
// All text conflicts across all files, flattened for one-at-a-time navigation.
private readonly List<(MergeFile File, MergeConflictBlock Block)> _flat = new();
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _error;
[ObservableProperty] private bool _canContinue;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ContinueHint))]
private bool _canContinue;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasCurrent))]
private MergeConflictBlock? _current;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PositionText))]
[NotifyPropertyChangedFor(nameof(CurrentPath))]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(PreviousCommand))]
private int _currentIndex = -1;
public bool HasCurrent => Current is not null;
public int TotalConflicts => _flat.Count;
public int ResolvedCount => _flat.Count(x => x.Block.IsResolved);
public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null;
public string PositionText => _flat.Count == 0
? "No text conflicts"
: $"Conflict {CurrentIndex + 1} of {_flat.Count} · {ResolvedCount} resolved";
public IReadOnlyList<string> BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList();
public bool HasBinaryFiles => Files.Any(f => f.IsBinary);
public string ContinueHint => HasBinaryFiles
? "Binary conflicts must be resolved externally — abort and resolve in your editor."
: "";
private bool InRange => CurrentIndex >= 0 && CurrentIndex < _flat.Count;
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
public event Action? CurrentChanged;
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
{
_worker = worker;
_taskId = taskId;
}
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
/// Returns true when there is something to resolve (caller should show the dialog).</summary>
public async Task<bool> OpenAsync(string targetBranch)
{
IsBusy = true;
@@ -45,19 +84,24 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
return false;
}
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
var docs = await _worker.GetMergeConflictDocumentsAsync(_taskId);
Files.Clear();
foreach (var f in conflicts.Files)
_flat.Clear();
foreach (var f in docs.Files)
{
var hunks = f.Hunks.Select(h =>
{
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
hk.PropertyChanged += OnHunkChanged;
return hk;
}).ToList();
Files.Add(new ConflictFile(f.Path, hunks));
var segments = f.Segments.Select(s => s.IsConflict
? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
: MergeFileSegment.Stable(s.Text)).ToList();
var file = new MergeFile(f.Path, f.IsBinary, segments);
Files.Add(file);
foreach (var c in file.Conflicts) _flat.Add((file, c));
}
OnPropertyChanged(nameof(TotalConflicts));
OnPropertyChanged(nameof(BinaryFilePaths));
OnPropertyChanged(nameof(HasBinaryFiles));
RecomputeCanContinue();
if (_flat.Count > 0) MoveTo(0);
return Files.Count > 0;
}
catch (Exception ex)
@@ -68,14 +112,41 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
finally { IsBusy = false; }
}
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
private MergeConflictBlock Hook(MergeConflictBlock block)
{
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
RecomputeCanContinue();
block.PropertyChanged += OnBlockChanged;
return block;
}
private void RecomputeCanContinue()
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
{
RecomputeCanContinue();
OnPropertyChanged(nameof(ResolvedCount));
OnPropertyChanged(nameof(PositionText));
}
}
private void RecomputeCanContinue() =>
CanContinue = Files.Count > 0 && Files.All(f => f.AllResolved);
private void MoveTo(int index)
{
CurrentIndex = index;
Current = _flat[index].Block;
OnPropertyChanged(nameof(CurrentPath));
CurrentChanged?.Invoke();
}
private bool CanGoNext() => CurrentIndex >= 0 && CurrentIndex < _flat.Count - 1;
private bool CanGoPrevious() => CurrentIndex > 0;
[RelayCommand(CanExecute = nameof(CanGoNext))]
private void Next() => MoveTo(CurrentIndex + 1);
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
private void Previous() => MoveTo(CurrentIndex - 1);
[RelayCommand]
private async Task ContinueAsync()
@@ -85,8 +156,8 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
Error = null;
try
{
foreach (var file in Files)
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
foreach (var file in Files.Where(f => !f.IsBinary))
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.Compose());
var result = await _worker.ContinueConflictMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))

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}"
<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}">
<!-- 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="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"/>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="path-mono" Text="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- 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);
}
}

View File

@@ -8,6 +8,15 @@ namespace ClaudeDo.Ui.Tests.ViewModels;
public class ConflictResolverViewModelTests
{
// A file split as: stable "a\n", one conflict, stable "z\n".
private static ConflictDocumentDto OneConflictFile(string path = "README.md") =>
new(path, false, new[]
{
new MergeSegmentDto(false, "a\n", "", null, ""),
new MergeSegmentDto(true, "", "ours\n", "base\n", "theirs\n"),
new MergeSegmentDto(false, "z\n", "", null, ""),
});
private sealed class FakeWorker : StubWorkerClient
{
public string? WrittenPath;
@@ -15,15 +24,13 @@ public class ConflictResolverViewModelTests
public bool Continued;
public bool Aborted;
public string ContinueStatus = "merged";
public IReadOnlyList<ConflictDocumentDto> Docs = new[] { OneConflictFile() };
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
{
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
}));
public override Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
=> Task.FromResult(new MergeConflictDocumentsDto(taskId, Docs));
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
{
@@ -43,19 +50,23 @@ public class ConflictResolverViewModelTests
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
{
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
var hasConflicts = await vm.OpenAsync("main");
Assert.True(hasConflicts);
var file = Assert.Single(vm.Files);
Assert.Equal("README.md", file.Path);
Assert.Equal(1, vm.TotalConflicts);
Assert.NotNull(vm.Current);
Assert.False(vm.CanContinue);
file.Hunks[0].AcceptIncomingCommand.Execute(null);
vm.Current!.AcceptTheirsCommand.Execute(null);
Assert.True(vm.CanContinue);
Assert.Equal(1, vm.ResolvedCount);
}
[Fact]
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
public async Task Continue_WritesComposedFile_AndClosesOnMerged()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
@@ -63,11 +74,12 @@ public class ConflictResolverViewModelTests
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null);
vm.Current!.AcceptOursCommand.Execute(null); // choose "ours\n" for the single conflict
await vm.ContinueCommand.ExecuteAsync(null);
Assert.Equal("README.md", worker.WrittenPath);
Assert.Equal("ours\n", worker.WrittenContent);
// stable "a\n" + chosen "ours\n" + stable "z\n"
Assert.Equal("a\nours\nz\n", worker.WrittenContent);
Assert.True(worker.Continued);
Assert.True(closed);
}
@@ -81,13 +93,60 @@ public class ConflictResolverViewModelTests
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
vm.Current!.AcceptBothCommand.Execute(null);
await vm.ContinueCommand.ExecuteAsync(null);
Assert.False(closed);
Assert.NotNull(vm.Error);
}
[Fact]
public async Task Navigation_MovesBetweenConflicts_AcrossTheFlattenedList()
{
var worker = new FakeWorker
{
Docs = new[]
{
new ConflictDocumentDto("a.cs", false, new[]
{
new MergeSegmentDto(true, "", "o1\n", null, "t1\n"),
new MergeSegmentDto(false, "mid\n", "", null, ""),
new MergeSegmentDto(true, "", "o2\n", null, "t2\n"),
}),
},
};
var vm = new ConflictResolverViewModel(worker, "task-1");
await vm.OpenAsync("main");
Assert.Equal(2, vm.TotalConflicts);
Assert.Equal("o1\n", vm.Current!.Ours);
Assert.False(vm.PreviousCommand.CanExecute(null));
Assert.True(vm.NextCommand.CanExecute(null));
vm.NextCommand.Execute(null);
Assert.Equal("o2\n", vm.Current!.Ours);
Assert.False(vm.NextCommand.CanExecute(null));
Assert.True(vm.PreviousCommand.CanExecute(null));
}
[Fact]
public async Task BinaryConflict_BlocksContinue()
{
var worker = new FakeWorker
{
Docs = new[] { new ConflictDocumentDto("logo.png", true, System.Array.Empty<MergeSegmentDto>()) },
};
var vm = new ConflictResolverViewModel(worker, "task-1");
var hasConflicts = await vm.OpenAsync("main");
Assert.True(hasConflicts); // file is shown
Assert.True(vm.HasBinaryFiles);
Assert.Equal(0, vm.TotalConflicts); // nothing to resolve in-app
Assert.False(vm.CanContinue); // binary blocks continue
}
[Fact]
public async Task Abort_CallsWorkerAndCloses()
{