From 8b02b63d3d9dd2765fbe06024e84807180cfc778 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 28 Apr 2026 09:18:39 +0200 Subject: [PATCH] feat(ui): split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ClaudeDo.App/Program.cs | 3 + src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs | 17 ++ .../Settings/FilesSettingsTabViewModel.cs | 51 +++++ .../Settings/GeneralSettingsTabViewModel.cs | 22 ++ .../Settings/PrimeClaudeTabViewModel.cs | 81 +++++++ .../Settings/PrimeScheduleRowViewModel.cs | 36 +++ .../Settings/WorktreesSettingsTabViewModel.cs | 67 ++++++ .../Modals/SettingsModalViewModel.cs | 211 +++--------------- .../Views/Modals/SettingsModalView.axaml | 2 +- .../PrimeClaudeTabViewModelTests.cs | 74 ++++++ 10 files changed, 389 insertions(+), 175 deletions(-) create mode 100644 src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 744e823..8e3942a 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -7,6 +7,7 @@ using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Modals; +using ClaudeDo.Ui.ViewModels.Modals.Settings; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System; @@ -94,6 +95,8 @@ sealed class Program // ViewModels sc.AddTransient(); + sc.AddSingleton(); + sc.AddTransient(); sc.AddTransient(); sc.AddTransient(); sc.AddTransient(); diff --git a/src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs b/src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs new file mode 100644 index 0000000..8351898 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs @@ -0,0 +1,17 @@ +namespace ClaudeDo.Ui.Services; + +public interface IPrimeScheduleApi +{ + Task> ListAsync(); + Task UpsertAsync(PrimeScheduleDto dto); + Task DeleteAsync(Guid id); +} + +public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi +{ + private readonly WorkerClient _client; + public WorkerPrimeScheduleApi(WorkerClient client) => _client = client; + public Task> ListAsync() => _client.GetPrimeSchedulesAsync(); + public Task UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto); + public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id); +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs new file mode 100644 index 0000000..a674624 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs @@ -0,0 +1,51 @@ +using System.Diagnostics; +using ClaudeDo.Data; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class FilesSettingsTabViewModel : ViewModelBase +{ + private readonly WorkerClient _worker; + + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _isBusy; + + public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System); + public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning); + public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent); + + public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker; + + [RelayCommand] + private async Task RestoreDefaultAgents() + { + IsBusy = true; StatusMessage = ""; + try + { + var r = await _worker.RestoreDefaultAgentsAsync(); + if (r is null) StatusMessage = "Worker offline."; + else if (r.Copied == 0 && r.Skipped == 0) StatusMessage = "No default agents bundled."; + else if (r.Copied == 0) StatusMessage = "All default agents already present."; + else StatusMessage = $"Restored {r.Copied} default agent(s)."; + await _worker.RefreshAgentsAsync(); + } + catch (Exception ex) { StatusMessage = $"Restore failed: {ex.Message}"; } + finally { IsBusy = false; } + } + + [RelayCommand] + private void OpenPrompt(string? kindName) + { + if (!Enum.TryParse(kindName, ignoreCase: true, out var kind)) return; + try + { + PromptFiles.EnsureExists(kind); + var path = PromptFiles.PathFor(kind); + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } + catch (Exception ex) { StatusMessage = $"Open failed: {ex.Message}"; } + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs new file mode 100644 index 0000000..e9fe848 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs @@ -0,0 +1,22 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class GeneralSettingsTabViewModel : ViewModelBase +{ + [ObservableProperty] private string _defaultClaudeInstructions = ""; + [ObservableProperty] private string _defaultModel = "sonnet"; + [ObservableProperty] private int _defaultMaxTurns = 100; + [ObservableProperty] private string _defaultPermissionMode = "auto"; + + public IReadOnlyList Models { get; } = new[] { "opus", "sonnet", "haiku" }; + public IReadOnlyList PermissionModes { get; } = new[] + { "auto", "bypassPermissions", "acceptEdits", "plan", "default" }; + + public string? Validate() + { + if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) + return "Max turns must be between 1 and 200."; + return null; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs new file mode 100644 index 0000000..767e4fb --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs @@ -0,0 +1,81 @@ +using System.Collections.ObjectModel; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class PrimeClaudeTabViewModel : ViewModelBase +{ + private readonly IPrimeScheduleApi _api; + private readonly HashSet _initialIds = new(); + + public ObservableCollection Rows { get; } = new(); + + public PrimeClaudeTabViewModel(IPrimeScheduleApi api) => _api = api; + + public async Task LoadAsync() + { + Rows.Clear(); + _initialIds.Clear(); + var list = await _api.ListAsync(); + foreach (var dto in list) + { + Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: true)); + _initialIds.Add(dto.Id); + } + } + + public string? Validate() + { + foreach (var r in Rows) + { + if (r.StartDate > r.EndDate) + return $"Schedule {r.TimeOfDay:hh\\:mm}: start date is after end date."; + if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1)) + return "Time must be between 00:00 and 23:59."; + } + return null; + } + + public async Task SaveAsync() + { + var keepIds = Rows.Select(r => r.Id).ToHashSet(); + foreach (var removed in _initialIds.Where(id => !keepIds.Contains(id)).ToList()) + await _api.DeleteAsync(removed); + foreach (var r in Rows) + await _api.UpsertAsync(r.ToDto()); + _initialIds.Clear(); + foreach (var id in keepIds) _initialIds.Add(id); + } + + [RelayCommand] + private void AddSchedule() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var dto = new PrimeScheduleDto( + Id: Guid.NewGuid(), + StartDate: today, + EndDate: today.AddDays(30), + TimeOfDay: new TimeSpan(7, 0, 0), + WorkdaysOnly: true, + Enabled: true, + LastRunAt: null, + PromptOverride: null); + Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false)); + } + + [RelayCommand] + private void RemoveSchedule(PrimeScheduleRowViewModel? row) + { + if (row is null) return; + Rows.Remove(row); + } + + public void ApplyFiredEvent(PrimeFiredEvent evt) + { + var row = Rows.FirstOrDefault(r => r.Id == evt.ScheduleId); + if (row is null) return; + if (evt.Success) row.LastRunAt = evt.FiredAt; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs new file mode 100644 index 0000000..efb3ded --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs @@ -0,0 +1,36 @@ +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class PrimeScheduleRowViewModel : ViewModelBase +{ + public Guid Id { get; } + public bool IsExisting { get; } + + [ObservableProperty] private bool _enabled; + [ObservableProperty] private DateOnly _startDate; + [ObservableProperty] private DateOnly _endDate; + [ObservableProperty] private TimeSpan _timeOfDay; + [ObservableProperty] private bool _workdaysOnly; + [ObservableProperty] private DateTimeOffset? _lastRunAt; + + public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—"; + + partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel)); + + public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting) + { + Id = dto.Id; + IsExisting = isExisting; + Enabled = dto.Enabled; + StartDate = dto.StartDate; + EndDate = dto.EndDate; + TimeOfDay = dto.TimeOfDay; + WorkdaysOnly = dto.WorkdaysOnly; + LastRunAt = dto.LastRunAt; + } + + public PrimeScheduleDto ToDto() => + new(Id, StartDate, EndDate, TimeOfDay, WorkdaysOnly, Enabled, LastRunAt, null); +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs new file mode 100644 index 0000000..21f7f52 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs @@ -0,0 +1,67 @@ +using System.IO; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase +{ + private readonly WorkerClient _worker; + + [ObservableProperty] private string _worktreeStrategy = "sibling"; + [ObservableProperty] private string? _centralWorktreeRoot; + [ObservableProperty] private bool _worktreeAutoCleanupEnabled; + [ObservableProperty] private int _worktreeAutoCleanupDays = 7; + + [ObservableProperty] private bool _showResetConfirm; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _isBusy; + + public IReadOnlyList WorktreeStrategies { get; } = new[] { "sibling", "central" }; + + public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker; + + public string? Validate() + { + if (WorktreeAutoCleanupEnabled && (WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365)) + return "Cleanup days must be between 1 and 365."; + if (WorktreeStrategy == "central") + { + if (string.IsNullOrWhiteSpace(CentralWorktreeRoot)) + return "Central worktree root is required for Central strategy."; + if (!Directory.Exists(CentralWorktreeRoot)) + return $"Directory not found: {CentralWorktreeRoot}"; + } + return null; + } + + [RelayCommand] + private async Task CleanupWorktrees() + { + IsBusy = true; StatusMessage = ""; + try + { + var r = await _worker.CleanupFinishedWorktreesAsync(); + StatusMessage = r is null ? "Worker offline." : $"Removed {r.Removed} worktree(s)."; + } + finally { IsBusy = false; } + } + + [RelayCommand] private void RequestResetConfirm() => ShowResetConfirm = true; + [RelayCommand] private void CancelResetConfirm() => ShowResetConfirm = false; + + [RelayCommand] + private async Task ConfirmResetAll() + { + ShowResetConfirm = false; IsBusy = true; StatusMessage = ""; + try + { + var r = await _worker.ResetAllWorktreesAsync(); + if (r is null) StatusMessage = "Worker offline."; + else if (r.Blocked) StatusMessage = $"Cannot force-remove: {r.RunningTasks} task(s) still running. Cancel them first."; + else StatusMessage = $"Removed {r.Removed} worktree(s) from {r.TasksAffected} task(s)."; + } + finally { IsBusy = false; } + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index ff4d775..e6d8253 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -1,8 +1,6 @@ -using System.Diagnostics; -using System.IO; -using System.Reflection; using ClaudeDo.Data; using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals.Settings; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -12,41 +10,24 @@ public sealed partial class SettingsModalViewModel : ViewModelBase { private readonly WorkerClient _worker; - [ObservableProperty] private string _defaultClaudeInstructions = ""; - [ObservableProperty] private string _defaultModel = "sonnet"; - [ObservableProperty] private int _defaultMaxTurns = 100; - [ObservableProperty] private string _defaultPermissionMode = "auto"; - [ObservableProperty] private string _worktreeStrategy = "sibling"; - [ObservableProperty] private string? _centralWorktreeRoot; - [ObservableProperty] private bool _worktreeAutoCleanupEnabled; - [ObservableProperty] private int _worktreeAutoCleanupDays = 7; + public GeneralSettingsTabViewModel General { get; } + public WorktreesSettingsTabViewModel Worktrees { get; } + public FilesSettingsTabViewModel Files { get; } + public PrimeClaudeTabViewModel Prime { get; } - [ObservableProperty] private string _statusMessage = ""; - [ObservableProperty] private bool _isBusy; - [ObservableProperty] private bool _showResetConfirm; [ObservableProperty] private string _validationError = ""; - - public IReadOnlyList Models { get; } = new[] { "opus", "sonnet", "haiku" }; - public IReadOnlyList PermissionModes { get; } = new[] - { "auto", "bypassPermissions", "acceptEdits", "plan", "default" }; - public IReadOnlyList WorktreeStrategies { get; } = new[] { "sibling", "central" }; - - public string AppVersion { get; } = - Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; - - public string DataFolderPath { get; } = Paths.AppDataRoot(); - public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs"); - public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json"); - - public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System); - public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning); - public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent); + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _statusMessage = ""; public Action? CloseAction { get; set; } - public SettingsModalViewModel(WorkerClient worker) + public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime) { _worker = worker; + General = new GeneralSettingsTabViewModel(); + Worktrees = new WorktreesSettingsTabViewModel(worker); + Files = new FilesSettingsTabViewModel(worker); + Prime = prime; } public async Task LoadAsync() @@ -57,166 +38,48 @@ public sealed partial class SettingsModalViewModel : ViewModelBase var dto = await _worker.GetAppSettingsAsync(); if (dto is not null) { - DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? ""; - DefaultModel = dto.DefaultModel ?? "sonnet"; - DefaultMaxTurns = dto.DefaultMaxTurns; - DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; - WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; - CentralWorktreeRoot = dto.CentralWorktreeRoot; - WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; - WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays; - } - else - { - StatusMessage = "Worker offline — settings read-only."; + General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? ""; + General.DefaultModel = dto.DefaultModel ?? "sonnet"; + General.DefaultMaxTurns = dto.DefaultMaxTurns; + General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; + Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; + Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; + Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; + Worktrees.WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays; } + else StatusMessage = "Worker offline — settings read-only."; + + await Prime.LoadAsync(); } finally { IsBusy = false; } } - private bool Validate() - { - if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) - { ValidationError = "Max turns must be between 1 and 200."; return false; } - - if (WorktreeAutoCleanupEnabled && - (WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365)) - { ValidationError = "Cleanup days must be between 1 and 365."; return false; } - - if (WorktreeStrategy == "central") - { - if (string.IsNullOrWhiteSpace(CentralWorktreeRoot)) - { ValidationError = "Central worktree root is required for Central strategy."; return false; } - if (!Directory.Exists(CentralWorktreeRoot)) - { ValidationError = $"Directory not found: {CentralWorktreeRoot}"; return false; } - } - - ValidationError = ""; - return true; - } - [RelayCommand] private async Task Save() { - if (!Validate()) return; + var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate(); + if (err is not null) { ValidationError = err; return; } + ValidationError = ""; IsBusy = true; try { var dto = new AppSettingsDto( - DefaultClaudeInstructions ?? "", - DefaultModel ?? "sonnet", - DefaultMaxTurns, - DefaultPermissionMode ?? "auto", - WorktreeStrategy ?? "sibling", - string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot, - WorktreeAutoCleanupEnabled, - WorktreeAutoCleanupDays); + General.DefaultClaudeInstructions ?? "", + General.DefaultModel ?? "sonnet", + General.DefaultMaxTurns, + General.DefaultPermissionMode ?? "auto", + Worktrees.WorktreeStrategy ?? "sibling", + string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot, + Worktrees.WorktreeAutoCleanupEnabled, + Worktrees.WorktreeAutoCleanupDays); await _worker.UpdateAppSettingsAsync(dto); + await Prime.SaveAsync(); CloseAction?.Invoke(); } - catch (Exception ex) - { - StatusMessage = $"Save failed: {ex.Message}"; - } + catch (Exception ex) { StatusMessage = $"Save failed: {ex.Message}"; } finally { IsBusy = false; } } - [RelayCommand] - private void Cancel() => CloseAction?.Invoke(); - - [RelayCommand] - private async Task CleanupWorktrees() - { - IsBusy = true; - StatusMessage = ""; - try - { - var result = await _worker.CleanupFinishedWorktreesAsync(); - StatusMessage = result is null - ? "Worker offline." - : $"Removed {result.Removed} worktree(s)."; - } - finally { IsBusy = false; } - } - - [RelayCommand] - private void RequestResetConfirm() => ShowResetConfirm = true; - - [RelayCommand] - private void CancelResetConfirm() => ShowResetConfirm = false; - - [RelayCommand] - private async Task ConfirmResetAll() - { - ShowResetConfirm = false; - IsBusy = true; - StatusMessage = ""; - try - { - var result = await _worker.ResetAllWorktreesAsync(); - if (result is null) - StatusMessage = "Worker offline."; - else if (result.Blocked) - StatusMessage = $"Cannot force-remove: {result.RunningTasks} task(s) still running. Cancel them first."; - else - StatusMessage = $"Removed {result.Removed} worktree(s) from {result.TasksAffected} task(s)."; - } - finally { IsBusy = false; } - } - - [RelayCommand] - private async Task RestoreDefaultAgents() - { - IsBusy = true; - StatusMessage = ""; - try - { - var result = await _worker.RestoreDefaultAgentsAsync(); - if (result is null) - StatusMessage = "Worker offline."; - else if (result.Copied == 0 && result.Skipped == 0) - StatusMessage = "No default agents bundled."; - else if (result.Copied == 0) - StatusMessage = "All default agents already present."; - else - StatusMessage = $"Restored {result.Copied} default agent(s)."; - - await _worker.RefreshAgentsAsync(); - } - catch (Exception ex) - { - StatusMessage = $"Restore failed: {ex.Message}"; - } - finally { IsBusy = false; } - } - - [RelayCommand] - private void OpenPath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) return; - try - { - var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null); - if (target is null) return; - Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true }); - } - catch { /* ignore */ } - } - - [RelayCommand] - private void OpenPrompt(string? kindName) - { - if (!Enum.TryParse(kindName, ignoreCase: true, out var kind)) return; - try - { - PromptFiles.EnsureExists(kind); - var path = PromptFiles.PathFor(kind); - Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); - } - catch (Exception ex) - { - StatusMessage = $"Open failed: {ex.Message}"; - } - } + [RelayCommand] private void Cancel() => CloseAction?.Invoke(); } diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 4256c04..9e474cc 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -2,7 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals" x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView" - x:DataType="vm:SettingsModalViewModel" + x:CompileBindings="False" Title="Settings" Width="580" Height="760" SystemDecorations="None" diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs new file mode 100644 index 0000000..f2c6032 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs @@ -0,0 +1,74 @@ +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals.Settings; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class PrimeClaudeTabViewModelTests +{ + private sealed class FakeApi : IPrimeScheduleApi + { + public List Stored { get; } = new(); + public List Upserts { get; } = new(); + public List Deletes { get; } = new(); + public Task> ListAsync() => Task.FromResult(Stored.ToList()); + public Task UpsertAsync(PrimeScheduleDto dto) + { + Upserts.Add(dto); + return Task.FromResult(dto); + } + public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; } + } + + [Fact] + public async Task Load_Populates_Rows() + { + var api = new FakeApi(); + api.Stored.Add(new PrimeScheduleDto( + Guid.NewGuid(), new DateOnly(2026,5,1), new DateOnly(2026,5,31), + new TimeSpan(7,0,0), true, true, null, null)); + var vm = new PrimeClaudeTabViewModel(api); + await vm.LoadAsync(); + Assert.Single(vm.Rows); + } + + [Fact] + public void AddSchedule_Appends_Row_With_Defaults() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + vm.AddScheduleCommand.Execute(null); + Assert.Single(vm.Rows); + Assert.True(vm.Rows[0].Enabled); + Assert.True(vm.Rows[0].WorkdaysOnly); + Assert.Equal(new TimeSpan(7,0,0), vm.Rows[0].TimeOfDay); + } + + [Fact] + public async Task Save_Diffs_New_And_Removed_Rows() + { + var api = new FakeApi(); + var keptId = Guid.NewGuid(); + var deletedId = Guid.NewGuid(); + api.Stored.Add(new PrimeScheduleDto(keptId, new(2026,5,1), new(2026,5,31), new(7,0,0), true, true, null, null)); + api.Stored.Add(new PrimeScheduleDto(deletedId, new(2026,5,1), new(2026,5,31), new(8,0,0), true, true, null, null)); + + var vm = new PrimeClaudeTabViewModel(api); + await vm.LoadAsync(); + vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId)); + vm.AddScheduleCommand.Execute(null); + + await vm.SaveAsync(); + + Assert.Contains(deletedId, api.Deletes); + Assert.Equal(2, api.Upserts.Count); + } + + [Fact] + public void Validate_Reports_StartAfterEnd() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + vm.AddScheduleCommand.Execute(null); + vm.Rows[0].StartDate = new DateOnly(2026, 6, 1); + vm.Rows[0].EndDate = new DateOnly(2026, 5, 1); + Assert.NotNull(vm.Validate()); + } +}