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);
|
||||
public static MergeFileSegment Stable(string text) => new(false, text, null);
|
||||
public static MergeFileSegment FromConflict(MergeConflictBlock block) => new(true, "", block);
|
||||
}
|
||||
|
||||
/// <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>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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user