feat(ui): merge action and robust jump-to-task in worktrees overview
Add Merge entry to the worktrees overview context menu wiring the existing MergeModalViewModel, replace fire-and-forget list selection with a collection-change-aware JumpToTaskHelper, and propagate list renames to visible task rows via a new ListUpdated event. Harden worktree state changes: WorkerHub.SetWorktreeState now rejects invalid transitions, WorktreeMaintenanceService only drops the DB row when the on-disk worktree was actually removed, and Cleanup/Reset broadcast WorktreeUpdated for affected tasks. SetWorktreeStateAsync returns the hub error message so the modal can surface it. Also: de-duplicate the worktrees overview modal opener, hook OnParentTaskIdChanged to refresh IsDraft, fix MergeModal CanExecute notifications, and add WorktreeStateHubTests for the transition rules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,8 @@ namespace ClaudeDo.Worker.Worktrees;
|
||||
|
||||
public sealed class WorktreeMaintenanceService
|
||||
{
|
||||
public sealed record CleanupResult(int Removed);
|
||||
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
public sealed record CleanupResult(int Removed, IReadOnlyList<string> RemovedTaskIds);
|
||||
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList<string> RemovedTaskIds);
|
||||
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
@@ -40,12 +40,16 @@ public sealed class WorktreeMaintenanceService
|
||||
var rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct);
|
||||
|
||||
int removed = 0;
|
||||
var removedTaskIds = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (await TryRemoveAsync(row, force: false, ct))
|
||||
{
|
||||
removed++;
|
||||
removedTaskIds.Add(row.TaskId);
|
||||
}
|
||||
}
|
||||
return new CleanupResult(removed);
|
||||
return new CleanupResult(removed, removedTaskIds);
|
||||
}
|
||||
|
||||
public async Task<ResetResult> ResetAllAsync(CancellationToken ct = default)
|
||||
@@ -54,7 +58,7 @@ public sealed class WorktreeMaintenanceService
|
||||
var running = await context.Tasks.AsNoTracking()
|
||||
.CountAsync(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running, ct);
|
||||
if (running > 0)
|
||||
return new ResetResult(0, 0, Blocked: true, RunningTasks: running);
|
||||
return new ResetResult(0, 0, Blocked: true, RunningTasks: running, Array.Empty<string>());
|
||||
|
||||
var rows = await (from w in context.Worktrees
|
||||
join t in context.Tasks on w.TaskId equals t.Id
|
||||
@@ -64,12 +68,16 @@ public sealed class WorktreeMaintenanceService
|
||||
.ToListAsync(ct);
|
||||
|
||||
int removed = 0;
|
||||
var removedTaskIds = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (await TryRemoveAsync(row, force: true, ct))
|
||||
{
|
||||
removed++;
|
||||
removedTaskIds.Add(row.TaskId);
|
||||
}
|
||||
}
|
||||
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
|
||||
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0, removedTaskIds);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(
|
||||
@@ -122,9 +130,11 @@ public sealed class WorktreeMaintenanceService
|
||||
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
||||
{
|
||||
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
||||
bool dirRemoved;
|
||||
|
||||
if (repoDirExists)
|
||||
{
|
||||
dirRemoved = true;
|
||||
try
|
||||
{
|
||||
await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct);
|
||||
@@ -137,15 +147,19 @@ public sealed class WorktreeMaintenanceService
|
||||
catch (Exception delEx)
|
||||
{
|
||||
_logger.LogError(delEx, "Directory.Delete fallback also failed for {Path}", row.Path);
|
||||
dirRemoved = false;
|
||||
}
|
||||
}
|
||||
if (Directory.Exists(row.Path)) dirRemoved = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
dirRemoved = true;
|
||||
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Directory.Delete failed for {Path}", row.Path);
|
||||
dirRemoved = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +184,10 @@ public sealed class WorktreeMaintenanceService
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the DB row only when the on-disk worktree is gone; otherwise we'd silently
|
||||
// strand a directory while reporting success.
|
||||
if (!dirRemoved) return false;
|
||||
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct);
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user