diff --git a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs new file mode 100644 index 0000000..c0c15a8 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs @@ -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 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; + } + + /// Starts the conflict merge and loads ours/theirs/base per file. + /// Returns true when there are conflicts to resolve (caller should show the dialog). + public async Task 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(); + } + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs new file mode 100644 index 0000000..842f95a --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs @@ -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 StartConflictMergeAsync(string taskId, string targetBranch) + => Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null)); + + public override Task 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 ContinueMergeAsync(string taskId) + { + Continued = true; + return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty(), 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); + } +}