Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs
T

229 lines
11 KiB
C#

using System.Linq;
using ClaudeDo.Data;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
/// <summary>One sidebar entry. <c>Group</c> is an internal split key ("Basis"/"Erweitert"), not
/// user-facing text — the sidebar renders the two groups as separate lists with their own
/// (localized) heading. <c>RequiresRepo</c> marks the categories that show the repo-hint strip.</summary>
public sealed record SettingsCategory(string Key, LocalizedString Label, string Group, bool IsVisible, bool RequiresRepo);
public sealed partial class SettingsModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public GeneralSettingsTabViewModel General { get; }
public WorktreesSettingsTabViewModel Worktrees { get; }
public FilesSettingsTabViewModel Files { get; }
public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
public TicketSettingsTabViewModel Tickets { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false;
// Fixed order — mirrors the TabItem order in SettingsModalView.axaml 1:1, so
// _categories.IndexOf(category) is a valid TabControl.SelectedIndex.
private readonly List<SettingsCategory> _categories;
public IReadOnlyList<SettingsCategory> Categories => _categories;
public IReadOnlyList<SettingsCategory> BasisCategories { get; }
public IReadOnlyList<SettingsCategory> ErweitertCategories { get; }
private SettingsCategory? _selectedCategory;
public SettingsCategory? SelectedCategory
{
get => _selectedCategory;
set
{
// The two sidebar ListBoxes share this property; selecting an item in one pushes null
// into the other's binding (item not found in its own ItemsSource) — ignore that noise
// instead of letting it clobber the real selection.
if (value is null) return;
if (SetProperty(ref _selectedCategory, value))
SelectedIndex = _categories.IndexOf(value);
}
}
[ObservableProperty] private int _selectedIndex;
// True once at least one list has a linked repo (WorkingDir). Drives the repo-hint strip on
// Worktrees/Prime Claude/Session Skills/Berichte — nothing is ever hidden, just annotated.
// Scope intentionally broader than ListsIslandViewModel.HasNoLinkedRepo (all Lists rows in
// the DB, not just UserLists).
[ObservableProperty] private bool _hasLinkedRepo;
// Wired by WindowDialogService to close this modal and open the existing repo-import flow.
public Action? RequestRepoImport { get; set; }
[RelayCommand]
private void OpenRepoImport() => RequestRepoImport?.Invoke();
// Same deal for the usage monitor, which owns the throttle-stage editor. It must *close* this
// modal rather than stack on top: Save() writes the throttle values it read at open, so a drag
// made while Settings is still open would be overwritten on save.
public Action? RequestUsageMonitor { get; set; }
[RelayCommand]
private void OpenUsageMonitor() => RequestUsageMonitor?.Invoke();
/// Read-only echo of the four throttle percentages, which are edited by dragging the
/// usage-monitor gauges — without this the Execution tab silently carries them through.
[ObservableProperty] private string _throttleSummary = "";
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = "";
public Action? CloseAction { get; set; }
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
OnlineLoginService onlineLoginService,
ILocalizer localizer, AppSettings appSettings,
IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_worker = worker;
_dbFactory = dbFactory;
General = new GeneralSettingsTabViewModel(localizer, code =>
{
appSettings.Language = code;
appSettings.Save();
});
General.InitAccentPresets(appSettings.AccentPreset, preset =>
{
appSettings.AccentPreset = preset;
appSettings.Save();
});
Worktrees = new WorktreesSettingsTabViewModel(worker);
Files = new FilesSettingsTabViewModel(worker);
Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker);
_categories = new List<SettingsCategory>
{
new("General", new LocalizedString(localizer, "settings.sidebar.categoryGeneral"), "Basis", true, false),
new("Execution", new LocalizedString(localizer, "settings.sidebar.categoryExecution"), "Basis", true, false),
new("Prime", new LocalizedString(localizer, "settings.sidebar.categoryPrime"), "Basis", true, true),
new("Worktrees", new LocalizedString(localizer, "settings.sidebar.categoryWorktrees"), "Erweitert", true, true),
new("Files", new LocalizedString(localizer, "settings.sidebar.categoryFiles"), "Erweitert", true, false),
new("SessionSkills", new LocalizedString(localizer, "settings.sidebar.categorySkills"), "Erweitert", true, true),
new("Reports", new LocalizedString(localizer, "settings.sidebar.categoryReports"), "Erweitert", true, true),
new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false),
new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", true, false),
};
BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
_selectedCategory = _categories[0];
}
// Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
// carried through load→save verbatim so saving Settings can never reset a dragged value.
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
public async Task LoadAsync()
{
IsBusy = true;
try
{
var dto = await _worker.GetAppSettingsAsync();
if (dto is not null)
{
General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
General.DefaultModel = dto.DefaultModel ?? "sonnet";
General.DefaultMaxTurns = dto.DefaultMaxTurns;
General.MaxTurnsCeiling = dto.MaxTurnsCeiling;
General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
General.MaxParallelExecutions = dto.MaxParallelExecutions;
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit;
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
ThrottleSummary = Loc.T("settings.general.throttleStagesValues",
_throttleStages.FiveSoft, _throttleStages.FiveHard,
_throttleStages.SevenSoft, _throttleStages.SevenHard);
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
Worktrees.WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays;
General.ReportExcludedPaths = string.IsNullOrWhiteSpace(dto.ReportExcludedPaths)
? @"C:\Private"
: string.Join(Environment.NewLine,
System.Text.Json.JsonSerializer.Deserialize<List<string>>(dto.ReportExcludedPaths) ?? new());
General.StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday;
Prime.DailyPrepMaxTasks = dto.DailyPrepMaxTasks < 1 ? 5 : dto.DailyPrepMaxTasks;
}
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
await Files.LoadClaudeBinAsync();
await Prime.LoadAsync();
await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync(dto?.SessionSkills);
await Tickets.LoadAsync();
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
await using var ctx = await _dbFactory.CreateDbContextAsync();
HasLinkedRepo = await ctx.Lists.AnyAsync(RepoLinkage.IsLinkedInDb);
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task Save()
{
var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate();
if (err is not null) { ValidationError = err; return; }
ValidationError = "";
IsBusy = true;
try
{
var dto = new AppSettingsDto(
General.DefaultClaudeInstructions ?? "",
General.DefaultModel ?? "sonnet",
General.DefaultMaxTurns,
General.DefaultPermissionMode ?? "auto",
General.MaxParallelExecutions,
Worktrees.WorktreeStrategy ?? "sibling",
string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot,
Worktrees.WorktreeAutoCleanupEnabled,
Worktrees.WorktreeAutoCleanupDays,
System.Text.Json.JsonSerializer.Serialize(
General.ReportExcludedPaths
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
General.StandupWeekday,
Prime.DailyPrepMaxTasks,
SessionSkills.SelectedSkillNames(),
General.ModelPresetDtos(),
General.UsageGateFiveHourPct,
General.UsageGateSevenDayPct,
General.MaxTurnsCeiling,
_throttleStages.FiveSoft,
_throttleStages.FiveHard,
_throttleStages.SevenSoft,
_throttleStages.SevenHard,
General.AutoContinueOnUsageLimit);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
await Tickets.SaveAsync();
CloseAction?.Invoke();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); }
finally { IsBusy = false; }
}
[RelayCommand] private void Cancel() => CloseAction?.Invoke();
}