feat(ui): wire batch selection, target loading and resolve seam

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-05 10:50:38 +02:00
parent 02b11c727c
commit 1aa06077a8
2 changed files with 116 additions and 5 deletions

View File

@@ -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<WorktreeOverviewRowViewModel> 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<WorktreeOverviewRowViewModel> 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<string, string, bool, string, Task<MergeResultDto>> mergeFn,
CancellationToken ct = default)

View File

@@ -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));
}
}