From 1aa06077a85ffb27ead2de071b42006c1ec0fabf Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 5 Jun 2026 10:50:38 +0200 Subject: [PATCH] feat(ui): wire batch selection, target loading and resolve seam Co-Authored-By: Claude Sonnet 4.6 --- .../Modals/WorktreesOverviewModalViewModel.cs | 71 +++++++++++++++++-- .../WorktreesOverviewBatchMergeTests.cs | 50 +++++++++++++ 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index f6c8f42..aa92c6a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -69,9 +69,9 @@ 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][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget; + [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount; + [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging; [ObservableProperty] private string? _batchProgress; public ObservableCollection Rows { get; } = new(); @@ -125,20 +125,24 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase Rows.Clear(); Groups.Clear(); + ConflictRows.Clear(); + SelectedCount = 0; + BatchProgress = null; if (IsGlobal) { foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName)) .OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase)) { var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName }; - foreach (var row in grp) group.Rows.Add(row); + foreach (var row in grp) { HookRow(row); group.Rows.Add(row); } Groups.Add(group); } } else { - foreach (var row in ordered) Rows.Add(row); + foreach (var row in ordered) { HookRow(row); Rows.Add(row); } } + await LoadMergeTargetsAsync(); } finally { @@ -278,6 +282,63 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase public IEnumerable AllRows => IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows; + private void HookRow(WorktreeOverviewRowViewModel row) + { + row.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked) + or nameof(WorktreeOverviewRowViewModel.State)) + RecomputeSelected(); + }; + } + + private void RecomputeSelected() => + SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive); + + // Test seam: adds a row to the flat list with selection tracking wired up. + internal void AddRowForTest(WorktreeOverviewRowViewModel row) + { + HookRow(row); + Rows.Add(row); + } + + private async Task LoadMergeTargetsAsync() + { + var anchor = AllRows.FirstOrDefault(r => r.IsActive); + if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; } + try + { + var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId); + MergeTargets.Clear(); + if (targets is null) { SelectedTarget = null; return; } + foreach (var b in targets.LocalBranches) MergeTargets.Add(b); + SelectedTarget = MergeTargets.Contains(targets.DefaultBranch) + ? targets.DefaultBranch + : MergeTargets.FirstOrDefault(); + } + catch { MergeTargets.Clear(); SelectedTarget = null; } + } + + private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget); + + [RelayCommand(CanExecute = nameof(CanMergeAll))] + private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync); + + [RelayCommand] + private void ResolveConflict(WorktreeOverviewRowViewModel? row) + { + if (row is null) return; + RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? ""); + } + + [RelayCommand] + private void ToggleSelectAll() + { + var actives = AllRows.Where(r => r.IsActive).ToList(); + var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked); + foreach (var r in actives) r.IsChecked = !allChecked; + } + public async Task MergeSelectedAsync( Func> mergeFn, CancellationToken ct = default) diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs index fda13bc..eab3fb6 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs @@ -114,4 +114,54 @@ public class WorktreesOverviewBatchMergeTests Assert.False(called); Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome); } + + [Fact] + public void SelectedCount_tracks_checked_active_rows() + { + var vm = NewVm(); + var a = ActiveRow("a"); + var b = ActiveRow("b"); + var merged = ActiveRow("c"); merged.State = WorktreeState.Merged; + vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged); + + Assert.Equal(0, vm.SelectedCount); + a.IsChecked = true; + Assert.Equal(1, vm.SelectedCount); + b.IsChecked = true; + merged.IsChecked = true; + Assert.Equal(2, vm.SelectedCount); + a.IsChecked = false; + Assert.Equal(1, vm.SelectedCount); + } + + [Fact] + public void ResolveConflict_invokes_seam_with_task_and_target() + { + var vm = NewVm(); + vm.SelectedTarget = "release"; + var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict; + + (string Task, string Target)? captured = null; + vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; }; + + vm.ResolveConflictCommand.Execute(row); + + Assert.Equal(("x", "release"), captured); + } + + [Fact] + public void MergeAll_canExecute_requires_target_selection_and_idle() + { + var vm = NewVm(); + var a = ActiveRow("a"); + vm.AddRowForTest(a); + + Assert.False(vm.MergeAllCommand.CanExecute(null)); + a.IsChecked = true; + Assert.False(vm.MergeAllCommand.CanExecute(null)); + vm.SelectedTarget = "main"; + Assert.True(vm.MergeAllCommand.CanExecute(null)); + vm.IsMerging = true; + Assert.False(vm.MergeAllCommand.CanExecute(null)); + } }