Merge feat/worktree-overview-modal
This commit is contained in:
1423
docs/superpowers/plans/2026-05-19-worktree-overview-modal.md
Normal file
1423
docs/superpowers/plans/2026-05-19-worktree-overview-modal.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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`
|
||||
@@ -95,6 +95,9 @@ sealed class Program
|
||||
|
||||
// ViewModels
|
||||
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.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
|
||||
@@ -35,6 +35,15 @@ public sealed class GitService
|
||||
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)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
||||
@@ -97,6 +106,15 @@ public sealed class GitService
|
||||
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)
|
||||
{
|
||||
var args = new List<string> { "worktree", "remove" };
|
||||
|
||||
24
src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs
Normal file
24
src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs
Normal 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();
|
||||
}
|
||||
24
src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs
Normal file
24
src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs
Normal 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();
|
||||
}
|
||||
@@ -395,11 +395,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
}
|
||||
|
||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
|
||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees");
|
||||
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||
}
|
||||
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)
|
||||
=> 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 ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||
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);
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
|
||||
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
||||
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
||||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenSettings()
|
||||
@@ -49,6 +50,18 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
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> SmartLists { get; } = new();
|
||||
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
private readonly UpdateCheckService _updateCheck;
|
||||
private readonly InstallerLocator _installerLocator;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
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.
|
||||
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 string? _updateBannerLatestVersion;
|
||||
[ObservableProperty] private string? _inlineUpdateStatus;
|
||||
@@ -159,12 +163,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
WorkerClient worker,
|
||||
UpdateCheckService updateCheck,
|
||||
InstallerLocator installerLocator,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory)
|
||||
{
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_dbFactory = dbFactory;
|
||||
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
@@ -249,6 +255,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
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]
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
|
||||
@@ -5,12 +5,22 @@ using ClaudeDo.Data.Git;
|
||||
|
||||
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 required string Name { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public bool IsDirectory { get; init; }
|
||||
public string RelativePath { get; init; } = "";
|
||||
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
}
|
||||
|
||||
public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
@@ -18,8 +28,11 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
private readonly GitService _git;
|
||||
|
||||
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
||||
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _worktreePath = "";
|
||||
[ObservableProperty] private string? _baseCommit;
|
||||
[ObservableProperty] private WorktreeNodeViewModel? _selectedNode;
|
||||
|
||||
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
|
||||
public Action? CloseAction { get; set; }
|
||||
@@ -29,6 +42,43 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
_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]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
@@ -37,7 +87,13 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
Root.Clear();
|
||||
|
||||
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; }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(stdout)) return;
|
||||
@@ -46,14 +102,27 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
|
||||
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)
|
||||
var xy = line[..2];
|
||||
// Pick staged char first, fall back to unstaged
|
||||
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
|
||||
var status = statusChar != ' ' ? statusChar.ToString() : null;
|
||||
var path = line[3..].Trim().Replace('\\', '/');
|
||||
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 statusChar = xy[0] != ' ' ? xy[0] : xy[1];
|
||||
status = statusChar != ' ' ? statusChar.ToString() : null;
|
||||
path = line[3..].Trim().Replace('\\', '/');
|
||||
}
|
||||
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0) continue;
|
||||
@@ -77,10 +146,24 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
{
|
||||
Name = segments[^1],
|
||||
Status = status,
|
||||
IsDirectory = false
|
||||
IsDirectory = false,
|
||||
RelativePath = path
|
||||
};
|
||||
if (parent == null) Root.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -132,6 +132,9 @@
|
||||
<MenuItem Header="Settings..."
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="Worktrees…"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="20,*,Auto">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
@@ -25,6 +26,31 @@ public partial class ListsIslandView : UserControl
|
||||
if (top is null) window.Show();
|
||||
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);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
Command="{Binding CheckForUpdatesCommand}"/>
|
||||
<MenuItem Header="Restart worker"
|
||||
Command="{Binding RestartWorkerCommand}"/>
|
||||
<MenuItem Header="Worktrees…"
|
||||
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||||
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
using ClaudeDo.Ui.Views.Planning;
|
||||
|
||||
@@ -31,6 +34,27 @@ public partial class MainWindow : Window
|
||||
aboutVm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); };
|
||||
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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<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.WorktreeModalView"
|
||||
x:DataType="vm:WorktreeModalViewModel"
|
||||
Title="Worktree"
|
||||
Width="640" Height="720"
|
||||
Width="1100" Height="720"
|
||||
MinWidth="640" MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
SystemDecorations="None"
|
||||
SystemDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
Background="Transparent"
|
||||
CanResize="False"
|
||||
CanResize="True"
|
||||
TransparencyLevelHint="AcrylicBlur">
|
||||
|
||||
<Window.Resources>
|
||||
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
@@ -39,27 +46,64 @@
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
|
||||
<!-- File tree -->
|
||||
<TreeView DockPanel.Dock="Top" ItemsSource="{Binding Root}"
|
||||
Background="Transparent" Margin="8,0,8,8">
|
||||
<TreeView.ItemTemplate>
|
||||
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
|
||||
ItemsSource="{Binding Children}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="12"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
<Border Tag="{Binding Status}" CornerRadius="3" Padding="4,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Status, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding Status}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
<!-- Split: file tree | splitter | diff pane -->
|
||||
<Grid ColumnDefinitions="260,4,*">
|
||||
|
||||
<!-- 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>
|
||||
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
|
||||
ItemsSource="{Binding Children}">
|
||||
<Border Background="Transparent" Tapped="OnNodeTapped" Cursor="Hand">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="12"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
<Border Tag="{Binding Status}" CornerRadius="3" Padding="4,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Status, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding Status}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.ItemTemplate>
|
||||
</TreeView>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.ItemTemplate>
|
||||
</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>
|
||||
</Border>
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
@@ -21,6 +22,18 @@ public partial class WorktreeModalView : Window
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is WorktreeModalViewModel vm)
|
||||
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)
|
||||
@@ -44,6 +57,15 @@ public partial class WorktreeModalView : Window
|
||||
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)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
|
||||
209
src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
Normal file
209
src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,22 @@ public record AppSettingsDto(
|
||||
|
||||
public record WorktreeCleanupDto(int Removed);
|
||||
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 MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -232,6 +248,33 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
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(
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ public sealed class WorktreeMaintenanceService
|
||||
{
|
||||
public sealed record CleanupResult(int Removed);
|
||||
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly GitService _git;
|
||||
@@ -24,16 +25,19 @@ public sealed class WorktreeMaintenanceService
|
||||
_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();
|
||||
var rows = 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.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
||||
select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir))
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
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)
|
||||
@@ -68,6 +72,53 @@ public sealed class WorktreeMaintenanceService
|
||||
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)
|
||||
{
|
||||
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
||||
|
||||
18
src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs
Normal file
18
src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs
Normal 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);
|
||||
@@ -200,4 +200,281 @@ public class WorktreeMaintenanceServiceTests : IDisposable
|
||||
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user