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

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