Add ShowNotesRow/OpenNotesCommand to TasksIslandViewModel; wire NotesRequested event to Details.ShowNotes() in the shell; show a Notes button above the task list when the My Day smart list is active. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
429 lines
15 KiB
C#
429 lines
15 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.Models;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using ClaudeDo.Ui.ViewModels.Planning;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels;
|
|
|
|
public sealed partial class IslandsShellViewModel : ViewModelBase
|
|
{
|
|
public ListsIslandViewModel? Lists { get; }
|
|
public TasksIslandViewModel? Tasks { get; }
|
|
public DetailsIslandViewModel? Details { get; }
|
|
public WorkerClient? Worker { get; }
|
|
public UpdateCheckService UpdateCheck => _updateCheck;
|
|
|
|
public string ConnectionText =>
|
|
Worker?.IsConnected == true ? "Online"
|
|
: Worker?.IsReconnecting == true ? "Connecting…"
|
|
: "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 IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
|
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
|
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
|
|
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
|
|
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
|
|
|
|
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
|
|
|
// Set by MainWindow to open the conflict resolution dialog.
|
|
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
|
|
|
// Set by MainWindow to open the About dialog.
|
|
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
|
|
|
// Set by MainWindow to open the repo-import dialog.
|
|
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
|
|
|
|
// Set by MainWindow to open the global worktrees overview dialog.
|
|
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
|
|
|
// Set by MainWindow to open the weekly report dialog.
|
|
public Func<WeeklyReportModalViewModel, Task>? ShowWeeklyReportModal { get; set; }
|
|
|
|
// Set by MainWindow to open the worker-connection help dialog.
|
|
public Func<WorkerConnectionModalViewModel, Task>? ShowWorkerConnectionModal { get; set; }
|
|
|
|
[ObservableProperty] private bool _isUpdateBannerVisible;
|
|
[ObservableProperty] private string? _updateBannerLatestVersion;
|
|
[ObservableProperty] private string? _inlineUpdateStatus;
|
|
private bool _bannerDismissedThisSession;
|
|
|
|
[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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
|
{
|
|
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
|
_ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
|
|
}
|
|
|
|
private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
|
{
|
|
if (ShowConflictDialog == null || _dbFactory == null) return;
|
|
|
|
string subtaskTitle = subtaskId;
|
|
string worktreePath = System.Environment.CurrentDirectory;
|
|
string targetBranch = Worker?.LastMergeAllTarget ?? "main";
|
|
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks
|
|
.Include(t => t.Worktree)
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
|
if (entity != null)
|
|
{
|
|
subtaskTitle = entity.Title;
|
|
if (entity.Worktree?.Path is { } p)
|
|
worktreePath = p;
|
|
}
|
|
}
|
|
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
|
|
|
var vm = new ConflictResolutionViewModel(
|
|
Worker!,
|
|
planningTaskId,
|
|
subtaskTitle,
|
|
targetBranch,
|
|
conflictedFiles,
|
|
worktreePath);
|
|
|
|
await ShowConflictDialog(vm);
|
|
}
|
|
|
|
// For tests only — does NOT wire up events.
|
|
internal IslandsShellViewModel() { }
|
|
|
|
public IslandsShellViewModel(
|
|
ListsIslandViewModel lists,
|
|
TasksIslandViewModel tasks,
|
|
DetailsIslandViewModel details,
|
|
WorkerClient worker,
|
|
UpdateCheckService updateCheck,
|
|
InstallerLocator installerLocator,
|
|
WorkerLocator workerLocator,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
|
Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
|
|
Func<MergeModalViewModel> mergeVmFactory,
|
|
Func<RepoImportModalViewModel> repoImportVmFactory)
|
|
{
|
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
|
_updateCheck = updateCheck;
|
|
_installerLocator = installerLocator;
|
|
_workerLocator = workerLocator;
|
|
_dbFactory = dbFactory;
|
|
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
|
_weeklyReportVmFactory = weeklyReportVmFactory;
|
|
_mergeVmFactory = mergeVmFactory;
|
|
_repoImportVmFactory = repoImportVmFactory;
|
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
|
Tasks.NotesRequested += () => Details.ShowNotes();
|
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
|
Tasks.OpenListSettingsRequested += (_, _) =>
|
|
{
|
|
if (Lists.SelectedList is { } row)
|
|
Lists.OpenListSettingsCommand.Execute(row);
|
|
};
|
|
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(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
|
|
{
|
|
OnPropertyChanged(nameof(ConnectionText));
|
|
OnPropertyChanged(nameof(IsOffline));
|
|
}
|
|
};
|
|
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
|
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
|
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 { }
|
|
});
|
|
}
|
|
|
|
private void RefreshBannerFromStatus()
|
|
{
|
|
switch (_updateCheck.LastCheckStatus)
|
|
{
|
|
case UpdateCheckStatus.UpdateAvailable:
|
|
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
|
|
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
|
|
IsUpdateBannerVisible = true;
|
|
InlineUpdateStatus = null;
|
|
break;
|
|
case UpdateCheckStatus.UpToDate:
|
|
IsUpdateBannerVisible = false;
|
|
ShowInlineStatus($"You're up to date (v{_updateCheck.CurrentVersion})");
|
|
break;
|
|
case UpdateCheckStatus.CheckFailed:
|
|
ShowInlineStatus("Could not check for updates");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async void ShowInlineStatus(string text)
|
|
{
|
|
InlineUpdateStatus = text;
|
|
await Task.Delay(3000);
|
|
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task OpenAbout()
|
|
{
|
|
var vm = new AboutModalViewModel();
|
|
if (ShowAboutModal is not null) await ShowAboutModal(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 (ShowWorkerConnectionModal is not null) await ShowWorkerConnectionModal(vm);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
|
|
|
|
[RelayCommand]
|
|
private async Task OpenRepoImport()
|
|
{
|
|
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
|
|
var vm = _repoImportVmFactory();
|
|
await vm.LoadAsync();
|
|
await ShowRepoImportModal(vm);
|
|
if (Lists is not null) await Lists.LoadAsync();
|
|
}
|
|
|
|
private bool _worktreesOverviewOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenWorktreesOverviewGlobalAsync()
|
|
{
|
|
if (ShowWorktreesOverviewModal is null || _worktreesOverviewOpen) return;
|
|
_worktreesOverviewOpen = true;
|
|
try
|
|
{
|
|
var vm = _worktreesOverviewVmFactory();
|
|
vm.Configure(null, null);
|
|
await vm.LoadAsync();
|
|
await ShowWorktreesOverviewModal(vm);
|
|
}
|
|
finally { _worktreesOverviewOpen = false; }
|
|
}
|
|
|
|
private bool _weeklyReportOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenWeeklyReport()
|
|
{
|
|
if (ShowWeeklyReportModal is null || _weeklyReportOpen) return;
|
|
_weeklyReportOpen = true;
|
|
try
|
|
{
|
|
var vm = _weeklyReportVmFactory();
|
|
await vm.InitializeAsync();
|
|
await ShowWeeklyReportModal(vm);
|
|
}
|
|
finally { _weeklyReportOpen = false; }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task CheckForUpdatesAsync()
|
|
{
|
|
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
|
}
|
|
|
|
[ObservableProperty] private string? _restartWorkerStatus;
|
|
|
|
[RelayCommand]
|
|
private async Task RestartWorkerAsync()
|
|
{
|
|
RestartWorkerStatus = "Restarting worker…";
|
|
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
|
|
{
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
|
Environment.Exit(0);
|
|
}
|
|
catch
|
|
{
|
|
// Intentionally silent — if this fails there's nothing useful to show.
|
|
}
|
|
}
|
|
}
|