feat(ui): merge action and robust jump-to-task in worktrees overview
Add Merge entry to the worktrees overview context menu wiring the existing MergeModalViewModel, replace fire-and-forget list selection with a collection-change-aware JumpToTaskHelper, and propagate list renames to visible task rows via a new ListUpdated event. Harden worktree state changes: WorkerHub.SetWorktreeState now rejects invalid transitions, WorktreeMaintenanceService only drops the DB row when the on-disk worktree was actually removed, and Cleanup/Reset broadcast WorktreeUpdated for affected tasks. SetWorktreeStateAsync returns the hub error message so the modal can surface it. Also: de-duplicate the worktrees overview modal opener, hook OnParentTaskIdChanged to refresh IsDraft, fix MergeModal CanExecute notifications, and add WorktreeStateHubTests for the transition rules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,7 @@ sealed class Program
|
||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
sc.AddTransient<Func<MergeModalViewModel>>(sp => () => sp.GetRequiredService<MergeModalViewModel>());
|
||||
sc.AddTransient<ListSettingsModalViewModel>();
|
||||
|
||||
// Islands shell VMs
|
||||
|
||||
@@ -15,6 +15,7 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// <summary>Raised once when the SignalR connection is first established, and again on every reconnect.</summary>
|
||||
event Action? ConnectionRestoredEvent;
|
||||
event Action<string>? WorktreeUpdatedEvent;
|
||||
event Action<string>? ListUpdatedEvent;
|
||||
event Action<string, string>? TaskMessageEvent;
|
||||
|
||||
event Action<string, string>? PlanningMergeStartedEvent;
|
||||
|
||||
@@ -433,15 +433,20 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SetWorktreeStateAsync(string taskId, WorktreeState newState)
|
||||
public async Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
|
||||
var ok = await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
|
||||
return (ok, null);
|
||||
}
|
||||
catch
|
||||
catch (HubException ex)
|
||||
{
|
||||
return false;
|
||||
return (false, ex.Message);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return (false, "Worker offline.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ReviewCombinedDiffCommand))]
|
||||
[NotifyPropertyChangedFor(nameof(TaskIdBadge))]
|
||||
private TaskRowViewModel? _task;
|
||||
|
||||
// Editable fields
|
||||
|
||||
@@ -50,16 +50,24 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
await RefreshRowAsync(row.Id);
|
||||
}
|
||||
|
||||
private bool _worktreesOverviewOpen;
|
||||
|
||||
[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);
|
||||
if (_worktreesOverviewOpen) return;
|
||||
_worktreesOverviewOpen = true;
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
finally { _worktreesOverviewOpen = false; }
|
||||
}
|
||||
|
||||
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
|
||||
@@ -91,6 +99,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
||||
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnParentTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
@@ -113,12 +120,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(StatusChipClass));
|
||||
}
|
||||
|
||||
partial void OnParentTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnHasPlanningChildrenChanged(bool value)
|
||||
=> OnPropertyChanged(nameof(IsPlanningParent));
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||
_worker.ListUpdatedEvent += OnWorkerListUpdated;
|
||||
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +68,29 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
if (row is not null) row.LiveTail = line;
|
||||
}
|
||||
|
||||
private async void OnWorkerListUpdated(string listId)
|
||||
{
|
||||
// Mirror the renamed list onto every task row that references it,
|
||||
// so the per-row ListName chip on virtual lists stays current.
|
||||
try
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
||||
if (entity is null) return;
|
||||
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
||||
if (visibleIds.Count == 0) return;
|
||||
var matchingIds = await db.Tasks.AsNoTracking()
|
||||
.Where(t => t.ListId == listId && visibleIds.Contains(t.Id))
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync();
|
||||
var matching = matchingIds.ToHashSet();
|
||||
foreach (var row in Items)
|
||||
if (matching.Contains(row.Id) && row.ListName != entity.Name)
|
||||
row.ListName = entity.Name;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async void OnWorkerTaskUpdated(string taskId)
|
||||
{
|
||||
var list = _currentList;
|
||||
|
||||
@@ -33,6 +33,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
private readonly InstallerLocator _installerLocator;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
|
||||
|
||||
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
||||
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||
@@ -164,13 +167,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
UpdateCheckService updateCheck,
|
||||
InstallerLocator installerLocator,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory)
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
||||
Func<MergeModalViewModel> mergeVmFactory)
|
||||
{
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_dbFactory = dbFactory;
|
||||
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
||||
_mergeVmFactory = mergeVmFactory;
|
||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
@@ -255,14 +260,21 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
if (ShowAboutModal is not null) await ShowAboutModal(vm);
|
||||
}
|
||||
|
||||
private bool _worktreesOverviewOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenWorktreesOverviewGlobalAsync()
|
||||
{
|
||||
if (ShowWorktreesOverviewModal is null) return;
|
||||
var vm = _worktreesOverviewVmFactory();
|
||||
vm.Configure(null, null);
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreesOverviewModal(vm);
|
||||
if (ShowWorktreesOverviewModal is null || _worktreesOverviewOpen) return;
|
||||
_worktreesOverviewOpen = true;
|
||||
try
|
||||
{
|
||||
var vm = _worktreesOverviewVmFactory();
|
||||
vm.Configure(null, null);
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreesOverviewModal(vm);
|
||||
}
|
||||
finally { _worktreesOverviewOpen = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -14,15 +14,15 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
|
||||
public ObservableCollection<string> Branches { get; } = new();
|
||||
|
||||
[ObservableProperty] private string? _selectedBranch;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private string? _selectedBranch;
|
||||
[ObservableProperty] private bool _removeWorktree = true;
|
||||
[ObservableProperty] private string _commitMessage = "";
|
||||
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
|
||||
[ObservableProperty] private string? _errorMessage;
|
||||
[ObservableProperty] private string? _warningMessage;
|
||||
[ObservableProperty] private string? _successMessage;
|
||||
[ObservableProperty] private bool _hasConflict;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _hasConflict;
|
||||
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
@@ -15,15 +15,15 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _taskId = "";
|
||||
[ObservableProperty] private string _taskTitle = "";
|
||||
[ObservableProperty] private TaskStatus _taskStatus;
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsRunning))] 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][NotifyPropertyChangedFor(nameof(IsActive))] private WorktreeState _state;
|
||||
[ObservableProperty] private string? _diffStat;
|
||||
[ObservableProperty] private DateTime _createdAt;
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
|
||||
[ObservableProperty] private bool _pathExistsOnDisk;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
|
||||
@@ -66,6 +66,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
|
||||
public Action<string, string>? JumpToTaskAction { get; set; }
|
||||
public Func<string, Task<bool>>? ConfirmAction { get; set; }
|
||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
|
||||
|
||||
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
|
||||
{
|
||||
@@ -103,7 +105,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
Groups.Clear();
|
||||
if (IsGlobal)
|
||||
{
|
||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName)).OrderBy(g => g.Key.ListName))
|
||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
||||
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
||||
foreach (var row in grp) group.Rows.Add(row);
|
||||
@@ -158,10 +161,21 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
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 }); }
|
||||
try { Process.Start(new ProcessStartInfo { FileName = row.Path, UseShellExecute = true }); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Merge(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.State != WorktreeState.Active) return;
|
||||
if (ResolveMergeVm is null || ShowMergeAction is null) return;
|
||||
var mergeVm = ResolveMergeVm();
|
||||
await mergeVm.InitializeAsync(row.TaskId, row.TaskTitle);
|
||||
await ShowMergeAction(mergeVm);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void JumpToTask(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
@@ -174,16 +188,18 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
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;
|
||||
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded);
|
||||
if (ok) row.State = WorktreeState.Discarded;
|
||||
else StatusMessage = err ?? "Failed to discard worktree.";
|
||||
}
|
||||
|
||||
[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;
|
||||
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept);
|
||||
if (ok) row.State = WorktreeState.Kept;
|
||||
else StatusMessage = err ?? "Failed to keep worktree.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
@@ -28,26 +32,34 @@ public partial class ListsIslandView : UserControl
|
||||
};
|
||||
vm.ShowWorktreesOverviewModal = async modal =>
|
||||
{
|
||||
var top = TopLevel.GetTopLevel(this) as Window;
|
||||
var shell = top?.DataContext as IslandsShellViewModel;
|
||||
var window = new WorktreesOverviewModalView { DataContext = modal };
|
||||
modal.CloseAction = () => window.Close();
|
||||
modal.JumpToTaskAction = (listId, _) =>
|
||||
modal.JumpToTaskAction = (listId, taskId) =>
|
||||
{
|
||||
if (vm is { } v)
|
||||
{
|
||||
var item = v.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
|
||||
if (item is not null) v.SelectedList = item;
|
||||
}
|
||||
if (shell is not null)
|
||||
_ = JumpToTaskAsync(shell, listId, taskId);
|
||||
};
|
||||
modal.ShowDiffAction = diffVm =>
|
||||
{
|
||||
var top2 = TopLevel.GetTopLevel(this) as Window;
|
||||
if (top2 is null) return;
|
||||
if (top is null) return;
|
||||
var dlg = new WorktreeModalView { DataContext = diffVm };
|
||||
diffVm.CloseAction = () => dlg.Close();
|
||||
_ = diffVm.LoadAsync();
|
||||
_ = dlg.ShowDialog(top2);
|
||||
_ = dlg.ShowDialog(top);
|
||||
};
|
||||
var top = TopLevel.GetTopLevel(this) as Window;
|
||||
modal.ConfirmAction = ShowConfirmAsync;
|
||||
if (shell is not null)
|
||||
{
|
||||
modal.ResolveMergeVm = shell.ResolveMergeVm;
|
||||
modal.ShowMergeAction = async mergeVm =>
|
||||
{
|
||||
if (top is null) return;
|
||||
var mergeDlg = new MergeModalView { DataContext = mergeVm };
|
||||
await mergeDlg.ShowDialog(top);
|
||||
};
|
||||
}
|
||||
if (top is null) window.Show();
|
||||
else await window.ShowDialog(top);
|
||||
};
|
||||
@@ -69,4 +81,51 @@ public partial class ListsIslandView : UserControl
|
||||
var modal = new SettingsModalView { DataContext = settingsVm };
|
||||
await modal.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private static System.Threading.Tasks.Task JumpToTaskAsync(IslandsShellViewModel s, string listId, string taskId)
|
||||
=> JumpToTaskHelper.SelectAsync(s, listId, taskId);
|
||||
|
||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { cancel, confirm },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(owner);
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
42
src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs
Normal file
42
src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
|
||||
internal static class JumpToTaskHelper
|
||||
{
|
||||
// Selects the list, then waits for the matching task row to appear in Tasks.Items.
|
||||
// Listens to CollectionChanged so it works regardless of how long LoadForListAsync takes;
|
||||
// bounded by a hard timeout so we never hang if the task is filtered out.
|
||||
public static async Task SelectAsync(IslandsShellViewModel s, string listId, string taskId)
|
||||
{
|
||||
if (s.Lists is null || s.Tasks is null) return;
|
||||
var item = s.Lists.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
|
||||
if (item is null) return;
|
||||
s.Lists.SelectedList = item;
|
||||
|
||||
var existing = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (existing is not null) { s.Tasks.SelectedTask = existing; return; }
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
void OnChanged(object? _, NotifyCollectionChangedEventArgs __)
|
||||
{
|
||||
var row = s.Tasks!.Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is not null) tcs.TrySetResult(true);
|
||||
}
|
||||
|
||||
s.Tasks.Items.CollectionChanged += OnChanged;
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(tcs.Task, Task.Delay(5000));
|
||||
var row = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is not null) s.Tasks.SelectedTask = row;
|
||||
}
|
||||
finally
|
||||
{
|
||||
s.Tasks.Items.CollectionChanged -= OnChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
@@ -38,13 +41,10 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var dlg = new WorktreesOverviewModalView { DataContext = modal };
|
||||
modal.CloseAction = () => dlg.Close();
|
||||
modal.JumpToTaskAction = (listId, _) =>
|
||||
modal.JumpToTaskAction = (listId, taskId) =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
_ = JumpToTaskAsync(s, listId, taskId);
|
||||
};
|
||||
modal.ShowDiffAction = diffVm =>
|
||||
{
|
||||
@@ -53,6 +53,13 @@ public partial class MainWindow : Window
|
||||
_ = diffVm.LoadAsync();
|
||||
_ = diffDlg.ShowDialog(this);
|
||||
};
|
||||
modal.ConfirmAction = ShowConfirmAsync;
|
||||
modal.ResolveMergeVm = vm.ResolveMergeVm;
|
||||
modal.ShowMergeAction = async mergeVm =>
|
||||
{
|
||||
var mergeDlg = new MergeModalView { DataContext = mergeVm };
|
||||
await mergeDlg.ShowDialog(this);
|
||||
};
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
}
|
||||
@@ -85,4 +92,48 @@ public partial class MainWindow : Window
|
||||
base.OnSizeChanged(e);
|
||||
if (DataContext is IslandsShellViewModel vm) vm.WindowWidth = Bounds.Width;
|
||||
}
|
||||
|
||||
private static System.Threading.Tasks.Task JumpToTaskAsync(IslandsShellViewModel s, string listId, string taskId)
|
||||
=> JumpToTaskHelper.SelectAsync(s, listId, taskId);
|
||||
|
||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { cancel, confirm },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(this);
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).JumpToTaskCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Merge…"
|
||||
IsEnabled="{Binding IsActive}"
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).MergeCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="Discard"
|
||||
IsEnabled="{Binding IsActive}"
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).DiscardCommand}"
|
||||
|
||||
@@ -239,12 +239,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
|
||||
{
|
||||
var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
|
||||
foreach (var id in result.RemovedTaskIds)
|
||||
await _broadcaster.WorktreeUpdated(id);
|
||||
return new WorktreeCleanupDto(result.Removed);
|
||||
}
|
||||
|
||||
public async Task<WorktreeResetDto> ResetAllWorktrees()
|
||||
{
|
||||
var result = await _wtMaintenance.ResetAllAsync();
|
||||
foreach (var id in result.RemovedTaskIds)
|
||||
await _broadcaster.WorktreeUpdated(id);
|
||||
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
|
||||
}
|
||||
|
||||
@@ -262,6 +266,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var repo = new WorktreeRepository(ctx);
|
||||
var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (existing is null) throw new HubException("worktree not found");
|
||||
|
||||
// Allowed transitions: Active -> Merged | Discarded | Kept. Terminal states are final.
|
||||
if (existing.State == newState) return true;
|
||||
if (existing.State != WorktreeState.Active || newState == WorktreeState.Active)
|
||||
throw new HubException($"invalid worktree state transition {existing.State} -> {newState}");
|
||||
|
||||
await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted);
|
||||
await _broadcaster.WorktreeUpdated(taskId);
|
||||
return true;
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace ClaudeDo.Worker.Worktrees;
|
||||
|
||||
public sealed class WorktreeMaintenanceService
|
||||
{
|
||||
public sealed record CleanupResult(int Removed);
|
||||
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
public sealed record CleanupResult(int Removed, IReadOnlyList<string> RemovedTaskIds);
|
||||
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList<string> RemovedTaskIds);
|
||||
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
@@ -40,12 +40,16 @@ public sealed class WorktreeMaintenanceService
|
||||
var rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct);
|
||||
|
||||
int removed = 0;
|
||||
var removedTaskIds = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (await TryRemoveAsync(row, force: false, ct))
|
||||
{
|
||||
removed++;
|
||||
removedTaskIds.Add(row.TaskId);
|
||||
}
|
||||
}
|
||||
return new CleanupResult(removed);
|
||||
return new CleanupResult(removed, removedTaskIds);
|
||||
}
|
||||
|
||||
public async Task<ResetResult> ResetAllAsync(CancellationToken ct = default)
|
||||
@@ -54,7 +58,7 @@ public sealed class WorktreeMaintenanceService
|
||||
var running = await context.Tasks.AsNoTracking()
|
||||
.CountAsync(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running, ct);
|
||||
if (running > 0)
|
||||
return new ResetResult(0, 0, Blocked: true, RunningTasks: running);
|
||||
return new ResetResult(0, 0, Blocked: true, RunningTasks: running, Array.Empty<string>());
|
||||
|
||||
var rows = await (from w in context.Worktrees
|
||||
join t in context.Tasks on w.TaskId equals t.Id
|
||||
@@ -64,12 +68,16 @@ public sealed class WorktreeMaintenanceService
|
||||
.ToListAsync(ct);
|
||||
|
||||
int removed = 0;
|
||||
var removedTaskIds = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (await TryRemoveAsync(row, force: true, ct))
|
||||
{
|
||||
removed++;
|
||||
removedTaskIds.Add(row.TaskId);
|
||||
}
|
||||
}
|
||||
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
|
||||
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0, removedTaskIds);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(
|
||||
@@ -122,9 +130,11 @@ public sealed class WorktreeMaintenanceService
|
||||
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
||||
{
|
||||
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
||||
bool dirRemoved;
|
||||
|
||||
if (repoDirExists)
|
||||
{
|
||||
dirRemoved = true;
|
||||
try
|
||||
{
|
||||
await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct);
|
||||
@@ -137,15 +147,19 @@ public sealed class WorktreeMaintenanceService
|
||||
catch (Exception delEx)
|
||||
{
|
||||
_logger.LogError(delEx, "Directory.Delete fallback also failed for {Path}", row.Path);
|
||||
dirRemoved = false;
|
||||
}
|
||||
}
|
||||
if (Directory.Exists(row.Path)) dirRemoved = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
dirRemoved = true;
|
||||
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Directory.Delete failed for {Path}", row.Path);
|
||||
dirRemoved = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +184,10 @@ public sealed class WorktreeMaintenanceService
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the DB row only when the on-disk worktree is gone; otherwise we'd silently
|
||||
// strand a directory while reporting success.
|
||||
if (!dirRemoved) return false;
|
||||
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct);
|
||||
return true;
|
||||
|
||||
107
tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs
Normal file
107
tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Xunit;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
public sealed class WorktreeStateHubTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private WorkerHub CreateHub()
|
||||
{
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
}
|
||||
|
||||
private async Task<string> SeedWorktreeAsync(WorktreeState initial)
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await new ListRepository(ctx).AddAsync(new ListEntity
|
||||
{
|
||||
Id = listId, Name = "L", CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await new TaskRepository(ctx).AddAsync(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "T",
|
||||
Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "feat",
|
||||
});
|
||||
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||||
{
|
||||
TaskId = taskId, Path = "/tmp/x", BranchName = "claudedo/x",
|
||||
BaseCommit = "deadbeef", State = initial, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
return taskId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetWorktreeState_Active_To_Discarded_Succeeds()
|
||||
{
|
||||
var taskId = await SeedWorktreeAsync(WorktreeState.Active);
|
||||
var hub = CreateHub();
|
||||
|
||||
var ok = await hub.SetWorktreeState(taskId, WorktreeState.Discarded);
|
||||
|
||||
Assert.True(ok);
|
||||
using var ctx = _db.CreateContext();
|
||||
var row = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId);
|
||||
Assert.Equal(WorktreeState.Discarded, row!.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetWorktreeState_Merged_To_Active_Throws()
|
||||
{
|
||||
var taskId = await SeedWorktreeAsync(WorktreeState.Merged);
|
||||
var hub = CreateHub();
|
||||
|
||||
await Assert.ThrowsAsync<HubException>(() =>
|
||||
hub.SetWorktreeState(taskId, WorktreeState.Active));
|
||||
|
||||
using var ctx = _db.CreateContext();
|
||||
var row = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId);
|
||||
Assert.Equal(WorktreeState.Merged, row!.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetWorktreeState_Discarded_To_Kept_Throws()
|
||||
{
|
||||
var taskId = await SeedWorktreeAsync(WorktreeState.Discarded);
|
||||
var hub = CreateHub();
|
||||
|
||||
await Assert.ThrowsAsync<HubException>(() =>
|
||||
hub.SetWorktreeState(taskId, WorktreeState.Kept));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetWorktreeState_SameState_IsNoOp()
|
||||
{
|
||||
var taskId = await SeedWorktreeAsync(WorktreeState.Active);
|
||||
var hub = CreateHub();
|
||||
|
||||
var ok = await hub.SetWorktreeState(taskId, WorktreeState.Active);
|
||||
|
||||
Assert.True(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetWorktreeState_Missing_Throws()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
|
||||
await Assert.ThrowsAsync<HubException>(() =>
|
||||
hub.SetWorktreeState("does-not-exist", WorktreeState.Discarded));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
public event Action<string, string>? TaskMessageEvent;
|
||||
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
||||
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
|
||||
|
||||
Reference in New Issue
Block a user