feat(ui): add inline conflict resolver view-model
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly string _taskId;
|
||||||
|
|
||||||
|
public ObservableCollection<ConflictFile> Files { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string? _error;
|
||||||
|
[ObservableProperty] private bool _canContinue;
|
||||||
|
|
||||||
|
public string TaskId => _taskId;
|
||||||
|
public Action? CloseRequested { get; set; }
|
||||||
|
|
||||||
|
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>
|
||||||
|
public async Task<bool> OpenAsync(string targetBranch)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
||||||
|
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
|
||||||
|
Error = start.ErrorMessage;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
||||||
|
Files.Clear();
|
||||||
|
foreach (var f in conflicts.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));
|
||||||
|
}
|
||||||
|
RecomputeCanContinue();
|
||||||
|
return Files.Count > 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
||||||
|
RecomputeCanContinue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeCanContinue()
|
||||||
|
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ContinueAsync()
|
||||||
|
{
|
||||||
|
if (!CanContinue) return;
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Files)
|
||||||
|
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
||||||
|
|
||||||
|
var result = await _worker.ContinueMergeAsync(_taskId);
|
||||||
|
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
else
|
||||||
|
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AbortAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try { await _worker.AbortMergeAsync(_taskId); }
|
||||||
|
catch (Exception ex) { Error = ex.Message; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
private sealed class FakeWorker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public string? WrittenPath;
|
||||||
|
public string? WrittenContent;
|
||||||
|
public bool Continued;
|
||||||
|
public bool Aborted;
|
||||||
|
public string ContinueStatus = "merged";
|
||||||
|
|
||||||
|
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 WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
|
{
|
||||||
|
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||||
|
{
|
||||||
|
Continued = true;
|
||||||
|
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task AbortMergeAsync(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.False(vm.CanContinue);
|
||||||
|
|
||||||
|
file.Hunks[0].AcceptIncomingCommand.Execute(null);
|
||||||
|
Assert.True(vm.CanContinue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null);
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("README.md", worker.WrittenPath);
|
||||||
|
Assert.Equal("ours\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.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(closed);
|
||||||
|
Assert.NotNull(vm.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user