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

@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -15,12 +16,12 @@ namespace Microsoft.CmdPal.UI;
internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFactory
{
private readonly SettingsModel _settingsModel;
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
public CommandPaletteContextMenuFactory(SettingsModel settingsModel, TopLevelCommandManager topLevelCommandManager)
public CommandPaletteContextMenuFactory(ISettingsService settingsService, TopLevelCommandManager topLevelCommandManager)
{
_settingsModel = settingsModel;
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
}
@@ -65,7 +66,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
{
var providerSettings = _settingsModel.GetProviderSettings(provider);
var providerSettings = _settingsService.Settings.GetProviderSettings(provider);
var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId);
@@ -82,7 +83,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
providerId: providerId,
pin: !alreadyPinnedToTopLevel,
PinLocation.TopLevel,
_settingsModel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
@@ -132,7 +133,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
{
var providerSettings = _settingsModel.GetProviderSettings(provider);
var providerSettings = _settingsService.Settings.GetProviderSettings(provider);
var isPinnedSubCommand = providerSettings.PinnedCommandIds.Contains(itemId);
if (isPinnedSubCommand)
@@ -142,7 +143,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
providerId: providerId,
pin: !isPinnedSubCommand,
PinLocation.TopLevel,
_settingsModel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
@@ -168,22 +169,22 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
List<IContextItem> moreCommands,
CommandItemViewModel commandItem)
{
if (!_settingsModel.EnableDock)
if (!_settingsService.Settings.EnableDock)
{
return;
}
var inStartBands = _settingsModel.DockSettings.StartBands.Any(band => MatchesBand(band, itemId, providerId));
var inCenterBands = _settingsModel.DockSettings.CenterBands.Any(band => MatchesBand(band, itemId, providerId));
var inEndBands = _settingsModel.DockSettings.EndBands.Any(band => MatchesBand(band, itemId, providerId));
var inStartBands = _settingsService.Settings.DockSettings.StartBands.Any(band => MatchesBand(band, itemId, providerId));
var inCenterBands = _settingsService.Settings.DockSettings.CenterBands.Any(band => MatchesBand(band, itemId, providerId));
var inEndBands = _settingsService.Settings.DockSettings.EndBands.Any(band => MatchesBand(band, itemId, providerId));
var alreadyPinned = inStartBands || inCenterBands || inEndBands; /** &&
_settingsModel.DockSettings.PinnedCommands.Contains(this.Id)**/
_settingsService.Settings.DockSettings.PinnedCommands.Contains(this.Id)**/
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: !alreadyPinned,
PinLocation.Dock,
_settingsModel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
@@ -231,7 +232,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
{
private readonly string _commandId;
private readonly string _providerId;
private readonly SettingsModel _settings;
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly bool _pin;
private readonly PinLocation _pinLocation;
@@ -251,13 +252,13 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
string providerId,
bool pin,
PinLocation pinLocation,
SettingsModel settings,
ISettingsService settingsService,
TopLevelCommandManager topLevelCommandManager)
{
_commandId = commandId;
_providerId = providerId;
_pinLocation = pinLocation;
_settings = settings;
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
_pin = pin;
}