Submit for Review is now disabled while a ConPTY pane is starting, has failed to launch, or has already exited, and MissionControlViewModel guards against a rapid double-click racing two SubmitTaskForReviewAsync calls. A failed launch no longer permanently occupies its TaskId dedupe slot -- a Retry button re-fetches the launch spec and restarts the pane in place. CloseConPtySession/Dispose now also unsubscribe SubmitForReviewRequested, matching the other pane event handlers. Also fixes the pre-existing nullable-dereference warning in IslandsShellViewModel.SyncInteractiveSessionChips.
664 lines
25 KiB
C#
664 lines
25 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; }
|
|
public MissionControlViewModel? MissionControl { get; }
|
|
public UsagePillViewModel? UsagePill { get; }
|
|
public UpdateCheckService UpdateCheck => _updateCheck;
|
|
|
|
public string ConnectionText =>
|
|
Worker?.IsConnected == true ? Loc.T("vm.connection.online")
|
|
: Worker?.IsReconnecting == true ? Loc.T("vm.connection.connecting")
|
|
: Loc.T("vm.connection.offline");
|
|
|
|
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
|
|
|
|
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) => ClearExternalMergeConflict(planningTaskId);
|
|
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
|
|
|
|
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.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.SelectedTask = null;
|
|
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))
|
|
{
|
|
OnPropertyChanged(nameof(ConnectionText));
|
|
OnPropertyChanged(nameof(IsOffline));
|
|
}
|
|
};
|
|
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();
|
|
}
|
|
};
|
|
// Fire-and-forget startup check — never block UI.
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
|
|
});
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_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);
|
|
}
|
|
|
|
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();
|
|
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);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
|
|
|
|
[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.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;
|
|
await vm.LoadAsync();
|
|
await Dialogs.ShowUsageMonitorAsync(vm);
|
|
}
|
|
finally { _usageMonitorOpen = false; }
|
|
}
|
|
|
|
[RelayCommand]
|
|
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.
|
|
}
|
|
}
|
|
}
|