feat(ui): split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-28 09:18:39 +02:00
parent f890fa85b9
commit 8b02b63d3d
10 changed files with 389 additions and 175 deletions

View File

@@ -7,6 +7,7 @@ using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
@@ -94,6 +95,8 @@ sealed class Program
// ViewModels // ViewModels
sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<WorktreeModalViewModel>();
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddTransient<PrimeClaudeTabViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>(); sc.AddTransient<MergeModalViewModel>();
sc.AddTransient<ListSettingsModalViewModel>(); sc.AddTransient<ListSettingsModalViewModel>();

View File

@@ -0,0 +1,17 @@
namespace ClaudeDo.Ui.Services;
public interface IPrimeScheduleApi
{
Task<List<PrimeScheduleDto>> ListAsync();
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
Task DeleteAsync(Guid id);
}
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
{
private readonly WorkerClient _client;
public WorkerPrimeScheduleApi(WorkerClient client) => _client = client;
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto);
public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id);
}

View File

@@ -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<PromptKind>(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}"; }
}
}

View File

@@ -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<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
public IReadOnlyList<string> 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;
}
}

View File

@@ -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<Guid> _initialIds = new();
public ObservableCollection<PrimeScheduleRowViewModel> 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;
}
}

View File

@@ -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);
}

View File

@@ -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<string> 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; }
}
}

View File

@@ -1,8 +1,6 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -12,41 +10,24 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
{ {
private readonly WorkerClient _worker; private readonly WorkerClient _worker;
[ObservableProperty] private string _defaultClaudeInstructions = ""; public GeneralSettingsTabViewModel General { get; }
[ObservableProperty] private string _defaultModel = "sonnet"; public WorktreesSettingsTabViewModel Worktrees { get; }
[ObservableProperty] private int _defaultMaxTurns = 100; public FilesSettingsTabViewModel Files { get; }
[ObservableProperty] private string _defaultPermissionMode = "auto"; public PrimeClaudeTabViewModel Prime { get; }
[ObservableProperty] private string _worktreeStrategy = "sibling";
[ObservableProperty] private string? _centralWorktreeRoot;
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
[ObservableProperty] private int _worktreeAutoCleanupDays = 7;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _showResetConfirm;
[ObservableProperty] private string _validationError = ""; [ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" }; [ObservableProperty] private string _statusMessage = "";
public IReadOnlyList<string> PermissionModes { get; } = new[]
{ "auto", "bypassPermissions", "acceptEdits", "plan", "default" };
public IReadOnlyList<string> 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);
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
public SettingsModalViewModel(WorkerClient worker) public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime)
{ {
_worker = worker; _worker = worker;
General = new GeneralSettingsTabViewModel();
Worktrees = new WorktreesSettingsTabViewModel(worker);
Files = new FilesSettingsTabViewModel(worker);
Prime = prime;
} }
public async Task LoadAsync() public async Task LoadAsync()
@@ -57,166 +38,48 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
var dto = await _worker.GetAppSettingsAsync(); var dto = await _worker.GetAppSettingsAsync();
if (dto is not null) if (dto is not null)
{ {
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? ""; General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
DefaultModel = dto.DefaultModel ?? "sonnet"; General.DefaultModel = dto.DefaultModel ?? "sonnet";
DefaultMaxTurns = dto.DefaultMaxTurns; General.DefaultMaxTurns = dto.DefaultMaxTurns;
DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
CentralWorktreeRoot = dto.CentralWorktreeRoot; Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays; Worktrees.WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays;
}
else
{
StatusMessage = "Worker offline — settings read-only.";
} }
else StatusMessage = "Worker offline — settings read-only.";
await Prime.LoadAsync();
} }
finally { IsBusy = false; } 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] [RelayCommand]
private async Task Save() 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; IsBusy = true;
try try
{ {
var dto = new AppSettingsDto( var dto = new AppSettingsDto(
DefaultClaudeInstructions ?? "", General.DefaultClaudeInstructions ?? "",
DefaultModel ?? "sonnet", General.DefaultModel ?? "sonnet",
DefaultMaxTurns, General.DefaultMaxTurns,
DefaultPermissionMode ?? "auto", General.DefaultPermissionMode ?? "auto",
WorktreeStrategy ?? "sibling", Worktrees.WorktreeStrategy ?? "sibling",
string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot, string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot,
WorktreeAutoCleanupEnabled, Worktrees.WorktreeAutoCleanupEnabled,
WorktreeAutoCleanupDays); Worktrees.WorktreeAutoCleanupDays);
await _worker.UpdateAppSettingsAsync(dto); await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
catch (Exception ex) catch (Exception ex) { StatusMessage = $"Save failed: {ex.Message}"; }
{
StatusMessage = $"Save failed: {ex.Message}";
}
finally { IsBusy = false; } finally { IsBusy = false; }
} }
[RelayCommand] [RelayCommand] private void Cancel() => CloseAction?.Invoke();
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<PromptKind>(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}";
}
}
} }

View File

@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView" x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
x:DataType="vm:SettingsModalViewModel" x:CompileBindings="False"
Title="Settings" Title="Settings"
Width="580" Height="760" Width="580" Height="760"
SystemDecorations="None" SystemDecorations="None"

View File

@@ -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<PrimeScheduleDto> Stored { get; } = new();
public List<PrimeScheduleDto> Upserts { get; } = new();
public List<Guid> Deletes { get; } = new();
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(Stored.ToList());
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto)
{
Upserts.Add(dto);
return Task.FromResult<PrimeScheduleDto?>(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());
}
}