diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index dc83e5e..7759ff4 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -11,6 +11,10 @@ + + + + net8.0 enable diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 14d5f49..f7beed4 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -1,5 +1,7 @@ +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; @@ -7,33 +9,44 @@ 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 ListsIslandViewModel? Lists { get; } + public TasksIslandViewModel? Tasks { get; } + public DetailsIslandViewModel? Details { get; } + public WorkerClient? Worker { get; } public string ConnectionText => - Worker.IsConnected ? "Online" - : Worker.IsReconnecting ? "Connecting…" + Worker?.IsConnected == true ? "Online" + : Worker?.IsReconnecting == true ? "Connecting…" : "Offline"; - public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting; + public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true; [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; - [RelayCommand] - private void FocusSearch() => Lists.RequestFocusSearch(); + private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false }; [RelayCommand] - private void FocusAddTask() => Tasks.RequestFocusAddTask(); + private void FocusSearch() => Lists?.RequestFocusSearch(); + + [RelayCommand] + private void FocusAddTask() => Tasks?.RequestFocusAddTask(); public async Task ToggleSelectedDoneAsync() { - if (Tasks.SelectedTask is { } row) + if (Tasks?.SelectedTask is { } row) await Tasks.ToggleDoneCommand.ExecuteAsync(row); } @@ -43,6 +56,25 @@ public sealed partial class IslandsShellViewModel : ViewModelBase 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; + } + + // For tests only — does NOT wire up events. + internal IslandsShellViewModel() { } + public IslandsShellViewModel( ListsIslandViewModel lists, TasksIslandViewModel tasks, @@ -68,6 +100,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase OnPropertyChanged(nameof(IsOffline)); } }; + Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; + _clearTimer.Elapsed += (_, _) => + { + if (Dispatcher.UIThread.CheckAccess()) + ClearWorkerLog(); + else + Dispatcher.UIThread.Post(ClearWorkerLog); + }; _ = Lists.LoadAsync(); } } diff --git a/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs new file mode 100644 index 0000000..a19527d --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs @@ -0,0 +1,58 @@ +using System; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +public class IslandsShellViewModelWorkerLogTests +{ + [Fact] + public void Receiving_event_sets_text_level_and_visible() + { + var vm = new IslandsShellViewModel(); + var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc); + + vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at)); + + Assert.True(vm.IsWorkerLogVisible); + Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel); + Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText); + } + + [Fact] + public void Second_event_replaces_first() + { + var vm = new IslandsShellViewModel(); + vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info, DateTime.UtcNow)); + vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow)); + + Assert.Contains("second", vm.WorkerLogText); + Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel); + } + + [Fact] + public void ClearWorkerLog_hides_line() + { + var vm = new IslandsShellViewModel(); + vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow)); + + vm.ClearWorkerLog(); + + Assert.False(vm.IsWorkerLogVisible); + Assert.Null(vm.WorkerLogText); + } + + [Fact] + public void Text_is_formatted_as_HHmm_dot_message_local_time() + { + var vm = new IslandsShellViewModel(); + var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc); + var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm"); + + vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc)); + + Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText); + } +}