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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user