Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,4 +18,17 @@ public record AppStateModel

// END SETTINGS
///////////////////////////////////////////////////////////////////////////

public AppStateModel()
{
}

[JsonConstructor]
public AppStateModel(
RecentCommandsManager recentCommands,
ImmutableList<string> runHistory)
{
RecentCommands = recentCommands ?? new();
RunHistory = runHistory ?? ImmutableList<string>.Empty;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ public ProviderSettings()
}

[JsonConstructor]
public ProviderSettings(bool isEnabled)
public ProviderSettings(
ImmutableDictionary<string, FallbackSettings> fallbackCommands,
ImmutableList<string> pinnedCommandIds,
bool isEnabled = true)
{
IsEnabled = isEnabled;
FallbackCommands = fallbackCommands ?? ImmutableDictionary<string, FallbackSettings>.Empty;
PinnedCommandIds = pinnedCommandIds ?? ImmutableList<string>.Empty;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ public RecentCommandsManager()
{
}

[JsonConstructor]
internal RecentCommandsManager(ImmutableList<HistoryItem> history)
{
History = history ?? ImmutableList<HistoryItem>.Empty;
}

public int GetCommandHistoryWeight(string commandId)
{
var entry = History
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,35 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// </summary>
public record DockSettings
{
private static readonly ImmutableList<DockBandSettings> 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<DockBandSettings> 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;
Expand All @@ -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;

Expand All @@ -46,32 +88,11 @@ public record DockSettings
public string? BackgroundImagePath { get; init; }

// </Theme settings>
public ImmutableList<DockBandSettings> 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<DockBandSettings> StartBands { get; init; } = DefaultStartBands;

public ImmutableList<DockBandSettings> CenterBands { get; init; } = ImmutableList<DockBandSettings>.Empty;

public ImmutableList<DockBandSettings> 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<DockBandSettings> EndBands { get; init; } = DefaultEndBands;

public bool ShowLabels { get; init; } = true;

Expand All @@ -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<DockBandSettings> startBands,
ImmutableList<DockBandSettings> centerBands,
ImmutableList<DockBandSettings> 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<DockBandSettings>.Empty;
EndBands = endBands ?? DefaultEndBands;
Side = side;
AlwaysOnTop = alwaysOnTop;
Backdrop = backdrop;
CustomThemeColorIntensity = customThemeColorIntensity;
BackgroundImageOpacity = backgroundImageOpacity;
ShowLabels = showLabels;
}
}

/// <summary>
Expand Down
69 changes: 67 additions & 2 deletions src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -94,6 +126,39 @@ public record SettingsModel
// END SETTINGS
///////////////////////////////////////////////////////////////////////////

public SettingsModel()
{
}

[JsonConstructor]
public SettingsModel(
ImmutableDictionary<string, ProviderSettings> providerSettings,
string[] fallbackRanks,
ImmutableDictionary<string, CommandAlias> aliases,
ImmutableList<TopLevelHotkey> 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<string, ProviderSettings>.Empty;
FallbackRanks = fallbackRanks ?? [];
Aliases = aliases ?? ImmutableDictionary<string, CommandAlias>.Empty;
CommandHotkeys = commandHotkeys ?? ImmutableList<TopLevelHotkey>.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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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");
}

/// <summary>
/// Verifies that deserializing empty JSON "{}" produces the same non-null
/// property values as calling <c>new()</c>. 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.
/// </summary>
[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);
}

/// <summary>
/// Deserializes "{}" into <typeparamref name="T"/> and compares every
/// readable instance property against a <c>new()</c> 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).
/// </summary>
private static void AssertDeserializedMatchesConstructor<T>(T constructed, JsonTypeInfo<T> 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<JsonIgnoreAttribute>() 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");
}
}
}
Loading