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 {}.
733 lines
29 KiB
C#
733 lines
29 KiB
C#
using Avalonia.Threading;
|
|
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels;
|
|
|
|
public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|
{
|
|
public ListsIslandViewModel? Lists { get; }
|
|
public TasksIslandViewModel? Tasks { get; }
|
|
public DetailsIslandViewModel? Details { get; }
|
|
public IWorkerClient? Worker { get; internal set; }
|
|
public MissionControlViewModel? MissionControl { get; }
|
|
public UsagePillViewModel? UsagePill { get; }
|
|
public UpdateCheckService UpdateCheck => _updateCheck;
|
|
|
|
// Stable opKey the six Lifecycle/*Recovery startup sweeps broadcast under (see
|
|
// OperationProgressOpKeys.StartupRecovery on the worker side — not shared across the
|
|
// SignalR wire, so the literal is duplicated here on purpose).
|
|
private const string StartupRecoveryOpKey = "startup-recovery";
|
|
|
|
// Last startup-recovery text received this connection cycle, shown in place of the generic
|
|
// "connecting" label. Cleared once actually connected so a later transient reconnect (not a
|
|
// fresh worker boot) doesn't replay stale recovery text.
|
|
private string? _startupRecoveryText;
|
|
|
|
public string ConnectionText =>
|
|
Worker?.IsConnected == true ? Loc.T("vm.connection.online")
|
|
: Worker?.IsReconnecting == true ? (_startupRecoveryText ?? Loc.T("vm.connection.connecting"))
|
|
: Loc.T("vm.connection.offline");
|
|
|
|
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
|
|
|
|
// Gate for the footer pill: the help dialog says "WORKER NOT REACHABLE", so it must never open
|
|
// on a connected worker. Not `IsOffline` — the retry loop stays in "connecting" forever while
|
|
// the worker is down, and that's exactly when the dialog's "Start Worker" is needed.
|
|
public bool CanOpenWorkerConnectionHelp => DecideCanOpenConnectionHelp(Worker?.IsConnected == true);
|
|
|
|
internal static bool DecideCanOpenConnectionHelp(bool isConnected) => !isConnected;
|
|
|
|
private readonly UpdateCheckService _updateCheck = null!;
|
|
private readonly InstallerLocator _installerLocator = null!;
|
|
private readonly WorkerLocator _workerLocator = null!;
|
|
private readonly GitService? _git;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
|
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
|
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
|
|
private readonly Func<UsageMonitorModalViewModel> _usageMonitorVmFactory = () => null!;
|
|
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
|
|
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
|
|
|
|
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
|
|
|
// Layer C seam: composition root sets the factory; the dialog service shows the resolver.
|
|
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
|
|
|
// Set by MainWindow so a reveal can bring the main window to the foreground.
|
|
public Action? BringToFront { get; set; }
|
|
|
|
// Single dialog seam (set by MainWindow); propagated to the lists island.
|
|
private IDialogService? _dialogs;
|
|
public IDialogService? Dialogs
|
|
{
|
|
get => _dialogs;
|
|
set
|
|
{
|
|
_dialogs = value;
|
|
if (Lists is not null) Lists.Dialogs = value;
|
|
if (Tasks is not null) Tasks.Dialogs = value;
|
|
}
|
|
}
|
|
|
|
public async Task RevealTaskAsync(string taskId)
|
|
{
|
|
if (Tasks is null || Lists is null) { BringToFront?.Invoke(); return; }
|
|
|
|
string? listId = null;
|
|
if (_dbFactory is not null)
|
|
{
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
listId = entity?.ListId;
|
|
}
|
|
catch { /* best-effort list resolution */ }
|
|
}
|
|
|
|
if (listId is not null)
|
|
{
|
|
var navItem = Lists.Items.FirstOrDefault(i => i.Id == $"user:{listId}");
|
|
if (navItem is not null && !ReferenceEquals(Lists.SelectedList, navItem))
|
|
Lists.SelectedList = navItem; // raises SelectionChanged → Tasks.LoadForList
|
|
}
|
|
|
|
await Tasks.SelectByIdAsync(taskId);
|
|
BringToFront?.Invoke();
|
|
}
|
|
|
|
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
|
{
|
|
if (ConflictResolverFactory is null || Dialogs is null) return;
|
|
var vm = ConflictResolverFactory(taskId);
|
|
var hasConflicts = await vm.OpenAsync(targetBranch);
|
|
if (hasConflicts)
|
|
await Dialogs.ShowConflictResolverAsync(vm);
|
|
}
|
|
|
|
[ObservableProperty] private bool _isUpdateBannerVisible;
|
|
[ObservableProperty] private string? _updateBannerLatestVersion;
|
|
private bool _bannerDismissedThisSession;
|
|
|
|
// Persistent (not auto-clearing) banner: the running worker predates the selected list's
|
|
// merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo
|
|
// only — see RefreshStaleWorkerCheckAsync.
|
|
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
|
|
|
|
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
|
|
// Kept as a dictionary so a later conflict on the same planning task updates in place instead
|
|
// of piling up duplicate entries.
|
|
private readonly Dictionary<string, string> _externalMergeConflicts = new();
|
|
[ObservableProperty] private bool _isExternalMergeBannerVisible;
|
|
|
|
|
|
[ObservableProperty]
|
|
private double _windowWidth = 1280;
|
|
|
|
[ObservableProperty]
|
|
private string? _workerLogText;
|
|
|
|
[ObservableProperty]
|
|
private WorkerLogLevel _workerLogLevel;
|
|
|
|
[ObservableProperty]
|
|
private bool _isWorkerLogVisible;
|
|
|
|
public bool ShowDetails => WindowWidth >= 1100;
|
|
public bool ShowLists => WindowWidth >= 780;
|
|
|
|
private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
|
|
private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false };
|
|
|
|
[ObservableProperty] private string? _primeStatus;
|
|
private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false };
|
|
|
|
[RelayCommand]
|
|
private void FocusSearch() => Lists?.RequestFocusSearch();
|
|
|
|
[RelayCommand]
|
|
private void FocusAddTask() => Tasks?.RequestFocusAddTask();
|
|
|
|
public async Task ToggleSelectedDoneAsync()
|
|
{
|
|
if (Tasks?.SelectedTask is { } row)
|
|
await Tasks.ToggleDoneCommand.ExecuteAsync(row);
|
|
}
|
|
|
|
partial void OnWindowWidthChanged(double value)
|
|
{
|
|
OnPropertyChanged(nameof(ShowDetails));
|
|
OnPropertyChanged(nameof(ShowLists));
|
|
}
|
|
|
|
public void OnWorkerLogReceived(WorkerLogEntry entry)
|
|
{
|
|
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
|
|
WorkerLogText = $"{hhmm} · {entry.Message}";
|
|
WorkerLogLevel = entry.Level;
|
|
IsWorkerLogVisible = true;
|
|
_clearTimer.Stop();
|
|
_clearTimer.Start();
|
|
}
|
|
|
|
public void ClearWorkerLog()
|
|
{
|
|
IsWorkerLogVisible = false;
|
|
WorkerLogText = null;
|
|
}
|
|
|
|
// Surfaces a UI-originated failure in the footer status strip (same line as the
|
|
// worker log), color-coded as an error and auto-cleared by _clearTimer.
|
|
public void FlashFooterError(string message)
|
|
{
|
|
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
|
|
WorkerLogLevel = WorkerLogLevel.Error;
|
|
IsWorkerLogVisible = true;
|
|
_clearTimer.Stop();
|
|
_clearTimer.Start();
|
|
}
|
|
|
|
private void OnPrimeFired(PrimeFiredEvent evt)
|
|
{
|
|
var when = evt.FiredAt.LocalDateTime.ToString("HH:mm");
|
|
PrimeStatus = evt.Success
|
|
? $"✓ Primed Claude at {when}"
|
|
: $"⚠ Prime failed: {evt.Message}";
|
|
_primeStatusTimer.Stop();
|
|
_primeStatusTimer.Start();
|
|
}
|
|
|
|
public void OnPlanningMergeConflict(
|
|
string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles, bool externallyDriven)
|
|
{
|
|
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
|
if (externallyDriven)
|
|
{
|
|
// An MCP session (review_task/continue_merge) is driving this merge — it owns
|
|
// resolution. Auto-opening the resolver here raced with the session's own writes
|
|
// (two parties editing the same shared checkout at once); show a banner instead and
|
|
// leave the resolver reachable only via a deliberate click.
|
|
_externalMergeConflicts[planningTaskId] = subtaskId;
|
|
IsExternalMergeBannerVisible = true;
|
|
return;
|
|
}
|
|
|
|
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
|
|
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
|
}
|
|
|
|
public void OnPlanningMergeAborted(string planningTaskId, string? reason = null)
|
|
{
|
|
ClearExternalMergeConflict(planningTaskId);
|
|
// A deliberate abort/conflict pause and a real merge failure both flow through this
|
|
// event; only the latter carries a reason worth surfacing in the footer strip.
|
|
if (!string.IsNullOrWhiteSpace(reason))
|
|
FlashFooterError(reason);
|
|
}
|
|
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
|
|
|
|
// Wired to Worker.OperationProgressEvent; also called directly by tests. A foreign opKey
|
|
// (merge phases, worktree cleanup, ...) is left untouched — this only tracks the
|
|
// startup-recovery channel.
|
|
public void OnOperationProgress(string opKey, string phase, int current, int total)
|
|
{
|
|
if (opKey != StartupRecoveryOpKey) return;
|
|
_startupRecoveryText = total > 0
|
|
? $"{Loc.T("ops.worker.startupRecovery")} ({current}/{total})"
|
|
: Loc.T("ops.worker.startupRecovery");
|
|
OnPropertyChanged(nameof(ConnectionText));
|
|
}
|
|
|
|
private void ClearExternalMergeConflict(string planningTaskId)
|
|
{
|
|
_externalMergeConflicts.Remove(planningTaskId);
|
|
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
|
|
}
|
|
|
|
/// <summary>Re-syncs the external-merge banner from the worker on (re)connect — the
|
|
/// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is
|
|
/// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active,
|
|
/// so a session that died mid-merge without cleaning up doesn't leave a stale banner up.</summary>
|
|
private async Task RefreshExternalMergeConflictsAsync()
|
|
{
|
|
if (Worker is null) return;
|
|
IReadOnlyList<PlanningMergeConflictStateDto> active;
|
|
try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); }
|
|
catch { return; }
|
|
|
|
_externalMergeConflicts.Clear();
|
|
foreach (var c in active)
|
|
_externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId;
|
|
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private Task OpenExternalMergeConflictAsync()
|
|
{
|
|
if (_externalMergeConflicts.Count == 0) return Task.CompletedTask;
|
|
var (planningTaskId, subtaskId) = _externalMergeConflicts.First();
|
|
return OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
|
}
|
|
|
|
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
|
|
{
|
|
if (ConflictResolverFactory is null || Dialogs is null) return;
|
|
var vm = ConflictResolverFactory(subtaskId);
|
|
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
|
|
if (hasConflicts)
|
|
await Dialogs.ShowConflictResolverAsync(vm);
|
|
}
|
|
|
|
// For tests only — does NOT wire up events.
|
|
internal IslandsShellViewModel() { }
|
|
|
|
public IslandsShellViewModel(
|
|
ListsIslandViewModel lists,
|
|
TasksIslandViewModel tasks,
|
|
DetailsIslandViewModel details,
|
|
IWorkerClient worker,
|
|
UpdateCheckService updateCheck,
|
|
InstallerLocator installerLocator,
|
|
WorkerLocator workerLocator,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
|
Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
|
|
Func<UsageMonitorModalViewModel> usageMonitorVmFactory,
|
|
Func<MergeModalViewModel> mergeVmFactory,
|
|
Func<RepoImportModalViewModel> repoImportVmFactory,
|
|
MissionControlViewModel missionControl,
|
|
UsagePillViewModel usagePill,
|
|
GitService? git = null)
|
|
{
|
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
|
MissionControl = missionControl;
|
|
UsagePill = usagePill;
|
|
UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor();
|
|
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
|
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
|
MissionControl.ErrorReported += FlashFooterError;
|
|
// Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
|
|
MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
|
|
_updateCheck = updateCheck;
|
|
_installerLocator = installerLocator;
|
|
_workerLocator = workerLocator;
|
|
_dbFactory = dbFactory;
|
|
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
|
_weeklyReportVmFactory = weeklyReportVmFactory;
|
|
_usageMonitorVmFactory = usageMonitorVmFactory;
|
|
_mergeVmFactory = mergeVmFactory;
|
|
_repoImportVmFactory = repoImportVmFactory;
|
|
_git = git;
|
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
|
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
|
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
|
|
Tasks.NotesRequested += () => Details.ShowNotes();
|
|
Tasks.PrepRequested += () => Details.ShowPrep();
|
|
Tasks.ErrorReported += FlashFooterError;
|
|
Lists.ErrorReported += FlashFooterError;
|
|
Tasks.OpenConPtySessionRequested += taskId =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenConPtySessionAsync(taskId);
|
|
};
|
|
Tasks.OpenQuickClaudeSessionRequested += dir =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenAdHocConPtySessionAsync(dir);
|
|
};
|
|
Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume);
|
|
};
|
|
Lists.LetClaudeHandleRequested += req =>
|
|
{
|
|
OpenMissionControl();
|
|
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
|
|
};
|
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
|
Tasks.OpenListSettingsRequested += (_, _) =>
|
|
{
|
|
if (Lists.SelectedList is { } row)
|
|
Lists.OpenListSettingsCommand.Execute(row);
|
|
};
|
|
Tasks.LetClaudeHandleRequested += (_, _) =>
|
|
{
|
|
if (Lists.SelectedList is { } row)
|
|
Lists.LetClaudeHandleListCommand.Execute(row);
|
|
};
|
|
Details.ErrorReported += FlashFooterError;
|
|
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
|
|
Details.DeleteFromList = row =>
|
|
{
|
|
Tasks.LoadForList(Lists.SelectedList);
|
|
_ = Lists.RefreshCountsAsync();
|
|
return System.Threading.Tasks.Task.CompletedTask;
|
|
};
|
|
Worker.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting))
|
|
{
|
|
if (Worker.IsConnected) _startupRecoveryText = null;
|
|
OnPropertyChanged(nameof(ConnectionText));
|
|
OnPropertyChanged(nameof(IsOffline));
|
|
OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp));
|
|
}
|
|
};
|
|
Worker.OperationProgressEvent += OnOperationProgress;
|
|
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
|
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
|
|
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
|
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
|
|
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
|
|
Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
|
|
Worker.PrimeFired += OnPrimeFired;
|
|
_clearTimer.Elapsed += (_, _) =>
|
|
{
|
|
if (Dispatcher.UIThread.CheckAccess())
|
|
ClearWorkerLog();
|
|
else
|
|
Dispatcher.UIThread.Post(ClearWorkerLog);
|
|
};
|
|
_primeStatusTimer.Elapsed += (_, _) =>
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
|
|
_connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync();
|
|
});
|
|
_connectTimer.Start();
|
|
_ = Lists.LoadAsync();
|
|
_updateCheck.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
|
|
{
|
|
RefreshBannerFromStatus();
|
|
}
|
|
};
|
|
_updateCheck.Op.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
|
};
|
|
// Fire-and-forget startup check — never block UI. Deliberately *not* Task.Run: the check is
|
|
// pure async I/O (no sync blocking), and `OperationStatus` writes its observable properties
|
|
// on the calling thread. From a threadpool thread the final `ShowIndicator=false` never
|
|
// reaches the binding, so the indicator froze on the last tick ("Checking… 00:03").
|
|
_ = StartupUpdateCheckAsync();
|
|
}
|
|
|
|
private async Task StartupUpdateCheckAsync()
|
|
{
|
|
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Worker is not null) Worker.OperationProgressEvent -= OnOperationProgress;
|
|
_clearTimer.Stop();
|
|
_clearTimer.Dispose();
|
|
_connectTimer.Stop();
|
|
_connectTimer.Dispose();
|
|
_primeStatusTimer.Stop();
|
|
_primeStatusTimer.Dispose();
|
|
_staleWorkerCts?.Cancel();
|
|
_staleWorkerCts?.Dispose();
|
|
}
|
|
|
|
private void RefreshBannerFromStatus()
|
|
{
|
|
switch (_updateCheck.LastCheckStatus)
|
|
{
|
|
case UpdateCheckStatus.UpdateAvailable:
|
|
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
|
|
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
|
|
IsUpdateBannerVisible = true;
|
|
break;
|
|
case UpdateCheckStatus.UpToDate:
|
|
IsUpdateBannerVisible = false;
|
|
break;
|
|
case UpdateCheckStatus.CheckFailed:
|
|
break;
|
|
}
|
|
}
|
|
|
|
private CancellationTokenSource? _staleWorkerCts;
|
|
|
|
// Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call +
|
|
// up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on
|
|
// every selection change / reconnect rather than caching.
|
|
private async Task RefreshStaleWorkerCheckAsync()
|
|
{
|
|
_staleWorkerCts?.Cancel();
|
|
var cts = new CancellationTokenSource();
|
|
_staleWorkerCts = cts;
|
|
|
|
var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token);
|
|
if (cts.IsCancellationRequested) return;
|
|
IsStaleWorkerBannerVisible = stale;
|
|
}
|
|
|
|
private async Task<bool> ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct)
|
|
{
|
|
if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false;
|
|
|
|
try
|
|
{
|
|
var buildInfo = await Worker.GetWorkerBuildInfoAsync();
|
|
var buildSha = buildInfo?.BuildSha;
|
|
if (string.IsNullOrWhiteSpace(buildSha)) return false;
|
|
if (!await _git.IsGitRepoAsync(workingDir, ct)) return false;
|
|
|
|
var head = await _git.RevParseHeadAsync(workingDir, ct);
|
|
var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase)
|
|
? (bool?)false // equal — never "stale" on a match, and no need to ask git
|
|
: await _git.IsAncestorAsync(workingDir, buildSha, head, ct);
|
|
return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor);
|
|
}
|
|
catch
|
|
{
|
|
// Worker offline, dir no longer a repo, etc. — unknown, so stay quiet.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Pure decision extracted for testability. isAncestor is the tri-state result of
|
|
// `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false =
|
|
// equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown
|
|
// (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale").
|
|
internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false;
|
|
if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false;
|
|
return isAncestor == true;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenMissionControl()
|
|
{
|
|
if (Dialogs is not null && MissionControl is not null)
|
|
Dialogs.ShowMissionControl(MissionControl);
|
|
}
|
|
|
|
/// <summary>Number of Mission Control ConPTY panes currently open — the main window's
|
|
/// close guard asks for confirmation whenever this is greater than zero.</summary>
|
|
public int OpenMissionControlSessionCount => MissionControl?.ConPtySessions.Count ?? 0;
|
|
|
|
// Pure decision extracted for testability — closing the main window kills every open
|
|
// Mission Control pane's `claude` process (ConPtyPaneViewModel.Dispose), so ask first
|
|
// rather than let it happen silently.
|
|
internal static bool RequiresCloseConfirmation(int openMissionControlSessionCount) =>
|
|
openMissionControlSessionCount > 0;
|
|
|
|
private void SyncInteractiveSessionChips()
|
|
{
|
|
if (MissionControl is null || Tasks is null) return;
|
|
Tasks.SyncInteractiveSessions(
|
|
MissionControl.ConPtySessions
|
|
.Select(s => s.TaskId)
|
|
.Where(id => !string.IsNullOrEmpty(id))
|
|
.Select(id => id!));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task OpenAbout()
|
|
{
|
|
var vm = new AboutModalViewModel();
|
|
vm.ErrorReported += FlashFooterError;
|
|
if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task OpenLogVisualizer()
|
|
{
|
|
if (Dialogs is null || Worker is null) return;
|
|
var vm = new LogVisualizerViewModel(Worker);
|
|
await vm.RefreshAsync();
|
|
await Dialogs.ShowLogVisualizerAsync(vm);
|
|
}
|
|
|
|
private bool _connectionPromptShown;
|
|
|
|
internal bool DecideShowConnectionPrompt(bool isOffline)
|
|
{
|
|
if (!isOffline) return false;
|
|
if (_connectionPromptShown) return false;
|
|
_connectionPromptShown = true;
|
|
return true;
|
|
}
|
|
|
|
private async Task OpenWorkerConnectionHelpAsync()
|
|
{
|
|
var vm = new WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
|
|
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
|
|
}
|
|
|
|
// The gate lives in the body, not in CanExecute: the footer pill *is* this button, so a
|
|
// disabled command renders the "ONLINE" chip greyed out — which reads as a broken connection,
|
|
// the opposite of what it says.
|
|
[RelayCommand]
|
|
private Task OpenWorkerConnectionHelp() =>
|
|
CanOpenWorkerConnectionHelp ? OpenWorkerConnectionHelpAsync() : Task.CompletedTask;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenRepoImport()
|
|
{
|
|
if (Dialogs is null || _repoImportVmFactory is null) return;
|
|
var vm = _repoImportVmFactory();
|
|
await vm.LoadAsync();
|
|
await Dialogs.ShowRepoImportAsync(vm);
|
|
if (Lists is not null) await Lists.LoadAsync();
|
|
}
|
|
|
|
private bool _worktreesOverviewOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenWorktreesOverviewGlobalAsync()
|
|
{
|
|
if (Dialogs is null || _worktreesOverviewOpen) return;
|
|
_worktreesOverviewOpen = true;
|
|
try
|
|
{
|
|
var vm = _worktreesOverviewVmFactory();
|
|
vm.ErrorReported += FlashFooterError;
|
|
vm.Configure(null, null);
|
|
await vm.LoadAsync();
|
|
await Dialogs.ShowWorktreesOverviewAsync(vm);
|
|
}
|
|
finally { _worktreesOverviewOpen = false; }
|
|
}
|
|
|
|
private bool _weeklyReportOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenWeeklyReport()
|
|
{
|
|
if (Dialogs is null || _weeklyReportOpen) return;
|
|
_weeklyReportOpen = true;
|
|
try
|
|
{
|
|
var vm = _weeklyReportVmFactory();
|
|
await vm.InitializeAsync();
|
|
await Dialogs.ShowWeeklyReportAsync(vm);
|
|
}
|
|
finally { _weeklyReportOpen = false; }
|
|
}
|
|
|
|
private bool _usageMonitorOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenUsageMonitor()
|
|
{
|
|
if (Dialogs is null || _usageMonitorOpen) return;
|
|
_usageMonitorOpen = true;
|
|
try
|
|
{
|
|
var vm = _usageMonitorVmFactory();
|
|
vm.ErrorReported += FlashFooterError;
|
|
// Show first, load after: the initial transcript scan takes seconds, and awaiting it
|
|
// here left the pill looking unresponsive until the window finally appeared.
|
|
vm.BeginLoad();
|
|
await Dialogs.ShowUsageMonitorAsync(vm);
|
|
}
|
|
finally { _usageMonitorOpen = false; }
|
|
}
|
|
|
|
private bool CanCheckForUpdates() => !_updateCheck.Op.IsRunning;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
|
|
private async Task CheckForUpdatesAsync()
|
|
{
|
|
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
|
}
|
|
|
|
[ObservableProperty] private string? _restartWorkerStatus;
|
|
|
|
[RelayCommand]
|
|
private async Task RestartWorkerAsync()
|
|
{
|
|
RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
|
|
try
|
|
{
|
|
await Task.Run(RestartWorkerService);
|
|
await FlashRestartStatusAsync("Worker restarted.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void RestartWorkerService()
|
|
{
|
|
var exe = _workerLocator.Find();
|
|
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
|
|
|
// Only kill the worker belonging to THIS installation — not any other
|
|
// ClaudeDo.Worker on the machine (e.g. a second install).
|
|
var exeFull = System.IO.Path.GetFullPath(exe);
|
|
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
|
{
|
|
try
|
|
{
|
|
var path = p.MainModule?.FileName;
|
|
if (path is not null &&
|
|
!string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
p.Kill(entireProcessTree: true);
|
|
p.WaitForExit(10000);
|
|
}
|
|
catch { /* may have exited or be inaccessible */ }
|
|
finally { p.Dispose(); }
|
|
}
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
|
}
|
|
|
|
private async Task FlashRestartStatusAsync(string text)
|
|
{
|
|
RestartWorkerStatus = text;
|
|
await Task.Delay(3000);
|
|
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void DismissBanner()
|
|
{
|
|
_bannerDismissedThisSession = true;
|
|
IsUpdateBannerVisible = false;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void UpdateNow()
|
|
{
|
|
var path = _installerLocator.Find();
|
|
if (path is null) return;
|
|
|
|
try
|
|
{
|
|
// WorkingDirectory must NOT stay empty: the child would inherit ours (<InstallDir>\app)
|
|
// and its locked current directory blocks the installer's own app\ rename.
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
|
|
{
|
|
UseShellExecute = true,
|
|
WorkingDirectory = System.IO.Path.GetTempPath(),
|
|
});
|
|
Environment.Exit(0);
|
|
}
|
|
catch
|
|
{
|
|
// Intentionally silent — if this fails there's nothing useful to show.
|
|
}
|
|
}
|
|
}
|