From 86115a54f6d2fa963801d2393fa13f6f6995bb9d Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Fri, 20 Mar 2026 18:58:27 -0500 Subject: [PATCH] CmdPal: Extract persistence services from SettingsModel and AppStateModel (#46312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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` / `Save` with AOT-compatible `JsonTypeInfo`, 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(); services.AddSingleton(); services.AddSingleton(); ``` `PersistenceService.Save` 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> --- .gitignore | 5 + .../AliasManager.cs | 7 +- .../AppStateModel.cs | 151 +-------------- .../AppearanceSettingsViewModel.cs | 123 ++++++------ .../CommandProviderWrapper.cs | 28 +-- .../Commands/MainListPage.cs | 37 ++-- .../Dock/DockBandSettingsViewModel.cs | 13 +- .../Dock/DockViewModel.cs | 33 ++-- .../DockAppearanceSettingsViewModel.cs | 86 ++++----- .../FallbackSettingsViewModel.cs | 11 +- .../HotkeyManager.cs | 5 +- .../ProviderSettingsViewModel.cs | 15 +- .../Services/AppStateService.cs | 46 +++++ .../Services/IAppStateService.cs | 28 +++ .../Services/IPersistenceService.cs | 29 +++ .../Services/ISettingsService.cs | 29 +++ .../Services/PersistenceService.cs | 79 ++++++++ .../Services/SettingsService.cs | 122 ++++++++++++ .../Settings/DockSettings.cs | 3 +- .../SettingsModel.cs | 181 +----------------- .../SettingsViewModel.cs | 98 +++++----- .../TopLevelViewModel.cs | 18 +- .../cmdpal/Microsoft.CmdPal.UI/App.xaml.cs | 9 +- .../CommandPaletteContextMenuFactory.cs | 33 ++-- .../Controls/FallbackRanker.xaml.cs | 4 +- .../Controls/SearchBar.xaml.cs | 3 +- .../Dock/DockWindow.xaml.cs | 14 +- .../ExtViews/ListPage.xaml.cs | 5 +- .../Helpers/TrayIconService.cs | 9 +- .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 17 +- .../Pages/ShellPage.xaml.cs | 9 +- .../PowerToysRootPageService.cs | 5 +- .../Microsoft.CmdPal.UI/RunHistoryService.cs | 21 +- .../Services/ThemeService.cs | 52 ++--- .../Settings/AppearancePage.xaml.cs | 4 +- .../Settings/DockSettingsPage.xaml.cs | 9 +- .../Settings/ExtensionsPage.xaml.cs | 4 +- .../Settings/GeneralPage.xaml.cs | 4 +- .../AppStateServiceTests.cs | 163 ++++++++++++++++ .../PersistenceServiceTests.cs | 136 +++++++++++++ .../SettingsServiceTests.cs | 181 ++++++++++++++++++ 41 files changed, 1169 insertions(+), 660 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AppStateService.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IAppStateService.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IPersistenceService.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ISettingsService.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/PersistenceService.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/SettingsService.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/AppStateServiceTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/PersistenceServiceTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SettingsServiceTests.cs diff --git a/.gitignore b/.gitignore index 001a59ebae..866f33ca49 100644 --- a/.gitignore +++ b/.gitignore @@ -360,3 +360,8 @@ src/common/Telemetry/*.etl # PowerToysInstaller Build Temp Files installer/*/*.wxs.bk /src/modules/awake/.claude + +# Squad / Copilot agents — local-only, not committed +.squad/ +.squad-workstream +.github/agents/ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs index 94c19e688c..c1d6e4053f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs @@ -5,20 +5,23 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; namespace Microsoft.CmdPal.UI.ViewModels; public partial class AliasManager : ObservableObject { private readonly TopLevelCommandManager _topLevelCommandManager; + private readonly ISettingsService _settingsService; // REMEMBER, CommandAlias.SearchPrefix is what we use as keys private readonly Dictionary _aliases; - public AliasManager(TopLevelCommandManager tlcManager, SettingsModel settings) + public AliasManager(TopLevelCommandManager tlcManager, ISettingsService settingsService) { _topLevelCommandManager = tlcManager; - _aliases = settings.Aliases; + _settingsService = settingsService; + _aliases = _settingsService.Settings.Aliases; if (_aliases.Count == 0) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs index b445bf881d..200b7cf585 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs @@ -1,26 +1,14 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CommunityToolkit.Mvvm.ComponentModel; -using ManagedCommon; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Foundation; namespace Microsoft.CmdPal.UI.ViewModels; public partial class AppStateModel : ObservableObject { - [JsonIgnore] - public static readonly string FilePath; - - public event TypedEventHandler? StateChanged; - /////////////////////////////////////////////////////////////////////////// // STATE HERE // Make sure that you make the setters public (JsonSerializer.Deserialize will fail silently otherwise)! @@ -31,141 +19,4 @@ public partial class AppStateModel : ObservableObject // END SETTINGS /////////////////////////////////////////////////////////////////////////// - - static AppStateModel() - { - FilePath = StateJsonPath(); - } - - public static AppStateModel LoadState() - { - if (string.IsNullOrEmpty(FilePath)) - { - throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(LoadState)}"); - } - - if (!File.Exists(FilePath)) - { - Debug.WriteLine("The provided settings file does not exist"); - return new(); - } - - try - { - // Read the JSON content from the file - var jsonContent = File.ReadAllText(FilePath); - - var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.AppStateModel); - - Debug.WriteLine(loaded is not null ? "Loaded settings file" : "Failed to parse"); - - return loaded ?? new(); - } - catch (Exception ex) - { - Debug.WriteLine(ex.ToString()); - } - - return new(); - } - - public static void SaveState(AppStateModel model) - { - if (string.IsNullOrEmpty(FilePath)) - { - throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveState)}"); - } - - try - { - // Serialize the main dictionary to JSON and save it to the file - var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.AppStateModel!); - - // validate JSON - if (JsonNode.Parse(settingsJson) is not JsonObject newSettings) - { - Logger.LogError("Failed to parse app state as a JsonObject."); - return; - } - - // read previous settings - if (!TryReadSavedState(out var savedSettings)) - { - savedSettings = new JsonObject(); - } - - // merge new settings into old ones - foreach (var item in newSettings) - { - savedSettings[item.Key] = item.Value?.DeepClone(); - } - - var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.AppStateModel!.Options); - File.WriteAllText(FilePath, serialized); - - // TODO: Instead of just raising the event here, we should - // have a file change watcher on the settings file, and - // reload the settings then - model.StateChanged?.Invoke(model, null); - } - catch (Exception ex) - { - Logger.LogError($"Failed to save application state to {FilePath}:", ex); - } - } - - private static bool TryReadSavedState([NotNullWhen(true)] out JsonObject? savedSettings) - { - savedSettings = null; - - // read existing content from the file - string oldContent; - try - { - if (File.Exists(FilePath)) - { - oldContent = File.ReadAllText(FilePath); - } - else - { - // file doesn't exist (might not have been created yet), so consider this a success - // and return empty settings - savedSettings = new JsonObject(); - return true; - } - } - catch (Exception ex) - { - Logger.LogWarning($"Failed to read app state file {FilePath}:\n{ex}"); - return false; - } - - // detect empty file, just for sake of logging - if (string.IsNullOrWhiteSpace(oldContent)) - { - Logger.LogInfo($"App state file is empty: {FilePath}"); - return false; - } - - // is it valid JSON? - try - { - savedSettings = JsonNode.Parse(oldContent) as JsonObject; - return savedSettings != null; - } - catch (Exception ex) - { - Logger.LogWarning($"Failed to parse app state from {FilePath}:\n{ex}"); - return false; - } - } - - internal static string StateJsonPath() - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - - // now, the settings is just next to the exe - return Path.Combine(directory, "state.json"); - } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs index e817ce8d96..941c962b86 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -87,7 +87,8 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis Color.FromArgb(255, 126, 115, 95), // #7e735f ]; - private readonly SettingsModel _settings; + private readonly ISettingsService _settingsService; + private readonly UISettings _uiSettings; private readonly IThemeService _themeService; private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); @@ -100,18 +101,18 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int ThemeIndex { - get => (int)_settings.Theme; + get => (int)_settingsService.Settings.Theme; set => Theme = (UserTheme)value; } public UserTheme Theme { - get => _settings.Theme; + get => _settingsService.Settings.Theme; set { - if (_settings.Theme != value) + if (_settingsService.Settings.Theme != value) { - _settings.Theme = value; + _settingsService.Settings.Theme = value; OnPropertyChanged(); OnPropertyChanged(nameof(ThemeIndex)); Save(); @@ -121,12 +122,12 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public ColorizationMode ColorizationMode { - get => _settings.ColorizationMode; + get => _settingsService.Settings.ColorizationMode; set { - if (_settings.ColorizationMode != value) + if (_settingsService.Settings.ColorizationMode != value) { - _settings.ColorizationMode = value; + _settingsService.Settings.ColorizationMode = value; OnPropertyChanged(); OnPropertyChanged(nameof(ColorizationModeIndex)); OnPropertyChanged(nameof(IsCustomTintVisible)); @@ -152,18 +153,18 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int ColorizationModeIndex { - get => (int)_settings.ColorizationMode; + get => (int)_settingsService.Settings.ColorizationMode; set => ColorizationMode = (ColorizationMode)value; } public Color ThemeColor { - get => _settings.CustomThemeColor; + get => _settingsService.Settings.CustomThemeColor; set { - if (_settings.CustomThemeColor != value) + if (_settingsService.Settings.CustomThemeColor != value) { - _settings.CustomThemeColor = value; + _settingsService.Settings.CustomThemeColor = value; OnPropertyChanged(); @@ -179,10 +180,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int ColorIntensity { - get => _settings.CustomThemeColorIntensity; + get => _settingsService.Settings.CustomThemeColorIntensity; set { - _settings.CustomThemeColorIntensity = value; + _settingsService.Settings.CustomThemeColorIntensity = value; OnPropertyChanged(); OnPropertyChanged(nameof(EffectiveTintIntensity)); Save(); @@ -191,10 +192,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int BackgroundImageTintIntensity { - get => _settings.BackgroundImageTintIntensity; + get => _settingsService.Settings.BackgroundImageTintIntensity; set { - _settings.BackgroundImageTintIntensity = value; + _settingsService.Settings.BackgroundImageTintIntensity = value; OnPropertyChanged(); OnPropertyChanged(nameof(EffectiveTintIntensity)); Save(); @@ -203,12 +204,12 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public string BackgroundImagePath { - get => _settings.BackgroundImagePath ?? string.Empty; + get => _settingsService.Settings.BackgroundImagePath ?? string.Empty; set { - if (_settings.BackgroundImagePath != value) + if (_settingsService.Settings.BackgroundImagePath != value) { - _settings.BackgroundImagePath = value; + _settingsService.Settings.BackgroundImagePath = value; OnPropertyChanged(); if (BackgroundImageOpacity == 0) @@ -223,12 +224,12 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int BackgroundImageOpacity { - get => _settings.BackgroundImageOpacity; + get => _settingsService.Settings.BackgroundImageOpacity; set { - if (_settings.BackgroundImageOpacity != value) + if (_settingsService.Settings.BackgroundImageOpacity != value) { - _settings.BackgroundImageOpacity = value; + _settingsService.Settings.BackgroundImageOpacity = value; OnPropertyChanged(); Save(); } @@ -237,12 +238,12 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int BackgroundImageBrightness { - get => _settings.BackgroundImageBrightness; + get => _settingsService.Settings.BackgroundImageBrightness; set { - if (_settings.BackgroundImageBrightness != value) + if (_settingsService.Settings.BackgroundImageBrightness != value) { - _settings.BackgroundImageBrightness = value; + _settingsService.Settings.BackgroundImageBrightness = value; OnPropertyChanged(); Save(); } @@ -251,12 +252,12 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int BackgroundImageBlurAmount { - get => _settings.BackgroundImageBlurAmount; + get => _settingsService.Settings.BackgroundImageBlurAmount; set { - if (_settings.BackgroundImageBlurAmount != value) + if (_settingsService.Settings.BackgroundImageBlurAmount != value) { - _settings.BackgroundImageBlurAmount = value; + _settingsService.Settings.BackgroundImageBlurAmount = value; OnPropertyChanged(); Save(); } @@ -265,12 +266,12 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public BackgroundImageFit BackgroundImageFit { - get => _settings.BackgroundImageFit; + get => _settingsService.Settings.BackgroundImageFit; set { - if (_settings.BackgroundImageFit != value) + if (_settingsService.Settings.BackgroundImageFit != value) { - _settings.BackgroundImageFit = value; + _settingsService.Settings.BackgroundImageFit = value; OnPropertyChanged(); OnPropertyChanged(nameof(BackgroundImageFitIndex)); Save(); @@ -299,12 +300,12 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int BackdropOpacity { - get => _settings.BackdropOpacity; + get => _settingsService.Settings.BackdropOpacity; set { - if (_settings.BackdropOpacity != value) + if (_settingsService.Settings.BackdropOpacity != value) { - _settings.BackdropOpacity = value; + _settingsService.Settings.BackdropOpacity = value; OnPropertyChanged(); OnPropertyChanged(nameof(EffectiveBackdropStyle)); OnPropertyChanged(nameof(EffectiveImageOpacity)); @@ -315,13 +316,13 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public int BackdropStyleIndex { - get => (int)_settings.BackdropStyle; + get => (int)_settingsService.Settings.BackdropStyle; set { var newStyle = (BackdropStyle)value; - if (_settings.BackdropStyle != newStyle) + if (_settingsService.Settings.BackdropStyle != newStyle) { - _settings.BackdropStyle = newStyle; + _settingsService.Settings.BackdropStyle = newStyle; OnPropertyChanged(); OnPropertyChanged(nameof(IsBackdropOpacityVisible)); @@ -343,25 +344,25 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis /// Gets whether the backdrop opacity slider should be visible. /// public bool IsBackdropOpacityVisible => - BackdropStyles.Get(_settings.BackdropStyle).SupportsOpacity; + BackdropStyles.Get(_settingsService.Settings.BackdropStyle).SupportsOpacity; /// /// Gets whether the backdrop description (for styles without options) should be visible. /// public bool IsMicaBackdropDescriptionVisible => - !BackdropStyles.Get(_settings.BackdropStyle).SupportsOpacity; + !BackdropStyles.Get(_settingsService.Settings.BackdropStyle).SupportsOpacity; /// /// Gets whether background/colorization settings are available. /// public bool IsBackgroundSettingsEnabled => - BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization; + BackdropStyles.Get(_settingsService.Settings.BackdropStyle).SupportsColorization; /// /// Gets whether the "not available" message should be shown (inverse of IsBackgroundSettingsEnabled). /// public bool IsBackgroundNotAvailableVisible => - !BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization; + !BackdropStyles.Get(_settingsService.Settings.BackdropStyle).SupportsColorization; public BackdropStyle? EffectiveBackdropStyle { @@ -370,9 +371,9 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis // Return style when transparency/blur is visible (not fully opaque Acrylic) // - Clear/Mica/MicaAlt/AcrylicThin always show their effect // - Acrylic shows effect only when opacity < 100 - if (_settings.BackdropStyle != BackdropStyle.Acrylic || _settings.BackdropOpacity < 100) + if (_settingsService.Settings.BackdropStyle != BackdropStyle.Acrylic || _settingsService.Settings.BackdropOpacity < 100) { - return _settings.BackdropStyle; + return _settingsService.Settings.BackdropStyle; } return null; @@ -381,39 +382,39 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public double EffectiveImageOpacity => EffectiveBackdropStyle is not null - ? (BackgroundImageOpacity / 100f) * Math.Sqrt(_settings.BackdropOpacity / 100.0) + ? (BackgroundImageOpacity / 100f) * Math.Sqrt(_settingsService.Settings.BackdropOpacity / 100.0) : (BackgroundImageOpacity / 100f); [ObservableProperty] public partial bool IsColorizationDetailsExpanded { get; set; } - public bool IsCustomTintVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image; + public bool IsCustomTintVisible => _settingsService.Settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image; - public bool IsColorIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor; + public bool IsColorIntensityVisible => _settingsService.Settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor; - public bool IsImageTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.Image; + public bool IsImageTintIntensityVisible => _settingsService.Settings.ColorizationMode is ColorizationMode.Image; /// /// Gets the effective tint intensity for the preview, based on the current colorization mode. /// - public int EffectiveTintIntensity => _settings.ColorizationMode is ColorizationMode.Image - ? _settings.BackgroundImageTintIntensity - : _settings.CustomThemeColorIntensity; + public int EffectiveTintIntensity => _settingsService.Settings.ColorizationMode is ColorizationMode.Image + ? _settingsService.Settings.BackgroundImageTintIntensity + : _settingsService.Settings.CustomThemeColorIntensity; - public bool IsBackgroundControlsVisible => _settings.ColorizationMode is ColorizationMode.Image; + public bool IsBackgroundControlsVisible => _settingsService.Settings.ColorizationMode is ColorizationMode.Image; - public bool IsNoBackgroundVisible => _settings.ColorizationMode is ColorizationMode.None; + public bool IsNoBackgroundVisible => _settingsService.Settings.ColorizationMode is ColorizationMode.None; - public bool IsAccentColorControlsVisible => _settings.ColorizationMode is ColorizationMode.WindowsAccentColor; + public bool IsAccentColorControlsVisible => _settingsService.Settings.ColorizationMode is ColorizationMode.WindowsAccentColor; - public bool IsResetButtonVisible => _settings.ColorizationMode is ColorizationMode.Image; + public bool IsResetButtonVisible => _settingsService.Settings.ColorizationMode is ColorizationMode.Image; public BackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f); public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme; public Color EffectiveThemeColor => - !BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization + !BackdropStyles.Get(_settingsService.Settings.BackdropStyle).SupportsColorization ? Colors.Transparent : ColorizationMode switch { @@ -428,7 +429,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0; public ImageSource? EffectiveBackgroundImageSource => - !BackdropStyles.Get(_settings.BackdropStyle).SupportsBackgroundImage + !BackdropStyles.Get(_settingsService.Settings.BackdropStyle).SupportsBackgroundImage ? null : ColorizationMode is ColorizationMode.Image && !string.IsNullOrWhiteSpace(BackgroundImagePath) @@ -436,11 +437,11 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri) : null; - public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings) + public AppearanceSettingsViewModel(IThemeService themeService, ISettingsService settingsService) { _themeService = themeService; _themeService.ThemeChanged += ThemeServiceOnThemeChanged; - _settings = settings; + _settingsService = settingsService; _uiSettings = new UISettings(); _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged; @@ -448,7 +449,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis Reapply(); - IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None && IsBackgroundSettingsEnabled; + IsColorizationDetailsExpanded = _settingsService.Settings.ColorizationMode != ColorizationMode.None && IsBackgroundSettingsEnabled; } private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender)); @@ -469,7 +470,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis private void Save() { - SettingsModel.SaveSettings(_settings); + _settingsService.Save(); _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index 48f4174c5d..73c613962e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -139,7 +139,8 @@ public sealed class CommandProviderWrapper : ICommandProviderContext return; } - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetRequiredService(); + var settings = settingsService.Settings; var providerSettings = GetProviderSettings(settings); IsActive = providerSettings.IsEnabled; @@ -249,16 +250,15 @@ public sealed class CommandProviderWrapper : ICommandProviderContext IServiceProvider serviceProvider, ICommandProvider4? four) { - var settings = serviceProvider.GetService()!; + var settings = serviceProvider.GetRequiredService().Settings; var contextMenuFactory = serviceProvider.GetService()!; - var state = serviceProvider.GetService()!; var providerSettings = GetProviderSettings(settings); var ourContext = GetProviderContext(); WeakReference pageContext = new(this.TopLevelPageContext); var make = (ICommandItem? i, TopLevelType t) => { CommandItemViewModel commandItemViewModel = new(new(i), pageContext, contextMenuFactory: contextMenuFactory); - TopLevelViewModel topLevelViewModel = new(commandItemViewModel, t, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i, contextMenuFactory: contextMenuFactory); + TopLevelViewModel topLevelViewModel = new(commandItemViewModel, t, ExtensionHost, ourContext, providerSettings, serviceProvider, i, contextMenuFactory: contextMenuFactory); topLevelViewModel.InitializeProperties(); return topLevelViewModel; @@ -407,7 +407,8 @@ public sealed class CommandProviderWrapper : ICommandProviderContext public void PinCommand(string commandId, IServiceProvider serviceProvider) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetRequiredService(); + var settings = settingsService.Settings; var providerSettings = GetProviderSettings(settings); if (!providerSettings.PinnedCommandIds.Contains(commandId)) @@ -416,13 +417,14 @@ public sealed class CommandProviderWrapper : ICommandProviderContext // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); - SettingsModel.SaveSettings(settings, false); + settingsService.Save(hotReload: false); } } public void UnpinCommand(string commandId, IServiceProvider serviceProvider) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetRequiredService(); + var settings = settingsService.Settings; var providerSettings = GetProviderSettings(settings); if (providerSettings.PinnedCommandIds.Remove(commandId)) @@ -430,13 +432,14 @@ public sealed class CommandProviderWrapper : ICommandProviderContext // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); - SettingsModel.SaveSettings(settings, false); + settingsService.Save(hotReload: false); } } public void PinDockBand(string commandId, IServiceProvider serviceProvider) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetRequiredService(); + var settings = settingsService.Settings; var bandSettings = new DockBandSettings { CommandId = commandId, @@ -447,19 +450,20 @@ public sealed class CommandProviderWrapper : ICommandProviderContext // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); - SettingsModel.SaveSettings(settings, false); + settingsService.Save(hotReload: false); } public void UnpinDockBand(string commandId, IServiceProvider serviceProvider) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetRequiredService(); + var settings = settingsService.Settings; settings.DockSettings.StartBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); settings.DockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); settings.DockSettings.EndBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); - SettingsModel.SaveSettings(settings, false); + settingsService.Save(hotReload: false); } public ICommandProviderContext GetProviderContext() => this; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 81d3a631a7..41f7283535 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -18,6 +18,7 @@ using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Properties; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -42,8 +43,8 @@ public sealed partial class MainListPage : DynamicListPage, private readonly ThrottledDebouncedAction _refreshThrottledDebouncedAction; private readonly TopLevelCommandManager _tlcManager; private readonly AliasManager _aliasManager; - private readonly SettingsModel _settings; - private readonly AppStateModel _appStateModel; + private readonly ISettingsService _settingsService; + private readonly IAppStateService _appStateService; private readonly ScoringFunction _scoringFunction; private readonly ScoringFunction _fallbackScoringFunction; private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider; @@ -79,23 +80,23 @@ public sealed partial class MainListPage : DynamicListPage, public MainListPage( TopLevelCommandManager topLevelCommandManager, - SettingsModel settings, AliasManager aliasManager, - AppStateModel appStateModel, - IFuzzyMatcherProvider fuzzyMatcherProvider) + IFuzzyMatcherProvider fuzzyMatcherProvider, + ISettingsService settingsService, + IAppStateService appStateService) { Id = "com.microsoft.cmdpal.home"; Title = Resources.builtin_home_name; Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png"); PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder; - _settings = settings; + _settingsService = settingsService; _aliasManager = aliasManager; - _appStateModel = appStateModel; + _appStateService = appStateService; _tlcManager = topLevelCommandManager; _fuzzyMatcherProvider = fuzzyMatcherProvider; - _scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateModel.RecentCommands, _fuzzyMatcherProvider.Current); - _fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settings.FallbackRanks); + _scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateService.State.RecentCommands, _fuzzyMatcherProvider.Current); + _fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settingsService.Settings.FallbackRanks); _tlcManager.PropertyChanged += TlcManager_PropertyChanged; _tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged; @@ -150,8 +151,8 @@ public sealed partial class MainListPage : DynamicListPage, WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); - settings.SettingsChanged += SettingsChangedHandler; - HotReloadSettings(settings); + _settingsService.SettingsChanged += SettingsChangedHandler; + HotReloadSettings(_settingsService.Settings); _includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId); IsLoading = true; @@ -364,7 +365,7 @@ public sealed partial class MainListPage : DynamicListPage, } // prefilter fallbacks - var globalFallbacks = _settings.GetGlobalFallbacks(); + var globalFallbacks = _settingsService.Settings.GetGlobalFallbacks(); var specialFallbacks = new List(globalFallbacks.Length); var commonFallbacks = new List(commands.Count - globalFallbacks.Length); @@ -479,7 +480,7 @@ public sealed partial class MainListPage : DynamicListPage, // We need to remove pinned apps from allNewApps so they don't show twice. // Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds. - _settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings); + _settingsService.Settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings); var pinnedCommandIds = providerSettings?.PinnedCommandIds; if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0) @@ -678,9 +679,9 @@ public sealed partial class MainListPage : DynamicListPage, public void UpdateHistory(IListItem topLevelOrAppItem) { var id = IdForTopLevelOrAppItem(topLevelOrAppItem); - var history = _appStateModel.RecentCommands; + var history = _appStateService.State.RecentCommands; history.AddHistoryItem(id); - AppStateModel.SaveState(_appStateModel); + _appStateService.Save(); } private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) @@ -703,7 +704,7 @@ public sealed partial class MainListPage : DynamicListPage, RequestRefresh(fullRefresh: false); } - private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender); + private void SettingsChangedHandler(ISettingsService sender, SettingsModel args) => HotReloadSettings(args); private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails; @@ -716,9 +717,9 @@ public sealed partial class MainListPage : DynamicListPage, _tlcManager.PropertyChanged -= TlcManager_PropertyChanged; _tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged; - if (_settings is not null) + if (_settingsService is not null) { - _settings.SettingsChanged -= SettingsChangedHandler; + _settingsService.SettingsChanged -= SettingsChangedHandler; } WeakReferenceMessenger.Default.UnregisterAll(this); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandSettingsViewModel.cs index d2b5dc1435..580ba3c669 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandSettingsViewModel.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Text; using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; namespace Microsoft.CmdPal.UI.ViewModels.Dock; @@ -12,7 +13,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Dock; public partial class DockBandSettingsViewModel : ObservableObject { private static readonly CompositeFormat PluralItemsFormatString = CompositeFormat.Parse(Properties.Resources.dock_item_count_plural); - private readonly SettingsModel _settingsModel; + private readonly ISettingsService _settingsService; private readonly DockBandSettings _dockSettingsModel; private readonly TopLevelViewModel _adapter; private readonly DockBandViewModel? _bandViewModel; @@ -128,19 +129,19 @@ public partial class DockBandSettingsViewModel : ObservableObject DockBandSettings dockSettingsModel, TopLevelViewModel topLevelAdapter, DockBandViewModel? bandViewModel, - SettingsModel settingsModel) + ISettingsService settingsService) { _dockSettingsModel = dockSettingsModel; _adapter = topLevelAdapter; _bandViewModel = bandViewModel; - _settingsModel = settingsModel; + _settingsService = settingsService; _pinSide = FetchPinSide(); _showLabels = FetchShowLabels(); } private DockPinSide FetchPinSide() { - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; var inStart = dockSettings.StartBands.Any(b => b.CommandId == _dockSettingsModel.CommandId); if (inStart) { @@ -175,7 +176,7 @@ public partial class DockBandSettingsViewModel : ObservableObject private void Save() { - SettingsModel.SaveSettings(_settingsModel); + _settingsService.Save(); } private void UpdatePinSide(DockPinSide value) @@ -188,7 +189,7 @@ public partial class DockBandSettingsViewModel : ObservableObject public void SetBandPosition(DockPinSide side, int? index) { - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; // Remove from all sides first dockSettings.StartBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockViewModel.cs index e7063e0c47..652b62410a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockViewModel.cs @@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.Messaging; using ManagedCommon; using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -15,7 +16,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Dock; public sealed partial class DockViewModel { private readonly TopLevelCommandManager _topLevelCommandManager; - private readonly SettingsModel _settingsModel; + private readonly ISettingsService _settingsService; private readonly DockPageContext _pageContext; // only to be used for our own context menu - not for dock bands themselves private readonly IContextMenuFactory _contextMenuFactory; @@ -34,13 +35,13 @@ public sealed partial class DockViewModel public DockViewModel( TopLevelCommandManager tlcManager, IContextMenuFactory contextMenuFactory, - SettingsModel settings, - TaskScheduler scheduler) + TaskScheduler scheduler, + ISettingsService settingsService) { _topLevelCommandManager = tlcManager; _contextMenuFactory = contextMenuFactory; - _settingsModel = settings; - _settings = settings.DockSettings; + _settingsService = settingsService; + _settings = _settingsService.Settings.DockSettings; Scheduler = scheduler; _pageContext = new(this); @@ -148,7 +149,7 @@ public sealed partial class DockViewModel private void SaveSettings() { - SettingsModel.SaveSettings(_settingsModel); + _settingsService.Save(); } public DockBandViewModel? FindBandByTopLevel(TopLevelViewModel tlc) @@ -193,7 +194,7 @@ public sealed partial class DockViewModel public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex) { var bandId = band.Id; - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId) ?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId) @@ -228,7 +229,7 @@ public sealed partial class DockViewModel public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex) { var bandId = band.Id; - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId) ?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId) @@ -301,7 +302,7 @@ public sealed partial class DockViewModel _snapshotCenterBands = null; _snapshotEndBands = null; _snapshotBandViewModels = null; - SettingsModel.SaveSettings(_settingsModel); + _settingsService.Save(); Logger.LogDebug("Saved band order to settings"); } @@ -316,7 +317,7 @@ public sealed partial class DockViewModel /// public void SnapshotBandOrder() { - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; _snapshotStartBands = dockSettings.StartBands.Select(b => b.Clone()).ToList(); _snapshotCenterBands = dockSettings.CenterBands.Select(b => b.Clone()).ToList(); _snapshotEndBands = dockSettings.EndBands.Select(b => b.Clone()).ToList(); @@ -358,7 +359,7 @@ public sealed partial class DockViewModel band.RestoreShowLabels(); } - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; // Restore settings from snapshot dockSettings.StartBands.Clear(); @@ -400,7 +401,7 @@ public sealed partial class DockViewModel return; } - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; StartItems.Clear(); CenterItems.Clear(); @@ -433,7 +434,7 @@ public sealed partial class DockViewModel private void RebuildUICollections() { - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; // Create a lookup of all current band ViewModels var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id); @@ -510,7 +511,7 @@ public sealed partial class DockViewModel // Create settings for the new band var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId, ShowLabels = null }; - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; // Create the band view model var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel); @@ -550,7 +551,7 @@ public sealed partial class DockViewModel public void UnpinBand(DockBandViewModel band) { var bandId = band.Id; - var dockSettings = _settingsModel.DockSettings; + var dockSettings = _settingsService.Settings.DockSettings; // Remove from settings dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId); @@ -616,7 +617,7 @@ public sealed partial class DockViewModel private void EmitDockConfiguration() { - var isDockEnabled = _settingsModel.EnableDock; + var isDockEnabled = _settingsService.Settings.EnableDock; var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none"; static string FormatBands(List bands) => diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DockAppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DockAppearanceSettingsViewModel.cs index 68751a5882..e794805efe 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DockAppearanceSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DockAppearanceSettingsViewModel.cs @@ -23,8 +23,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; /// public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, IDisposable { - private readonly SettingsModel _settings; - private readonly DockSettings _dockSettings; + private readonly ISettingsService _settingsService; private readonly UISettings _uiSettings; private readonly IThemeService _themeService; private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); @@ -37,18 +36,18 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public int ThemeIndex { - get => (int)_dockSettings.Theme; + get => (int)_settingsService.Settings.DockSettings.Theme; set => Theme = (UserTheme)value; } public UserTheme Theme { - get => _dockSettings.Theme; + get => _settingsService.Settings.DockSettings.Theme; set { - if (_dockSettings.Theme != value) + if (_settingsService.Settings.DockSettings.Theme != value) { - _dockSettings.Theme = value; + _settingsService.Settings.DockSettings.Theme = value; OnPropertyChanged(); OnPropertyChanged(nameof(ThemeIndex)); Save(); @@ -58,18 +57,18 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public int BackdropIndex { - get => (int)_dockSettings.Backdrop; + get => (int)_settingsService.Settings.DockSettings.Backdrop; set => Backdrop = (DockBackdrop)value; } public DockBackdrop Backdrop { - get => _dockSettings.Backdrop; + get => _settingsService.Settings.DockSettings.Backdrop; set { - if (_dockSettings.Backdrop != value) + if (_settingsService.Settings.DockSettings.Backdrop != value) { - _dockSettings.Backdrop = value; + _settingsService.Settings.DockSettings.Backdrop = value; OnPropertyChanged(); OnPropertyChanged(nameof(BackdropIndex)); Save(); @@ -79,12 +78,12 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public ColorizationMode ColorizationMode { - get => _dockSettings.ColorizationMode; + get => _settingsService.Settings.DockSettings.ColorizationMode; set { - if (_dockSettings.ColorizationMode != value) + if (_settingsService.Settings.DockSettings.ColorizationMode != value) { - _dockSettings.ColorizationMode = value; + _settingsService.Settings.DockSettings.ColorizationMode = value; OnPropertyChanged(); OnPropertyChanged(nameof(ColorizationModeIndex)); OnPropertyChanged(nameof(IsCustomTintVisible)); @@ -107,18 +106,18 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public int ColorizationModeIndex { - get => (int)_dockSettings.ColorizationMode; + get => (int)_settingsService.Settings.DockSettings.ColorizationMode; set => ColorizationMode = (ColorizationMode)value; } public Color ThemeColor { - get => _dockSettings.CustomThemeColor; + get => _settingsService.Settings.DockSettings.CustomThemeColor; set { - if (_dockSettings.CustomThemeColor != value) + if (_settingsService.Settings.DockSettings.CustomThemeColor != value) { - _dockSettings.CustomThemeColor = value; + _settingsService.Settings.DockSettings.CustomThemeColor = value; OnPropertyChanged(); @@ -134,10 +133,10 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public int ColorIntensity { - get => _dockSettings.CustomThemeColorIntensity; + get => _settingsService.Settings.DockSettings.CustomThemeColorIntensity; set { - _dockSettings.CustomThemeColorIntensity = value; + _settingsService.Settings.DockSettings.CustomThemeColorIntensity = value; OnPropertyChanged(); Save(); } @@ -145,12 +144,12 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public string BackgroundImagePath { - get => _dockSettings.BackgroundImagePath ?? string.Empty; + get => _settingsService.Settings.DockSettings.BackgroundImagePath ?? string.Empty; set { - if (_dockSettings.BackgroundImagePath != value) + if (_settingsService.Settings.DockSettings.BackgroundImagePath != value) { - _dockSettings.BackgroundImagePath = value; + _settingsService.Settings.DockSettings.BackgroundImagePath = value; OnPropertyChanged(); if (BackgroundImageOpacity == 0) @@ -165,12 +164,12 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public int BackgroundImageOpacity { - get => _dockSettings.BackgroundImageOpacity; + get => _settingsService.Settings.DockSettings.BackgroundImageOpacity; set { - if (_dockSettings.BackgroundImageOpacity != value) + if (_settingsService.Settings.DockSettings.BackgroundImageOpacity != value) { - _dockSettings.BackgroundImageOpacity = value; + _settingsService.Settings.DockSettings.BackgroundImageOpacity = value; OnPropertyChanged(); Save(); } @@ -179,12 +178,12 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public int BackgroundImageBrightness { - get => _dockSettings.BackgroundImageBrightness; + get => _settingsService.Settings.DockSettings.BackgroundImageBrightness; set { - if (_dockSettings.BackgroundImageBrightness != value) + if (_settingsService.Settings.DockSettings.BackgroundImageBrightness != value) { - _dockSettings.BackgroundImageBrightness = value; + _settingsService.Settings.DockSettings.BackgroundImageBrightness = value; OnPropertyChanged(); Save(); } @@ -193,12 +192,12 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public int BackgroundImageBlurAmount { - get => _dockSettings.BackgroundImageBlurAmount; + get => _settingsService.Settings.DockSettings.BackgroundImageBlurAmount; set { - if (_dockSettings.BackgroundImageBlurAmount != value) + if (_settingsService.Settings.DockSettings.BackgroundImageBlurAmount != value) { - _dockSettings.BackgroundImageBlurAmount = value; + _settingsService.Settings.DockSettings.BackgroundImageBlurAmount = value; OnPropertyChanged(); Save(); } @@ -207,12 +206,12 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, public BackgroundImageFit BackgroundImageFit { - get => _dockSettings.BackgroundImageFit; + get => _settingsService.Settings.DockSettings.BackgroundImageFit; set { - if (_dockSettings.BackgroundImageFit != value) + if (_settingsService.Settings.DockSettings.BackgroundImageFit != value) { - _dockSettings.BackgroundImageFit = value; + _settingsService.Settings.DockSettings.BackgroundImageFit = value; OnPropertyChanged(); OnPropertyChanged(nameof(BackgroundImageFitIndex)); Save(); @@ -237,15 +236,15 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, [ObservableProperty] public partial bool IsColorizationDetailsExpanded { get; set; } - public bool IsCustomTintVisible => _dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image; + public bool IsCustomTintVisible => _settingsService.Settings.DockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image; - public bool IsCustomTintIntensityVisible => _dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image; + public bool IsCustomTintIntensityVisible => _settingsService.Settings.DockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image; - public bool IsBackgroundControlsVisible => _dockSettings.ColorizationMode is ColorizationMode.Image; + public bool IsBackgroundControlsVisible => _settingsService.Settings.DockSettings.ColorizationMode is ColorizationMode.Image; - public bool IsNoBackgroundVisible => _dockSettings.ColorizationMode is ColorizationMode.None; + public bool IsNoBackgroundVisible => _settingsService.Settings.DockSettings.ColorizationMode is ColorizationMode.None; - public bool IsAccentColorControlsVisible => _dockSettings.ColorizationMode is ColorizationMode.WindowsAccentColor; + public bool IsAccentColorControlsVisible => _settingsService.Settings.DockSettings.ColorizationMode is ColorizationMode.WindowsAccentColor; public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme; @@ -268,12 +267,11 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri) : null; - public DockAppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings) + public DockAppearanceSettingsViewModel(IThemeService themeService, ISettingsService settingsService) { _themeService = themeService; _themeService.ThemeChanged += ThemeServiceOnThemeChanged; - _settings = settings; - _dockSettings = settings.DockSettings; + _settingsService = settingsService; _uiSettings = new UISettings(); _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged; @@ -281,7 +279,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, Reapply(); - IsColorizationDetailsExpanded = _dockSettings.ColorizationMode != ColorizationMode.None; + IsColorizationDetailsExpanded = _settingsService.Settings.DockSettings.ColorizationMode != ColorizationMode.None; } private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender)); @@ -302,7 +300,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, private void Save() { - SettingsModel.SaveSettings(_settings); + _settingsService.Save(); _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs index db1e8c0f14..de209e0822 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs @@ -5,12 +5,13 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; namespace Microsoft.CmdPal.UI.ViewModels; public partial class FallbackSettingsViewModel : ObservableObject { - private readonly SettingsModel _settings; + private readonly ISettingsService _settingsService; private readonly FallbackSettings _fallbackSettings; public string DisplayName { get; private set; } = string.Empty; @@ -62,10 +63,10 @@ public partial class FallbackSettingsViewModel : ObservableObject public FallbackSettingsViewModel( TopLevelViewModel fallback, FallbackSettings fallbackSettings, - SettingsModel settingsModel, - ProviderSettingsViewModel providerSettings) + ProviderSettingsViewModel providerSettings, + ISettingsService settingsService) { - _settings = settingsModel; + _settingsService = settingsService; _fallbackSettings = fallbackSettings; Id = fallback.Id; @@ -79,7 +80,7 @@ public partial class FallbackSettingsViewModel : ObservableObject private void Save() { - SettingsModel.SaveSettings(_settings); + _settingsService.Save(); WeakReferenceMessenger.Default.Send(new()); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs index 7fcd2ec7fd..4694664ed0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; namespace Microsoft.CmdPal.UI.ViewModels; @@ -12,10 +13,10 @@ public partial class HotkeyManager : ObservableObject private readonly TopLevelCommandManager _topLevelCommandManager; private readonly List _commandHotkeys; - public HotkeyManager(TopLevelCommandManager tlcManager, SettingsModel settings) + public HotkeyManager(TopLevelCommandManager tlcManager, ISettingsService settingsService) { _topLevelCommandManager = tlcManager; - _commandHotkeys = settings.CommandHotkeys; + _commandHotkeys = settingsService.Settings.CommandHotkeys; } public void UpdateHotkey(string commandId, HotkeySettings? hotkey) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs index 15787344ee..7bcb494dd0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Properties; +using Microsoft.CmdPal.UI.ViewModels.Services; namespace Microsoft.CmdPal.UI.ViewModels; @@ -22,7 +23,7 @@ public partial class ProviderSettingsViewModel : ObservableObject private readonly CommandProviderWrapper _provider; private readonly ProviderSettings _providerSettings; - private readonly SettingsModel _settings; + private readonly ISettingsService _settingsService; private readonly Lock _initializeSettingsLock = new(); private Task? _initializeSettingsTask; @@ -30,11 +31,11 @@ public partial class ProviderSettingsViewModel : ObservableObject public ProviderSettingsViewModel( CommandProviderWrapper provider, ProviderSettings providerSettings, - SettingsModel settings) + ISettingsService settingsService) { _provider = provider; _providerSettings = providerSettings; - _settings = settings; + _settingsService = settingsService; LoadingSettings = _provider.Settings?.HasSettings ?? false; @@ -179,18 +180,18 @@ public partial class ProviderSettingsViewModel : ObservableObject { if (_providerSettings.FallbackCommands.TryGetValue(fallbackItem.Id, out var fallbackSettings)) { - fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, fallbackSettings, _settings, this)); + fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, fallbackSettings, this, _settingsService)); } else { - fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, new(), _settings, this)); + fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, new(), this, _settingsService)); } } FallbackCommands = fallbackViewModels; } - private void Save() => SettingsModel.SaveSettings(_settings); + private void Save() => _settingsService.Save(); private void InitializeSettingsPage() { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AppStateService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AppStateService.cs new file mode 100644 index 0000000000..b0b250b41b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AppStateService.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Common.Services; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Default implementation of . +/// Handles loading, saving, and change notification for . +/// +public sealed class AppStateService : IAppStateService +{ + private readonly IPersistenceService _persistence; + private readonly IApplicationInfoService _appInfoService; + private readonly string _filePath; + + public AppStateService(IPersistenceService persistence, IApplicationInfoService appInfoService) + { + _persistence = persistence; + _appInfoService = appInfoService; + _filePath = StateJsonPath(); + State = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel); + } + + /// + public AppStateModel State { get; private set; } + + /// + public event TypedEventHandler? StateChanged; + + /// + public void Save() + { + _persistence.Save(State, _filePath, JsonSerializationContext.Default.AppStateModel); + StateChanged?.Invoke(this, State); + } + + private string StateJsonPath() + { + var directory = _appInfoService.ConfigDirectory; + return Path.Combine(directory, "state.json"); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IAppStateService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IAppStateService.cs new file mode 100644 index 0000000000..07c67be7ff --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IAppStateService.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Manages the lifecycle of : load, save, and change notification. +/// +public interface IAppStateService +{ + /// + /// Gets the current application state instance. + /// + AppStateModel State { get; } + + /// + /// Persists the current state to disk and raises . + /// + void Save(); + + /// + /// Raised after state has been saved to disk. + /// + event TypedEventHandler StateChanged; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IPersistenceService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IPersistenceService.cs new file mode 100644 index 0000000000..12fa8dbdcd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IPersistenceService.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Provides AOT-compatible JSON file persistence with shallow-merge strategy. +/// +public interface IPersistenceService +{ + /// + /// Loads and deserializes a model from the specified JSON file. + /// Returns a new instance when the file is missing or unreadable. + /// + T Load(string filePath, JsonTypeInfo typeInfo) + where T : new(); + + /// + /// Serializes , shallow-merges into the existing file + /// (preserving unknown keys), and writes the result back to disk. + /// + /// The model to persist. + /// Target JSON file path. + /// AOT-compatible type metadata. + void Save(T model, string filePath, JsonTypeInfo typeInfo); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ISettingsService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ISettingsService.cs new file mode 100644 index 0000000000..383330caee --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ISettingsService.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Manages the lifecycle of : load, save, migration, and change notification. +/// +public interface ISettingsService +{ + /// + /// Gets the current settings instance. + /// + SettingsModel Settings { get; } + + /// + /// Persists the current settings to disk. + /// + /// When , raises after saving. + void Save(bool hotReload = true); + + /// + /// Raised after settings are saved with enabled, or after . + /// + event TypedEventHandler SettingsChanged; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/PersistenceService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/PersistenceService.cs new file mode 100644 index 0000000000..56b7dc9200 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/PersistenceService.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using ManagedCommon; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Default implementation of that reads/writes +/// JSON files with a shallow-merge strategy to preserve unknown keys. +/// +public sealed class PersistenceService : IPersistenceService +{ + /// + public T Load(string filePath, JsonTypeInfo typeInfo) + where T : new() + { + if (!File.Exists(filePath)) + { + Logger.LogDebug("Settings file not found at {FilePath}", filePath); + return new T(); + } + + try + { + var jsonContent = File.ReadAllText(filePath); + var loaded = JsonSerializer.Deserialize(jsonContent, typeInfo); + + if (loaded is null) + { + Logger.LogDebug("Failed to parse settings file at {FilePath}", filePath); + } + else + { + Logger.LogDebug("Successfully loaded settings file from {FilePath}", filePath); + } + + return loaded ?? new T(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load settings from {filePath}:", ex); + } + + return new T(); + } + + /// + public void Save(T model, string filePath, JsonTypeInfo typeInfo) + { + try + { + var settingsJson = JsonSerializer.Serialize(model, typeInfo); + + if (JsonNode.Parse(settingsJson) is not JsonObject newSettings) + { + Logger.LogError("Failed to parse serialized model as JsonObject."); + return; + } + + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var serialized = newSettings.ToJsonString(typeInfo.Options); + File.WriteAllText(filePath, serialized); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save to {filePath}:", ex); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/SettingsService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/SettingsService.cs new file mode 100644 index 0000000000..8f36d67a0f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/SettingsService.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using ManagedCommon; +using Microsoft.CmdPal.Common.Services; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Default implementation of . +/// Handles loading, saving, migration, and change notification for . +/// +public sealed class SettingsService : ISettingsService +{ + private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome"; + + private readonly IPersistenceService _persistence; + private readonly IApplicationInfoService _appInfoService; + private readonly string _filePath; + + public SettingsService(IPersistenceService persistence, IApplicationInfoService appInfoService) + { + _persistence = persistence; + _appInfoService = appInfoService; + _filePath = SettingsJsonPath(); + Settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel); + ApplyMigrations(); + } + + /// + public SettingsModel Settings { get; private set; } + + /// + public event TypedEventHandler? SettingsChanged; + + /// + public void Save(bool hotReload = true) + { + _persistence.Save( + Settings, + _filePath, + JsonSerializationContext.Default.SettingsModel); + + if (hotReload) + { + SettingsChanged?.Invoke(this, Settings); + } + } + + private string SettingsJsonPath() + { + var directory = _appInfoService.ConfigDirectory; + return Path.Combine(directory, "settings.json"); + } + + private void ApplyMigrations() + { + var migratedAny = false; + + try + { + var jsonContent = File.Exists(_filePath) ? File.ReadAllText(_filePath) : null; + if (jsonContent is not null && JsonNode.Parse(jsonContent) is JsonObject root) + { + migratedAny |= TryMigrate( + "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)", + root, + Settings, + nameof(SettingsModel.AutoGoHomeInterval), + DeprecatedHotkeyGoesHomeKey, + (model, goesHome) => model.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, + JsonSerializationContext.Default.Boolean); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Migration check failed: {ex}"); + } + + if (migratedAny) + { + Save(hotReload: false); + } + } + + private static bool TryMigrate( + string migrationName, + JsonObject root, + SettingsModel model, + string newKey, + string oldKey, + Action apply, + JsonTypeInfo jsonTypeInfo) + { + try + { + if (root.ContainsKey(newKey) && root[newKey] is not null) + { + return false; + } + + if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null) + { + var value = oldNode.Deserialize(jsonTypeInfo); + apply(model, value!); + return true; + } + } + catch (Exception ex) + { + Logger.LogError($"Error during migration {migrationName}.", ex); + } + + return false; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs index 60bade639e..5f27939c38 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System.Text.Json.Serialization; -using Microsoft.UI; using Windows.UI; namespace Microsoft.CmdPal.UI.ViewModels.Settings; @@ -29,7 +28,7 @@ public class DockSettings public ColorizationMode ColorizationMode { get; set; } - public Color CustomThemeColor { get; set; } = Colors.Transparent; + public Color CustomThemeColor { get; set; } = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent and COM in class init public int CustomThemeColorIntensity { get; set; } = 100; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index 97a8713e07..bc6c70b55a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -2,30 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; using CommunityToolkit.Mvvm.ComponentModel; -using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels.Settings; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.UI; -using Windows.Foundation; using Windows.UI; namespace Microsoft.CmdPal.UI.ViewModels; public partial class SettingsModel : ObservableObject { - private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome"; - - [JsonIgnore] - public static readonly string FilePath; - - public event TypedEventHandler? SettingsChanged; - /////////////////////////////////////////////////////////////////////////// // SETTINGS HERE public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space @@ -77,7 +62,7 @@ public partial class SettingsModel : ObservableObject public ColorizationMode ColorizationMode { get; set; } - public Color CustomThemeColor { get; set; } = Colors.Transparent; + public Color CustomThemeColor { get; set; } = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent public int CustomThemeColorIntensity { get; set; } = 100; @@ -102,11 +87,6 @@ public partial class SettingsModel : ObservableObject // END SETTINGS /////////////////////////////////////////////////////////////////////////// - static SettingsModel() - { - FilePath = SettingsJsonPath(); - } - public ProviderSettings GetProviderSettings(CommandProviderWrapper provider) { ProviderSettings? settings; @@ -143,165 +123,6 @@ public partial class SettingsModel : ObservableObject return globalFallbacks.ToArray(); } - public static SettingsModel LoadSettings() - { - if (string.IsNullOrEmpty(FilePath)) - { - throw new InvalidOperationException($"You must set a valid {nameof(SettingsModel.FilePath)} before calling {nameof(LoadSettings)}"); - } - - if (!File.Exists(FilePath)) - { - Debug.WriteLine("The provided settings file does not exist"); - return new(); - } - - try - { - // Read the JSON content from the file - var jsonContent = File.ReadAllText(FilePath); - var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.SettingsModel) ?? new(); - - var migratedAny = false; - try - { - if (JsonNode.Parse(jsonContent) is JsonObject root) - { - migratedAny |= ApplyMigrations(root, loaded); - } - } - catch (Exception ex) - { - Debug.WriteLine($"Migration check failed: {ex}"); - } - - Debug.WriteLine("Loaded settings file"); - - if (migratedAny) - { - SaveSettings(loaded); - } - - return loaded; - } - catch (Exception ex) - { - Debug.WriteLine(ex.ToString()); - } - - return new(); - } - - private static bool ApplyMigrations(JsonObject root, SettingsModel model) - { - var migrated = false; - - // Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan) - // The old 'HotkeyGoesHome' boolean indicated whether the "go home" action should happen immediately (true) or never (false). - // The new 'AutoGoHomeInterval' uses a TimeSpan: 'TimeSpan.Zero' means immediate, 'Timeout.InfiniteTimeSpan' means never. - migrated |= TryMigrate( - "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)", - root, - model, - nameof(AutoGoHomeInterval), - DeprecatedHotkeyGoesHomeKey, - (settingsModel, goesHome) => settingsModel.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, - JsonSerializationContext.Default.Boolean); - - return migrated; - } - - private static bool TryMigrate(string migrationName, JsonObject root, SettingsModel model, string newKey, string oldKey, Action apply, JsonTypeInfo jsonTypeInfo) - { - try - { - // If new key already present, skip migration - if (root.ContainsKey(newKey) && root[newKey] is not null) - { - return false; - } - - // If old key present, try to deserialize and apply - if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null) - { - var value = oldNode.Deserialize(jsonTypeInfo); - apply(model, value!); - return true; - } - } - catch (Exception ex) - { - Logger.LogError($"Error during migration {migrationName}.", ex); - } - - return false; - } - - public static void SaveSettings(SettingsModel model, bool hotReload = true) - { - if (string.IsNullOrEmpty(FilePath)) - { - throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveSettings)}"); - } - - try - { - // Serialize the main dictionary to JSON and save it to the file - var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.SettingsModel); - - // Is it valid JSON? - if (JsonNode.Parse(settingsJson) is JsonObject newSettings) - { - // Now, read the existing content from the file - var oldContent = File.Exists(FilePath) ? File.ReadAllText(FilePath) : "{}"; - - // Is it valid JSON? - if (JsonNode.Parse(oldContent) is JsonObject savedSettings) - { - foreach (var item in newSettings) - { - savedSettings[item.Key] = item.Value?.DeepClone(); - } - - // Remove deprecated keys - savedSettings.Remove(DeprecatedHotkeyGoesHomeKey); - - var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.Options); - File.WriteAllText(FilePath, serialized); - - // TODO: Instead of just raising the event here, we should - // have a file change watcher on the settings file, and - // reload the settings then - if (hotReload) - { - model.SettingsChanged?.Invoke(model, null); - } - } - else - { - Debug.WriteLine("Failed to parse settings file as JsonObject."); - } - } - else - { - Debug.WriteLine("Failed to parse settings file as JsonObject."); - } - } - catch (Exception ex) - { - Debug.WriteLine(ex.ToString()); - } - } - - internal static string SettingsJsonPath() - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - - // now, the settings is just next to the exe - return Path.Combine(directory, "settings.json"); - } - // [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] // private static readonly JsonSerializerOptions _serializerOptions = new() // { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 34e958d57b..5310c9f9aa 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -27,7 +27,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged TimeSpan.FromSeconds(180), ]; - private readonly SettingsModel _settings; + private readonly ISettingsService _settingsService; private readonly TopLevelCommandManager _topLevelCommandManager; public event PropertyChangedEventHandler? PropertyChanged; @@ -38,10 +38,10 @@ public partial class SettingsViewModel : INotifyPropertyChanged public HotkeySettings? Hotkey { - get => _settings.Hotkey; + get => _settingsService.Settings.Hotkey; set { - _settings.Hotkey = value ?? SettingsModel.DefaultActivationShortcut; + _settingsService.Settings.Hotkey = value ?? SettingsModel.DefaultActivationShortcut; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey))); Save(); } @@ -49,10 +49,10 @@ public partial class SettingsViewModel : INotifyPropertyChanged public bool UseLowLevelGlobalHotkey { - get => _settings.UseLowLevelGlobalHotkey; + get => _settingsService.Settings.UseLowLevelGlobalHotkey; set { - _settings.UseLowLevelGlobalHotkey = value; + _settingsService.Settings.UseLowLevelGlobalHotkey = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey))); Save(); } @@ -60,100 +60,100 @@ public partial class SettingsViewModel : INotifyPropertyChanged public bool AllowExternalReload { - get => _settings.AllowExternalReload; + get => _settingsService.Settings.AllowExternalReload; set { - _settings.AllowExternalReload = value; + _settingsService.Settings.AllowExternalReload = value; Save(); } } public bool ShowAppDetails { - get => _settings.ShowAppDetails; + get => _settingsService.Settings.ShowAppDetails; set { - _settings.ShowAppDetails = value; + _settingsService.Settings.ShowAppDetails = value; Save(); } } public bool BackspaceGoesBack { - get => _settings.BackspaceGoesBack; + get => _settingsService.Settings.BackspaceGoesBack; set { - _settings.BackspaceGoesBack = value; + _settingsService.Settings.BackspaceGoesBack = value; Save(); } } public bool SingleClickActivates { - get => _settings.SingleClickActivates; + get => _settingsService.Settings.SingleClickActivates; set { - _settings.SingleClickActivates = value; + _settingsService.Settings.SingleClickActivates = value; Save(); } } public bool HighlightSearchOnActivate { - get => _settings.HighlightSearchOnActivate; + get => _settingsService.Settings.HighlightSearchOnActivate; set { - _settings.HighlightSearchOnActivate = value; + _settingsService.Settings.HighlightSearchOnActivate = value; Save(); } } public bool KeepPreviousQuery { - get => _settings.KeepPreviousQuery; + get => _settingsService.Settings.KeepPreviousQuery; set { - _settings.KeepPreviousQuery = value; + _settingsService.Settings.KeepPreviousQuery = value; Save(); } } public int MonitorPositionIndex { - get => (int)_settings.SummonOn; + get => (int)_settingsService.Settings.SummonOn; set { - _settings.SummonOn = (MonitorBehavior)value; + _settingsService.Settings.SummonOn = (MonitorBehavior)value; Save(); } } public bool ShowSystemTrayIcon { - get => _settings.ShowSystemTrayIcon; + get => _settingsService.Settings.ShowSystemTrayIcon; set { - _settings.ShowSystemTrayIcon = value; + _settingsService.Settings.ShowSystemTrayIcon = value; Save(); } } public bool IgnoreShortcutWhenFullscreen { - get => _settings.IgnoreShortcutWhenFullscreen; + get => _settingsService.Settings.IgnoreShortcutWhenFullscreen; set { - _settings.IgnoreShortcutWhenFullscreen = value; + _settingsService.Settings.IgnoreShortcutWhenFullscreen = value; Save(); } } public bool DisableAnimations { - get => _settings.DisableAnimations; + get => _settingsService.Settings.DisableAnimations; set { - _settings.DisableAnimations = value; + _settingsService.Settings.DisableAnimations = value; Save(); } } @@ -162,7 +162,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged { get { - var index = AutoGoHomeIntervals.IndexOf(_settings.AutoGoHomeInterval); + var index = AutoGoHomeIntervals.IndexOf(_settingsService.Settings.AutoGoHomeInterval); return index >= 0 ? index : 0; } @@ -170,7 +170,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged { if (value >= 0 && value < AutoGoHomeIntervals.Count) { - _settings.AutoGoHomeInterval = AutoGoHomeIntervals[value]; + _settingsService.Settings.AutoGoHomeInterval = AutoGoHomeIntervals[value]; } Save(); @@ -179,60 +179,60 @@ public partial class SettingsViewModel : INotifyPropertyChanged public int EscapeKeyBehaviorIndex { - get => (int)_settings.EscapeKeyBehaviorSetting; + get => (int)_settingsService.Settings.EscapeKeyBehaviorSetting; set { - _settings.EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value; + _settingsService.Settings.EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value; Save(); } } public DockSide Dock_Side { - get => _settings.DockSettings.Side; + get => _settingsService.Settings.DockSettings.Side; set { - _settings.DockSettings.Side = value; + _settingsService.Settings.DockSettings.Side = value; Save(); } } public DockSize Dock_DockSize { - get => _settings.DockSettings.DockSize; + get => _settingsService.Settings.DockSettings.DockSize; set { - _settings.DockSettings.DockSize = value; + _settingsService.Settings.DockSettings.DockSize = value; Save(); } } public DockBackdrop Dock_Backdrop { - get => _settings.DockSettings.Backdrop; + get => _settingsService.Settings.DockSettings.Backdrop; set { - _settings.DockSettings.Backdrop = value; + _settingsService.Settings.DockSettings.Backdrop = value; Save(); } } public bool Dock_ShowLabels { - get => _settings.DockSettings.ShowLabels; + get => _settingsService.Settings.DockSettings.ShowLabels; set { - _settings.DockSettings.ShowLabels = value; + _settingsService.Settings.DockSettings.ShowLabels = value; Save(); } } public bool EnableDock { - get => _settings.EnableDock; + get => _settingsService.Settings.EnableDock; set { - _settings.EnableDock = value; + _settingsService.Settings.EnableDock = value; Save(); WeakReferenceMessenger.Default.Send(new ShowHideDockMessage(value)); WeakReferenceMessenger.Default.Send(new ReloadCommandsMessage()); // TODO! we need to update the MoreCommands of all top level items, but we don't _really_ want to reload @@ -245,26 +245,26 @@ public partial class SettingsViewModel : INotifyPropertyChanged public SettingsExtensionsViewModel Extensions { get; } - public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler, IThemeService themeService) + public SettingsViewModel(TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler, IThemeService themeService, ISettingsService settingsService) { - _settings = settings; + _settingsService = settingsService; _topLevelCommandManager = topLevelCommandManager; - Appearance = new AppearanceSettingsViewModel(themeService, _settings); - DockAppearance = new DockAppearanceSettingsViewModel(themeService, _settings); + Appearance = new AppearanceSettingsViewModel(themeService, settingsService); + DockAppearance = new DockAppearanceSettingsViewModel(themeService, settingsService); var activeProviders = GetCommandProviders(); - var allProviderSettings = _settings.ProviderSettings; + var allProviderSettings = _settingsService.Settings.ProviderSettings; var fallbacks = new List(); - var currentRankings = _settings.FallbackRanks; + var currentRankings = _settingsService.Settings.FallbackRanks; var needsSave = false; foreach (var item in activeProviders) { - var providerSettings = settings.GetProviderSettings(item); + var providerSettings = _settingsService.Settings.GetProviderSettings(item); - var settingsModel = new ProviderSettingsViewModel(item, providerSettings, _settings); + var settingsModel = new ProviderSettingsViewModel(item, providerSettings, settingsService); CommandProviders.Add(settingsModel); fallbacks.AddRange(settingsModel.FallbackCommands); @@ -306,10 +306,10 @@ public partial class SettingsViewModel : INotifyPropertyChanged public void ApplyFallbackSort() { - _settings.FallbackRanks = FallbackRankings.Select(s => s.Id).ToArray(); + _settingsService.Settings.FallbackRanks = FallbackRankings.Select(s => s.Id).ToArray(); Save(); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings))); } - private void Save() => SettingsModel.SaveSettings(_settings); + private void Save() => _settingsService.Save(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index 70f0774524..0ca7cbbfe1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -9,6 +9,7 @@ using ManagedCommon; using Microsoft.CmdPal.Common.Helpers; using Microsoft.CmdPal.Common.Text; 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; @@ -21,7 +22,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider, IPrecomputedListItem { - private readonly SettingsModel _settings; + private readonly ISettingsService _settingsService; private readonly ProviderSettings _providerSettings; private readonly IServiceProvider _serviceProvider; private readonly CommandItemViewModel _commandItemViewModel; @@ -185,9 +186,9 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx return null; } - var bandSettings = _settings.DockSettings.StartBands - .Concat(_settings.DockSettings.CenterBands) - .Concat(_settings.DockSettings.EndBands) + var bandSettings = _settingsService.Settings.DockSettings.StartBands + .Concat(_settingsService.Settings.DockSettings.CenterBands) + .Concat(_settingsService.Settings.DockSettings.EndBands) .FirstOrDefault(band => band.CommandId == this.Id); if (bandSettings is null) { @@ -208,14 +209,13 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx TopLevelType topLevelType, CommandPaletteHost extensionHost, ICommandProviderContext commandProviderContext, - SettingsModel settings, ProviderSettings providerSettings, IServiceProvider serviceProvider, ICommandItem? commandItem, IContextMenuFactory? contextMenuFactory) { _serviceProvider = serviceProvider; - _settings = settings; + _settingsService = serviceProvider.GetRequiredService(); _providerSettings = providerSettings; ProviderContext = commandProviderContext; _commandItemViewModel = item; @@ -313,7 +313,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx } } - private void Save() => SettingsModel.SaveSettings(_settings); + private void Save() => _settingsService.Save(); private void HandleChangeAlias() { @@ -347,7 +347,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx private void UpdateHotkey() { - var hotkey = _settings.CommandHotkeys.Where(hk => hk.CommandId == Id).FirstOrDefault(); + var hotkey = _settingsService.Settings.CommandHotkeys.Where(hk => hk.CommandId == Id).FirstOrDefault(); if (hotkey is not null) { _hotkey = hotkey.Hotkey; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 32f1161e97..81d5892664 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -183,11 +183,10 @@ public partial class App : Application, IDisposable private static void AddUIServices(ServiceCollection services, DispatcherQueue dispatcherQueue) { - // Models - var sm = SettingsModel.LoadSettings(); - services.AddSingleton(sm); - var state = AppStateModel.LoadState(); - services.AddSingleton(state); + // Models & persistence services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Services services.AddSingleton(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs index 2f15e4b213..0517ddf4b8 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs @@ -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 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; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs index 57ad2c1d8c..da36347544 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs @@ -18,10 +18,10 @@ public sealed partial class FallbackRanker : UserControl { this.InitializeComponent(); - var settings = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetService()!; - viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + var settingsService = App.Current.Services.GetRequiredService(); + viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService); } private void ListView_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index bebc5b1e99..e61967bb3d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -9,6 +9,7 @@ using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.Views; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Dispatching; @@ -49,7 +50,7 @@ public sealed partial class SearchBar : UserControl, // 0.6+ suggestions private string? _textToSuggest; - private SettingsModel Settings => App.Current.Services.GetRequiredService(); + private SettingsModel Settings => App.Current.Services.GetRequiredService().Settings; public PageViewModel? CurrentPageViewModel { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs index f51e83790d..d9803b1403 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs @@ -46,6 +46,7 @@ public sealed partial class DockWindow : WindowEx, #pragma warning restore SA1306 // Field names should begin with lower-case letter private readonly IThemeService _themeService; + private readonly ISettingsService _settingsService; private readonly DockWindowViewModel _windowViewModel; private readonly HiddenOwnerWindowBehavior _hiddenOwnerWindowBehavior = new(); @@ -68,8 +69,9 @@ public sealed partial class DockWindow : WindowEx, public DockWindow() { var serviceProvider = App.Current.Services; - var mainSettings = serviceProvider.GetService()!; - mainSettings.SettingsChanged += SettingsChangedHandler; + var mainSettings = serviceProvider.GetRequiredService().Settings; + _settingsService = serviceProvider.GetRequiredService(); + _settingsService.SettingsChanged += SettingsChangedHandler; _settings = mainSettings.DockSettings; _lastSize = _settings.DockSize; @@ -128,9 +130,9 @@ public sealed partial class DockWindow : WindowEx, UpdateSettingsOnUiThread(); } - private void SettingsChangedHandler(SettingsModel sender, object? args) + private void SettingsChangedHandler(ISettingsService sender, SettingsModel args) { - _settings = sender.DockSettings; + _settings = args.DockSettings; DispatcherQueue.TryEnqueue(UpdateSettingsOnUiThread); } @@ -621,9 +623,7 @@ public sealed partial class DockWindow : WindowEx, private void DockWindow_Closed(object sender, WindowEventArgs args) { - var serviceProvider = App.Current.Services; - var settings = serviceProvider.GetService(); - settings?.SettingsChanged -= SettingsChangedHandler; + _settingsService.SettingsChanged -= SettingsChangedHandler; _themeService.ThemeChanged -= ThemeService_ThemeChanged; DisposeAcrylic(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index 4a7b33b960..a3c2dcab11 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -9,6 +9,7 @@ using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation.Peers; @@ -183,7 +184,7 @@ public sealed partial class ListPage : Page, return; } - var settings = App.Current.Services.GetService()!; + var settings = App.Current.Services.GetRequiredService().Settings; if (settings.SingleClickActivates) { ViewModel?.InvokeItemCommand.Execute(item); @@ -203,7 +204,7 @@ public sealed partial class ListPage : Page, { if (ItemView.SelectedItem is ListItemViewModel vm) { - var settings = App.Current.Services.GetService()!; + var settings = App.Current.Services.GetRequiredService().Settings; if (!settings.SingleClickActivates) { ViewModel?.InvokeItemCommand.Execute(vm); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs index 79bd2a509f..c921bce99d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.UI.Xaml; using Windows.Win32; using Windows.Win32.Foundation; @@ -25,7 +26,7 @@ internal sealed partial class TrayIconService private const uint MY_NOTIFY_ID = 1000; private const uint WM_TRAY_ICON = PInvoke.WM_USER + 1; - private readonly SettingsModel _settingsModel; + private readonly ISettingsService _settingsService; private readonly uint WM_TASKBAR_RESTART; private Window? _window; @@ -36,9 +37,9 @@ internal sealed partial class TrayIconService private DestroyIconSafeHandle? _largeIcon; private DestroyMenuSafeHandle? _popupMenu; - public TrayIconService(SettingsModel settingsModel) + public TrayIconService(ISettingsService settingsService) { - _settingsModel = settingsModel; + _settingsService = settingsService; // TaskbarCreated is the message that's broadcast when explorer.exe // restarts. We need to know when that happens to be able to bring our @@ -48,7 +49,7 @@ internal sealed partial class TrayIconService public void SetupTrayIcon(bool? showSystemTrayIcon = null) { - if (showSystemTrayIcon ?? _settingsModel.ShowSystemTrayIcon) + if (showSystemTrayIcon ?? _settingsService.Settings.ShowSystemTrayIcon) { if (_window is null) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index 3dc5bab99b..5389c5875c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -170,7 +170,7 @@ public sealed partial class MainWindow : WindowEx, // Load our settings, and then also wire up a settings changed handler HotReloadSettings(); - App.Current.Services.GetService()!.SettingsChanged += SettingsChangedHandler; + App.Current.Services.GetRequiredService().SettingsChanged += SettingsChangedHandler; // Make sure that we update the acrylic theme when the OS theme changes RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateBackdrop); @@ -211,7 +211,7 @@ public sealed partial class MainWindow : WindowEx, } } - private void SettingsChangedHandler(SettingsModel sender, object? args) + private void SettingsChangedHandler(ISettingsService sender, SettingsModel args) { DispatcherQueue.TryEnqueue(HotReloadSettings); } @@ -292,7 +292,7 @@ public sealed partial class MainWindow : WindowEx, private void RestoreWindowPositionFromSavedSettings() { - var settings = App.Current.Services.GetService(); + var settings = App.Current.Services.GetRequiredService().Settings; RestoreWindowPosition(settings?.LastWindowPosition); } @@ -363,7 +363,7 @@ public sealed partial class MainWindow : WindowEx, private void HotReloadSettings() { - var settings = App.Current.Services.GetService()!; + var settings = App.Current.Services.GetRequiredService().Settings; SetupHotkey(settings); App.Current.Services.GetService()!.SetupTrayIcon(settings.ShowSystemTrayIcon); @@ -707,7 +707,7 @@ public sealed partial class MainWindow : WindowEx, { _isLoadedFromDock = false; - var settings = App.Current.Services.GetService()!; + var settings = App.Current.Services.GetRequiredService().Settings; // Start session tracking _sessionStopwatch = Stopwatch.StartNew(); @@ -902,7 +902,8 @@ public sealed partial class MainWindow : WindowEx, UpdateWindowPositionInMemory(); } - var settings = serviceProvider.GetService(); + var settingsService = serviceProvider.GetRequiredService(); + var settings = settingsService.Settings; if (settings is not null) { // If we were last shown from the dock, _currentWindowPosition still holds @@ -910,7 +911,7 @@ public sealed partial class MainWindow : WindowEx, if (_currentWindowPosition.IsSizeValid) { settings.LastWindowPosition = _currentWindowPosition; - SettingsModel.SaveSettings(settings); + settingsService.Save(); } } @@ -1080,7 +1081,7 @@ public sealed partial class MainWindow : WindowEx, } else if (uri.StartsWith("x-cmdpal://reload", StringComparison.OrdinalIgnoreCase)) { - var settings = App.Current.Services.GetService(); + var settings = App.Current.Services.GetRequiredService().Settings; if (settings?.AllowExternalReload == true) { Logger.LogInfo("External Reload triggered"); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 77973c4fc0..6565b8438b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -16,6 +16,7 @@ using Microsoft.CmdPal.UI.Services; using Microsoft.CmdPal.UI.Settings; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; @@ -111,7 +112,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, var pageAnnouncementFormat = ResourceLoaderInstance.GetString("ScreenReader_Announcement_NavigatedToPage0"); _pageNavigatedAnnouncement = CompositeFormat.Parse(pageAnnouncementFormat); - if (App.Current.Services.GetService()!.EnableDock) + if (App.Current.Services.GetRequiredService().Settings.EnableDock) { _dockWindow = new DockWindow(); _dockWindow.Show(); @@ -125,14 +126,14 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, { get { - var settings = App.Current.Services.GetService()!; + var settings = App.Current.Services.GetRequiredService().Settings; return settings.DisableAnimations ? _noAnimation : _slideRightTransition; } } public void Receive(NavigateBackMessage message) { - var settings = App.Current.Services.GetService()!; + var settings = App.Current.Services.GetRequiredService().Settings; if (RootFrame.CanGoBack) { @@ -362,7 +363,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, private void SummonOnUiThread(HotkeySummonMessage message) { - var settings = App.Current.Services.GetService()!; + var settings = App.Current.Services.GetRequiredService().Settings; var commandId = message.CommandId; var isRoot = string.IsNullOrEmpty(commandId); if (isRoot) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs index 76a4e8b5e3..f24d9273f6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs @@ -9,6 +9,7 @@ using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.Common.Text; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.MainPage; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using WinRT; @@ -23,13 +24,13 @@ internal sealed class PowerToysRootPageService : IRootPageService private IExtensionWrapper? _activeExtension; private Lazy _mainListPage; - public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel, IFuzzyMatcherProvider fuzzyMatcherProvider) + public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, AliasManager aliasManager, IFuzzyMatcherProvider fuzzyMatcherProvider, ISettingsService settingsService, IAppStateService appStateService) { _tlcManager = topLevelCommandManager; _mainListPage = new Lazy(() => { - return new MainListPage(_tlcManager, settings, aliasManager, appStateModel, fuzzyMatcherProvider); + return new MainListPage(_tlcManager, aliasManager, fuzzyMatcherProvider, settingsService, appStateService); }); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs index e1ef0cb57f..6d7a4d628f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs @@ -4,32 +4,33 @@ using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; namespace Microsoft.CmdPal.UI; internal sealed class RunHistoryService : IRunHistoryService { - private readonly AppStateModel _appStateModel; + private readonly IAppStateService _appStateService; - public RunHistoryService(AppStateModel appStateModel) + public RunHistoryService(IAppStateService appStateService) { - _appStateModel = appStateModel; + _appStateService = appStateService; } public IReadOnlyList GetRunHistory() { - if (_appStateModel.RunHistory.Count == 0) + if (_appStateService.State.RunHistory.Count == 0) { var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory(); - _appStateModel.RunHistory.AddRange(history); + _appStateService.State.RunHistory.AddRange(history); } - return _appStateModel.RunHistory; + return _appStateService.State.RunHistory; } public void ClearRunHistory() { - _appStateModel.RunHistory.Clear(); + _appStateService.State.RunHistory.Clear(); } public void AddRunHistoryItem(string item) @@ -40,11 +41,11 @@ internal sealed class RunHistoryService : IRunHistoryService return; // Do not add empty or whitespace items } - _appStateModel.RunHistory.Remove(item); + _appStateService.State.RunHistory.Remove(item); // Add the item to the front of the history - _appStateModel.RunHistory.Insert(0, item); + _appStateService.State.RunHistory.Insert(0, item); - AppStateModel.SaveState(_appStateModel); + _appStateService.Save(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs index c1f548eaeb..bb7408e79d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs @@ -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 diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs index e047255c43..00343a4de2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs @@ -31,10 +31,10 @@ public sealed partial class AppearancePage : Page { InitializeComponent(); - var settings = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetRequiredService(); var topLevelCommandManager = App.Current.Services.GetService()!; - ViewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + var settingsService = App.Current.Services.GetRequiredService(); + ViewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService); } private async void PickBackgroundImage_Click(object sender, RoutedEventArgs e) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml.cs index 666049285d..228368b6ed 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml.cs @@ -28,11 +28,11 @@ public sealed partial class DockSettingsPage : Page { this.InitializeComponent(); - var settings = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetRequiredService(); - ViewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + ViewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService); // Initialize UI state InitializeSettings(); @@ -195,7 +195,8 @@ public sealed partial class DockSettingsPage : Page // var allBands = GetAllBands(); var tlcManager = App.Current.Services.GetService()!; - var settingsModel = App.Current.Services.GetService()!; + var settingsModel = App.Current.Services.GetRequiredService().Settings; + var settingsService = App.Current.Services.GetRequiredService(); var dockViewModel = App.Current.Services.GetService()!; var allBands = tlcManager.GetDockBandsSnapshot(); foreach (var band in allBands) @@ -208,7 +209,7 @@ public sealed partial class DockSettingsPage : Page dockSettingsModel: setting, topLevelAdapter: band, bandViewModel: bandVm, - settingsModel: settingsModel + settingsService: settingsService )); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs index f19be9f0cf..952b9ffee5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs @@ -24,10 +24,10 @@ public sealed partial class ExtensionsPage : Page { this.InitializeComponent(); - var settings = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetService()!; - viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + var settingsService = App.Current.Services.GetRequiredService(); + viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService); } private void SettingsCard_Click(object sender, RoutedEventArgs e) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs index 2016f8edb7..cc78a00f56 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs @@ -23,11 +23,11 @@ public sealed partial class GeneralPage : Page { this.InitializeComponent(); - var settings = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetRequiredService(); _appInfoService = App.Current.Services.GetRequiredService(); - viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService); } public string ApplicationVersion diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/AppStateServiceTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/AppStateServiceTests.cs new file mode 100644 index 0000000000..6224eb360f --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/AppStateServiceTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public class AppStateServiceTests +{ + private Mock _mockPersistence = null!; + private Mock _mockAppInfo = null!; + private string _testDirectory = null!; + + [TestInitialize] + public void Setup() + { + _mockPersistence = new Mock(); + _mockAppInfo = new Mock(); + _testDirectory = Path.Combine(Path.GetTempPath(), $"CmdPalTest_{Guid.NewGuid():N}"); + _mockAppInfo.Setup(a => a.ConfigDirectory).Returns(_testDirectory); + + // Default: Load returns a new AppStateModel + _mockPersistence + .Setup(p => p.Load( + It.IsAny(), + It.IsAny>())) + .Returns(new AppStateModel()); + } + + [TestMethod] + public void Constructor_LoadsState_ViaPersistenceService() + { + // Arrange + var expectedState = new AppStateModel + { + RunHistory = new List { "command1", "command2" }, + }; + _mockPersistence + .Setup(p => p.Load( + It.IsAny(), + It.IsAny>())) + .Returns(expectedState); + + // Act + var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object); + + // Assert + Assert.IsNotNull(service.State); + Assert.AreEqual(2, service.State.RunHistory.Count); + Assert.AreEqual("command1", service.State.RunHistory[0]); + _mockPersistence.Verify( + p => p.Load( + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public void State_ReturnsLoadedModel() + { + // Arrange + var expectedState = new AppStateModel(); + _mockPersistence + .Setup(p => p.Load( + It.IsAny(), + It.IsAny>())) + .Returns(expectedState); + + // Act + var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object); + + // Assert + Assert.AreSame(expectedState, service.State); + } + + [TestMethod] + public void Save_DelegatesToPersistenceService() + { + // Arrange + var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object); + service.State.RunHistory.Add("test-command"); + + // Act + service.Save(); + + // Assert + _mockPersistence.Verify( + p => p.Save( + service.State, + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public void Save_RaisesStateChangedEvent() + { + // Arrange + var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object); + var eventRaised = false; + service.StateChanged += (sender, state) => + { + eventRaised = true; + }; + + // Act + service.Save(); + + // Assert + Assert.IsTrue(eventRaised); + } + + [TestMethod] + public void StateChanged_PassesCorrectArguments() + { + // Arrange + var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object); + IAppStateService? receivedSender = null; + AppStateModel? receivedState = null; + + service.StateChanged += (sender, state) => + { + receivedSender = sender; + receivedState = state; + }; + + // Act + service.Save(); + + // Assert + Assert.AreSame(service, receivedSender); + Assert.AreSame(service.State, receivedState); + } + + [TestMethod] + public void Save_Always_RaisesStateChangedEvent() + { + // Arrange - AppStateService.Save() should always raise StateChanged + // (unlike SettingsService which has hotReload parameter) + var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object); + var eventCount = 0; + + service.StateChanged += (sender, state) => + { + eventCount++; + }; + + // Act + service.Save(); + service.Save(); + + // Assert + Assert.AreEqual(2, eventCount); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/PersistenceServiceTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/PersistenceServiceTests.cs new file mode 100644 index 0000000000..9ecfc0a0db --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/PersistenceServiceTests.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public partial class PersistenceServiceTests +{ + private PersistenceService _service = null!; + private string _testDirectory = null!; + private string _testFilePath = null!; + + // Simple test model for persistence testing + private sealed class TestModel + { + public string Name { get; set; } = string.Empty; + + public int Value { get; set; } + } + + [JsonSerializable(typeof(TestModel))] + private sealed partial class TestJsonContext : JsonSerializerContext + { + } + + [TestInitialize] + public void Setup() + { + _service = new PersistenceService(); + _testDirectory = Path.Combine(Path.GetTempPath(), $"PersistenceServiceTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDirectory); + _testFilePath = Path.Combine(_testDirectory, "test.json"); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } + + [TestMethod] + public void Load_ReturnsNewInstance_WhenFileDoesNotExist() + { + // Arrange + var nonExistentPath = Path.Combine(_testDirectory, "nonexistent.json"); + + // Act + var result = _service.Load(nonExistentPath, TestJsonContext.Default.TestModel); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(string.Empty, result.Name); + Assert.AreEqual(0, result.Value); + } + + [TestMethod] + public void Load_ReturnsDeserializedModel_WhenFileContainsValidJson() + { + // Arrange + var expectedModel = new TestModel { Name = "Test", Value = 42 }; + var json = JsonSerializer.Serialize(expectedModel, TestJsonContext.Default.TestModel); + File.WriteAllText(_testFilePath, json); + + // Act + var result = _service.Load(_testFilePath, TestJsonContext.Default.TestModel); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("Test", result.Name); + Assert.AreEqual(42, result.Value); + } + + [TestMethod] + public void Load_ReturnsNewInstance_WhenFileContainsInvalidJson() + { + // Arrange + File.WriteAllText(_testFilePath, "{ invalid json }"); + + // Act + var result = _service.Load(_testFilePath, TestJsonContext.Default.TestModel); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(string.Empty, result.Name); + Assert.AreEqual(0, result.Value); + } + + [TestMethod] + public void Save_CreatesFile_WhenFileDoesNotExist() + { + // Arrange + var model = new TestModel { Name = "NewFile", Value = 123 }; + + // Act + _service.Save(model, _testFilePath, TestJsonContext.Default.TestModel); + + // Assert + Assert.IsTrue(File.Exists(_testFilePath)); + var content = File.ReadAllText(_testFilePath); + Assert.IsTrue(content.Contains("NewFile")); + Assert.IsTrue(content.Contains("123")); + } + + [TestMethod] + public void Save_HandlesExistingDirectory() + { + // Arrange + // Note: PersistenceService.Save() does NOT create missing directories + // It relies on Directory.CreateDirectory being called by the consumer + // (e.g., SettingsService calls Directory.CreateDirectory in SettingsJsonPath()) + var nestedDir = Path.Combine(_testDirectory, "nested"); + Directory.CreateDirectory(nestedDir); // Create directory beforehand + var nestedFilePath = Path.Combine(nestedDir, "test.json"); + var model = new TestModel { Name = "NestedTest", Value = 321 }; + + // Act + _service.Save(model, nestedFilePath, TestJsonContext.Default.TestModel); + + // Assert + Assert.IsTrue(File.Exists(nestedFilePath)); + var result = _service.Load(nestedFilePath, TestJsonContext.Default.TestModel); + Assert.AreEqual("NestedTest", result.Name); + Assert.AreEqual(321, result.Value); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SettingsServiceTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SettingsServiceTests.cs new file mode 100644 index 0000000000..bade964e94 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SettingsServiceTests.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +/// +/// Tests for . +/// NOTE: These tests currently fail in console test runners due to WinUI3 COM dependencies in SettingsModel. +/// SettingsModel constructor initializes DockSettings which uses Microsoft.UI.Colors.Transparent, +/// requiring WinUI3 runtime registration. Tests pass when run within VS Test Explorer with WinUI3 host. +/// +[TestClass] +public class SettingsServiceTests +{ + private Mock _mockPersistence = null!; + private Mock _mockAppInfo = null!; + private SettingsModel _testSettings = null!; + private string _testDirectory = null!; + + [TestInitialize] + public void Setup() + { + _mockPersistence = new Mock(); + _mockAppInfo = new Mock(); + _testDirectory = Path.Combine(Path.GetTempPath(), $"CmdPalTest_{Guid.NewGuid():N}"); + _mockAppInfo.Setup(a => a.ConfigDirectory).Returns(_testDirectory); + + // Create a minimal test settings instance without triggering WinUI3 dependencies + // We'll mock the Load to return this, avoiding SettingsModel constructor that uses Colors.Transparent + _testSettings = CreateMinimalSettingsModel(); + + // Default: Load returns our test settings + _mockPersistence + .Setup(p => p.Load( + It.IsAny(), + It.IsAny>())) + .Returns(_testSettings); + } + + private static SettingsModel CreateMinimalSettingsModel() + { + // Bypass constructor by using deserialize from minimal JSON + // This avoids WinUI3 dependencies (Colors.Transparent) + var minimalJson = "{}"; + var settings = System.Text.Json.JsonSerializer.Deserialize( + minimalJson, + JsonSerializationContext.Default.SettingsModel) ?? new SettingsModel(); + return settings; + } + + [TestMethod] + public void Constructor_LoadsSettings_ViaPersistenceService() + { + // Act + var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); + + // Assert + Assert.IsNotNull(service.Settings); + _mockPersistence.Verify( + p => p.Load( + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public void Settings_ReturnsLoadedModel() + { + // Arrange + _testSettings.ShowAppDetails = true; + + // Act + var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); + + // Assert + Assert.IsTrue(service.Settings.ShowAppDetails); + } + + [TestMethod] + public void Save_DelegatesToPersistenceService() + { + // Arrange + var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); + service.Settings.SingleClickActivates = true; + + // Act + service.Save(hotReload: false); + + // Assert + _mockPersistence.Verify( + p => p.Save( + service.Settings, + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public void Save_WithHotReloadTrue_RaisesSettingsChangedEvent() + { + // Arrange + var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); + var eventRaised = false; + service.SettingsChanged += (sender, settings) => + { + eventRaised = true; + }; + + // Act + service.Save(hotReload: true); + + // Assert + Assert.IsTrue(eventRaised); + } + + [TestMethod] + public void Save_WithHotReloadFalse_DoesNotRaiseSettingsChangedEvent() + { + // Arrange + var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); + var eventRaised = false; + service.SettingsChanged += (sender, settings) => + { + eventRaised = true; + }; + + // Act + service.Save(hotReload: false); + + // Assert + Assert.IsFalse(eventRaised); + } + + [TestMethod] + public void Save_WithDefaultHotReload_RaisesSettingsChangedEvent() + { + // Arrange + var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); + var eventRaised = false; + service.SettingsChanged += (sender, settings) => + { + eventRaised = true; + }; + + // Act + service.Save(); // Default is hotReload: true + + // Assert + Assert.IsTrue(eventRaised); + } + + [TestMethod] + public void SettingsChanged_PassesCorrectArguments() + { + // Arrange + var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); + ISettingsService? receivedSender = null; + SettingsModel? receivedSettings = null; + + service.SettingsChanged += (sender, settings) => + { + receivedSender = sender; + receivedSettings = settings; + }; + + // Act + service.Save(hotReload: true); + + // Assert + Assert.AreSame(service, receivedSender); + Assert.AreSame(service.Settings, receivedSettings); + } +}