Sechs Konsistenz-Fixes: Delete-Task ins Zeilen-Kontextmenü (routet über den Worker wie DetailsIslandViewModel.DeleteTaskAsync, damit ein gelöschtes Child den WaitingForChildren-Parent korrekt weiterschaltet); EnqueueCommand als "Send to queue"-Button im Detail-Pane verdrahtet; Settings-Eintrag im Listen-Kontextmenü ergänzt; Worktree-Discard und Reset-All laufen jetzt über das jeweils vorhandene Confirm-Hook (ConfirmAction / neuer ConfirmAsync-Hook auf WorktreesSettingsTabViewModel) statt ohne Rückfrage bzw. über ein Inline-Reveal-Banner; killSessionTip/closeSession und die deutschen Queue-Strings im usageMonitor vereinheitlicht. Kein zweiter Dialog-Mechanismus eingeführt — überall die vorhandenen Hooks wiederverwendet.
497 lines
20 KiB
C#
497 lines
20 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, 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();
|
|
|
|
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)
|
|
{
|
|
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;
|
|
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);
|
|
}
|
|
}
|