using System.Collections.ObjectModel; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input.Platform; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.ViewModels.Modals; public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, VerifyFailed, Failed } public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase { [ObservableProperty] private string _taskId = ""; [ObservableProperty] private string _taskTitle = ""; [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] [NotifyPropertyChangedFor(nameof(IsActive))] [NotifyPropertyChangedFor(nameof(IsMerged))] [NotifyPropertyChangedFor(nameof(IsDiscarded))] [NotifyPropertyChangedFor(nameof(IsKept))] private WorktreeState _state; [ObservableProperty] private string? _diffStat; [ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt; [ObservableProperty] private bool _pathExistsOnDisk; [ObservableProperty] private bool _isSelected; [ObservableProperty] private bool _isChecked; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsConflict))] [NotifyPropertyChangedFor(nameof(HasOutcome))] private BatchMergeOutcome _mergeOutcome; public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict; public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None; public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt); public bool IsActive => State == WorktreeState.Active; public bool IsMerged => State == WorktreeState.Merged; public bool IsDiscarded => State == WorktreeState.Discarded; public bool IsKept => State == WorktreeState.Kept; 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 Rows { get; } = new(); } public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase { private readonly IWorkerClient _worker; private readonly Func _diffVmFactory; private readonly IMergeCoordinator _merge; [ObservableProperty] private string? _listIdFilter; [ObservableProperty] private string _title = "Worktrees"; [ObservableProperty] private bool _isGlobal; [ObservableProperty] private string? _statusMessage; [ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow; [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget; [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount; [ObservableProperty] private string? _batchProgress; // Separate per-action status: a running Refresh must not block the Cleanup indicator (and // vice versa). OperationStatus is a nested ObservableObject, so [NotifyCanExecuteChangedFor] // can't observe it directly — IsRunning changes are wired to NotifyCanExecuteChanged() below. public OperationStatus RefreshStatus { get; } = new(); public OperationStatus CleanupStatus { get; } = new(); public OperationStatus ForceRemoveStatus { get; } = new(); public OperationStatus BatchMergeStatus { get; } = new(); public ObservableCollection Rows { get; } = new(); public ObservableCollection Groups { get; } = new(); public ObservableCollection MergeTargets { get; } = new(); public ObservableCollection ConflictRows { get; } = new(); // Mirrors UsageMonitorModalViewModel.ErrorReported — surfaces modal-owned failures in the // footer strip once the caller wires it (the modal's own window sits in front of the strip). public event Action? ErrorReported; public Action? CloseAction { get; set; } 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(IWorkerClient worker, Func diffVmFactory, IMergeCoordinator merge) { _worker = worker; _diffVmFactory = diffVmFactory; _merge = merge; BatchMergeStatus.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(OperationStatus.IsRunning)) MergeAllCommand.NotifyCanExecuteChanged(); }; ForceRemoveStatus.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(OperationStatus.IsRunning)) ForceRemoveCommand.NotifyCanExecuteChanged(); }; // Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reviews // worktrees), so refresh it on the same cadence as TasksIslandViewModel's tick instead of // leaving it frozen at the moment it was opened. Skipped while a load or batch merge is // already in flight — reloading mid-merge would replace the row instances the merge loop // is still updating. _reconcileTimer.Elapsed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync()); _reconcileTimer.Start(); } private readonly System.Timers.Timer _reconcileTimer = new(4_000); // LoadAsync rebuilds every row instance from scratch and resets SelectedCount/ConflictRows/ // BatchProgress, so a bare reload on a 4s timer would wipe the user's batch-merge ticks, the // highlighted row and a finished batch's outcome badges out from under them — assembling a // multi-worktree selection would be impossible. Carry the state that is the user's (or a // finished batch's) across the reload, keyed by task id; genuinely new rows come up unticked. internal async Task ReconcileTickAsync() { if (RefreshStatus.IsRunning || CleanupStatus.IsRunning || ForceRemoveStatus.IsRunning || BatchMergeStatus.IsRunning) return; // Hold the row INSTANCES, not a value snapshot, and read them back only after the reload: // a tick the user lands during the await happens before LoadAsync clears the collection, // so the live instance still carries it. var previousRows = AllRows.ToList(); var previousProgress = BatchProgress; await LoadAsync(); var previous = new Dictionary(previousRows.Count); foreach (var r in previousRows) previous[r.TaskId] = (r.IsChecked, r.MergeOutcome, r.IsSelected); WorktreeOverviewRowViewModel? reselect = null; foreach (var row in AllRows) { if (!previous.TryGetValue(row.TaskId, out var state)) continue; row.IsChecked = state.Checked; row.MergeOutcome = state.Outcome; if (state.Outcome == BatchMergeOutcome.Conflict) ConflictRows.Add(row); if (state.Selected) reselect = row; } // SelectedRow still points at a detached pre-reload instance either way — re-point it at // the fresh row, or clear it when that worktree is gone rather than leave it dangling. if (reselect is not null) SelectRow(reselect); else SelectedRow = null; BatchProgress = previousProgress; } 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 ? Loc.T("vm.worktreesOverview.titleAll") : Loc.T("vm.worktreesOverview.titleList", listName ?? Loc.T("vm.worktreesOverview.listFallback")); } public async Task LoadAsync(CancellationToken ct = default) { 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(); ConflictRows.Clear(); SelectedCount = 0; BatchProgress = null; if (IsGlobal) { 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) { HookRow(row); group.Rows.Add(row); } Groups.Add(group); } } else { foreach (var row in ordered) { HookRow(row); Rows.Add(row); } } await LoadMergeTargetsAsync(); } [RelayCommand] private async Task Refresh() { StatusMessage = null; using var op = RefreshStatus.Begin(Loc.T("ops.worktrees.refreshing")); await LoadAsync(); } [RelayCommand] private async Task CleanupFinished() { using var op = CleanupStatus.Begin(Loc.T("ops.worktrees.cleaningUp")); try { var result = await _worker.CleanupFinishedWorktreesAsync(ListIdFilter); StatusMessage = result is null ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed); await LoadAsync(); } catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesOverview.cleanupFailedDetailed", ex.Message); } } [RelayCommand] private void Close() { StopReconcileTick(); CloseAction?.Invoke(); } /// Stops the reconcile tick. Also called from the dialog's native-close fallback /// (WindowDialogService), which bypasses — otherwise an Alt+F4 /// leaves the timer re-running LoadAsync (git + DB) on an orphaned VM. Idempotent. internal void StopReconcileTick() { _reconcileTimer.Stop(); _reconcileTimer.Dispose(); } [RelayCommand] private void ShowDiff(WorktreeOverviewRowViewModel? row) { if (row is null) return; var diffVm = _diffVmFactory(); diffVm.ConfigureWorktree(row.Path, row.BaseCommit); ShowDiffAction?.Invoke(diffVm); } [RelayCommand] private void OpenInExplorer(WorktreeOverviewRowViewModel? row) { var (ok, error) = ShellOpen.Path(row?.Path); if (!ok && error is not null) ErrorReported?.Invoke(Loc.T("vm.worktreesOverview.openInExplorerFailed", error)); } [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) { 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 (ConfirmAction is not null && !await ConfirmAction(Loc.T("vm.worktreesOverview.discardConfirm", row.TaskTitle))) return; var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded); if (ok) row.State = WorktreeState.Discarded; else StatusMessage = err ?? Loc.T("vm.worktreesOverview.discardFailed"); } [RelayCommand] private async Task Keep(WorktreeOverviewRowViewModel? row) { if (row is null || row.State != WorktreeState.Active) return; var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept); if (ok) row.State = WorktreeState.Kept; else StatusMessage = err ?? Loc.T("vm.worktreesOverview.keepFailed"); } private bool CanForceRemove(WorktreeOverviewRowViewModel? row) => !ForceRemoveStatus.IsRunning; [RelayCommand(CanExecute = nameof(CanForceRemove))] private async Task ForceRemove(WorktreeOverviewRowViewModel? row) { if (row is null) return; if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; } if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return; using var op = ForceRemoveStatus.Begin(Loc.T("ops.worktrees.forceRemoving")); ForceRemoveResultDto? result; try { result = await _worker.ForceRemoveWorktreeAsync(row.TaskId); } catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesOverview.forceRemoveFailedDetailed", ex.Message); return; } if (result is null || !result.Removed) { StatusMessage = result?.Reason ?? Loc.T("vm.worktreesOverview.forceRemoveFailed"); 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, }; public IEnumerable AllRows => IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows; private void HookRow(WorktreeOverviewRowViewModel row) { row.PropertyChanged += (_, e) => { if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked) or nameof(WorktreeOverviewRowViewModel.State)) RecomputeSelected(); }; } private void RecomputeSelected() => SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive); // Test seam: adds a row to the flat list with selection tracking wired up. internal void AddRowForTest(WorktreeOverviewRowViewModel row) { HookRow(row); Rows.Add(row); } private async Task LoadMergeTargetsAsync() { var anchor = AllRows.FirstOrDefault(r => r.IsActive); if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; } try { var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId); MergeTargets.Clear(); if (targets is null) { SelectedTarget = null; return; } foreach (var b in targets.LocalBranches) MergeTargets.Add(b); SelectedTarget = MergeTargets.Contains(targets.DefaultBranch) ? targets.DefaultBranch : MergeTargets.FirstOrDefault(); } catch { MergeTargets.Clear(); SelectedTarget = null; } } private bool CanMergeAll() => !BatchMergeStatus.IsRunning && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget); [RelayCommand(CanExecute = nameof(CanMergeAll))] private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync); [RelayCommand] private void ResolveConflict(WorktreeOverviewRowViewModel? row) { if (row is null) return; _ = _merge.ResolveConflictAsync(row.TaskId, SelectedTarget ?? ""); } [RelayCommand] private void ToggleSelectAll() { var actives = AllRows.Where(r => r.IsActive).ToList(); var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked); foreach (var r in actives) r.IsChecked = !allChecked; } public async Task MergeSelectedAsync( Func> mergeFn, CancellationToken ct = default) { var target = SelectedTarget; if (string.IsNullOrWhiteSpace(target)) return; var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList(); if (selected.Count == 0) return; ConflictRows.Clear(); var done = 0; using var op = BatchMergeStatus.Begin(Loc.T("ops.worktrees.batchMerging", done, selected.Count)); foreach (var row in selected) { ct.ThrowIfCancellationRequested(); row.MergeOutcome = BatchMergeOutcome.Merging; BatchMergeStatus.Report(Loc.T("ops.worktrees.batchMerging", ++done, selected.Count)); MergeResultDto result; try { // Blank message: the worker builds the conventional default per task. result = await mergeFn(row.TaskId, target!, false, ""); } catch { row.MergeOutcome = BatchMergeOutcome.Failed; continue; } switch (result.Status) { case "merged": row.MergeOutcome = BatchMergeOutcome.Merged; row.State = WorktreeState.Merged; row.IsChecked = false; break; case "conflict": row.MergeOutcome = BatchMergeOutcome.Conflict; ConflictRows.Add(row); break; case "blocked": row.MergeOutcome = BatchMergeOutcome.Blocked; break; case "verify_failed": // The merge landed (so the worktree really is merged), but the list's // verify command failed and the task was kept out of Done. Reporting // this as a plain Failed would claim the merge didn't happen. row.MergeOutcome = BatchMergeOutcome.VerifyFailed; row.State = WorktreeState.Merged; row.IsChecked = false; break; default: row.MergeOutcome = BatchMergeOutcome.Failed; break; } } // Batch-Merge OperationStatus.ShowIndicator (and its live "i/n" label) hides once this // method returns and `op` disposes — BatchProgress is the post-run summary that survives // after the indicator disappears, not a duplicate of the live ticker. BatchProgress = Loc.T("vm.worktreesOverview.batchDone", selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count); } }