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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user