Merge feat/worktree-overview-modal

This commit is contained in:
mika kuns
2026-05-19 11:55:34 +02:00
23 changed files with 2890 additions and 44 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
# Worktree Overview Modal — Design
**Status:** Approved
**Date:** 2026-05-19
## Problem
Worktree management is becoming hard to oversee. The current UI only exposes per-task worktree actions (merge / keep / discard) from `TaskDetailView`, plus two global maintenance buttons (`CleanupFinishedWorktrees`, `ResetAllWorktrees`). There is no view that shows *all existing worktrees at a glance* with their state, age, branch, and diff stat. Stale or "phantom" worktrees (DB row but missing directory, or vice versa) have no targeted recovery path.
## Goals
- A modal that lists every worktree row from the DB, joined with task + list metadata.
- Two entry points: filtered to one list (List context menu), and global grouped by list (Help menu).
- Quick per-row actions hidden behind a right-click context menu.
- Targeted force-remove for stuck / phantom worktrees.
- Manual refresh only; no live SignalR subscription needed.
## Non-Goals
- No auto-refresh / live updates from SignalR events.
- No UI tests (the project has none for the Ui project).
- No changes to `WorktreeManager`, `TaskRunner`, or the existing per-worktree file-tree modal (`WorktreeModalView`) — it gets reused as the "Show diff" target.
## UI
### New view pair
`WorktreesOverviewModalView` + `WorktreesOverviewModalViewModel`, parallel to existing `WorktreeModalView` (which shows the *file tree inside one* worktree).
### Layout
```
┌─ Worktrees [List "Foo"] or Worktrees (all) ───────────────┐
│ [ Refresh ] [ Cleanup finished ] │
│ │
│ ▼ List Foo (global mode only) │
│ Title Branch State +/- Age │
│ Fix login bug claudedo/ab… Active +42-7 3h ago │
│ Add API … claudedo/cd… Merged +8 -0 1d ago │
│ ▼ List Bar │
│ … │
└──────────────────────────────────────────────────────────────┘
```
- `DataGrid` (or `ItemsControl` with Grid template) for rows.
- List-filtered mode: no group headers, just the table.
- Global mode: `Expander` per list with list name as header (default expanded).
- State as a colored badge — new `WorktreeStateColorConverter` analogous to `StatusColorConverter`:
- Active=Blue, Merged=Green, Discarded=Gray, Kept=Orange.
- Right-click on a row opens a `MenuFlyout` with all actions.
- Phantom rows (`PathExistsOnDisk == false`) get a small warning icon in the Path tooltip area.
### Default sort
State (Active first), then `CreatedAt` descending. Same inside each list group in global mode.
### Per-row context menu
| Item | Enabled when | Behavior |
|---|---|---|
| Show diff | always | Opens existing `WorktreeModalView` with `WorktreePath` set |
| Open in Explorer | `PathExistsOnDisk == true` | `Process.Start("explorer.exe", path)` |
| Jump to task | always | Closes modal, selects list + task in main window |
| Merge | `State == Active` | Calls existing `MergeTask` hub method |
| Discard | `State == Active` | `SetWorktreeState(taskId, Discarded)` |
| Keep | `State == Active` | `SetWorktreeState(taskId, Kept)` |
| Copy branch | always | Clipboard |
| Copy path | always | Clipboard |
| —————— | | (separator) |
| Force remove | `Task.Status != Running` | Confirmation dialog → `ForceRemoveWorktree(taskId)` (red label) |
### Bulk buttons (toolbar)
- **Refresh** — re-runs `GetWorktreesOverview`.
- **Cleanup finished** — `CleanupFinishedWorktrees(listId)`; in list-filtered mode acts on that list, in global mode on all.
### Entry points
- **List context menu** → "Worktrees anzeigen…" → opens modal in filtered mode (`listId` = the list).
- **Help menu** → "Worktrees" → opens modal in global mode (`listId = null`).
`MainWindowViewModel` gets `OpenWorktreesOverviewCommand(listId)` and `OpenWorktreesOverviewGlobalCommand()`, both using a DI `Func<WorktreesOverviewModalViewModel>` factory analogous to existing editor patterns.
## SignalR Contract
### New `WorkerHub` methods
```csharp
Task<IReadOnlyList<WorktreeOverviewDto>> GetWorktreesOverview(string? listId);
Task<bool> SetWorktreeState(string taskId, WorktreeState newState);
Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId);
```
`CleanupFinishedWorktrees` already exists — extend its signature to accept an optional `listId`:
```csharp
Task<CleanupResult> CleanupFinishedWorktrees(string? listId); // was: ()
```
`MergeTask` is reused unchanged.
### DTOs
```csharp
public sealed record WorktreeOverviewDto(
string TaskId,
string TaskTitle,
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);
```
### Broadcasts
After successful `SetWorktreeState` and `ForceRemoveWorktree`, fire `HubBroadcaster.WorktreeUpdated(taskId)` so `TaskDetailView` (if open) refreshes. `CleanupFinishedWorktrees` already broadcasts; keep behavior, optionally batch.
### `WorkerClient` (UI)
Add wrapper methods for the four new/changed hub calls.
## Backend Changes
### `WorktreeMaintenanceService`
```csharp
public sealed record ForceRemoveResult(bool Removed, string? Reason);
public Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(string? listId, CancellationToken ct);
public Task<CleanupResult> CleanupFinishedAsync(string? listId, CancellationToken ct); // signature extended
public Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct);
```
- `GetOverviewAsync` — joins `worktrees × tasks × lists` (`AsNoTracking`), maps to DTO including `PathExistsOnDisk = Directory.Exists(path)`.
- `CleanupFinishedAsync(listId)` — same join as today but also filters `t.ListId == listId` when not null.
- `ForceRemoveAsync` — refactors existing `TryRemoveAsync(row, force: true, …)` into a single-row entry point shared with `ResetAllAsync`. Refuses when the task is currently `Running`, returning `ForceRemoveResult(false, "task is currently running")`. Otherwise removes the worktree directory, prunes, deletes the branch, deletes the DB row.
### `WorktreeRepository`
`SetStateAsync(string taskId, WorktreeState newState, CancellationToken ct)` already documented in CLAUDE.md. If absent, add it; if present, just expose it via the hub.
### Unchanged
`WorktreeManager`, `TaskRunner`, `WorktreeModalView`, all existing merge / cleanup flows.
## Data Flow
1. User opens modal → `WorkerClient.GetWorktreesOverviewAsync(listId)` → bind rows.
2. Refresh button → same call.
3. Per-row action → corresponding hub call → on success, update the affected row locally (no full reload).
4. Bulk Cleanup → hub call → full reload.
## Force-Remove Semantics
| Initial state | Result |
|---|---|
| Active, task not Running | Worktree dir removed, branch deleted, DB row deleted. Task remains in current status (Done/Failed/Idle). |
| Active, task Running | Refused with reason "task is currently running". |
| Merged / Discarded / Kept | Same removal path. |
| Phantom (dir missing) | DB row deleted, branch best-effort deleted. |
## Testing
New tests in `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` (real SQLite, real git):
1. `GetOverviewAsync_returns_all_when_listId_null`
2. `GetOverviewAsync_filters_by_listId`
3. `GetOverviewAsync_flags_PathExistsOnDisk_false_for_phantom_row`
4. `CleanupFinishedAsync_filters_by_listId`
5. `ForceRemoveAsync_removes_active_worktree` (happy path incl. branch delete)
6. `ForceRemoveAsync_blocked_when_task_running`
7. `ForceRemoveAsync_removes_phantom_row`
UI verification (manual):
- Open from list context menu → only that list's rows.
- Open from Help menu → all lists grouped, default expanded.
- Force-remove an Active worktree → row vanishes, DB row gone, branch deleted.
- Force-remove while task Running → toast / dialog with reason, row unchanged.
- Cleanup finished in filtered mode → only finished rows of the selected list disappear.
- "Show diff" reuses existing `WorktreeModalView`.
## Files Touched
**New:**
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`
- `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`
- `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewDto.cs` (or extend an existing DTOs file)
**Modified:**
- `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs`
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu entry, list context menu entry)
- `src/ClaudeDo.App/Program.cs` (DI registration of new VM)
- `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`

View File

@@ -95,6 +95,9 @@ sealed class Program
// ViewModels // ViewModels
sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>());
sc.AddTransient<WorktreesOverviewModalViewModel>();
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>(); sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddTransient<PrimeClaudeTabViewModel>(); sc.AddTransient<PrimeClaudeTabViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();

View File

@@ -35,6 +35,15 @@ public sealed class GitService
return stdout; return stdout;
} }
public async Task<string> GetCommittedFilesAsync(string worktreePath, string baseCommit, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "--name-status", $"{baseCommit}..HEAD"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff --name-status failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct); var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
@@ -97,6 +106,15 @@ public sealed class GitService
return stdout.Trim(); return stdout.Trim();
} }
public async Task<string> GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default)
{
string[] args = string.IsNullOrEmpty(baseCommit)
? ["diff", "--", relativePath]
: ["diff", $"{baseCommit}..HEAD", "--", relativePath];
var (_, stdout, _) = await RunGitAsync(worktreePath, args, ct);
return stdout;
}
public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default) public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default)
{ {
var args = new List<string> { "worktree", "remove" }; var args = new List<string> { "worktree", "remove" };

View File

@@ -0,0 +1,24 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Converters;
public sealed class DiffLineKindToBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is WorktreeDiffLineKind kind
? kind switch
{
WorktreeDiffLineKind.Added => new SolidColorBrush(Color.Parse("#66BB6A")),
WorktreeDiffLineKind.Removed => new SolidColorBrush(Color.Parse("#EF5350")),
WorktreeDiffLineKind.Hunk => new SolidColorBrush(Color.Parse("#42A5F5")),
WorktreeDiffLineKind.Header => new SolidColorBrush(Color.Parse("#9E9E9E")),
_ => new SolidColorBrush(Color.Parse("#CFD8DC")),
}
: new SolidColorBrush(Color.Parse("#CFD8DC"));
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,24 @@
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")),
WorktreeState.Merged => new SolidColorBrush(Color.Parse("#66BB6A")),
WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")),
WorktreeState.Kept => new SolidColorBrush(Color.Parse("#FFA726")),
_ => new SolidColorBrush(Colors.Gray),
}
: new SolidColorBrush(Colors.Gray);
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -395,11 +395,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
} }
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync() public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
{ {
try try
{ {
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees"); return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
} }
catch catch
{ {
@@ -419,6 +419,43 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
} }
} }
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;
}
}
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default) public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct); => await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
@@ -523,3 +560,19 @@ public sealed record UpdateListConfigDto(string ListId, string? Model, string? S
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath); public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath); public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
public sealed record SeedResultDto(int Copied, int Skipped); public sealed record SeedResultDto(int Copied, int Skipped);
public sealed record WorktreeOverviewDto(
string TaskId,
string TaskTitle,
ClaudeDo.Data.Models.TaskStatus TaskStatus,
string ListId,
string ListName,
string Path,
string BranchName,
string BaseCommit,
WorktreeState State,
string? DiffStat,
DateTime CreatedAt,
bool PathExistsOnDisk);
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);

View File

@@ -28,6 +28,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; } public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; } public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
[RelayCommand] [RelayCommand]
private async Task OpenSettings() private async Task OpenSettings()
@@ -49,6 +50,18 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
await RefreshRowAsync(row.Id); await RefreshRowAsync(row.Id);
} }
[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);
}
public ObservableCollection<ListNavItemViewModel> Items { get; } = new(); public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();

View File

@@ -32,6 +32,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
private readonly UpdateCheckService _updateCheck; private readonly UpdateCheckService _updateCheck;
private readonly InstallerLocator _installerLocator; private readonly InstallerLocator _installerLocator;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
// Set by MainWindow to open the conflict resolution dialog. // Set by MainWindow to open the conflict resolution dialog.
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; } public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
@@ -39,6 +40,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
// Set by MainWindow to open the About dialog. // Set by MainWindow to open the About dialog.
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; } public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
// Set by MainWindow to open the global worktrees overview dialog.
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
[ObservableProperty] private bool _isUpdateBannerVisible; [ObservableProperty] private bool _isUpdateBannerVisible;
[ObservableProperty] private string? _updateBannerLatestVersion; [ObservableProperty] private string? _updateBannerLatestVersion;
[ObservableProperty] private string? _inlineUpdateStatus; [ObservableProperty] private string? _inlineUpdateStatus;
@@ -159,12 +163,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
WorkerClient worker, WorkerClient worker,
UpdateCheckService updateCheck, UpdateCheckService updateCheck,
InstallerLocator installerLocator, InstallerLocator installerLocator,
IDbContextFactory<ClaudeDoDbContext> dbFactory) IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory)
{ {
Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists = lists; Tasks = tasks; Details = details; Worker = worker;
_updateCheck = updateCheck; _updateCheck = updateCheck;
_installerLocator = installerLocator; _installerLocator = installerLocator;
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
@@ -249,6 +255,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
if (ShowAboutModal is not null) await ShowAboutModal(vm); if (ShowAboutModal is not null) await ShowAboutModal(vm);
} }
[RelayCommand]
private async Task OpenWorktreesOverviewGlobalAsync()
{
if (ShowWorktreesOverviewModal is null) return;
var vm = _worktreesOverviewVmFactory();
vm.Configure(null, null);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
}
[RelayCommand] [RelayCommand]
private async Task CheckForUpdatesAsync() private async Task CheckForUpdatesAsync()
{ {

View File

@@ -5,12 +5,22 @@ using ClaudeDo.Data.Git;
namespace ClaudeDo.Ui.ViewModels.Modals; namespace ClaudeDo.Ui.ViewModels.Modals;
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
{
public required string Text { get; init; }
public required WorktreeDiffLineKind Kind { get; init; }
}
public sealed partial class WorktreeNodeViewModel : ViewModelBase public sealed partial class WorktreeNodeViewModel : ViewModelBase
{ {
public required string Name { get; init; } public required string Name { get; init; }
public string? Status { get; init; } public string? Status { get; init; }
public bool IsDirectory { get; init; } public bool IsDirectory { get; init; }
public string RelativePath { get; init; } = "";
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new(); public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
[ObservableProperty] private bool _isExpanded = true;
} }
public sealed partial class WorktreeModalViewModel : ViewModelBase public sealed partial class WorktreeModalViewModel : ViewModelBase
@@ -18,8 +28,11 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
private readonly GitService _git; private readonly GitService _git;
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new(); public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
[ObservableProperty] private string _worktreePath = ""; [ObservableProperty] private string _worktreePath = "";
[ObservableProperty] private string? _baseCommit;
[ObservableProperty] private WorktreeNodeViewModel? _selectedNode;
// Set by the view (same pattern as DiffModalViewModel.CloseAction) // Set by the view (same pattern as DiffModalViewModel.CloseAction)
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
@@ -29,6 +42,43 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
_git = git; _git = git;
} }
partial void OnSelectedNodeChanged(WorktreeNodeViewModel? value)
{
_ = LoadFileDiffAsync(value);
}
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
{
SelectedFileDiffLines.Clear();
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
return;
string diff;
try
{
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
}
catch
{
return;
}
foreach (var line in diff.Split('\n'))
{
var kind = line switch
{
_ when line.StartsWith("+++") || line.StartsWith("---") => WorktreeDiffLineKind.Header,
_ when line.StartsWith("@@") => WorktreeDiffLineKind.Hunk,
_ when line.StartsWith('+') => WorktreeDiffLineKind.Added,
_ when line.StartsWith('-') => WorktreeDiffLineKind.Removed,
_ when line.StartsWith("diff ") || line.StartsWith("index ") || line.StartsWith("\\ ") => WorktreeDiffLineKind.Header,
_ => WorktreeDiffLineKind.Context,
};
SelectedFileDiffLines.Add(new WorktreeDiffLineViewModel { Text = line, Kind = kind });
}
}
[RelayCommand] [RelayCommand]
private void Close() => CloseAction?.Invoke(); private void Close() => CloseAction?.Invoke();
@@ -37,7 +87,13 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
Root.Clear(); Root.Clear();
string stdout; string stdout;
try { stdout = await _git.GetStatusPorcelainAsync(WorktreePath, ct); } bool committedMode = !string.IsNullOrEmpty(BaseCommit);
try
{
stdout = committedMode
? await _git.GetCommittedFilesAsync(WorktreePath, BaseCommit!, ct)
: await _git.GetStatusPorcelainAsync(WorktreePath, ct);
}
catch { return; } catch { return; }
if (string.IsNullOrWhiteSpace(stdout)) return; if (string.IsNullOrWhiteSpace(stdout)) return;
@@ -46,14 +102,27 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{ {
if (line.Length < 4) continue; string? path;
string? status;
// porcelain format: XY<space>path (XY = two-char status) if (committedMode)
{
// diff --name-status format: <status>\t<path>
var tab = line.IndexOf('\t');
if (tab < 0) continue;
var statusChar = line[0];
status = statusChar != ' ' ? statusChar.ToString() : null;
path = line[(tab + 1)..].Trim().Replace('\\', '/');
}
else
{
// porcelain format: XY<space>path
if (line.Length < 4) continue;
var xy = line[..2]; var xy = line[..2];
// Pick staged char first, fall back to unstaged
var statusChar = xy[0] != ' ' ? xy[0] : xy[1]; var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
var status = statusChar != ' ' ? statusChar.ToString() : null; status = statusChar != ' ' ? statusChar.ToString() : null;
var path = line[3..].Trim().Replace('\\', '/'); path = line[3..].Trim().Replace('\\', '/');
}
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0) continue; if (segments.Length == 0) continue;
@@ -77,10 +146,24 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
{ {
Name = segments[^1], Name = segments[^1],
Status = status, Status = status,
IsDirectory = false IsDirectory = false,
RelativePath = path
}; };
if (parent == null) Root.Add(leaf); if (parent == null) Root.Add(leaf);
else parent.Children.Add(leaf); else parent.Children.Add(leaf);
} }
SelectedNode = FindFirstLeaf(Root);
}
private static WorktreeNodeViewModel? FindFirstLeaf(IEnumerable<WorktreeNodeViewModel> nodes)
{
foreach (var n in nodes)
{
if (!n.IsDirectory) return n;
var nested = FindFirstLeaf(n.Children);
if (nested is not null) return nested;
}
return null;
} }
} }

View File

@@ -0,0 +1,239 @@
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 string _baseCommit = "";
[ObservableProperty] private WorktreeState _state;
[ObservableProperty] private string? _diffStat;
[ObservableProperty] private DateTime _createdAt;
[ObservableProperty] private bool _pathExistsOnDisk;
[ObservableProperty] private bool _isSelected;
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;
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
[ObservableProperty] private string? _listIdFilter;
[ObservableProperty] private string _title = "Worktrees";
[ObservableProperty] private bool _isGlobal;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _statusMessage;
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
public Action? CloseAction { get; set; }
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
public Action<string, string>? JumpToTaskAction { get; set; }
public Func<string, Task<bool>>? ConfirmAction { get; set; }
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
{
_worker = worker;
_diffVmFactory = diffVmFactory;
}
public void SelectRow(WorktreeOverviewRowViewModel row)
{
if (SelectedRow is not null) SelectedRow.IsSelected = false;
SelectedRow = row;
row.IsSelected = true;
}
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;
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()
{
StatusMessage = null;
return 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;
var diffVm = _diffVmFactory();
diffVm.WorktreePath = row.Path;
diffVm.BaseCommit = string.IsNullOrEmpty(row.BaseCommit) ? null : row.BaseCommit;
ShowDiffAction?.Invoke(diffVm);
}
[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 { }
}
[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;
}
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 Task CopyBranch(WorktreeOverviewRowViewModel? row) => CopyToClipboardAsync(row?.BranchName);
[RelayCommand]
private Task CopyPath(WorktreeOverviewRowViewModel? row) => 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, BaseCommit = d.BaseCommit, State = d.State,
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
};
}

View File

@@ -132,6 +132,9 @@
<MenuItem Header="Settings..." <MenuItem Header="Settings..."
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}" Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
<MenuItem Header="Worktrees…"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/>
</ContextMenu> </ContextMenu>
</Border.ContextMenu> </Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto"> <Grid ColumnDefinitions="20,*,Auto">

View File

@@ -1,3 +1,4 @@
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
@@ -25,6 +26,31 @@ public partial class ListsIslandView : UserControl
if (top is null) window.Show(); if (top is null) window.Show();
else await window.ShowDialog(top); else await window.ShowDialog(top);
}; };
vm.ShowWorktreesOverviewModal = async modal =>
{
var window = new WorktreesOverviewModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
modal.JumpToTaskAction = (listId, _) =>
{
if (vm is { } v)
{
var item = v.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
if (item is not null) v.SelectedList = item;
}
};
modal.ShowDiffAction = diffVm =>
{
var top2 = TopLevel.GetTopLevel(this) as Window;
if (top2 is null) return;
var dlg = new WorktreeModalView { DataContext = diffVm };
diffVm.CloseAction = () => dlg.Close();
_ = diffVm.LoadAsync();
_ = dlg.ShowDialog(top2);
};
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
} }
}; };
} }

View File

@@ -69,6 +69,8 @@
Command="{Binding CheckForUpdatesCommand}"/> Command="{Binding CheckForUpdatesCommand}"/>
<MenuItem Header="Restart worker" <MenuItem Header="Restart worker"
Command="{Binding RestartWorkerCommand}"/> Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="Worktrees…"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/> <MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
</MenuItem> </MenuItem>
</Menu> </Menu>

View File

@@ -1,6 +1,9 @@
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals; using ClaudeDo.Ui.Views.Modals;
using ClaudeDo.Ui.Views.Planning; using ClaudeDo.Ui.Views.Planning;
@@ -31,6 +34,27 @@ public partial class MainWindow : Window
aboutVm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); }; aboutVm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); };
await dlg.ShowDialog(this); await dlg.ShowDialog(this);
}; };
vm.ShowWorktreesOverviewModal = async (modal) =>
{
var dlg = new WorktreesOverviewModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
modal.JumpToTaskAction = (listId, _) =>
{
if (DataContext is IslandsShellViewModel s)
{
var item = s.Lists?.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
if (item is not null && s.Lists is not null) s.Lists.SelectedList = item;
}
};
modal.ShowDiffAction = diffVm =>
{
var diffDlg = new WorktreeModalView { DataContext = diffVm };
diffVm.CloseAction = () => diffDlg.Close();
_ = diffVm.LoadAsync();
_ = diffDlg.ShowDialog(this);
};
await dlg.ShowDialog(this);
};
} }
} }

View File

@@ -1,17 +1,24 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:converters="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView" x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
x:DataType="vm:WorktreeModalViewModel" x:DataType="vm:WorktreeModalViewModel"
Title="Worktree" Title="Worktree"
Width="640" Height="720" Width="1100" Height="720"
MinWidth="640" MinHeight="400"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
SystemDecorations="None" SystemDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
Background="Transparent" Background="Transparent"
CanResize="False" CanResize="True"
TransparencyLevelHint="AcrylicBlur"> TransparencyLevelHint="AcrylicBlur">
<Window.Resources>
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
</Window.Resources>
<Window.KeyBindings> <Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/> <KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
@@ -39,12 +46,25 @@
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
</Border> </Border>
<!-- File tree --> <!-- Split: file tree | splitter | diff pane -->
<TreeView DockPanel.Dock="Top" ItemsSource="{Binding Root}" <Grid ColumnDefinitions="260,4,*">
Background="Transparent" Margin="8,0,8,8">
<!-- Left: file tree -->
<TreeView x:Name="FileTree"
Grid.Column="0"
ItemsSource="{Binding Root}"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}"
Background="Transparent"
Margin="8,0,4,8">
<TreeView.Styles>
<Style Selector="TreeViewItem" x:DataType="vm:WorktreeNodeViewModel">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
</Style>
</TreeView.Styles>
<TreeView.ItemTemplate> <TreeView.ItemTemplate>
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel" <TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
ItemsSource="{Binding Children}"> ItemsSource="{Binding Children}">
<Border Background="Transparent" Tapped="OnNodeTapped" Cursor="Hand">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Name}" <TextBlock Text="{Binding Name}"
FontFamily="{DynamicResource MonoFont}" FontSize="12" FontFamily="{DynamicResource MonoFont}" FontSize="12"
@@ -57,10 +77,34 @@
Foreground="{DynamicResource TextBrush}"/> Foreground="{DynamicResource TextBrush}"/>
</Border> </Border>
</StackPanel> </StackPanel>
</Border>
</TreeDataTemplate> </TreeDataTemplate>
</TreeView.ItemTemplate> </TreeView.ItemTemplate>
</TreeView> </TreeView>
<!-- Splitter -->
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="{DynamicResource LineBrush}"/>
<!-- Right: diff content -->
<ScrollViewer Grid.Column="2" Padding="8"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Margin="4,0,8,8">
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
<SelectableTextBlock Text="{Binding Text}"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
TextWrapping="NoWrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</DockPanel> </DockPanel>
</Border> </Border>
</Window> </Window>

View File

@@ -5,6 +5,7 @@ using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Modals;
@@ -21,6 +22,18 @@ public partial class WorktreeModalView : Window
base.OnDataContextChanged(e); base.OnDataContextChanged(e);
if (DataContext is WorktreeModalViewModel vm) if (DataContext is WorktreeModalViewModel vm)
vm.CloseAction = Close; vm.CloseAction = Close;
// Wire TreeView selection — SelectedItem TwoWay binding may not fire
// reliably in Avalonia 12 for TreeView; use SelectionChanged as backup.
var tree = this.FindControl<TreeView>("FileTree");
if (tree is not null)
tree.SelectionChanged += OnFileTreeSelectionChanged;
}
private void OnFileTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (DataContext is WorktreeModalViewModel vm && sender is TreeView tree)
vm.SelectedNode = tree.SelectedItem as WorktreeNodeViewModel;
} }
protected override async void OnOpened(EventArgs e) protected override async void OnOpened(EventArgs e)
@@ -44,6 +57,15 @@ public partial class WorktreeModalView : Window
RenderTransform = new ScaleTransform(1.0, 1.0); RenderTransform = new ScaleTransform(1.0, 1.0);
} }
private void OnNodeTapped(object? sender, Avalonia.Input.TappedEventArgs e)
{
if (sender is not Control c) return;
if (c.DataContext is not WorktreeNodeViewModel node) return;
if (!node.IsDirectory) return;
node.IsExpanded = !node.IsExpanded;
e.Handled = true;
}
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e) private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{ {
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)

View File

@@ -0,0 +1,209 @@
<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"
CanResize="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"
SystemDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1">
<Window.Resources>
<converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/>
<DataTemplate x:Key="WorktreeRowTemplate" x:DataType="vm:WorktreeOverviewRowViewModel">
<Border Classes="wt-row"
Classes.selected="{Binding IsSelected}"
Tapped="OnRowTapped">
<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="*,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>
<Border Grid.Column="1" 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="2" Text="{Binding DiffStat}" VerticalAlignment="Center"
FontFamily="{DynamicResource MonoFont}" FontSize="11"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Grid.Column="3" Text="{Binding AgeText}" VerticalAlignment="Center"
FontSize="11" Foreground="{DynamicResource TextDimBrush}"/>
</Grid>
</Border>
</DataTemplate>
</Window.Resources>
<Window.Styles>
<Style Selector="Border.wt-row">
<Setter Property="Padding" Value="12,10"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Margin" Value="0,0,0,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="BorderBrush" Duration="0:0:0.10"/>
</Transitions>
</Setter>
</Style>
<Style Selector="Border.wt-row:pointerover">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrightBrush}"/>
</Style>
<Style Selector="Border.wt-row.selected">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrightBrush}"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="36,Auto,*,52">
<!-- Title bar -->
<Border Grid.Row="0"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="OnTitleBarPressed">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Title}"
VerticalAlignment="Center"
Margin="14,0,0,0"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"/>
<Button Grid.Column="1"
Content="✕"
Command="{Binding CloseCommand}"
Margin="0,0,8,0"
Width="28" Height="28"
FontSize="11"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"/>
</Grid>
</Border>
<!-- Toolbar -->
<Border Grid.Row="1"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
Padding="12,8">
<StackPanel Orientation="Horizontal" Spacing="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>
</Border>
<!-- Content -->
<ScrollViewer Grid.Row="2" Padding="12,8">
<StackPanel>
<!-- Column headers -->
<Grid ColumnDefinitions="*,90,80,80" Margin="12,0,12,4">
<TextBlock Grid.Column="0" Text="TASK"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/>
<TextBlock Grid.Column="1" Text="STATE"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/>
<TextBlock Grid.Column="2" Text="DIFF"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/>
<TextBlock Grid.Column="3" Text="AGE"
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"/>
</Grid>
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8"/>
<!-- Rows (per-list) -->
<ItemsControl ItemsSource="{Binding Rows}" IsVisible="{Binding !IsGlobal}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
<ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Rows (global, grouped) -->
<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>
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="3"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="12,10">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Close" Command="{Binding CloseCommand}"/>
</StackPanel>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class WorktreesOverviewModalView : Window
{
public WorktreesOverviewModalView() => InitializeComponent();
private void OnRowTapped(object? sender, TappedEventArgs e)
{
if (sender is Border { DataContext: WorktreeOverviewRowViewModel row } &&
DataContext is WorktreesOverviewModalViewModel vm)
{
vm.SelectRow(row);
}
}
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
}

View File

@@ -29,6 +29,22 @@ public record AppSettingsDto(
public record WorktreeCleanupDto(int Removed); public record WorktreeCleanupDto(int Removed);
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record WorktreeOverviewDto(
string TaskId,
string TaskTitle,
ClaudeDo.Data.Models.TaskStatus TaskStatus,
string ListId,
string ListName,
string Path,
string BranchName,
string BaseCommit,
WorktreeState State,
string? DiffStat,
DateTime CreatedAt,
bool PathExistsOnDisk);
public record ForceRemoveResultDto(bool Removed, string? Reason);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage); public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches); public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
@@ -220,9 +236,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
}); });
} }
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees() public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
{ {
var result = await _wtMaintenance.CleanupFinishedAsync(); var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
return new WorktreeCleanupDto(result.Removed); return new WorktreeCleanupDto(result.Removed);
} }
@@ -232,6 +248,33 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks); return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
} }
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.BaseCommit, 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);
}
public async Task<MergeResultDto> MergeTask( public async Task<MergeResultDto> MergeTask(
string taskId, string targetBranch, bool removeWorktree, string commitMessage) string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{ {

View File

@@ -9,6 +9,7 @@ public sealed class WorktreeMaintenanceService
{ {
public sealed record CleanupResult(int Removed); public sealed record CleanupResult(int Removed);
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public sealed record ForceRemoveResult(bool Removed, string? Reason);
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git; private readonly GitService _git;
@@ -24,16 +25,19 @@ public sealed class WorktreeMaintenanceService
_logger = logger; _logger = logger;
} }
public async Task<CleanupResult> CleanupFinishedAsync(CancellationToken ct = default) public async Task<CleanupResult> CleanupFinishedAsync(string? listId = null, CancellationToken ct = default)
{ {
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var rows = await (from w in context.Worktrees var query = from w in context.Worktrees
join t in context.Tasks on w.TaskId equals t.Id join t in context.Tasks on w.TaskId equals t.Id
join l in context.Lists on t.ListId equals l.Id join l in context.Lists on t.ListId equals l.Id
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir)) select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId };
.AsNoTracking()
.ToListAsync(ct); 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; int removed = 0;
foreach (var row in rows) foreach (var row in rows)
@@ -68,6 +72,53 @@ public sealed class WorktreeMaintenanceService
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0); return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
} }
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.BaseCommit, 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.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt,
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList();
}
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");
}
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct) private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
{ {
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir); var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);

View File

@@ -0,0 +1,18 @@
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,
string BaseCommit,
WorktreeState State,
string? DiffStat,
DateTime CreatedAt,
bool PathExistsOnDisk);

View File

@@ -200,4 +200,281 @@ public class WorktreeMaintenanceServiceTests : IDisposable
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync(); var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
Assert.Empty(remaining); Assert.Empty(remaining);
} }
[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);
}
[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);
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);
}
[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}");
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);
}
} }