diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs index ae21c73eb5e2..fc10814c9518 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Collections.Immutable; +using System.Text.Json.Serialization; namespace Microsoft.CmdPal.UI.ViewModels; @@ -17,4 +18,17 @@ public record AppStateModel // END SETTINGS /////////////////////////////////////////////////////////////////////////// + + public AppStateModel() + { + } + + [JsonConstructor] + public AppStateModel( + RecentCommandsManager recentCommands, + ImmutableList runHistory) + { + RecentCommands = recentCommands ?? new(); + RunHistory = runHistory ?? ImmutableList.Empty; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs index da79a7fe0e1a..2eb6aee5b4d9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs @@ -38,9 +38,14 @@ public ProviderSettings() } [JsonConstructor] - public ProviderSettings(bool isEnabled) + public ProviderSettings( + ImmutableDictionary fallbackCommands, + ImmutableList pinnedCommandIds, + bool isEnabled = true) { IsEnabled = isEnabled; + FallbackCommands = fallbackCommands ?? ImmutableDictionary.Empty; + PinnedCommandIds = pinnedCommandIds ?? ImmutableList.Empty; } /// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs index da7a5711a68d..1d8a67d275a5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs @@ -16,6 +16,12 @@ public RecentCommandsManager() { } + [JsonConstructor] + internal RecentCommandsManager(ImmutableList history) + { + History = history ?? ImmutableList.Empty; + } + public int GetCommandHistoryWeight(string commandId) { var entry = History 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 ddf2a1a82ed8..30bfd5ee0884 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs @@ -16,6 +16,35 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings; /// public record DockSettings { + private static readonly ImmutableList DefaultStartBands = ImmutableList.Create( + new DockBandSettings + { + ProviderId = "com.microsoft.cmdpal.builtin.core", + CommandId = "com.microsoft.cmdpal.home", + }, + new DockBandSettings + { + ProviderId = "WinGet", + CommandId = "com.microsoft.cmdpal.winget", + ShowLabels = false, + }); + + private static readonly ImmutableList DefaultEndBands = ImmutableList.Create( + new DockBandSettings + { + ProviderId = "PerformanceMonitor", + CommandId = "com.microsoft.cmdpal.performanceWidget", + }, + new DockBandSettings + { + ProviderId = "com.microsoft.cmdpal.builtin.datetime", + CommandId = "com.microsoft.cmdpal.timedate.dockBand", + }); + + private static readonly Color DefaultCustomThemeColor = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent and COM in class init + + private readonly Color? _customThemeColor; + public DockSide Side { get; init; } = DockSide.Top; public DockSize DockSize { get; init; } = DockSize.Small; @@ -31,7 +60,20 @@ public record DockSettings public ColorizationMode ColorizationMode { get; init; } - public Color CustomThemeColor { get; init; } = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent and COM in class init + [JsonPropertyName("CustomThemeColor")] + [JsonInclude] + internal Color? CustomThemeColorFallback + { + get => _customThemeColor; + init => _customThemeColor = value; + } + + [JsonIgnore] + public Color CustomThemeColor + { + get => _customThemeColor ?? DefaultCustomThemeColor; + init => _customThemeColor = value; + } public int CustomThemeColorIntensity { get; init; } = 100; @@ -46,32 +88,11 @@ public record DockSettings public string? BackgroundImagePath { get; init; } // - public ImmutableList StartBands { get; init; } = ImmutableList.Create( - new DockBandSettings - { - ProviderId = "com.microsoft.cmdpal.builtin.core", - CommandId = "com.microsoft.cmdpal.home", - }, - new DockBandSettings - { - ProviderId = "WinGet", - CommandId = "com.microsoft.cmdpal.winget", - ShowLabels = false, - }); + public ImmutableList StartBands { get; init; } = DefaultStartBands; public ImmutableList CenterBands { get; init; } = ImmutableList.Empty; - public ImmutableList EndBands { get; init; } = ImmutableList.Create( - new DockBandSettings - { - ProviderId = "PerformanceMonitor", - CommandId = "com.microsoft.cmdpal.performanceWidget", - }, - new DockBandSettings - { - ProviderId = "com.microsoft.cmdpal.builtin.datetime", - CommandId = "com.microsoft.cmdpal.timedate.dockBand", - }); + public ImmutableList EndBands { get; init; } = DefaultEndBands; public bool ShowLabels { get; init; } = true; @@ -80,6 +101,33 @@ public record DockSettings StartBands.Select(b => (b.ProviderId, b.CommandId)) .Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId))) .Concat(EndBands.Select(b => (b.ProviderId, b.CommandId))); + + public DockSettings() + { + } + + [JsonConstructor] + public DockSettings( + ImmutableList startBands, + ImmutableList centerBands, + ImmutableList endBands, + DockSide side = DockSide.Top, + bool alwaysOnTop = true, + DockBackdrop backdrop = DockBackdrop.Acrylic, + int customThemeColorIntensity = 100, + int backgroundImageOpacity = 20, + bool showLabels = true) + { + StartBands = startBands ?? DefaultStartBands; + CenterBands = centerBands ?? ImmutableList.Empty; + EndBands = endBands ?? DefaultEndBands; + Side = side; + AlwaysOnTop = alwaysOnTop; + Backdrop = backdrop; + CustomThemeColorIntensity = customThemeColorIntensity; + BackgroundImageOpacity = backgroundImageOpacity; + ShowLabels = showLabels; + } } /// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index 8e4323524002..b54730ef12f5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -15,6 +15,8 @@ public record SettingsModel // SETTINGS HERE public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space + private static readonly Color DefaultCustomThemeColor = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent + public HotkeySettings? Hotkey { get; init; } = DefaultActivationShortcut; public bool UseLowLevelGlobalHotkey { get; init; } @@ -56,7 +58,22 @@ public record SettingsModel public WindowPosition? LastWindowPosition { get; init; } - public TimeSpan AutoGoHomeInterval { get; init; } = Timeout.InfiniteTimeSpan; + [JsonPropertyName("AutoGoHomeInterval")] + [JsonInclude] + internal string? AutoGoHomeIntervalString + { + get => _autoGoHomeInterval?.ToString(); + init => _autoGoHomeInterval = TimeSpan.TryParse(value, out var ts) ? ts : null; + } + + private TimeSpan? _autoGoHomeInterval; + + [JsonIgnore] + public TimeSpan AutoGoHomeInterval + { + get => _autoGoHomeInterval ?? Timeout.InfiniteTimeSpan; + init => _autoGoHomeInterval = value; + } public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; init; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack; @@ -69,7 +86,22 @@ public record SettingsModel public ColorizationMode ColorizationMode { get; init; } - public Color CustomThemeColor { get; init; } = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent + private Color? _customThemeColor; + + [JsonPropertyName("CustomThemeColor")] + [JsonInclude] + internal Color? CustomThemeColorFallback + { + get => _customThemeColor; + init => _customThemeColor = value; + } + + [JsonIgnore] + public Color CustomThemeColor + { + get => _customThemeColor ?? DefaultCustomThemeColor; + init => _customThemeColor = value; + } public int CustomThemeColorIntensity { get; init; } = 100; @@ -94,6 +126,39 @@ public record SettingsModel // END SETTINGS /////////////////////////////////////////////////////////////////////////// + public SettingsModel() + { + } + + [JsonConstructor] + public SettingsModel( + ImmutableDictionary providerSettings, + string[] fallbackRanks, + ImmutableDictionary aliases, + ImmutableList commandHotkeys, + DockSettings dockSettings, + bool highlightSearchOnActivate = true, + bool showSystemTrayIcon = true, + bool ignoreShortcutWhenFullscreen = true, + bool disableAnimations = true, + int customThemeColorIntensity = 100, + int backgroundImageOpacity = 20, + int backdropOpacity = 100) + { + ProviderSettings = providerSettings ?? ImmutableDictionary.Empty; + FallbackRanks = fallbackRanks ?? []; + Aliases = aliases ?? ImmutableDictionary.Empty; + CommandHotkeys = commandHotkeys ?? ImmutableList.Empty; + DockSettings = dockSettings ?? new(); + HighlightSearchOnActivate = highlightSearchOnActivate; + ShowSystemTrayIcon = showSystemTrayIcon; + IgnoreShortcutWhenFullscreen = ignoreShortcutWhenFullscreen; + DisableAnimations = disableAnimations; + CustomThemeColorIntensity = customThemeColorIntensity; + BackgroundImageOpacity = backgroundImageOpacity; + BackdropOpacity = backdropOpacity; + } + public (SettingsModel Model, ProviderSettings Settings) GetProviderSettings(CommandProviderWrapper provider) { if (!ProviderSettings.TryGetValue(provider.ProviderId, out var settings)) 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 index 7961be82cb13..42ea828ad25a 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SettingsServiceTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SettingsServiceTests.cs @@ -4,8 +4,13 @@ using System; using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -224,4 +229,76 @@ public void UpdateSettings_ConcurrentUpdates_NoLostUpdates() Assert.IsTrue(service.Settings.ShowAppDetails, "ShowAppDetails lost — a stale snapshot overwrote it"); Assert.IsTrue(service.Settings.SingleClickActivates, "SingleClickActivates lost — a stale snapshot overwrote it"); } + + /// + /// Verifies that deserializing empty JSON "{}" produces the same non-null + /// property values as calling new(). This catches the System.Text.Json + /// source-gen issue where property initializers are not honored and reference-type + /// properties silently end up null. The test is future-proof: adding a new + /// property with a default value to any of these models will automatically be + /// covered without touching this test. + /// + [TestMethod] + public void Deserialize_EmptyJson_MatchesConstructorDefaults() + { + AssertDeserializedMatchesConstructor(new SettingsModel(), JsonSerializationContext.Default.SettingsModel); + AssertDeserializedMatchesConstructor(new AppStateModel(), JsonSerializationContext.Default.AppStateModel); + AssertDeserializedMatchesConstructor(new RecentCommandsManager(), JsonSerializationContext.Default.RecentCommandsManager); + AssertDeserializedMatchesConstructor(new DockSettings(), JsonSerializationContext.Default.DockSettings); + AssertDeserializedMatchesConstructor(new ProviderSettings(), JsonSerializationContext.Default.ProviderSettings); + } + + /// + /// Deserializes "{}" into and compares every + /// readable instance property against a new() instance. Asserts + /// that every non-nullable property has the same value after deserialization + /// as it does from the parameterless constructor. This catches the + /// System.Text.Json source-gen issue where property initializers are not + /// honored — both for reference types (which end up null) and value types + /// (which end up as default(T) instead of the intended default). + /// + private static void AssertDeserializedMatchesConstructor(T constructed, JsonTypeInfo typeInfo) + where T : class + { + var deserialized = JsonSerializer.Deserialize("{}", typeInfo); + Assert.IsNotNull(deserialized, $"Deserialize<{typeof(T).Name}>(\"{{}}\") returned null"); + + var nullabilityContext = new NullabilityInfoContext(); + var typeName = typeof(T).Name; + foreach (var prop in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (!prop.CanRead || prop.GetIndexParameters().Length > 0) + { + continue; + } + + // Skip computed [JsonIgnore] properties (no setter) - they aren't + // deserialized and comparing complex return types is unreliable. + // Writable [JsonIgnore] properties (e.g. fallback-backed properties) + // are still checked since they should match constructor defaults. + if (prop.GetCustomAttribute() is not null && !prop.CanWrite) + { + continue; + } + + // Skip nullable properties — null is a valid value for them, + // so a mismatch with the constructor default is expected. + var nullabilityInfo = nullabilityContext.Create(prop); + if (nullabilityInfo.WriteState is NullabilityState.Nullable) + { + continue; + } + + var expected = prop.GetValue(constructed); + var actual = prop.GetValue(deserialized); + var label = $"{typeName}.{prop.Name}"; + + if (expected is not null) + { + Assert.IsNotNull(actual, $"{label} is null after deserialization but non-null from constructor"); + } + + Assert.AreEqual(expected, actual, $"{label} value mismatch - deserialized default differs from constructor default"); + } + } }