From 182a9df7f3f19f49577c148cc8dfd7cf9482de9a Mon Sep 17 00:00:00 2001 From: mika kuns Date: Tue, 19 May 2026 09:42:37 +0200 Subject: [PATCH] feat(ui): add WorktreesOverviewModalViewModel --- .../Modals/WorktreesOverviewModalViewModel.cs | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs new file mode 100644 index 0000000..a293b5b --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -0,0 +1,221 @@ +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] private TaskStatus _taskStatus; + [ObservableProperty] private string _listId = ""; + [ObservableProperty] private string _listName = ""; + [ObservableProperty] private string _path = ""; + [ObservableProperty] private string _branchName = ""; + [ObservableProperty] private WorktreeState _state; + [ObservableProperty] private string? _diffStat; + [ObservableProperty] private DateTime _createdAt; + [ObservableProperty] private bool _pathExistsOnDisk; + + 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; + + [ObservableProperty] private string? _listIdFilter; + [ObservableProperty] private string _title = "Worktrees"; + [ObservableProperty] private bool _isGlobal; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string? _statusMessage; + + 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 WorktreesOverviewModalViewModel(WorkerClient worker) + { + _worker = worker; + } + + 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; + StatusMessage = null; + 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)) + { + 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() => 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; + ShowDiffAction?.Invoke(row); + } + + [RelayCommand] + 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 }); } + catch { } + } + + [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 (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded)) + row.State = WorktreeState.Discarded; + } + + [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; + } + + [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, State = d.State, + DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk, + }; +}