From 967e0cd319b8d286e99f3d4ad9de801e5cada8f1 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 27 May 2026 13:43:39 +0200 Subject: [PATCH] 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) --- src/ClaudeDo.App/Program.cs | 1 + src/ClaudeDo.Ui/Services/IWorkerClient.cs | 1 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 13 ++- .../Islands/DetailsIslandViewModel.cs | 2 + .../Islands/ListsIslandViewModel.cs | 19 +++- .../ViewModels/Islands/TaskRowViewModel.cs | 13 ++- .../Islands/TasksIslandViewModel.cs | 24 ++++ .../ViewModels/IslandsShellViewModel.cs | 24 +++- .../ViewModels/Modals/MergeModalViewModel.cs | 6 +- .../Modals/WorktreesOverviewModalViewModel.cs | 34 ++++-- .../Views/Islands/ListsIslandView.axaml.cs | 79 +++++++++++-- src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs | 42 +++++++ src/ClaudeDo.Ui/Views/MainWindow.axaml.cs | 61 +++++++++- .../Modals/WorktreesOverviewModalView.axaml | 4 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 10 ++ .../Worktrees/WorktreeMaintenanceService.cs | 28 ++++- .../Hub/WorktreeStateHubTests.cs | 107 ++++++++++++++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 1 + 18 files changed, 416 insertions(+), 53 deletions(-) create mode 100644 src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index f9c32e6..889ac4d 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -102,6 +102,7 @@ sealed class Program sc.AddTransient(); sc.AddTransient(); sc.AddTransient(); + sc.AddTransient>(sp => () => sp.GetRequiredService()); sc.AddTransient(); // Islands shell VMs diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs index cc4950f..dffe671 100644 --- a/src/ClaudeDo.Ui/Services/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs @@ -15,6 +15,7 @@ public interface IWorkerClient : INotifyPropertyChanged /// Raised once when the SignalR connection is first established, and again on every reconnect. event Action? ConnectionRestoredEvent; event Action? WorktreeUpdatedEvent; + event Action? ListUpdatedEvent; event Action? TaskMessageEvent; event Action? PlanningMergeStartedEvent; diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 3d01c72..c01d2b0 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -433,15 +433,20 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC } } - public async Task SetWorktreeStateAsync(string taskId, WorktreeState newState) + public async Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState) { try { - return await _hub.InvokeAsync("SetWorktreeState", taskId, newState); + var ok = await _hub.InvokeAsync("SetWorktreeState", taskId, newState); + return (ok, null); } - catch + catch (HubException ex) { - return false; + return (false, ex.Message); + } + catch (Exception) + { + return (false, "Worker offline."); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 4426cd6..d45a04f 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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 diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index fc9880a..b0801a9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -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(); - 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(); + vm.Configure(rawId, row.Name); + await vm.LoadAsync(); + await ShowWorktreesOverviewModal(vm); + } + finally { _worktreesOverviewOpen = false; } } public ObservableCollection 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(); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index c98874f..a4b8ce0 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -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)); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index b05c5f7..0108fae 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -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; diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 49e6d6f..739ded8 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -33,6 +33,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase private readonly InstallerLocator _installerLocator; private readonly IDbContextFactory? _dbFactory; private readonly Func _worktreesOverviewVmFactory = () => null!; + private readonly Func _mergeVmFactory = () => null!; + + public Func ResolveMergeVm => _mergeVmFactory; // Set by MainWindow to open the conflict resolution dialog. public Func? ShowConflictDialog { get; set; } @@ -164,13 +167,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase UpdateCheckService updateCheck, InstallerLocator installerLocator, IDbContextFactory dbFactory, - Func worktreesOverviewVmFactory) + Func worktreesOverviewVmFactory, + Func 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] diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs index 2768170..9b7d454 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs @@ -14,15 +14,15 @@ public sealed partial class MergeModalViewModel : ViewModelBase public ObservableCollection 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 _conflictFiles = Array.Empty(); public Action? CloseAction { get; set; } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index 8717257..5b3b87d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -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? ShowDiffAction { get; set; } public Action? JumpToTaskAction { get; set; } public Func>? ConfirmAction { get; set; } + public Func? ResolveMergeVm { get; set; } + public Func? ShowMergeAction { get; set; } public WorktreesOverviewModalViewModel(WorkerClient worker, Func 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] diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs index 832bf1e..7351071 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs @@ -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 ShowConfirmAsync(string message) + { + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner is null) return false; + + var tcs = new TaskCompletionSource(); + 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; + } } diff --git a/src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs b/src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs new file mode 100644 index 0000000..675969f --- /dev/null +++ b/src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs @@ -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(); + 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; + } + } +} diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs index d8fb811..d2697e3 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs @@ -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 ShowConfirmAsync(string message) + { + var tcs = new TaskCompletionSource(); + 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; + } } diff --git a/src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml index f5dd330..2a83845 100644 --- a/src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml @@ -32,6 +32,10 @@ Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).JumpToTaskCommand}" CommandParameter="{Binding}"/> + 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 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; diff --git a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs index 52afe83..0cfad8d 100644 --- a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs +++ b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs @@ -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 RemovedTaskIds); + public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList RemovedTaskIds); public sealed record ForceRemoveResult(bool Removed, string? Reason); private readonly IDbContextFactory _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(); 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 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()); 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(); 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> GetOverviewAsync( @@ -122,9 +130,11 @@ public sealed class WorktreeMaintenanceService private async Task 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; diff --git a/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs new file mode 100644 index 0000000..780018e --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs @@ -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 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(() => + 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(() => + 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(() => + hub.SetWorktreeState("does-not-exist", WorktreeState.Discarded)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index ab06d35..71558aa 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -27,6 +27,7 @@ sealed class FakeWorkerClient : IWorkerClient public event Action? TaskUpdatedEvent; public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; + public event Action? ListUpdatedEvent; public event Action? TaskMessageEvent; public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId); public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);