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)
164 lines
5.7 KiB
C#
164 lines
5.7 KiB
C#
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
|
using Xunit;
|
|
|
|
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;
|
|
public string? WrittenContent;
|
|
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<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
|
=> Task.FromResult(new MergeConflictDocumentsDto(taskId, Docs));
|
|
|
|
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
|
{
|
|
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
|
}
|
|
|
|
public override Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
|
{
|
|
Continued = true;
|
|
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
|
|
}
|
|
|
|
public override Task AbortConflictMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
|
|
}
|
|
|
|
[Fact]
|
|
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);
|
|
|
|
vm.Current!.AcceptTheirsCommand.Execute(null);
|
|
Assert.True(vm.CanContinue);
|
|
Assert.Equal(1, vm.ResolvedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Continue_WritesComposedFile_AndClosesOnMerged()
|
|
{
|
|
var worker = new FakeWorker();
|
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
|
var closed = false;
|
|
vm.CloseRequested = () => closed = true;
|
|
|
|
await vm.OpenAsync("main");
|
|
vm.Current!.AcceptOursCommand.Execute(null); // choose "ours\n" for the single conflict
|
|
await vm.ContinueCommand.ExecuteAsync(null);
|
|
|
|
Assert.Equal("README.md", worker.WrittenPath);
|
|
// stable "a\n" + chosen "ours\n" + stable "z\n"
|
|
Assert.Equal("a\nours\nz\n", worker.WrittenContent);
|
|
Assert.True(worker.Continued);
|
|
Assert.True(closed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
|
|
{
|
|
var worker = new FakeWorker { ContinueStatus = "conflict" };
|
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
|
var closed = false;
|
|
vm.CloseRequested = () => closed = true;
|
|
|
|
await vm.OpenAsync("main");
|
|
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()
|
|
{
|
|
var worker = new FakeWorker();
|
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
|
var closed = false;
|
|
vm.CloseRequested = () => closed = true;
|
|
|
|
await vm.AbortCommand.ExecuteAsync(null);
|
|
|
|
Assert.True(worker.Aborted);
|
|
Assert.True(closed);
|
|
}
|
|
}
|