diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index 9a1a930..f6c8f42 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -69,9 +69,18 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase [ObservableProperty] private bool _isBusy; [ObservableProperty] private string? _statusMessage; [ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow; + [ObservableProperty] private string? _selectedTarget; + [ObservableProperty] private int _selectedCount; + [ObservableProperty] private bool _isMerging; + [ObservableProperty] private string? _batchProgress; public ObservableCollection Rows { get; } = new(); public ObservableCollection Groups { get; } = new(); + public ObservableCollection MergeTargets { get; } = new(); + public ObservableCollection ConflictRows { get; } = new(); + + /// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch) + public Func? RequestConflictResolution { get; set; } public Action? CloseAction { get; set; } public Action? ShowDiffAction { get; set; } @@ -265,4 +274,68 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State, DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk, }; + + public IEnumerable AllRows => + IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows; + + public async Task MergeSelectedAsync( + Func> mergeFn, + CancellationToken ct = default) + { + var target = SelectedTarget; + if (string.IsNullOrWhiteSpace(target)) return; + + var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList(); + if (selected.Count == 0) return; + + IsMerging = true; + ConflictRows.Clear(); + var done = 0; + try + { + foreach (var row in selected) + { + ct.ThrowIfCancellationRequested(); + row.MergeOutcome = BatchMergeOutcome.Merging; + BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count); + + MergeResultDto result; + try + { + result = await mergeFn(row.TaskId, target!, false, + Loc.T("vm.merge.commitMessage", row.TaskTitle)); + } + catch + { + row.MergeOutcome = BatchMergeOutcome.Failed; + continue; + } + + switch (result.Status) + { + case "merged": + row.MergeOutcome = BatchMergeOutcome.Merged; + row.State = WorktreeState.Merged; + row.IsChecked = false; + break; + case "conflict": + row.MergeOutcome = BatchMergeOutcome.Conflict; + ConflictRows.Add(row); + break; + case "blocked": + row.MergeOutcome = BatchMergeOutcome.Blocked; + break; + default: + row.MergeOutcome = BatchMergeOutcome.Failed; + break; + } + } + BatchProgress = Loc.T("vm.worktreesOverview.batchDone", + selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count); + } + finally + { + IsMerging = false; + } + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs index 4db056d..fda13bc 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs @@ -1,4 +1,5 @@ using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; @@ -28,4 +29,89 @@ public class WorktreesOverviewBatchMergeTests row.MergeOutcome = BatchMergeOutcome.Merged; Assert.False(row.IsConflict); } + + private static WorktreesOverviewModalViewModel NewVm() => + new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!); + + private static MergeResultDto Merged() => new("merged", System.Array.Empty(), null); + private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null); + private static MergeResultDto Blocked() => new("blocked", System.Array.Empty(), "blocked"); + + [Fact] + public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows() + { + var vm = NewVm(); + var a = ActiveRow("a"); a.IsChecked = true; + var b = ActiveRow("b"); b.IsChecked = false; + var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; + vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c); + vm.SelectedTarget = "main"; + + var seen = new System.Collections.Generic.List(); + await vm.MergeSelectedAsync((id, target, remove, msg) => + { + seen.Add(id); + Assert.Equal("main", target); + Assert.False(remove); + return System.Threading.Tasks.Task.FromResult(Merged()); + }); + + Assert.Equal(new[] { "a" }, seen); + Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome); + Assert.False(a.IsChecked); + } + + [Fact] + public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it() + { + var vm = NewVm(); + var a = ActiveRow("a"); a.IsChecked = true; + var b = ActiveRow("b"); b.IsChecked = true; + var c = ActiveRow("c"); c.IsChecked = true; + vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c); + vm.SelectedTarget = "main"; + + await vm.MergeSelectedAsync((id, target, remove, msg) => + System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged())); + + Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome); + Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome); + Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); + Assert.Contains(b, vm.ConflictRows); + Assert.Single(vm.ConflictRows); + } + + [Fact] + public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes() + { + var vm = NewVm(); + var a = ActiveRow("a"); a.IsChecked = true; + var b = ActiveRow("b"); b.IsChecked = true; + vm.Rows.Add(a); vm.Rows.Add(b); + vm.SelectedTarget = "main"; + + await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a" + ? System.Threading.Tasks.Task.FromResult(Blocked()) + : throw new System.InvalidOperationException("boom")); + + Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome); + Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome); + Assert.Empty(vm.ConflictRows); + Assert.False(vm.IsMerging); + } + + [Fact] + public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target() + { + var vm = NewVm(); + var a = ActiveRow("a"); a.IsChecked = true; + vm.Rows.Add(a); + vm.SelectedTarget = null; + + var called = false; + await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); }); + + Assert.False(called); + Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome); + } }