feat(ui): add WorktreesOverviewModalViewModel
This commit is contained in:
@@ -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<WorktreeOverviewRowViewModel> 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<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||||
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
public Action<WorktreeOverviewRowViewModel>? ShowDiffAction { get; set; }
|
||||
public Action<string, string>? JumpToTaskAction { get; set; }
|
||||
public Func<string, Task<bool>>? 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user