feat(ui): Settings-Modal auf Sidebar-Kategorien umbauen

TabControl bleibt Content-Host, TabStrip wird retempliert (nur
PART_SelectedContentHost) und durch eine zweigruppige Sidebar
(BASIS/ERWEITERT) ersetzt. General wird in Allgemein/Ausfuehrung/Berichte
gesplittet, Session Skills (Checkbox-Liste aus General + Skills-Tab)
zu einer Liste zusammengelegt, und ein Repo-Hinweisstreifen erscheint
auf Worktrees/Prime Claude/Session Skills/Berichte solange keine Liste
ein WorkingDir hat. Fensterbreite 580 -> 700.
This commit is contained in:
mika kuns
2026-08-21 15:04:15 +02:00
parent a80a31eeac
commit 56c3719e98
10 changed files with 548 additions and 209 deletions
+15
View File
@@ -10,6 +10,21 @@
"tabFiles": "Dateien", "tabFiles": "Dateien",
"tabPrime": "Prime Claude", "tabPrime": "Prime Claude",
"tabSkills": "Skills", "tabSkills": "Skills",
"sidebar": {
"groupBasis": "BASIS",
"groupAdvanced": "ERWEITERT",
"categoryGeneral": "Allgemein",
"categoryExecution": "Ausführung",
"categoryPrime": "Prime Claude",
"categoryWorktrees": "Worktrees",
"categoryFiles": "Dateien & Prompts",
"categorySkills": "Session Skills",
"categoryReports": "Berichte"
},
"repoHint": {
"text": "Wirkt erst mit verknüpftem Repo.",
"link": "Repo importieren"
},
"general": { "general": {
"defaultInstructions": "Standard-Anweisungen", "defaultInstructions": "Standard-Anweisungen",
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden", "defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
+15
View File
@@ -10,6 +10,21 @@
"tabFiles": "Files", "tabFiles": "Files",
"tabPrime": "Prime Claude", "tabPrime": "Prime Claude",
"tabSkills": "Skills", "tabSkills": "Skills",
"sidebar": {
"groupBasis": "BASICS",
"groupAdvanced": "ADVANCED",
"categoryGeneral": "General",
"categoryExecution": "Execution",
"categoryPrime": "Prime Claude",
"categoryWorktrees": "Worktrees",
"categoryFiles": "Files & Prompts",
"categorySkills": "Session Skills",
"categoryReports": "Reports"
},
"repoHint": {
"text": "Only takes effect once a repo is linked.",
"link": "Import repo"
},
"general": { "general": {
"defaultInstructions": "Default instructions", "defaultInstructions": "Default instructions",
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task", "defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
@@ -3,7 +3,6 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Localization; using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -33,8 +32,6 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases; public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes; public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
public ObservableCollection<AccentPresetSwatchViewModel> AccentPresetSwatches { get; } = new(); public ObservableCollection<AccentPresetSwatchViewModel> AccentPresetSwatches { get; } = new();
private Action<string>? _persistAccent; private Action<string>? _persistAccent;
@@ -109,22 +106,6 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
return $"Max turns for {row.Model} must be between 1 and 200."; return $"Max turns for {row.Model} must be between 1 and 200.";
return null; return null;
} }
/// <summary>Loads the installed-skills checkbox list, reflecting the given selection.</summary>
public async Task LoadSessionSkillsAsync(IWorkerClient worker, IReadOnlyCollection<string>? selected)
{
var installed = await worker.GetSessionSkillsAsync();
var selectedSet = selected is null ? new HashSet<string>() : new HashSet<string>(selected);
SessionSkills.Clear();
foreach (var s in installed)
SessionSkills.Add(new SelectableSkillViewModel(s.Name, s.Description, selectedSet.Contains(s.Name)));
}
public List<string>? SelectedSessionSkillNames()
{
var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
return names.Count == 0 ? null : names;
}
} }
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary> /// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
@@ -18,7 +18,7 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
[ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _isEmpty = true; [ObservableProperty] private bool _isEmpty = true;
public ObservableCollection<SessionSkillDto> Skills { get; } = new(); public ObservableCollection<SessionSkillRowViewModel> Skills { get; } = new();
public SessionSkillsSettingsTabViewModel(IWorkerClient worker) public SessionSkillsSettingsTabViewModel(IWorkerClient worker)
{ {
@@ -33,19 +33,31 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
}; };
} }
public async Task LoadAsync() /// <summary>Loads the installed skills, marking each row selected per <paramref name="selectedNames"/>.
/// When omitted, the current selection is preserved across the refresh (e.g. after install/update/remove).</summary>
public async Task LoadAsync(IReadOnlyCollection<string>? selectedNames = null)
{ {
IsBusy = true; IsBusy = true;
try try
{ {
var selected = selectedNames ?? Skills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
var selectedSet = new HashSet<string>(selected);
var skills = await _worker.GetSessionSkillsAsync(); var skills = await _worker.GetSessionSkillsAsync();
Skills.Clear(); Skills.Clear();
foreach (var s in skills) Skills.Add(s); foreach (var s in skills) Skills.Add(new SessionSkillRowViewModel(s, selectedSet.Contains(s.Name)));
IsEmpty = Skills.Count == 0; IsEmpty = Skills.Count == 0;
} }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
/// <summary>The globally-selected skill names, or null when none are selected (matches the
/// AppSettingsDto.SessionSkills convention of "null means no global selection").</summary>
public List<string>? SelectedSkillNames()
{
var names = Skills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
return names.Count == 0 ? null : names;
}
private bool CanInstall() => !InstallOp.IsRunning; private bool CanInstall() => !InstallOp.IsRunning;
[RelayCommand(CanExecute = nameof(CanInstall))] [RelayCommand(CanExecute = nameof(CanInstall))]
@@ -96,3 +108,27 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
finally { IsBusy = false; } finally { IsBusy = false; }
} }
} }
/// <summary>One installed skill row: identity/management fields from <see cref="SessionSkillDto"/>
/// plus the global "active for every task" checkbox state that used to live in a separate
/// General-tab list.</summary>
public sealed partial class SessionSkillRowViewModel : ViewModelBase
{
public string Name { get; }
public string Description { get; }
public string SourceUrl { get; }
public string PinnedRef { get; }
public DateTimeOffset AddedAt { get; }
[ObservableProperty] private bool _isSelected;
public SessionSkillRowViewModel(SessionSkillDto dto, bool isSelected)
{
Name = dto.Name;
Description = dto.Description;
SourceUrl = dto.SourceUrl;
PinnedRef = dto.PinnedRef;
AddedAt = dto.AddedAt;
_isSelected = isSelected;
}
}
@@ -6,12 +6,19 @@ using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings; using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals; 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 public sealed partial class SettingsModalViewModel : ViewModelBase
{ {
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public GeneralSettingsTabViewModel General { get; } public GeneralSettingsTabViewModel General { get; }
public WorktreesSettingsTabViewModel Worktrees { get; } public WorktreesSettingsTabViewModel Worktrees { get; }
@@ -23,6 +30,40 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung. // Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false; 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.
[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();
[ObservableProperty] private string _validationError = ""; [ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = ""; [ObservableProperty] private string _statusMessage = "";
@@ -31,9 +72,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime, public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
IOnlineLoginService onlineLoginService, IOnlineLoginService onlineLoginService,
ILocalizer localizer, AppSettings appSettings) ILocalizer localizer, AppSettings appSettings,
IDbContextFactory<ClaudeDoDbContext> dbFactory)
{ {
_worker = worker; _worker = worker;
_dbFactory = dbFactory;
General = new GeneralSettingsTabViewModel(localizer, code => General = new GeneralSettingsTabViewModel(localizer, code =>
{ {
appSettings.Language = code; appSettings.Language = code;
@@ -49,6 +92,21 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
Prime = prime; Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService); OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker); SessionSkills = new SessionSkillsSettingsTabViewModel(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),
};
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 — // Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
@@ -89,9 +147,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await Prime.LoadAsync(); await Prime.LoadAsync();
await OnlineInbox.LoadAsync(); await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync(); await SessionSkills.LoadAsync(dto?.SessionSkills);
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling); General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
await using var ctx = await _dbFactory.CreateDbContextAsync();
HasLinkedRepo = await ctx.Lists.AnyAsync(l => l.WorkingDir != null && l.WorkingDir != "");
} }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
@@ -121,7 +181,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()), .Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
General.StandupWeekday, General.StandupWeekday,
Prime.DailyPrepMaxTasks, Prime.DailyPrepMaxTasks,
General.SelectedSessionSkillNames(), SessionSkills.SelectedSkillNames(),
General.ModelPresetDtos(), General.ModelPresetDtos(),
General.UsageGateFiveHourPct, General.UsageGateFiveHourPct,
General.UsageGateSevenDayPct, General.UsageGateSevenDayPct,
@@ -11,7 +11,7 @@
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView" x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
x:DataType="vm:SettingsModalViewModel" x:DataType="vm:SettingsModalViewModel"
Title="{loc:Tr settings.title}" Title="{loc:Tr settings.title}"
Width="580" Height="760" MinWidth="480" MinHeight="520" Width="700" Height="760" MinWidth="600" MinHeight="520"
CanResize="True" CanResize="True"
WindowDecorations="BorderOnly" WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
@@ -35,7 +35,7 @@
</StackPanel> </StackPanel>
</ctl:ModalShell.Footer> </ctl:ModalShell.Footer>
<!-- Body: tabs + bottom validation/status strip --> <!-- Body: sidebar + tab-content host + bottom validation/status strip -->
<DockPanel> <DockPanel>
<StackPanel DockPanel.Dock="Bottom" Margin="20,0,20,8" Spacing="2"> <StackPanel DockPanel.Dock="Bottom" Margin="20,0,20,8" Spacing="2">
<TextBlock Classes="meta" Text="{Binding ValidationError}" <TextBlock Classes="meta" Text="{Binding ValidationError}"
@@ -45,9 +45,58 @@
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<TabControl Padding="20,16" TabStripPlacement="Top"> <Grid ColumnDefinitions="170,1,*">
<TabItem Header="{loc:Tr settings.tabGeneral}"> <!-- Sidebar: two independently-scrolled groups sharing one SelectedCategory. -->
<StackPanel Grid.Column="0" Spacing="16" Margin="12,16,8,8">
<StackPanel Spacing="2">
<TextBlock Classes="eyebrow" Opacity="0.6" Margin="10,0,0,4"
Text="{loc:Tr settings.sidebar.groupBasis}"/>
<ListBox ItemsSource="{Binding BasisCategories}"
SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsCategory">
<TextBlock Text="{Binding Label.Value}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Classes="eyebrow" Opacity="0.6" Margin="10,0,0,4"
Text="{loc:Tr settings.sidebar.groupAdvanced}"/>
<ListBox ItemsSource="{Binding ErweitertCategories}"
SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
Background="Transparent">
<ListBox.Styles>
<Style Selector="ListBoxItem" x:DataType="vm:SettingsCategory">
<Setter Property="IsVisible" Value="{Binding IsVisible}"/>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsCategory">
<TextBlock Text="{Binding Label.Value}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</StackPanel>
<Border Grid.Column="1" Background="{DynamicResource LineBrush}" Width="1" Margin="0,8"/>
<!-- TabControl stays the content host (SelectedIndex driven by the sidebar); its own
header strip is re-templated away so only PART_SelectedContentHost renders. -->
<TabControl Grid.Column="2" Padding="20,16" SelectedIndex="{Binding SelectedIndex, Mode=OneWay}">
<TabControl.Template>
<ControlTemplate TargetType="TabControl">
<ContentPresenter Name="PART_SelectedContentHost"
Content="{TemplateBinding SelectedContent}"
ContentTemplate="{TemplateBinding SelectedContentTemplate}"/>
</ControlTemplate>
</TabControl.Template>
<!-- Allgemein: language, accent, default instructions. -->
<TabItem Header="{loc:Tr settings.sidebar.categoryGeneral}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
@@ -101,6 +150,14 @@
PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}" PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}"
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/> Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
</StackPanel> </StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- Ausführung: model/permission, per-model presets, parallelism, usage gate. -->
<TabItem Header="{loc:Tr settings.sidebar.categoryExecution}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="*,12,*"> <Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.model}"/> <TextBlock Classes="field-label" Text="{loc:Tr settings.general.model}"/>
@@ -181,42 +238,83 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> </StackPanel>
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.reportExcludedPaths}"/> </ScrollViewer>
<TextBox AcceptsReturn="True" MinHeight="60" Text="{Binding General.ReportExcludedPaths, Mode=TwoWay}"/> </TabItem>
</StackPanel>
<StackPanel Spacing="4"> <TabItem Header="{loc:Tr settings.sidebar.categoryPrime}">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.standupWeekday}"/> <ScrollViewer>
<ComboBox SelectedIndex="{Binding General.StandupWeekday, Mode=TwoWay}" HorizontalAlignment="Left"> <StackPanel Spacing="12" Margin="0,8,0,0">
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySunday}"/> <Border IsVisible="{Binding !HasLinkedRepo}"
<ComboBoxItem Content="{loc:Tr settings.general.weekdayMonday}"/> Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
<ComboBoxItem Content="{loc:Tr settings.general.weekdayTuesday}"/> BorderThickness="1" CornerRadius="6" Padding="10,8">
<ComboBoxItem Content="{loc:Tr settings.general.weekdayWednesday}"/> <StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<ComboBoxItem Content="{loc:Tr settings.general.weekdayThursday}"/> <TextBlock Classes="meta" Text="{loc:Tr settings.repoHint.text}" VerticalAlignment="Center"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayFriday}"/> <Button Classes="btn" Content="{loc:Tr settings.repoHint.link}" Command="{Binding OpenRepoImportCommand}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySaturday}"/> </StackPanel>
</ComboBox> </Border>
</StackPanel> <TextBlock Classes="meta" TextWrapping="Wrap"
<StackPanel Spacing="4"> Text="{loc:Tr settings.prime.description}"/>
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.sessionSkills}"/> <ItemsControl ItemsSource="{Binding Prime.Rows}">
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap" <ItemsControl.ItemTemplate>
Text="{loc:Tr settings.general.sessionSkillsHint}"/> <DataTemplate x:DataType="settings:PrimeScheduleRowViewModel">
<ItemsControl ItemsSource="{Binding General.SessionSkills}"> <Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
<ItemsControl.ItemTemplate> CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
<DataTemplate x:DataType="agent:SelectableSkillViewModel"> Background="{DynamicResource DeepBrush}">
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Content="{Binding Name}" <StackPanel Spacing="6">
ToolTip.Tip="{Binding Description}"/> <Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
</DataTemplate> <CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
</ItemsControl.ItemTemplate> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
</ItemsControl> <ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayMo}" IsChecked="{Binding Monday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayTu}" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayWe}" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayTh}" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayFr}" IsChecked="{Binding Friday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.daySa}" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.daySu}" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
</StackPanel>
<TextBox Grid.Column="2"
Text="{Binding TimeText, Mode=TwoWay}"
PlaceholderText="HH:mm" MaxLength="5"
Width="68" VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
ToolTip.Tip="{loc:Tr settings.prime.removeScheduleTip}"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.promptOverrideLabel}"/>
<TextBox Text="{Binding PromptOverride, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.prime.promptOverridePlaceholder}"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="48" MaxHeight="120"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="btn" Content="{loc:Tr settings.prime.addSchedule}" Command="{Binding Prime.AddScheduleCommand}" HorizontalAlignment="Left"/>
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.dailyPrepMaxTasks}" VerticalAlignment="Center"/>
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100" FormatString="0"
Value="{Binding Prime.DailyPrepMaxTasks, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.tabWorktrees}"> <TabItem Header="{loc:Tr settings.sidebar.categoryWorktrees}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<Border IsVisible="{Binding !HasLinkedRepo}"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="10,8">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{loc:Tr settings.repoHint.text}" VerticalAlignment="Center"/>
<Button Classes="btn" Content="{loc:Tr settings.repoHint.link}" Command="{Binding OpenRepoImportCommand}"/>
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,12,2*"> <Grid ColumnDefinitions="*,12,2*">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.worktrees.strategy}"/> <TextBlock Classes="field-label" Text="{loc:Tr settings.worktrees.strategy}"/>
@@ -258,7 +356,7 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.tabFiles}"> <TabItem Header="{loc:Tr settings.sidebar.categoryFiles}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
@@ -332,55 +430,104 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.tabPrime}"> <!-- Session Skills: merged install/manage list (was the "Skills" tab) plus the global
active-selection checkbox that used to be a separate list on the General tab. -->
<TabItem Header="{loc:Tr settings.sidebar.categorySkills}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<TextBlock Classes="meta" TextWrapping="Wrap" <Border IsVisible="{Binding !HasLinkedRepo}"
Text="{loc:Tr settings.prime.description}"/> Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
<ItemsControl ItemsSource="{Binding Prime.Rows}"> BorderThickness="1" CornerRadius="6" Padding="10,8">
<ItemsControl.ItemTemplate> <StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<DataTemplate x:DataType="settings:PrimeScheduleRowViewModel"> <TextBlock Classes="meta" Text="{loc:Tr settings.repoHint.text}" VerticalAlignment="Center"/>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" <Button Classes="btn" Content="{loc:Tr settings.repoHint.link}" Command="{Binding OpenRepoImportCommand}"/>
CornerRadius="6" Padding="10,8" Margin="0,0,0,8" </StackPanel>
Background="{DynamicResource DeepBrush}"> </Border>
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8"> <StackPanel Spacing="6">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/> <TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installedSection}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"> <TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayMo}" IsChecked="{Binding Monday, Mode=TwoWay}"/> Text="{loc:Tr settings.general.sessionSkillsHint}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayTu}" IsChecked="{Binding Tuesday, Mode=TwoWay}"/> <ctl:OperationIndicator Status="{Binding SessionSkills.UpdateOp}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayWe}" IsChecked="{Binding Wednesday, Mode=TwoWay}"/> <ItemsControl ItemsSource="{Binding SessionSkills.Skills}">
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayTh}" IsChecked="{Binding Thursday, Mode=TwoWay}"/> <ItemsControl.ItemTemplate>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayFr}" IsChecked="{Binding Friday, Mode=TwoWay}"/> <DataTemplate x:DataType="settings:SessionSkillRowViewModel">
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.daySa}" IsChecked="{Binding Saturday, Mode=TwoWay}"/> <Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.daySu}" IsChecked="{Binding Sunday, Mode=TwoWay}"/> CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
</StackPanel> Background="{DynamicResource DeepBrush}">
<TextBox Grid.Column="2" <StackPanel Spacing="4">
Text="{Binding TimeText, Mode=TwoWay}" <Grid ColumnDefinitions="Auto,*,Auto,Auto">
PlaceholderText="HH:mm" MaxLength="5" <CheckBox Grid.Column="0" IsChecked="{Binding IsSelected, Mode=TwoWay}"
Width="68" VerticalAlignment="Center"/> VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center" <TextBlock Grid.Column="1" Classes="title" Text="{Binding Name}" VerticalAlignment="Center"/>
MinWidth="80"/> <Button Grid.Column="2" Classes="btn" Content="{loc:Tr settings.skills.updateButton}"
<Button Classes="icon-btn" Grid.Column="4" Content="✕" Margin="0,0,6,0"
ToolTip.Tip="{loc:Tr settings.prime.removeScheduleTip}" Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.UpdateCommand}"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}" CommandParameter="{Binding SourceUrl}"/>
CommandParameter="{Binding}"/> <Button Grid.Column="3" Classes="btn danger" Content="{loc:Tr settings.skills.removeButton}"
</Grid> Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.RemoveCommand}"
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.promptOverrideLabel}"/> CommandParameter="{Binding SourceUrl}"/>
<TextBox Text="{Binding PromptOverride, Mode=TwoWay}" </Grid>
PlaceholderText="{loc:Tr settings.prime.promptOverridePlaceholder}" <TextBlock Classes="meta" Text="{Binding Description}" TextWrapping="Wrap"
AcceptsReturn="True" TextWrapping="Wrap" IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
MinHeight="48" MaxHeight="120"/> <TextBlock Classes="meta" Opacity="0.6" Text="{Binding PinnedRef}"/>
</StackPanel> <TextBlock Classes="path-mono" Text="{Binding SourceUrl}" TextTrimming="PrefixCharacterEllipsis"/>
</Border> </StackPanel>
</DataTemplate> </Border>
</ItemsControl.ItemTemplate> </DataTemplate>
</ItemsControl> </ItemsControl.ItemTemplate>
<Button Classes="btn" Content="{loc:Tr settings.prime.addSchedule}" Command="{Binding Prime.AddScheduleCommand}" HorizontalAlignment="Left"/> </ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"> <TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.dailyPrepMaxTasks}" VerticalAlignment="Center"/> Text="{loc:Tr settings.skills.emptyState}"
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100" FormatString="0" IsVisible="{Binding SessionSkills.IsEmpty}"/>
Value="{Binding Prime.DailyPrepMaxTasks, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"/> </StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installSection}"/>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding SessionSkills.InstallUrl, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.skills.installUrlPlaceholder}"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.installButton}"
Command="{Binding SessionSkills.InstallCommand}"/>
</Grid>
<ctl:OperationIndicator Status="{Binding SessionSkills.InstallOp}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding SessionSkills.StatusMessage}"
IsVisible="{Binding SessionSkills.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- Berichte: standup weekday + weekly-report excluded paths (split out of General). -->
<TabItem Header="{loc:Tr settings.sidebar.categoryReports}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<Border IsVisible="{Binding !HasLinkedRepo}"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="10,8">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{loc:Tr settings.repoHint.text}" VerticalAlignment="Center"/>
<Button Classes="btn" Content="{loc:Tr settings.repoHint.link}" Command="{Binding OpenRepoImportCommand}"/>
</StackPanel>
</Border>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.standupWeekday}"/>
<ComboBox SelectedIndex="{Binding General.StandupWeekday, Mode=TwoWay}" HorizontalAlignment="Left">
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySunday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayMonday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayTuesday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayWednesday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayThursday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayFriday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySaturday}"/>
</ComboBox>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.reportExcludedPaths}"/>
<TextBox AcceptsReturn="True" MinHeight="60" Text="{Binding General.ReportExcludedPaths, Mode=TwoWay}"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -480,63 +627,8 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.tabSkills}"> </TabControl>
<ScrollViewer> </Grid>
<StackPanel Spacing="12" Margin="0,8,0,0">
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installSection}"/>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBox Grid.Column="0" Text="{Binding SessionSkills.InstallUrl, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.skills.installUrlPlaceholder}"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.installButton}"
Command="{Binding SessionSkills.InstallCommand}"/>
</Grid>
<ctl:OperationIndicator Status="{Binding SessionSkills.InstallOp}"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installedSection}"/>
<ctl:OperationIndicator Status="{Binding SessionSkills.UpdateOp}"/>
<ItemsControl ItemsSource="{Binding SessionSkills.Skills}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="services:SessionSkillDto">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
Background="{DynamicResource DeepBrush}">
<StackPanel Spacing="4">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="title" Text="{Binding Name}"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.updateButton}"
Margin="0,0,6,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.UpdateCommand}"
CommandParameter="{Binding SourceUrl}"/>
<Button Grid.Column="2" Classes="btn danger" Content="{loc:Tr settings.skills.removeButton}"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.RemoveCommand}"
CommandParameter="{Binding SourceUrl}"/>
</Grid>
<TextBlock Classes="meta" Text="{Binding Description}" TextWrapping="Wrap"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Classes="meta" Opacity="0.6" Text="{Binding PinnedRef}"/>
<TextBlock Classes="path-mono" Text="{Binding SourceUrl}" TextTrimming="PrefixCharacterEllipsis"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{loc:Tr settings.skills.emptyState}"
IsVisible="{Binding SessionSkills.IsEmpty}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding SessionSkills.StatusMessage}"
IsVisible="{Binding SessionSkills.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel> </DockPanel>
</ctl:ModalShell> </ctl:ModalShell>
@@ -72,6 +72,11 @@ public sealed class WindowDialogService : IDialogService
{ {
vm.Worktrees.ConfirmAsync = ConfirmAsync; vm.Worktrees.ConfirmAsync = ConfirmAsync;
var dlg = new SettingsModalView { DataContext = vm }; var dlg = new SettingsModalView { DataContext = vm };
vm.RequestRepoImport = () =>
{
dlg.Close();
if (Shell is not null) _ = Shell.OpenRepoImportCommand.ExecuteAsync(null);
};
await dlg.ShowDialog(ActiveOwner()); await dlg.ShowDialog(ActiveOwner());
} }
@@ -1,4 +1,3 @@
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings; using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Xunit; using Xunit;
@@ -6,46 +5,6 @@ namespace ClaudeDo.Ui.Tests.ViewModels;
public class GeneralSettingsTabViewModelTests public class GeneralSettingsTabViewModelTests
{ {
private sealed class FakeWorker : StubWorkerClient
{
public List<SessionSkillDto> Installed = new();
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(Installed);
}
[Fact]
public async Task LoadSessionSkillsAsync_reflects_current_selection()
{
var w = new FakeWorker
{
Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow),
new SessionSkillDto("b", "", "url-b", "ref", DateTimeOffset.UtcNow) },
};
var vm = new GeneralSettingsTabViewModel();
await vm.LoadSessionSkillsAsync(w, new List<string> { "b" });
Assert.Equal(2, vm.SessionSkills.Count);
Assert.False(vm.SessionSkills.Single(s => s.Name == "a").IsSelected);
Assert.True(vm.SessionSkills.Single(s => s.Name == "b").IsSelected);
}
[Fact]
public async Task SelectedSessionSkillNames_round_trips_through_toggle()
{
var w = new FakeWorker
{
Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow) },
};
var vm = new GeneralSettingsTabViewModel();
await vm.LoadSessionSkillsAsync(w, null);
Assert.Null(vm.SelectedSessionSkillNames());
vm.SessionSkills.Single().IsSelected = true;
Assert.Equal(new List<string> { "a" }, vm.SelectedSessionSkillNames());
}
[Theory] [Theory]
[InlineData(-1, false)] [InlineData(-1, false)]
[InlineData(0, true)] [InlineData(0, true)]
@@ -180,4 +180,53 @@ public class SessionSkillsSettingsTabViewModelTests
Assert.Equal("url-a", w.UpdatedSourceUrl); Assert.Equal("url-a", w.UpdatedSourceUrl);
Assert.NotEmpty(vm.StatusMessage); Assert.NotEmpty(vm.StatusMessage);
} }
[Fact]
public async Task LoadAsync_marks_rows_selected_per_given_names()
{
var w = new FakeWorker
{
Installed = new()
{
new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow),
new SessionSkillDto("b", "", "url-b", "ref", DateTimeOffset.UtcNow),
},
};
var vm = new SessionSkillsSettingsTabViewModel(w);
await vm.LoadAsync(new List<string> { "b" });
Assert.False(vm.Skills.Single(s => s.Name == "a").IsSelected);
Assert.True(vm.Skills.Single(s => s.Name == "b").IsSelected);
}
[Fact]
public async Task SelectedSkillNames_round_trips_through_toggle()
{
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow) } };
var vm = new SessionSkillsSettingsTabViewModel(w);
await vm.LoadAsync(null);
Assert.Null(vm.SelectedSkillNames());
vm.Skills.Single().IsSelected = true;
Assert.Equal(new List<string> { "a" }, vm.SelectedSkillNames());
}
[Fact]
public async Task RefreshAfterInstall_preserves_existing_selection()
{
// Install/Update/Remove call LoadAsync() with no explicit selection — that must not
// reset the checkbox state of the skills that were already selected.
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow) } };
var vm = new SessionSkillsSettingsTabViewModel(w) { InstallUrl = "https://example.com/repo.git" };
await vm.LoadAsync(new List<string> { "a" });
Assert.True(vm.Skills.Single(s => s.Name == "a").IsSelected);
await vm.InstallCommand.ExecuteAsync(null);
Assert.True(vm.Skills.Single(s => s.Name == "a").IsSelected);
Assert.False(vm.Skills.Single(s => s.Name == "new-skill").IsSelected);
}
} }
@@ -1,20 +1,56 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Localization; using ClaudeDo.Localization;
using ClaudeDo.Ui; using ClaudeDo.Ui;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings; using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore;
using Xunit; using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels; namespace ClaudeDo.Ui.Tests.ViewModels;
public class SettingsModalViewModelTests public class SettingsModalViewModelTests : IDisposable
{ {
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"claudedo_settingsmodal_test_{Guid.NewGuid():N}.db");
public SettingsModalViewModelTests()
{
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class FakeWorker : StubWorkerClient private sealed class FakeWorker : StubWorkerClient
{ {
public AppSettingsDto? AppToReturn; public AppSettingsDto? AppToReturn;
public AppSettingsDto? Saved; public AppSettingsDto? Saved;
public List<SessionSkillDto> InstalledSkills = new();
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(AppToReturn); public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(AppToReturn);
public override Task UpdateAppSettingsAsync(AppSettingsDto dto) { Saved = dto; return Task.CompletedTask; } public override Task UpdateAppSettingsAsync(AppSettingsDto dto) { Saved = dto; return Task.CompletedTask; }
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(InstalledSkills);
} }
private sealed class FakePrimeApi : IPrimeScheduleApi private sealed class FakePrimeApi : IPrimeScheduleApi
@@ -70,9 +106,9 @@ public class SettingsModalViewModelTests
Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct); Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct);
} }
private static SettingsModalViewModel MakeVm(FakeWorker worker) => private SettingsModalViewModel MakeVm(FakeWorker worker) =>
new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(), new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(),
MakeLocalizer(), new AppSettings()); MakeLocalizer(), new AppSettings(), new TestDbFactory(NewContext));
[Fact] [Fact]
public async Task LoadAsync_fills_usage_gate_fields_from_dto() public async Task LoadAsync_fills_usage_gate_fields_from_dto()
@@ -100,4 +136,95 @@ public class SettingsModalViewModelTests
Assert.Equal(42, worker.Saved!.UsageGateFiveHourPct); Assert.Equal(42, worker.Saved!.UsageGateFiveHourPct);
Assert.Equal(77, worker.Saved.UsageGateSevenDayPct); Assert.Equal(77, worker.Saved.UsageGateSevenDayPct);
} }
[Fact]
public async Task HasLinkedRepo_false_when_no_list_has_a_working_dir()
{
using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "No repo", CreatedAt = DateTime.UtcNow, WorkingDir = null });
ctx.SaveChanges();
}
var vm = MakeVm(new FakeWorker());
await vm.LoadAsync();
Assert.False(vm.HasLinkedRepo);
}
[Fact]
public async Task HasLinkedRepo_true_when_a_list_has_a_working_dir()
{
using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "Repo", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo" });
ctx.SaveChanges();
}
var vm = MakeVm(new FakeWorker());
await vm.LoadAsync();
Assert.True(vm.HasLinkedRepo);
}
[Fact]
public void SelectedCategory_updates_SelectedIndex_and_ignores_null()
{
var vm = MakeVm(new FakeWorker());
var worktrees = vm.Categories.Single(c => c.Key == "Worktrees");
vm.SelectedCategory = worktrees;
Assert.Equal(vm.Categories.ToList().IndexOf(worktrees), vm.SelectedIndex);
// Simulated deselection push from the sibling sidebar ListBox — must not clobber the pick.
vm.SelectedCategory = null;
Assert.Equal(worktrees, vm.SelectedCategory);
}
[Fact]
public void Categories_default_to_first_entry_and_mark_the_repo_gated_ones()
{
var vm = MakeVm(new FakeWorker());
Assert.Equal(vm.Categories[0], vm.SelectedCategory);
Assert.True(vm.Categories.Single(c => c.Key == "Worktrees").RequiresRepo);
Assert.True(vm.Categories.Single(c => c.Key == "Prime").RequiresRepo);
Assert.True(vm.Categories.Single(c => c.Key == "SessionSkills").RequiresRepo);
Assert.True(vm.Categories.Single(c => c.Key == "Reports").RequiresRepo);
Assert.False(vm.Categories.Single(c => c.Key == "General").RequiresRepo);
Assert.False(vm.Categories.Single(c => c.Key == "Files").RequiresRepo);
}
[Fact]
public async Task SessionSkillSelection_survives_a_save_and_reload_round_trip()
{
// The only real regression risk of the Settings-sidebar rework: Session Skills used to be
// two separate lists (a General-tab checkbox list + the Skills-tab management list) and is
// now one merged list. Setting the selection, saving, and reloading into a *fresh* VM must
// come back with the identical selection.
var installed = new List<SessionSkillDto>
{
new("alpha", "", "url-alpha", "ref", DateTimeOffset.UtcNow),
new("beta", "", "url-beta", "ref", DateTimeOffset.UtcNow),
};
var worker = new FakeWorker { AppToReturn = DtoWith(80, 90), InstalledSkills = installed };
var vm = MakeVm(worker);
await vm.LoadAsync();
vm.SessionSkills.Skills.Single(s => s.Name == "alpha").IsSelected = true;
vm.SessionSkills.Skills.Single(s => s.Name == "beta").IsSelected = false;
await vm.SaveCommand.ExecuteAsync(null);
Assert.Equal(new List<string> { "alpha" }, worker.Saved!.SessionSkills);
// Reload into a brand-new VM instance, as if the modal were closed and reopened.
var worker2 = new FakeWorker { AppToReturn = worker.Saved, InstalledSkills = installed };
var vm2 = MakeVm(worker2);
await vm2.LoadAsync();
Assert.True(vm2.SessionSkills.Skills.Single(s => s.Name == "alpha").IsSelected);
Assert.False(vm2.SessionSkills.Skills.Single(s => s.Name == "beta").IsSelected);
Assert.Equal(new List<string> { "alpha" }, vm2.SessionSkills.SelectedSkillNames());
}
} }