CmdPal: Extract persistence services from SettingsModel and AppStateModel (#46312)

## Summary of the Pull Request

Extracts persistence (load/save) logic from `SettingsModel` and
`AppStateModel` into dedicated service classes, following the
single-responsibility principle. Consumers now interact with
`ISettingsService` and `IAppStateService` instead of receiving raw model
objects through DI.

**New services introduced:**
- `IPersistenceService` / `PersistenceService` — generic `Load<T>` /
`Save<T>` with AOT-compatible `JsonTypeInfo<T>`, ensures target
directory exists before writing
- `ISettingsService` / `SettingsService` — loads settings on
construction, runs migrations, exposes `Settings` property and
`SettingsChanged` event
- `IAppStateService` / `AppStateService` — loads state on construction,
exposes `State` property and `StateChanged` event

**Key changes:**
- `SettingsModel` and `AppStateModel` are now pure data models — all
file I/O, migration, and directory management removed
- Raw `SettingsModel` and `AppStateModel` removed from DI container;
consumers receive the appropriate service
- `IApplicationInfoService.ConfigDirectory` injected into services for
config path resolution (no more hardcoded `Utilities.BaseSettingsPath`)
- ~30 consumer files updated across `Microsoft.CmdPal.UI.ViewModels` and
`Microsoft.CmdPal.UI` projects
- All `#pragma warning disable SA1300` suppressions removed —
convenience accessors replaced with direct `_settingsService.Settings` /
`_appStateService.State` access
- Namespace prefixes (`Services.ISettingsService`) replaced with proper
`using` directives

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already.
- [x] **Tests:** Added/updated and all pass
- [ ] **Localization:** N/A — no end-user-facing strings changed
- [ ] **Dev docs:** N/A — internal refactor, no public API changes
- [ ] **New binaries:** N/A — no new binaries introduced

## Detailed Description of the Pull Request / Additional comments

### Architecture

Services are registered as singletons in `App.xaml.cs`:
```csharp
services.AddSingleton<IPersistenceService, PersistenceService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IAppStateService, AppStateService>();
```

`PersistenceService.Save<T>` writes the serialized model directly to
disk, creating the target directory if it doesn't exist. It also does
not attempt to merge existing and new settings/state. `SettingsService`
runs hotkey migrations on load and raises `SettingsChanged` after saves.
`AppStateService` always raises `StateChanged` after saves.

### Files changed (41 files, +1169/−660)

| Area | Files | What changed |
|------|-------|-------------|
| New services | `Services/IPersistenceService.cs`,
`PersistenceService.cs`, `ISettingsService.cs`, `SettingsService.cs`,
`IAppStateService.cs`, `AppStateService.cs` | New service interfaces and
implementations |
| Models | `SettingsModel.cs`, `AppStateModel.cs` | Stripped to pure
data bags |
| DI | `App.xaml.cs` | Service registration, removed raw model DI |
| ViewModels | 12 files | Constructor injection of services |
| UI | 10 files | Service injection replacing model access |
| Settings | `DockSettings.cs` | `Colors.Transparent` replaced with
struct literal to avoid WinUI3 COM dependency |
| Tests | `PersistenceServiceTests.cs`, `SettingsServiceTests.cs`,
`AppStateServiceTests.cs` | 38 unit tests covering all three services |
| Config | `.gitignore` | Added `.squad/`, `.github/agents/` exclusions
|

## Validation Steps Performed

- Built `Microsoft.CmdPal.UI` with MSBuild (x64/Debug) — exit code 0,
clean build
- Ran 38 unit tests via `vstest.console.exe` — all passing
- Verified no remaining `#pragma warning disable SA1300` blocks
- Verified no remaining `Services.` namespace prefixes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Jolley
2026-03-20 18:58:27 -05:00
committed by GitHub
parent 99706d4324
commit 86115a54f6
41 changed files with 1169 additions and 660 deletions

View File

@@ -27,7 +27,8 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
private static readonly TimeSpan ReloadDebounceInterval = TimeSpan.FromMilliseconds(500);
private readonly UISettings _uiSettings;
private readonly SettingsModel _settings;
private readonly ISettingsService _settingsService;
private readonly ResourceSwapper _resourceSwapper;
private readonly NormalThemeProvider _normalThemeProvider;
private readonly ColorfulThemeProvider _colorfulThemeProvider;
@@ -76,32 +77,32 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
}
// provider selection
var themeColorIntensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100);
var imageTintIntensity = Math.Clamp(_settings.BackgroundImageTintIntensity, 0, 100);
var effectiveColorIntensity = _settings.ColorizationMode == ColorizationMode.Image
var themeColorIntensity = Math.Clamp(_settingsService.Settings.CustomThemeColorIntensity, 0, 100);
var imageTintIntensity = Math.Clamp(_settingsService.Settings.BackgroundImageTintIntensity, 0, 100);
var effectiveColorIntensity = _settingsService.Settings.ColorizationMode == ColorizationMode.Image
? imageTintIntensity
: themeColorIntensity;
IThemeProvider provider = UseColorfulProvider(effectiveColorIntensity) ? _colorfulThemeProvider : _normalThemeProvider;
// Calculate values
var tint = _settings.ColorizationMode switch
var tint = _settingsService.Settings.ColorizationMode switch
{
ColorizationMode.CustomColor => _settings.CustomThemeColor,
ColorizationMode.CustomColor => _settingsService.Settings.CustomThemeColor,
ColorizationMode.WindowsAccentColor => _uiSettings.GetColorValue(UIColorType.Accent),
ColorizationMode.Image => _settings.CustomThemeColor,
ColorizationMode.Image => _settingsService.Settings.CustomThemeColor,
_ => Colors.Transparent,
};
var effectiveTheme = GetElementTheme((ElementTheme)_settings.Theme);
var imageSource = _settings.ColorizationMode == ColorizationMode.Image
? LoadImageSafe(_settings.BackgroundImagePath)
var effectiveTheme = GetElementTheme((ElementTheme)_settingsService.Settings.Theme);
var imageSource = _settingsService.Settings.ColorizationMode == ColorizationMode.Image
? LoadImageSafe(_settingsService.Settings.BackgroundImagePath)
: null;
var stretch = _settings.BackgroundImageFit switch
var stretch = _settingsService.Settings.BackgroundImageFit switch
{
BackgroundImageFit.Fill => Stretch.Fill,
_ => Stretch.UniformToFill,
};
var opacity = Math.Clamp(_settings.BackgroundImageOpacity, 0, 100) / 100.0;
var opacity = Math.Clamp(_settingsService.Settings.BackgroundImageOpacity, 0, 100) / 100.0;
// create input and offload to actual theme provider
var context = new ThemeContext
@@ -112,16 +113,16 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
BackgroundImageSource = imageSource,
BackgroundImageStretch = stretch,
BackgroundImageOpacity = opacity,
BackdropStyle = _settings.BackdropStyle,
BackdropOpacity = Math.Clamp(_settings.BackdropOpacity, 0, 100) / 100f,
BackdropStyle = _settingsService.Settings.BackdropStyle,
BackdropOpacity = Math.Clamp(_settingsService.Settings.BackdropOpacity, 0, 100) / 100f,
};
var backdrop = provider.GetBackdropParameters(context);
var blur = _settings.BackgroundImageBlurAmount;
var brightness = _settings.BackgroundImageBrightness;
var blur = _settingsService.Settings.BackgroundImageBlurAmount;
var brightness = _settingsService.Settings.BackgroundImageBrightness;
// Create public snapshot (no provider!)
var hasColorization = effectiveColorIntensity > 0
&& _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
&& _settingsService.Settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
var snapshot = new ThemeSnapshot
{
@@ -149,7 +150,7 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
Interlocked.Exchange(ref _currentState, newState);
// Compute DockThemeSnapshot from DockSettings
var dockSettings = _settings.DockSettings;
var dockSettings = _settingsService.Settings.DockSettings;
var dockIntensity = Math.Clamp(dockSettings.CustomThemeColorIntensity, 0, 100);
IThemeProvider dockProvider = dockIntensity > 0 && dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image
? _colorfulThemeProvider
@@ -208,8 +209,8 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
private bool UseColorfulProvider(int effectiveColorIntensity)
{
return _settings.ColorizationMode == ColorizationMode.Image
|| (effectiveColorIntensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor);
return _settingsService.Settings.ColorizationMode == ColorizationMode.Image
|| (effectiveColorIntensity > 0 && _settingsService.Settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor);
}
private static BitmapImage? LoadImageSafe(string? path)
@@ -241,13 +242,12 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
}
}
public ThemeService(SettingsModel settings, ResourceSwapper resourceSwapper)
public ThemeService(ResourceSwapper resourceSwapper, ISettingsService settingsService)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(resourceSwapper);
_settings = settings;
_settings.SettingsChanged += SettingsOnSettingsChanged;
_settingsService = settingsService;
_settingsService.SettingsChanged += SettingsOnSettingsChanged;
_resourceSwapper = resourceSwapper;
@@ -319,7 +319,7 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
};
}
private void SettingsOnSettingsChanged(SettingsModel sender, object? args)
private void SettingsOnSettingsChanged(ISettingsService sender, SettingsModel args)
{
RequestReload();
}
@@ -339,7 +339,7 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
_disposed = true;
_dispatcherQueueTimer?.Stop();
_uiSettings.ColorValuesChanged -= UiSettings_ColorValuesChanged;
_settings.SettingsChanged -= SettingsOnSettingsChanged;
_settingsService.SettingsChanged -= SettingsOnSettingsChanged;
}
private sealed class InternalThemeState