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

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