Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs

269 lines
9.9 KiB
C#

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))] 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 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;
private readonly Func<WorktreeModalViewModel> _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<WorktreeOverviewRowViewModel> Rows { get; } = new();
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
public Action? CloseAction { get; set; }
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
public Action<string, string>? JumpToTaskAction { get; set; }
public Func<string, Task<bool>>? ConfirmAction { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> 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
? 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();
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 ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed);
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 ?? 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;
var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
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,
};
}