1424 lines
54 KiB
Markdown
1424 lines
54 KiB
Markdown
# Worktree Overview Modal Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a modal that lists every worktree with quick info and context-menu actions, openable per-list (from List context menu) or globally (from Help menu).
|
||
|
||
**Architecture:** Backend additions go on existing `WorktreeMaintenanceService` (overview query, list-scoped cleanup, single-row force remove). New SignalR DTOs + hub methods route data to a new `WorktreesOverviewModalViewModel` + Avalonia view. The existing single-worktree file-tree modal (`WorktreeModalView`) is reused for the "Show diff" action. No new services, no new tables.
|
||
|
||
**Tech Stack:** .NET 8, Avalonia 12, EF Core (SQLite), SignalR, xUnit, CommunityToolkit.Mvvm.
|
||
|
||
**Reference spec:** `docs/superpowers/specs/2026-05-19-worktree-overview-modal-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New:**
|
||
- `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs` — record returned from service
|
||
- `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs` — badge color
|
||
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`
|
||
|
||
**Modified:**
|
||
- `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
|
||
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` (+ DTOs at file bottom)
|
||
- `src/ClaudeDo.Ui/Services/IWorkerClient.cs` (if interface defines the changed methods — check first)
|
||
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` (list context menu command)
|
||
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (Help-menu global command)
|
||
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` (context-menu entry)
|
||
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` (wire `ShowWorktreesOverviewModal`)
|
||
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu item)
|
||
- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` (wire `ShowWorktreesOverviewModal` for global)
|
||
- `src/ClaudeDo.App/Program.cs` (DI registration)
|
||
- `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` (new tests)
|
||
|
||
---
|
||
|
||
## Task 1: Extend `CleanupFinishedAsync` to accept optional `listId`
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs:27-45`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `WorktreeMaintenanceServiceTests`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task CleanupFinished_With_ListId_Only_Removes_That_Lists_Rows()
|
||
{
|
||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||
|
||
var repo = NewRepo();
|
||
var git = new GitService();
|
||
var db = NewDb();
|
||
|
||
var (listA, taskA) = MakeEntities(repo.RepoDir);
|
||
var (listB, taskB) = MakeEntities(repo.RepoDir);
|
||
|
||
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
|
||
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
|
||
|
||
using (var ctx = db.CreateContext())
|
||
{
|
||
await new ListRepository(ctx).AddAsync(listA);
|
||
await new ListRepository(ctx).AddAsync(listB);
|
||
var taskRepo = new TaskRepository(ctx);
|
||
await taskRepo.AddAsync(taskA);
|
||
await taskRepo.AddAsync(taskB);
|
||
var wtRepo = new WorktreeRepository(ctx);
|
||
await wtRepo.AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
await wtRepo.AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
}
|
||
|
||
var svc = new WorktreeMaintenanceService(
|
||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||
|
||
var result = await svc.CleanupFinishedAsync(listA.Id, CancellationToken.None);
|
||
|
||
Assert.Equal(1, result.Removed);
|
||
Assert.False(Directory.Exists(wtA));
|
||
Assert.True(Directory.Exists(wtB));
|
||
|
||
using var checkCtx = db.CreateContext();
|
||
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||
Assert.Single(remaining);
|
||
Assert.Equal(taskB.Id, remaining[0].TaskId);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~CleanupFinished_With_ListId_Only_Removes_That_Lists_Rows"`
|
||
Expected: FAIL — method signature does not accept a `listId`.
|
||
|
||
- [ ] **Step 3: Change `CleanupFinishedAsync` signature and add list filter**
|
||
|
||
Replace the existing `CleanupFinishedAsync` (currently at `WorktreeMaintenanceService.cs:27-45`) with:
|
||
|
||
```csharp
|
||
public async Task<CleanupResult> CleanupFinishedAsync(string? listId = null, CancellationToken ct = default)
|
||
{
|
||
using var context = _dbFactory.CreateDbContext();
|
||
var query = from w in context.Worktrees
|
||
join t in context.Tasks on w.TaskId equals t.Id
|
||
join l in context.Lists on t.ListId equals l.Id
|
||
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
||
select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId };
|
||
|
||
if (!string.IsNullOrEmpty(listId))
|
||
query = query.Where(x => x.ListId == listId);
|
||
|
||
var rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct);
|
||
|
||
int removed = 0;
|
||
foreach (var row in rows)
|
||
{
|
||
if (await TryRemoveAsync(row, force: false, ct))
|
||
removed++;
|
||
}
|
||
return new CleanupResult(removed);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run all tests in the file to verify pass and no regression**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"`
|
||
Expected: PASS (all four tests, existing three plus new one).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs
|
||
git commit -m "feat(worktrees): allow CleanupFinishedAsync to filter by list"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Add `GetOverviewAsync` to `WorktreeMaintenanceService`
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs`
|
||
- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`
|
||
|
||
- [ ] **Step 1: Create the row record**
|
||
|
||
Create `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs`:
|
||
|
||
```csharp
|
||
using ClaudeDo.Data.Models;
|
||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||
|
||
namespace ClaudeDo.Worker.Worktrees;
|
||
|
||
public sealed record WorktreeOverviewRow(
|
||
string TaskId,
|
||
string TaskTitle,
|
||
TaskStatus TaskStatus,
|
||
string ListId,
|
||
string ListName,
|
||
string Path,
|
||
string BranchName,
|
||
WorktreeState State,
|
||
string? DiffStat,
|
||
DateTime CreatedAt,
|
||
bool PathExistsOnDisk);
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Append to `WorktreeMaintenanceServiceTests`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task GetOverview_Returns_All_When_ListId_Null()
|
||
{
|
||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||
|
||
var repo = NewRepo();
|
||
var git = new GitService();
|
||
var db = NewDb();
|
||
|
||
var (listA, taskA) = MakeEntities(repo.RepoDir);
|
||
var (listB, taskB) = MakeEntities(repo.RepoDir);
|
||
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
|
||
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
|
||
|
||
using (var ctx = db.CreateContext())
|
||
{
|
||
await new ListRepository(ctx).AddAsync(listA);
|
||
await new ListRepository(ctx).AddAsync(listB);
|
||
await new TaskRepository(ctx).AddAsync(taskA);
|
||
await new TaskRepository(ctx).AddAsync(taskB);
|
||
var wtRepo = new WorktreeRepository(ctx);
|
||
await wtRepo.AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
await wtRepo.AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
}
|
||
|
||
var svc = new WorktreeMaintenanceService(
|
||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||
|
||
var rows = await svc.GetOverviewAsync(null, CancellationToken.None);
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.Contains(rows, r => r.TaskId == taskA.Id && r.PathExistsOnDisk);
|
||
Assert.Contains(rows, r => r.TaskId == taskB.Id && r.PathExistsOnDisk);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetOverview_Filters_By_ListId()
|
||
{
|
||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||
|
||
var repo = NewRepo();
|
||
var git = new GitService();
|
||
var db = NewDb();
|
||
|
||
var (listA, taskA) = MakeEntities(repo.RepoDir);
|
||
var (listB, taskB) = MakeEntities(repo.RepoDir);
|
||
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
|
||
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
|
||
|
||
using (var ctx = db.CreateContext())
|
||
{
|
||
await new ListRepository(ctx).AddAsync(listA);
|
||
await new ListRepository(ctx).AddAsync(listB);
|
||
await new TaskRepository(ctx).AddAsync(taskA);
|
||
await new TaskRepository(ctx).AddAsync(taskB);
|
||
var wtRepo = new WorktreeRepository(ctx);
|
||
await wtRepo.AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
await wtRepo.AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
}
|
||
|
||
var svc = new WorktreeMaintenanceService(
|
||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||
|
||
var rows = await svc.GetOverviewAsync(listA.Id, CancellationToken.None);
|
||
|
||
Assert.Single(rows);
|
||
Assert.Equal(taskA.Id, rows[0].TaskId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetOverview_Flags_PathExistsOnDisk_False_For_Phantom_Row()
|
||
{
|
||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||
|
||
var repo = NewRepo();
|
||
var git = new GitService();
|
||
var db = NewDb();
|
||
|
||
var (list, task) = MakeEntities(repo.RepoDir);
|
||
var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);
|
||
|
||
// Delete the worktree directory but keep the DB row → phantom.
|
||
try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { }
|
||
if (Directory.Exists(wt)) Directory.Delete(wt, recursive: true);
|
||
|
||
using (var ctx = db.CreateContext())
|
||
{
|
||
await new ListRepository(ctx).AddAsync(list);
|
||
await new TaskRepository(ctx).AddAsync(task);
|
||
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
}
|
||
|
||
var svc = new WorktreeMaintenanceService(
|
||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||
|
||
var rows = await svc.GetOverviewAsync(null, CancellationToken.None);
|
||
|
||
Assert.Single(rows);
|
||
Assert.False(rows[0].PathExistsOnDisk);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~GetOverview"`
|
||
Expected: FAIL — `GetOverviewAsync` does not exist.
|
||
|
||
- [ ] **Step 4: Add `GetOverviewAsync` to `WorktreeMaintenanceService`**
|
||
|
||
Add this method to `WorktreeMaintenanceService` (e.g. directly after the existing `CleanupFinishedAsync`):
|
||
|
||
```csharp
|
||
public async Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(
|
||
string? listId, CancellationToken ct = default)
|
||
{
|
||
using var context = _dbFactory.CreateDbContext();
|
||
var query = from w in context.Worktrees
|
||
join t in context.Tasks on w.TaskId equals t.Id
|
||
join l in context.Lists on t.ListId equals l.Id
|
||
select new
|
||
{
|
||
w.TaskId, t.Title, t.Status, ListId = l.Id, ListName = l.Name,
|
||
w.Path, w.BranchName, w.State, w.DiffStat, w.CreatedAt,
|
||
};
|
||
|
||
if (!string.IsNullOrEmpty(listId))
|
||
query = query.Where(x => x.ListId == listId);
|
||
|
||
var rows = await query.AsNoTracking().ToListAsync(ct);
|
||
|
||
return rows.Select(x => new WorktreeOverviewRow(
|
||
x.TaskId, x.Title, x.Status, x.ListId, x.ListName,
|
||
x.Path, x.BranchName, x.State, x.DiffStat, x.CreatedAt,
|
||
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs
|
||
git commit -m "feat(worktrees): add GetOverviewAsync for overview modal"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Add `ForceRemoveAsync` to `WorktreeMaintenanceService`
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `WorktreeMaintenanceServiceTests`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task ForceRemove_Removes_Active_Worktree()
|
||
{
|
||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||
|
||
var repo = NewRepo();
|
||
var git = new GitService();
|
||
var db = NewDb();
|
||
|
||
var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Done);
|
||
var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);
|
||
|
||
using (var ctx = db.CreateContext())
|
||
{
|
||
await new ListRepository(ctx).AddAsync(list);
|
||
await new TaskRepository(ctx).AddAsync(task);
|
||
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
}
|
||
|
||
var svc = new WorktreeMaintenanceService(
|
||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||
|
||
var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);
|
||
|
||
Assert.True(result.Removed);
|
||
Assert.Null(result.Reason);
|
||
Assert.False(Directory.Exists(wt));
|
||
|
||
using var checkCtx = db.CreateContext();
|
||
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||
Assert.Empty(remaining);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ForceRemove_Blocked_When_Task_Running()
|
||
{
|
||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||
|
||
var repo = NewRepo();
|
||
var git = new GitService();
|
||
var db = NewDb();
|
||
|
||
var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Running);
|
||
var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);
|
||
|
||
using (var ctx = db.CreateContext())
|
||
{
|
||
await new ListRepository(ctx).AddAsync(list);
|
||
await new TaskRepository(ctx).AddAsync(task);
|
||
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
}
|
||
|
||
var svc = new WorktreeMaintenanceService(
|
||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||
|
||
var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);
|
||
|
||
Assert.False(result.Removed);
|
||
Assert.Equal("task is currently running", result.Reason);
|
||
Assert.True(Directory.Exists(wt));
|
||
|
||
try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { }
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ForceRemove_Removes_Phantom_Row()
|
||
{
|
||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||
|
||
var repo = NewRepo();
|
||
var git = new GitService();
|
||
var db = NewDb();
|
||
|
||
var (list, task) = MakeEntities(repo.RepoDir);
|
||
var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}");
|
||
// Note: never created on disk.
|
||
|
||
using (var ctx = db.CreateContext())
|
||
{
|
||
await new ListRepository(ctx).AddAsync(list);
|
||
await new TaskRepository(ctx).AddAsync(task);
|
||
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||
{
|
||
TaskId = task.Id, Path = phantomPath, BranchName = $"test/{task.Id}-phantom",
|
||
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||
});
|
||
}
|
||
|
||
var svc = new WorktreeMaintenanceService(
|
||
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||
|
||
var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);
|
||
|
||
Assert.True(result.Removed);
|
||
|
||
using var checkCtx = db.CreateContext();
|
||
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||
Assert.Empty(remaining);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ForceRemove"`
|
||
Expected: FAIL — `ForceRemoveAsync` does not exist; `ForceRemoveResult` does not exist.
|
||
|
||
- [ ] **Step 3: Add `ForceRemoveResult` record + `ForceRemoveAsync` method**
|
||
|
||
Add to `WorktreeMaintenanceService.cs`, alongside the existing nested records (line 10–11 area):
|
||
|
||
```csharp
|
||
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
||
```
|
||
|
||
Add this method to `WorktreeMaintenanceService` (after `ResetAllAsync`):
|
||
|
||
```csharp
|
||
public async Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct = default)
|
||
{
|
||
using var context = _dbFactory.CreateDbContext();
|
||
|
||
var row = await (from w in context.Worktrees
|
||
join t in context.Tasks on w.TaskId equals t.Id
|
||
join l in context.Lists on t.ListId equals l.Id
|
||
where w.TaskId == taskId
|
||
select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir),
|
||
Status = t.Status })
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(ct);
|
||
|
||
if (row is null)
|
||
return new ForceRemoveResult(false, "worktree not found");
|
||
|
||
if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running)
|
||
return new ForceRemoveResult(false, "task is currently running");
|
||
|
||
var ok = await TryRemoveAsync(row.Row, force: true, ct);
|
||
return new ForceRemoveResult(ok, ok ? null : "remove failed");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"`
|
||
Expected: PASS (all tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs
|
||
git commit -m "feat(worktrees): add ForceRemoveAsync for targeted removal"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Add hub DTOs + new hub methods
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||
|
||
- [ ] **Step 1: Add DTOs at the top of the file (alongside existing records)**
|
||
|
||
In `WorkerHub.cs`, add after `record WorktreeResetDto`:
|
||
|
||
```csharp
|
||
public record WorktreeOverviewDto(
|
||
string TaskId,
|
||
string TaskTitle,
|
||
ClaudeDo.Data.Models.TaskStatus TaskStatus,
|
||
string ListId,
|
||
string ListName,
|
||
string Path,
|
||
string BranchName,
|
||
WorktreeState State,
|
||
string? DiffStat,
|
||
DateTime CreatedAt,
|
||
bool PathExistsOnDisk);
|
||
|
||
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||
```
|
||
|
||
- [ ] **Step 2: Replace existing `CleanupFinishedWorktrees` with the list-scoped version**
|
||
|
||
Replace the current method (around line 223–227):
|
||
|
||
```csharp
|
||
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
|
||
{
|
||
var result = await _wtMaintenance.CleanupFinishedAsync(listId, CancellationToken.None);
|
||
return new WorktreeCleanupDto(result.Removed);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`**
|
||
|
||
Add after `ResetAllWorktrees` (around line 233):
|
||
|
||
```csharp
|
||
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverview(string? listId)
|
||
{
|
||
var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
|
||
return rows.Select(r => new WorktreeOverviewDto(
|
||
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
|
||
r.Path, r.BranchName, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
|
||
}
|
||
|
||
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
|
||
{
|
||
using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new WorktreeRepository(ctx);
|
||
var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||
if (existing is null) throw new HubException("worktree not found");
|
||
await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted);
|
||
await _broadcaster.WorktreeUpdated(taskId);
|
||
return true;
|
||
}
|
||
|
||
public async Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId)
|
||
{
|
||
var result = await _wtMaintenance.ForceRemoveAsync(taskId, Context.ConnectionAborted);
|
||
if (result.Removed)
|
||
await _broadcaster.WorktreeUpdated(taskId);
|
||
return new ForceRemoveResultDto(result.Removed, result.Reason);
|
||
}
|
||
```
|
||
|
||
Note: `HubBroadcaster.WorktreeUpdated(string taskId)` already exists (see `WorkerClient`'s `WorktreeUpdated` handler). If for some reason it does not, add it to `HubBroadcaster` as:
|
||
|
||
```csharp
|
||
public Task WorktreeUpdated(string taskId) => _hub.Clients.All.SendAsync("WorktreeUpdated", taskId);
|
||
```
|
||
|
||
- [ ] **Step 4: Build the Worker project**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
||
git commit -m "feat(hub): expose worktree overview, state mutation, force-remove"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Add `WorkerClient` wrappers
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||
|
||
- [ ] **Step 1: Add UI-side DTOs at the bottom of `WorkerClient.cs`**
|
||
|
||
Append to the existing DTO section at the bottom of the file:
|
||
|
||
```csharp
|
||
public sealed record WorktreeOverviewDto(
|
||
string TaskId,
|
||
string TaskTitle,
|
||
ClaudeDo.Data.Models.TaskStatus TaskStatus,
|
||
string ListId,
|
||
string ListName,
|
||
string Path,
|
||
string BranchName,
|
||
WorktreeState State,
|
||
string? DiffStat,
|
||
DateTime CreatedAt,
|
||
bool PathExistsOnDisk);
|
||
|
||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||
```
|
||
|
||
- [ ] **Step 2: Replace the existing `CleanupFinishedWorktreesAsync` to accept optional `listId`**
|
||
|
||
Replace lines around 398–408 in `WorkerClient.cs`:
|
||
|
||
```csharp
|
||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||
{
|
||
try
|
||
{
|
||
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add the three new wrapper methods**
|
||
|
||
Add after `ResetAllWorktreesAsync` (around line 420):
|
||
|
||
```csharp
|
||
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
|
||
{
|
||
try
|
||
{
|
||
var rows = await _hub.InvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId);
|
||
return rows ?? new List<WorktreeOverviewDto>();
|
||
}
|
||
catch
|
||
{
|
||
return new List<WorktreeOverviewDto>();
|
||
}
|
||
}
|
||
|
||
public async Task<bool> SetWorktreeStateAsync(string taskId, WorktreeState newState)
|
||
{
|
||
try
|
||
{
|
||
return await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
|
||
{
|
||
try
|
||
{
|
||
return await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Check for `IWorkerClient` interface**
|
||
|
||
Run: `grep -n "CleanupFinishedWorktreesAsync\|interface IWorkerClient" src/ClaudeDo.Ui/Services/IWorkerClient.cs`
|
||
|
||
If `IWorkerClient` defines `CleanupFinishedWorktreesAsync`, update its signature to match. If it does not declare it, skip.
|
||
|
||
- [ ] **Step 5: Build the Ui project**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/Services/IWorkerClient.cs
|
||
git commit -m "feat(ui): expose worktree overview client methods"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Add `WorktreeStateColorConverter`
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`
|
||
|
||
- [ ] **Step 1: Create the converter**
|
||
|
||
Create `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`:
|
||
|
||
```csharp
|
||
using System.Globalization;
|
||
using Avalonia.Data.Converters;
|
||
using Avalonia.Media;
|
||
using ClaudeDo.Data.Models;
|
||
|
||
namespace ClaudeDo.Ui.Converters;
|
||
|
||
public sealed class WorktreeStateColorConverter : IValueConverter
|
||
{
|
||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||
value is WorktreeState state
|
||
? state switch
|
||
{
|
||
WorktreeState.Active => new SolidColorBrush(Color.Parse("#42A5F5")), // blue
|
||
WorktreeState.Merged => new SolidColorBrush(Color.Parse("#66BB6A")), // green
|
||
WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")), // gray
|
||
WorktreeState.Kept => new SolidColorBrush(Color.Parse("#FFA726")), // orange
|
||
_ => new SolidColorBrush(Colors.Gray),
|
||
}
|
||
: new SolidColorBrush(Colors.Gray);
|
||
|
||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||
=> throw new NotSupportedException();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build to confirm**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs
|
||
git commit -m "feat(ui): add WorktreeStateColorConverter"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Add `WorktreesOverviewModalViewModel`
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||
|
||
- [ ] **Step 1: Create the row VM and the modal VM**
|
||
|
||
Create `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`:
|
||
|
||
```csharp
|
||
using System.Collections.ObjectModel;
|
||
using System.Diagnostics;
|
||
using Avalonia;
|
||
using Avalonia.Controls.ApplicationLifetimes;
|
||
using Avalonia.Input.Platform;
|
||
using ClaudeDo.Data.Models;
|
||
using ClaudeDo.Ui.Services;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||
|
||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||
|
||
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||
{
|
||
[ObservableProperty] private string _taskId = "";
|
||
[ObservableProperty] private string _taskTitle = "";
|
||
[ObservableProperty] private TaskStatus _taskStatus;
|
||
[ObservableProperty] private string _listId = "";
|
||
[ObservableProperty] private string _listName = "";
|
||
[ObservableProperty] private string _path = "";
|
||
[ObservableProperty] private string _branchName = "";
|
||
[ObservableProperty] private WorktreeState _state;
|
||
[ObservableProperty] private string? _diffStat;
|
||
[ObservableProperty] private DateTime _createdAt;
|
||
[ObservableProperty] private bool _pathExistsOnDisk;
|
||
|
||
public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt);
|
||
public bool IsActive => State == WorktreeState.Active;
|
||
public bool IsRunning => TaskStatus == TaskStatus.Running;
|
||
|
||
private static string FormatAge(TimeSpan ts)
|
||
{
|
||
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d ago";
|
||
if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h ago";
|
||
if (ts.TotalMinutes >= 1) return $"{(int)ts.TotalMinutes}m ago";
|
||
return "just now";
|
||
}
|
||
}
|
||
|
||
public sealed partial class WorktreesGroupViewModel : ViewModelBase
|
||
{
|
||
public required string ListId { get; init; }
|
||
public required string ListName { get; init; }
|
||
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||
}
|
||
|
||
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||
{
|
||
private readonly WorkerClient _worker;
|
||
|
||
[ObservableProperty] private string? _listIdFilter;
|
||
[ObservableProperty] private string _title = "Worktrees";
|
||
[ObservableProperty] private bool _isGlobal;
|
||
[ObservableProperty] private bool _isBusy;
|
||
[ObservableProperty] private string? _statusMessage;
|
||
|
||
// Filtered (per-list) mode populates this directly.
|
||
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||
// Global mode populates this.
|
||
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
|
||
|
||
public Action? CloseAction { get; set; }
|
||
public Action<WorktreeOverviewRowViewModel>? ShowDiffAction { get; set; }
|
||
public Action<string, string>? JumpToTaskAction { get; set; } // (listId, taskId)
|
||
public Func<string, Task<bool>>? ConfirmAction { get; set; } // message → confirmed?
|
||
|
||
public WorktreesOverviewModalViewModel(WorkerClient worker)
|
||
{
|
||
_worker = worker;
|
||
}
|
||
|
||
public void Configure(string? listId, string? listName)
|
||
{
|
||
ListIdFilter = listId;
|
||
IsGlobal = listId is null;
|
||
Title = listId is null ? "Worktrees" : $"Worktrees — {listName ?? "list"}";
|
||
}
|
||
|
||
public async Task LoadAsync(CancellationToken ct = default)
|
||
{
|
||
IsBusy = true;
|
||
StatusMessage = null;
|
||
try
|
||
{
|
||
var dtos = await _worker.GetWorktreesOverviewAsync(ListIdFilter);
|
||
var ordered = dtos
|
||
.OrderBy(d => d.State == WorktreeState.Active ? 0 : 1)
|
||
.ThenByDescending(d => d.CreatedAt)
|
||
.Select(Map)
|
||
.ToList();
|
||
|
||
Rows.Clear();
|
||
Groups.Clear();
|
||
if (IsGlobal)
|
||
{
|
||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName)).OrderBy(g => g.Key.ListName))
|
||
{
|
||
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
||
foreach (var row in grp) group.Rows.Add(row);
|
||
Groups.Add(group);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (var row in ordered) Rows.Add(row);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
IsBusy = false;
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private Task Refresh() => LoadAsync();
|
||
|
||
[RelayCommand]
|
||
private async Task CleanupFinished()
|
||
{
|
||
IsBusy = true;
|
||
try
|
||
{
|
||
var result = await _worker.CleanupFinishedWorktreesAsync(ListIdFilter);
|
||
StatusMessage = result is null ? "Cleanup failed." : $"Removed {result.Removed} worktree(s).";
|
||
await LoadAsync();
|
||
}
|
||
finally { IsBusy = false; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void Close() => CloseAction?.Invoke();
|
||
|
||
[RelayCommand]
|
||
private void ShowDiff(WorktreeOverviewRowViewModel? row)
|
||
{
|
||
if (row is null) return;
|
||
ShowDiffAction?.Invoke(row);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void OpenInExplorer(WorktreeOverviewRowViewModel? row)
|
||
{
|
||
if (row is null || !row.PathExistsOnDisk) return;
|
||
try { Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{row.Path}\"", UseShellExecute = true }); }
|
||
catch { /* best-effort */ }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void JumpToTask(WorktreeOverviewRowViewModel? row)
|
||
{
|
||
if (row is null) return;
|
||
JumpToTaskAction?.Invoke(row.ListId, row.TaskId);
|
||
CloseAction?.Invoke();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task Discard(WorktreeOverviewRowViewModel? row)
|
||
{
|
||
if (row is null || row.State != WorktreeState.Active) return;
|
||
if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded))
|
||
{
|
||
row.State = WorktreeState.Discarded;
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task Keep(WorktreeOverviewRowViewModel? row)
|
||
{
|
||
if (row is null || row.State != WorktreeState.Active) return;
|
||
if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept))
|
||
{
|
||
row.State = WorktreeState.Kept;
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task ForceRemove(WorktreeOverviewRowViewModel? row)
|
||
{
|
||
if (row is null) return;
|
||
if (row.IsRunning) { StatusMessage = "Cannot force-remove a running task."; return; }
|
||
if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return;
|
||
|
||
var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
|
||
if (result is null || !result.Removed)
|
||
{
|
||
StatusMessage = result?.Reason ?? "Force remove failed.";
|
||
return;
|
||
}
|
||
// Remove the row locally.
|
||
if (IsGlobal)
|
||
{
|
||
foreach (var grp in Groups)
|
||
{
|
||
var idx = grp.Rows.IndexOf(row);
|
||
if (idx >= 0) { grp.Rows.RemoveAt(idx); break; }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
Rows.Remove(row);
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task CopyBranch(WorktreeOverviewRowViewModel? row) => await CopyToClipboardAsync(row?.BranchName);
|
||
|
||
[RelayCommand]
|
||
private async Task CopyPath(WorktreeOverviewRowViewModel? row) => await CopyToClipboardAsync(row?.Path);
|
||
|
||
private static async Task CopyToClipboardAsync(string? text)
|
||
{
|
||
if (string.IsNullOrEmpty(text)) return;
|
||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop &&
|
||
desktop.MainWindow?.Clipboard is { } clipboard)
|
||
{
|
||
try { await clipboard.SetTextAsync(text); } catch { }
|
||
}
|
||
}
|
||
|
||
private static WorktreeOverviewRowViewModel Map(WorktreeOverviewDto d) => new()
|
||
{
|
||
TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
|
||
ListId = d.ListId, ListName = d.ListName,
|
||
Path = d.Path, BranchName = d.BranchName, State = d.State,
|
||
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build to confirm**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs
|
||
git commit -m "feat(ui): add WorktreesOverviewModalViewModel"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Add `WorktreesOverviewModalView`
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||
- Create: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`
|
||
|
||
- [ ] **Step 1: Create the XAML**
|
||
|
||
Create `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`:
|
||
|
||
```xml
|
||
<Window xmlns="https://github.com/avaloniaui"
|
||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||
x:Class="ClaudeDo.Ui.Views.Modals.WorktreesOverviewModalView"
|
||
x:DataType="vm:WorktreesOverviewModalViewModel"
|
||
Title="{Binding Title}"
|
||
Width="900" Height="560" MinWidth="640" MinHeight="360"
|
||
WindowStartupLocation="CenterOwner"
|
||
Background="{DynamicResource VoidBrush}">
|
||
<Window.Resources>
|
||
<converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/>
|
||
</Window.Resources>
|
||
|
||
<DockPanel LastChildFill="True" Margin="12">
|
||
<!-- Toolbar -->
|
||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
|
||
<Button Content="Refresh" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||
<Button Content="Cleanup finished" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="12,0,0,0"
|
||
Foreground="{DynamicResource TextDimBrush}"/>
|
||
</StackPanel>
|
||
|
||
<!-- Bottom bar -->
|
||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||
<Button Content="Close" Command="{Binding CloseCommand}"/>
|
||
</StackPanel>
|
||
|
||
<!-- Content: either a flat list (filtered) or groups (global) -->
|
||
<ScrollViewer>
|
||
<Grid>
|
||
<!-- Filtered (single-list) mode -->
|
||
<ItemsControl ItemsSource="{Binding Rows}" IsVisible="{Binding !IsGlobal}">
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
|
||
<ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
|
||
<!-- Global mode -->
|
||
<ItemsControl ItemsSource="{Binding Groups}" IsVisible="{Binding IsGlobal}">
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate DataType="vm:WorktreesGroupViewModel">
|
||
<Expander Header="{Binding ListName}" IsExpanded="True" Margin="0,0,0,6">
|
||
<ItemsControl ItemsSource="{Binding Rows}">
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
|
||
<ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
</Expander>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
</Grid>
|
||
</ScrollViewer>
|
||
</DockPanel>
|
||
|
||
<Window.Styles>
|
||
<Style Selector="Border.wt-row">
|
||
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
|
||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
|
||
<Setter Property="BorderThickness" Value="0,0,0,1"/>
|
||
<Setter Property="Padding" Value="10,8"/>
|
||
</Style>
|
||
</Window.Styles>
|
||
|
||
<Window.Resources>
|
||
<!-- Row template, shared by both modes -->
|
||
<DataTemplate x:Key="WorktreeRowTemplate" x:DataType="vm:WorktreeOverviewRowViewModel">
|
||
<Border Classes="wt-row">
|
||
<Border.ContextMenu>
|
||
<ContextMenu>
|
||
<MenuItem Header="Show diff"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ShowDiffCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
<MenuItem Header="Open in Explorer"
|
||
IsEnabled="{Binding PathExistsOnDisk}"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).OpenInExplorerCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
<MenuItem Header="Jump to task"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).JumpToTaskCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
<Separator/>
|
||
<MenuItem Header="Discard"
|
||
IsEnabled="{Binding IsActive}"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).DiscardCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
<MenuItem Header="Keep"
|
||
IsEnabled="{Binding IsActive}"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).KeepCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
<Separator/>
|
||
<MenuItem Header="Copy branch"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).CopyBranchCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
<MenuItem Header="Copy path"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).CopyPathCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
<Separator/>
|
||
<MenuItem Header="Force remove"
|
||
Foreground="#EF5350"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ForceRemoveCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
</ContextMenu>
|
||
</Border.ContextMenu>
|
||
<Grid ColumnDefinitions="*,200,90,80,80">
|
||
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
|
||
<TextBlock Text="{Binding TaskTitle}" FontWeight="SemiBold"/>
|
||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||
<TextBlock Text="{Binding TaskStatus}" FontSize="10"
|
||
Foreground="{DynamicResource TextFaintBrush}"/>
|
||
<TextBlock Text="•" FontSize="10" Foreground="{DynamicResource TextFaintBrush}"
|
||
IsVisible="{Binding !PathExistsOnDisk}"/>
|
||
<TextBlock Text="phantom" FontSize="10" Foreground="#EF5350"
|
||
IsVisible="{Binding !PathExistsOnDisk}"
|
||
ToolTip.Tip="Directory missing on disk"/>
|
||
</StackPanel>
|
||
</StackPanel>
|
||
<TextBlock Grid.Column="1" Text="{Binding BranchName}"
|
||
FontFamily="{DynamicResource MonoFont}" FontSize="11"
|
||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||
<Border Grid.Column="2" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||
<TextBlock Text="{Binding State}" FontSize="10" Foreground="White"
|
||
HorizontalAlignment="Center"/>
|
||
</Border>
|
||
<TextBlock Grid.Column="3" Text="{Binding DiffStat}" VerticalAlignment="Center"
|
||
FontFamily="{DynamicResource MonoFont}" FontSize="11"
|
||
Foreground="{DynamicResource TextDimBrush}"/>
|
||
<TextBlock Grid.Column="4" Text="{Binding AgeText}" VerticalAlignment="Center"
|
||
FontSize="11" Foreground="{DynamicResource TextDimBrush}"/>
|
||
</Grid>
|
||
</Border>
|
||
</DataTemplate>
|
||
</Window.Resources>
|
||
</Window>
|
||
```
|
||
|
||
> Note: keep only ONE `<Window.Resources>` block. The block above declares the converter and `WorktreeRowTemplate` together. Adjust if Avalonia complains about duplicate `Resources` — merge into a single `<Window.Resources>` near the top.
|
||
|
||
- [ ] **Step 2: Create the code-behind**
|
||
|
||
Create `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`:
|
||
|
||
```csharp
|
||
using Avalonia.Controls;
|
||
|
||
namespace ClaudeDo.Ui.Views.Modals;
|
||
|
||
public partial class WorktreesOverviewModalView : Window
|
||
{
|
||
public WorktreesOverviewModalView() => InitializeComponent();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build to confirm XAML compiles**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||
Expected: build succeeds. If duplicate `Window.Resources` is reported, merge the two blocks into one at the top of the file.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs
|
||
git commit -m "feat(ui): add WorktreesOverviewModalView"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Wire entry points (DI, ListsIslandViewModel, IslandsShellViewModel, views)
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.App/Program.cs`
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`
|
||
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
|
||
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
|
||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
|
||
|
||
- [ ] **Step 1: Register the new VM in DI**
|
||
|
||
In `src/ClaudeDo.App/Program.cs`, find line 97 (`sc.AddTransient<WorktreeModalViewModel>();`) and add right after:
|
||
|
||
```csharp
|
||
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
||
```
|
||
|
||
- [ ] **Step 2: Add the list-scoped open command to `ListsIslandViewModel`**
|
||
|
||
In `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`, add a new modal hook next to `ShowListSettingsModal` (around line 30):
|
||
|
||
```csharp
|
||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||
```
|
||
|
||
Add a new relay command after `OpenListSettingsAsync` (around line 50):
|
||
|
||
```csharp
|
||
[RelayCommand]
|
||
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
|
||
{
|
||
if (row is null || ShowWorktreesOverviewModal is null || _services is null) return;
|
||
if (row.Kind != ListKind.User) return;
|
||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
|
||
vm.Configure(rawId, row.Name);
|
||
await vm.LoadAsync();
|
||
await ShowWorktreesOverviewModal(vm);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add the context-menu entry in the list row template**
|
||
|
||
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`, modify the existing `ContextMenu` block (around line 131–135) to add a second item:
|
||
|
||
```xml
|
||
<ContextMenu>
|
||
<MenuItem Header="Settings..."
|
||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
<MenuItem Header="Worktrees anzeigen…"
|
||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
</ContextMenu>
|
||
```
|
||
|
||
- [ ] **Step 4: Wire `ShowWorktreesOverviewModal` in `ListsIslandView.axaml.cs`**
|
||
|
||
Inside the `DataContextChanged` handler (after `ShowListSettingsModal = …` around line 27 of the file), add:
|
||
|
||
```csharp
|
||
vm.ShowWorktreesOverviewModal = async modal =>
|
||
{
|
||
var window = new WorktreesOverviewModalView { DataContext = modal };
|
||
modal.CloseAction = () => window.Close();
|
||
modal.ShowDiffAction = row =>
|
||
{
|
||
var diffVm = vm._services?.GetRequiredService<WorktreeModalViewModel>();
|
||
// Fallback: ignore if DI not reachable from this codepath.
|
||
if (diffVm is null) return;
|
||
diffVm.WorktreePath = row.Path;
|
||
var diffWindow = new WorktreeModalView { DataContext = diffVm };
|
||
diffVm.CloseAction = () => diffWindow.Close();
|
||
_ = diffVm.LoadAsync();
|
||
_ = diffWindow.ShowDialog(window);
|
||
};
|
||
var top = TopLevel.GetTopLevel(this) as Window;
|
||
if (top is null) window.Show();
|
||
else await window.ShowDialog(top);
|
||
};
|
||
```
|
||
|
||
> If `_services` is private on `ListsIslandViewModel` and inaccessible from the view, expose it as `internal IServiceProvider? Services => _services;` in that VM file, OR construct the diff VM via a different DI accessor available on the view. Verify the access pattern matches existing code; mirror whatever the `ListSettingsModal` wiring does for service-locator-style DI lookups, or expose an internal helper. Note: this internal exposure stays internal-only (no public surface).
|
||
|
||
- [ ] **Step 5: Add the global Help-menu command in `IslandsShellViewModel`**
|
||
|
||
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, near the existing `ShowAboutModal` declaration, add:
|
||
|
||
```csharp
|
||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||
```
|
||
|
||
Add this command (e.g., right after `OpenAbout` around line 250):
|
||
|
||
```csharp
|
||
[RelayCommand]
|
||
private async Task OpenWorktreesOverviewGlobalAsync()
|
||
{
|
||
if (ShowWorktreesOverviewModal is null || _services is null) return;
|
||
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
|
||
vm.Configure(null, null);
|
||
await vm.LoadAsync();
|
||
await ShowWorktreesOverviewModal(vm);
|
||
}
|
||
```
|
||
|
||
> If `IslandsShellViewModel` does not have `_services`, follow the same DI accessor pattern it already uses for `OpenAbout` (e.g., a `Func<AboutModalViewModel>` factory injected via constructor). Find how `AboutModalViewModel` is obtained — the new command must use the same pattern.
|
||
|
||
- [ ] **Step 6: Add the Help-menu entry in `MainWindow.axaml`**
|
||
|
||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, inside the existing Help `MenuItem` (line 65–73), add an item before "About…":
|
||
|
||
```xml
|
||
<MenuItem Header="Worktrees…"
|
||
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||
```
|
||
|
||
- [ ] **Step 7: Wire `ShowWorktreesOverviewModal` for global mode in `MainWindow.axaml.cs`**
|
||
|
||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, find where the existing modal hooks on `IslandsShellViewModel` are wired (look for `ShowAboutModal = ...`). Add right after:
|
||
|
||
```csharp
|
||
shell.ShowWorktreesOverviewModal = async modal =>
|
||
{
|
||
var window = new WorktreesOverviewModalView { DataContext = modal };
|
||
modal.CloseAction = () => window.Close();
|
||
modal.ShowDiffAction = row =>
|
||
{
|
||
// Reuse the existing diff modal.
|
||
var diffVm = App.Services?.GetService(typeof(WorktreeModalViewModel)) as WorktreeModalViewModel;
|
||
if (diffVm is null) return;
|
||
diffVm.WorktreePath = row.Path;
|
||
var diffWindow = new WorktreeModalView { DataContext = diffVm };
|
||
diffVm.CloseAction = () => diffWindow.Close();
|
||
_ = diffVm.LoadAsync();
|
||
_ = diffWindow.ShowDialog(window);
|
||
};
|
||
modal.JumpToTaskAction = (listId, taskId) =>
|
||
{
|
||
// Best-effort: select the list, leave task selection to whatever existing event handlers do.
|
||
if (DataContext is IslandsShellViewModel s)
|
||
{
|
||
var item = s.Lists.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
|
||
if (item is not null) s.Lists.SelectedList = item;
|
||
}
|
||
};
|
||
await window.ShowDialog(this);
|
||
};
|
||
```
|
||
|
||
> If `App.Services` is not the actual DI accessor, use whatever the codebase uses (search for an existing place that retrieves a VM after construction — e.g., a static `App.Current.Services` or a property on the window). The diff VM must be a transient. Keep the pattern simple — if no clean accessor exists in `MainWindow.axaml.cs`, omit `ShowDiffAction` here and only wire it from `ListsIslandView` (where `_services` is reachable on the VM). The user can still close-and-reopen the modal from the list context menu for the diff feature.
|
||
|
||
- [ ] **Step 8: Build the whole solution**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||
Expected: build succeeds. If any DI or ViewModel access errors appear, fix the local accessor pattern to match the existing one in `IslandsShellViewModel` (look at `OpenAbout` for the exact DI shape).
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels src/ClaudeDo.Ui/Views/Islands src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
|
||
git commit -m "feat(ui): wire worktree overview modal entry points"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Final verification
|
||
|
||
- [ ] **Step 1: Run full test suite**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 2: Build whole app**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||
Expected: build succeeds, no warnings about missing XAML resources or unbound commands.
|
||
|
||
- [ ] **Step 3: Manual smoke test (record findings — no commit needed)**
|
||
|
||
Start the Worker (`dotnet run --project src/ClaudeDo.Worker`) and the App (`dotnet run --project src/ClaudeDo.App`). Then exercise:
|
||
|
||
1. Create a user list with a working directory pointing at a git repo. Create a task and let it produce a worktree.
|
||
2. Right-click the user list → "Worktrees anzeigen…" → modal opens, lists this list's worktrees only, no group headers.
|
||
3. Help menu → "Worktrees…" → modal opens, lists grouped by list with expanders.
|
||
4. Right-click a row:
|
||
- "Show diff" → opens the existing file-tree diff modal.
|
||
- "Open in Explorer" → opens the directory (greyed out for phantom rows).
|
||
- "Jump to task" → closes modal, selects the list (best-effort).
|
||
- "Discard" / "Keep" → state badge changes color.
|
||
- "Copy branch" / "Copy path" → clipboard contains expected text.
|
||
- "Force remove" → confirmation dialog (if `ConfirmAction` is wired), then row vanishes, branch + directory removed.
|
||
5. With a task in `Running` state, "Force remove" sets `StatusMessage = "Cannot force-remove a running task."`.
|
||
6. "Cleanup finished" in filtered mode → only that list's Merged/Discarded rows vanish.
|
||
7. "Refresh" reloads from worker.
|
||
|
||
Report any failures in the conversation; do not auto-fix without confirmation.
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
- **Spec coverage**: All UI sections, SignalR contract, backend changes, force-remove semantics, and testing requirements from the spec map to specific tasks (1–9 for code; 1, 2, 3 for tests).
|
||
- **Type consistency**: `WorktreeOverviewDto` shape matches between hub (Task 4) and client (Task 5); `ForceRemoveResult` (service) vs. `ForceRemoveResultDto` (hub/client) is intentional — service uses internal type, hub exposes serializable record.
|
||
- **Open dependency**: `ConfirmAction` on the modal VM is optional; if the view does not wire it, force-remove proceeds without confirmation. That is acceptable for v1; a follow-up can add a styled confirm dialog. Documented in spec as design tradeoff (not gating).
|
||
- **Diff-modal accessor**: Task 9 step 4 and 7 contain a fallback note for DI access — the implementing agent should mirror whatever pattern `IslandsShellViewModel` already uses for `OpenAbout`, not invent a new one. If the existing shell uses a constructor-injected factory, do the same.
|