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:
@@ -69,9 +69,9 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
[ObservableProperty] private string? _statusMessage;
|
[ObservableProperty] private string? _statusMessage;
|
||||||
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
|
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
|
||||||
[ObservableProperty] private string? _selectedTarget;
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
|
||||||
[ObservableProperty] private int _selectedCount;
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
|
||||||
[ObservableProperty] private bool _isMerging;
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
|
||||||
[ObservableProperty] private string? _batchProgress;
|
[ObservableProperty] private string? _batchProgress;
|
||||||
|
|
||||||
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||||||
@@ -125,20 +125,24 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
Rows.Clear();
|
Rows.Clear();
|
||||||
Groups.Clear();
|
Groups.Clear();
|
||||||
|
ConflictRows.Clear();
|
||||||
|
SelectedCount = 0;
|
||||||
|
BatchProgress = null;
|
||||||
if (IsGlobal)
|
if (IsGlobal)
|
||||||
{
|
{
|
||||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
||||||
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
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);
|
Groups.Add(group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var row in ordered) Rows.Add(row);
|
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||||||
}
|
}
|
||||||
|
await LoadMergeTargetsAsync();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -278,6 +282,63 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||||||
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
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(
|
public async Task MergeSelectedAsync(
|
||||||
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
|
|||||||
@@ -114,4 +114,54 @@ public class WorktreesOverviewBatchMergeTests
|
|||||||
Assert.False(called);
|
Assert.False(called);
|
||||||
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user