using System.Collections.ObjectModel; using System.Diagnostics; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input.Platform; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.ViewModels.Modals; 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))] private WorktreeState _state; [ObservableProperty] private string? _diffStat; [ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt; [ObservableProperty] private bool _pathExistsOnDisk; [ObservableProperty] private bool _isSelected; public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt); public bool IsActive => State == WorktreeState.Active; 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 WorkerClient _worker; private readonly Func _diffVmFactory; [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; public ObservableCollection Rows { get; } = new(); public ObservableCollection Groups { 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(WorkerClient worker, Func diffVmFactory) { _worker = worker; _diffVmFactory = diffVmFactory; } 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 ? "Worktrees" : $"Worktrees — {listName ?? "list"}"; } 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(); 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) group.Rows.Add(row); Groups.Add(group); } } else { foreach (var row in ordered) Rows.Add(row); } } 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 ? "Cleanup failed." : $"Removed {result.Removed} worktree(s)."; await LoadAsync(); } finally { IsBusy = false; } } [RelayCommand] private void Close() => CloseAction?.Invoke(); [RelayCommand] private void ShowDiff(WorktreeOverviewRowViewModel? row) { if (row is null) return; var diffVm = _diffVmFactory(); diffVm.WorktreePath = row.Path; diffVm.BaseCommit = string.IsNullOrEmpty(row.BaseCommit) ? null : 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 ?? "Failed to discard worktree."; } [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 ?? "Failed to keep worktree."; } [RelayCommand] private async Task ForceRemove(WorktreeOverviewRowViewModel? row) { if (row is null) return; if (row.IsRunning) { StatusMessage = "Cannot force-remove a running task."; return; } if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return; var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId); if (result is null || !result.Removed) { StatusMessage = result?.Reason ?? "Force remove failed."; 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, }; }