Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs
T
Mika Kuns b2a1b79029 refactor(ui): dedupe path-open helper across five view models
ListsIslandViewModel.OpenInExplorer, MergeSectionViewModel.OpenWorktree,
WorktreesOverviewModalViewModel.OpenInExplorer, AboutModalViewModel.OpenPath and
TasksIslandViewModel.OpenTaskWorktree each reimplemented "open this path in the
shell" with their own existence check, launch mechanism and error handling.
Replace all five with the new ShellOpen.Path helper and route failures through
the existing ErrorReported -> footer-strip convention instead of bare catch {}.
2026-08-24 09:38:41 +02:00

500 lines
20 KiB
C#

using System.Collections.ObjectModel;
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, VerifyFailed, 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<WorktreeOverviewRowViewModel> Rows { get; } = new();
}
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly Func<DiffViewerViewModel> _diffVmFactory;
private readonly IMergeCoordinator _merge;
[ObservableProperty] private string? _listIdFilter;
[ObservableProperty] private string _title = "Worktrees";
[ObservableProperty] private bool _isGlobal;
[ObservableProperty] private string? _statusMessage;
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
[ObservableProperty] private string? _batchProgress;
// Separate per-action status: a running Refresh must not block the Cleanup indicator (and
// vice versa). OperationStatus is a nested ObservableObject, so [NotifyCanExecuteChangedFor]
// can't observe it directly — IsRunning changes are wired to NotifyCanExecuteChanged() below.
public OperationStatus RefreshStatus { get; } = new();
public OperationStatus CleanupStatus { get; } = new();
public OperationStatus ForceRemoveStatus { get; } = new();
public OperationStatus BatchMergeStatus { get; } = new();
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
public ObservableCollection<string> MergeTargets { get; } = new();
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
// Mirrors UsageMonitorModalViewModel.ErrorReported — surfaces modal-owned failures in the
// footer strip once the caller wires it (the modal's own window sits in front of the strip).
public event Action<string>? ErrorReported;
public Action? CloseAction { get; set; }
public Action<DiffViewerViewModel>? 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(IWorkerClient worker, Func<DiffViewerViewModel> diffVmFactory, IMergeCoordinator merge)
{
_worker = worker;
_diffVmFactory = diffVmFactory;
_merge = merge;
BatchMergeStatus.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) MergeAllCommand.NotifyCanExecuteChanged();
};
ForceRemoveStatus.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ForceRemoveCommand.NotifyCanExecuteChanged();
};
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reviews
// worktrees), so refresh it on the same cadence as TasksIslandViewModel's tick instead of
// leaving it frozen at the moment it was opened. Skipped while a load or batch merge is
// already in flight — reloading mid-merge would replace the row instances the merge loop
// is still updating.
_reconcileTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
_reconcileTimer.Start();
}
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
// LoadAsync rebuilds every row instance from scratch and resets SelectedCount/ConflictRows/
// BatchProgress, so a bare reload on a 4s timer would wipe the user's batch-merge ticks, the
// highlighted row and a finished batch's outcome badges out from under them — assembling a
// multi-worktree selection would be impossible. Carry the state that is the user's (or a
// finished batch's) across the reload, keyed by task id; genuinely new rows come up unticked.
internal async Task ReconcileTickAsync()
{
if (RefreshStatus.IsRunning || CleanupStatus.IsRunning || ForceRemoveStatus.IsRunning || BatchMergeStatus.IsRunning) return;
// Hold the row INSTANCES, not a value snapshot, and read them back only after the reload:
// a tick the user lands during the await happens before LoadAsync clears the collection,
// so the live instance still carries it.
var previousRows = AllRows.ToList();
var previousProgress = BatchProgress;
await LoadAsync();
var previous = new Dictionary<string, (bool Checked, BatchMergeOutcome Outcome, bool Selected)>(previousRows.Count);
foreach (var r in previousRows) previous[r.TaskId] = (r.IsChecked, r.MergeOutcome, r.IsSelected);
WorktreeOverviewRowViewModel? reselect = null;
foreach (var row in AllRows)
{
if (!previous.TryGetValue(row.TaskId, out var state)) continue;
row.IsChecked = state.Checked;
row.MergeOutcome = state.Outcome;
if (state.Outcome == BatchMergeOutcome.Conflict) ConflictRows.Add(row);
if (state.Selected) reselect = row;
}
// SelectedRow still points at a detached pre-reload instance either way — re-point it at
// the fresh row, or clear it when that worktree is gone rather than leave it dangling.
if (reselect is not null) SelectRow(reselect);
else SelectedRow = null;
BatchProgress = previousProgress;
}
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)
{
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();
}
[RelayCommand]
private async Task Refresh()
{
StatusMessage = null;
using var op = RefreshStatus.Begin(Loc.T("ops.worktrees.refreshing"));
await LoadAsync();
}
[RelayCommand]
private async Task CleanupFinished()
{
using var op = CleanupStatus.Begin(Loc.T("ops.worktrees.cleaningUp"));
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); }
}
[RelayCommand]
private void Close()
{
StopReconcileTick();
CloseAction?.Invoke();
}
/// <summary>Stops the reconcile tick. Also called from the dialog's native-close fallback
/// (WindowDialogService), which bypasses <see cref="CloseCommand"/> — otherwise an Alt+F4
/// leaves the timer re-running LoadAsync (git + DB) on an orphaned VM. Idempotent.</summary>
internal void StopReconcileTick()
{
_reconcileTimer.Stop();
_reconcileTimer.Dispose();
}
[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)
{
var (ok, error) = ShellOpen.Path(row?.Path);
if (!ok && error is not null)
ErrorReported?.Invoke(Loc.T("vm.worktreesOverview.openInExplorerFailed", error));
}
[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;
if (ConfirmAction is not null && !await ConfirmAction(Loc.T("vm.worktreesOverview.discardConfirm", row.TaskTitle))) 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");
}
private bool CanForceRemove(WorktreeOverviewRowViewModel? row) => !ForceRemoveStatus.IsRunning;
[RelayCommand(CanExecute = nameof(CanForceRemove))]
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;
using var op = ForceRemoveStatus.Begin(Loc.T("ops.worktrees.forceRemoving"));
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<WorktreeOverviewRowViewModel> 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() => !BatchMergeStatus.IsRunning && 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<string, string, bool, string, Task<MergeResultDto>> 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;
ConflictRows.Clear();
var done = 0;
using var op = BatchMergeStatus.Begin(Loc.T("ops.worktrees.batchMerging", done, selected.Count));
foreach (var row in selected)
{
ct.ThrowIfCancellationRequested();
row.MergeOutcome = BatchMergeOutcome.Merging;
BatchMergeStatus.Report(Loc.T("ops.worktrees.batchMerging", ++done, selected.Count));
MergeResultDto result;
try
{
// Blank message: the worker builds the conventional default per task.
result = await mergeFn(row.TaskId, target!, false, "");
}
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;
case "verify_failed":
// The merge landed (so the worktree really is merged), but the list's
// verify command failed and the task was kept out of Done. Reporting
// this as a plain Failed would claim the merge didn't happen.
row.MergeOutcome = BatchMergeOutcome.VerifyFailed;
row.State = WorktreeState.Merged;
row.IsChecked = false;
break;
default:
row.MergeOutcome = BatchMergeOutcome.Failed;
break;
}
}
// Batch-Merge OperationStatus.ShowIndicator (and its live "i/n" label) hides once this
// method returns and `op` disposes — BatchProgress is the post-run summary that survives
// after the indicator disappears, not a duplicate of the live ticker.
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
}
}