feat(ui): add skip-and-continue batch merge orchestration
This commit is contained in:
@@ -69,9 +69,18 @@ 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] private int _selectedCount;
|
||||||
|
[ObservableProperty] private bool _isMerging;
|
||||||
|
[ObservableProperty] private string? _batchProgress;
|
||||||
|
|
||||||
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||||||
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
|
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
|
||||||
|
public ObservableCollection<string> MergeTargets { get; } = new();
|
||||||
|
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
||||||
|
|
||||||
|
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
||||||
|
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
|
public Action<WorktreeModalViewModel>? 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,
|
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
||||||
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||||||
|
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
||||||
|
|
||||||
|
public async Task MergeSelectedAsync(
|
||||||
|
Func<string, string, bool, string, Task<MergeResultDto>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -28,4 +29,89 @@ public class WorktreesOverviewBatchMergeTests
|
|||||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||||
Assert.False(row.IsConflict);
|
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<string>(), null);
|
||||||
|
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
|
||||||
|
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user