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

@@ -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 AcceptBoth() => Resolution = Ours + Theirs;
[RelayCommand] private void EditManually() => Resolution ??= Ours;
[RelayCommand] private void AcceptOurs() => Resolution = Ours;
[RelayCommand] private void AcceptTheirs() => Resolution = Theirs;
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
[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 string Path { get; }
public IReadOnlyList<ConflictHunk> Hunks { get; }
public bool IsConflict { get; }
public string StableText { get; }
public MergeConflictBlock? Conflict { get; }
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
private MergeFileSegment(bool isConflict, string stableText, MergeConflictBlock? conflict)
{
Path = path;
Hunks = hunks;
IsConflict = isConflict;
StableText = stableText;
Conflict = conflict;
}
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.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));
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 bool IsBinary { get; }
public IReadOnlyList<MergeFileSegment> Segments { get; }
public IReadOnlyList<MergeConflictBlock> Conflicts { get; }
public MergeFile(string path, bool isBinary, IReadOnlyList<MergeFileSegment> segments)
{
Path = path;
IsBinary = isBinary;
Segments = segments;
Conflicts = segments.Where(s => s.IsConflict).Select(s => s.Conflict!).ToList();
}
/// <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>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))