using System.Collections.ObjectModel; using System.Diagnostics; 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, 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 bool _isBusy; [ObservableProperty] private string? _statusMessage; [ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow; [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget; [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount; [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging; [ObservableProperty] private string? _batchProgress; public ObservableCollection Rows { get; } = new(); public ObservableCollection Groups { get; } = new(); public ObservableCollection MergeTargets { get; } = new(); public ObservableCollection ConflictRows { get; } = new(); 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; } 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) { IsBusy = true; try { var dtos = await _worker.GetWorktreesOverviewAsync(ListIdFilter); var ordered = dtos .OrderBy(d => d.State == WorktreeState.Active ? 0 : 1) .ThenByDescending(d => d.CreatedAt) .Select(Map) .ToList(); Rows.Clear(); Groups.Clear(); 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(); } finally { IsBusy = false; } } [RelayCommand] private Task Refresh() { StatusMessage = null; return LoadAsync(); } [RelayCommand] private async Task CleanupFinished() { IsBusy = true; try { var result = await _worker.CleanupFinishedWorktreesAsync(ListIdFilter); StatusMessage = result is null ? 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); } finally { IsBusy = false; } } [RelayCommand] private void Close() => CloseAction?.Invoke(); [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) { if (row is null || !row.PathExistsOnDisk) return; 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) { 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; 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"); } [RelayCommand] 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; 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() => !IsMerging && 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; IsMerging = true; ConflictRows.Clear(); var done = 0; try { foreach (var row in selected) { ct.ThrowIfCancellationRequested(); row.MergeOutcome = BatchMergeOutcome.Merging; BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count); MergeResultDto result; try { result = await mergeFn(row.TaskId, target!, false, Loc.T("vm.merge.commitMessage", row.TaskTitle)); } 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; default: row.MergeOutcome = BatchMergeOutcome.Failed; break; } } BatchProgress = Loc.T("vm.worktreesOverview.batchDone", selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count); } finally { IsMerging = false; } } }