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:
@@ -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>();
|
||||||
|
|||||||
17
src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs
Normal file
17
src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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}"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user