feat(ui): add merge-target dropdown and merge-all controls to planning detail
- Add SubtaskDiffDto and CombinedDiffResultDto to PlanningDtos.cs - Extend IWorkerClient with 5 planning merge methods and 5 events - Implement methods and hub subscriptions on WorkerClient - Add Status and WorktreeState to SubtaskRowViewModel - Add MergeTargetBranches, SelectedMergeTarget, CanMergeAll, MergeAllDisabledReason, MergeAllError, RecomputeCanMergeAll, MergeAllCommand, ReviewCombinedDiffCommand (Task 14 TODO) to DetailsIslandViewModel - Add planning merge section to DetailsIslandView.axaml (merge target ComboBox + buttons + error label), gated on Task.IsPlanningParent - Add 4 xUnit tests covering CanMergeAll logic and DTO shape Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
[ObservableProperty] private string _newSubtaskTitle = "";
|
||||
|
||||
// Planning merge controls
|
||||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||||
[ObservableProperty] private string? _selectedMergeTarget;
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(MergeAllCommand))]
|
||||
private bool _canMergeAll;
|
||||
[ObservableProperty] private string? _mergeAllDisabledReason;
|
||||
[ObservableProperty] private string? _mergeAllError;
|
||||
|
||||
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _claudeBuf = new();
|
||||
@@ -185,6 +194,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
_worker.WorktreeUpdatedEvent += taskId =>
|
||||
{
|
||||
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
||||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||
};
|
||||
|
||||
_worker.TaskUpdatedEvent += taskId =>
|
||||
{
|
||||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||
};
|
||||
|
||||
Subtasks.CollectionChanged += (_, _) =>
|
||||
{
|
||||
RecomputeCanMergeAll();
|
||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,6 +334,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(TaskIdBadge));
|
||||
Log.Clear();
|
||||
Subtasks.Clear();
|
||||
MergeTargetBranches.Clear();
|
||||
SelectedMergeTarget = null;
|
||||
CanMergeAll = false;
|
||||
MergeAllDisabledReason = null;
|
||||
MergeAllError = null;
|
||||
_claudeBuf.Clear();
|
||||
|
||||
if (row == null)
|
||||
@@ -388,6 +414,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
ct.ThrowIfCancellationRequested();
|
||||
foreach (var s in subs)
|
||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||
|
||||
if (entity.Status == ClaudeDo.Data.Models.TaskStatus.Planning ||
|
||||
entity.Status == ClaudeDo.Data.Models.TaskStatus.Planned)
|
||||
{
|
||||
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
@@ -445,6 +477,119 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
return path;
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var children = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.Where(t => t.ParentTaskId == parentTaskId)
|
||||
.ToListAsync(ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Status = child.Status;
|
||||
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||||
}
|
||||
}
|
||||
|
||||
if (MergeTargetBranches.Count == 0)
|
||||
{
|
||||
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
|
||||
if (childWithWorktree != null)
|
||||
{
|
||||
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
|
||||
if (targets != null)
|
||||
{
|
||||
MergeTargetBranches.Clear();
|
||||
foreach (var b in targets.LocalBranches)
|
||||
MergeTargetBranches.Add(b);
|
||||
SelectedMergeTarget = targets.DefaultBranch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecomputeCanMergeAll();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
|
||||
{
|
||||
if (Task is null) return;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var child = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.FirstOrDefaultAsync(t => t.Id == childTaskId && t.ParentTaskId == Task.Id);
|
||||
if (child == null) return;
|
||||
|
||||
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Status = child.Status;
|
||||
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||||
}
|
||||
|
||||
RecomputeCanMergeAll();
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private void RecomputeCanMergeAll()
|
||||
{
|
||||
var notDone = Subtasks.Count(c => c.Status != ClaudeDo.Data.Models.TaskStatus.Done);
|
||||
if (notDone > 0)
|
||||
{
|
||||
CanMergeAll = false;
|
||||
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
|
||||
return;
|
||||
}
|
||||
var badWt = Subtasks.FirstOrDefault(c =>
|
||||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Discarded ||
|
||||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Kept);
|
||||
if (badWt is not null)
|
||||
{
|
||||
CanMergeAll = false;
|
||||
MergeAllDisabledReason = "at least one worktree was discarded/kept";
|
||||
return;
|
||||
}
|
||||
CanMergeAll = true;
|
||||
MergeAllDisabledReason = null;
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||||
{
|
||||
// TODO(Task 14): open PlanningDiffView once it exists
|
||||
await System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool CanReviewDiff() => Task?.IsPlanningParent == true && Subtasks.Any();
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||
private async System.Threading.Tasks.Task MergeAllAsync()
|
||||
{
|
||||
MergeAllError = null;
|
||||
try
|
||||
{
|
||||
await _worker.MergeAllPlanningAsync(Task!.Id, SelectedMergeTarget ?? "main");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MergeAllError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
||||
{
|
||||
try
|
||||
@@ -665,4 +810,6 @@ public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||
public required string Id { get; init; }
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private bool _done;
|
||||
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
|
||||
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user