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:
@@ -239,12 +239,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
|
||||
{
|
||||
var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
|
||||
foreach (var id in result.RemovedTaskIds)
|
||||
await _broadcaster.WorktreeUpdated(id);
|
||||
return new WorktreeCleanupDto(result.Removed);
|
||||
}
|
||||
|
||||
public async Task<WorktreeResetDto> ResetAllWorktrees()
|
||||
{
|
||||
var result = await _wtMaintenance.ResetAllAsync();
|
||||
foreach (var id in result.RemovedTaskIds)
|
||||
await _broadcaster.WorktreeUpdated(id);
|
||||
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
|
||||
}
|
||||
|
||||
@@ -262,6 +266,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var repo = new WorktreeRepository(ctx);
|
||||
var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (existing is null) throw new HubException("worktree not found");
|
||||
|
||||
// Allowed transitions: Active -> Merged | Discarded | Kept. Terminal states are final.
|
||||
if (existing.State == newState) return true;
|
||||
if (existing.State != WorktreeState.Active || newState == WorktreeState.Active)
|
||||
throw new HubException($"invalid worktree state transition {existing.State} -> {newState}");
|
||||
|
||||
await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted);
|
||||
await _broadcaster.WorktreeUpdated(taskId);
|
||||
return true;
|
||||
|
||||
@@ -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