The RequestConflictResolution Func was declared on 5 VMs and hand-threaded shell->details->merge-section->diff->merge-modal. Replaced with a DI-singleton IMergeCoordinator (MergeCoordinator holder; shell wires its Handler at composition, breaking the shell<->island cycle). Invokers (MergeModal, DetailsIsland, WorktreesOverview) depend on the interface; the two pass-through VMs (DiffModal, MergeSection) drop the seam entirely. No behavior change; conflict-seam + batch tests rewired to assert via the coordinator.
422 lines
14 KiB
C#
422 lines
14 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.Localization;
|
|
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, IDisposable
|
|
{
|
|
public ListsIslandViewModel? Lists { get; }
|
|
public TasksIslandViewModel? Tasks { get; }
|
|
public DetailsIslandViewModel? Details { get; }
|
|
public IWorkerClient? Worker { 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 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;
|
|
|
|
// 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; }
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
[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).
|
|
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
|
|
_ = 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<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.PrepRequested += () => Details.ShowPrep();
|
|
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(IWorkerClient.IsConnected) or nameof(IWorkerClient.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 { }
|
|
});
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_clearTimer.Stop();
|
|
_clearTimer.Dispose();
|
|
_connectTimer.Stop();
|
|
_connectTimer.Dispose();
|
|
_primeStatusTimer.Stop();
|
|
_primeStatusTimer.Dispose();
|
|
}
|
|
|
|
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 (Dialogs is not null) await Dialogs.ShowAboutAsync(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; }
|
|
}
|
|
|
|
[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
|
|
{
|
|
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.
|
|
}
|
|
}
|
|
}
|