CmdPal: Make settings and app state immutable (#46451)

## Summary
 
 This PR refactors CmdPal settings/state to be immutable end-to-end.
 
 ### Core changes
 - Convert model types to immutable records / init-only properties:
   - `SettingsModel`
   - `AppStateModel`
   - `ProviderSettings`
   - `DockSettings`
   - `RecentCommandsManager`
- supporting settings types (fallback/hotkey/alias/top-level
hotkey/history items, etc.)
- Replace mutable collections with immutable equivalents where
appropriate:
   - `ImmutableDictionary<,>`
   - `ImmutableList<>`
 - Move mutation flow to atomic service updates:
- `ISettingsService.UpdateSettings(Func<SettingsModel, SettingsModel>)`
   - `IAppStateService.UpdateState(Func<AppStateModel, AppStateModel>)`
- Update ViewModels/managers/services to use copy-on-write (`with`)
patterns instead of in-place
mutation.
- Update serialization context + tests for immutable model graph
compatibility.
 
 ## Why
 
Issue #46437 is caused by mutable shared state being updated from
different execution paths/threads,
leading to race-prone behavior during persistence/serialization.
 
By making settings/app state immutable and using atomic swap/update
patterns, we remove in-place
mutation and eliminate this class of concurrency bug.
 
 ## Validation
 
 - Built successfully:
   - `Microsoft.CmdPal.UI.ViewModels`
   - `Microsoft.CmdPal.UI`
   - `Microsoft.CmdPal.UI.ViewModels.UnitTests`
 - Updated unit tests for immutable update patterns.
 
 Fixes #46437

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Jolley
2026-03-27 12:54:58 -05:00
committed by GitHub
parent ed47bceac2
commit 4337f8e5ff
34 changed files with 891 additions and 578 deletions

View File

@@ -2,6 +2,7 @@
// 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.Collections.Immutable;
using System.Text.Json.Serialization;
using Windows.UI;
@@ -13,103 +14,93 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// Settings for the Dock. These are settings for _the whole dock_. Band-specific
/// settings are in <see cref="DockBandSettings"/>.
/// </summary>
public class DockSettings
public record DockSettings
{
public DockSide Side { get; set; } = DockSide.Top;
public DockSide Side { get; init; } = DockSide.Top;
public DockSize DockSize { get; set; } = DockSize.Small;
public DockSize DockSize { get; init; } = DockSize.Small;
public DockSize DockIconsSize { get; set; } = DockSize.Small;
public DockSize DockIconsSize { get; init; } = DockSize.Small;
// <Theme settings>
public DockBackdrop Backdrop { get; set; } = DockBackdrop.Acrylic;
public DockBackdrop Backdrop { get; init; } = DockBackdrop.Acrylic;
public UserTheme Theme { get; set; } = UserTheme.Default;
public UserTheme Theme { get; init; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; }
public ColorizationMode ColorizationMode { get; init; }
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 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
public int CustomThemeColorIntensity { get; set; } = 100;
public int CustomThemeColorIntensity { get; init; } = 100;
public int BackgroundImageOpacity { get; set; } = 20;
public int BackgroundImageOpacity { get; init; } = 20;
public int BackgroundImageBlurAmount { get; set; }
public int BackgroundImageBlurAmount { get; init; }
public int BackgroundImageBrightness { get; set; }
public int BackgroundImageBrightness { get; init; }
public BackgroundImageFit BackgroundImageFit { get; set; }
public BackgroundImageFit BackgroundImageFit { get; init; }
public string? BackgroundImagePath { get; set; }
public string? BackgroundImagePath { get; init; }
// </Theme settings>
// public List<string> PinnedCommands { get; set; } = [];
public List<DockBandSettings> StartBands { get; set; } = [];
public List<DockBandSettings> CenterBands { get; set; } = [];
public List<DockBandSettings> EndBands { get; set; } = [];
public bool ShowLabels { get; set; } = true;
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
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()
{
// Initialize with default values
// PinnedCommands = [
// "com.microsoft.cmdpal.winget"
// ];
StartBands.Add(new DockBandSettings
public ImmutableList<DockBandSettings> StartBands { get; init; } = ImmutableList.Create(
new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.core",
CommandId = "com.microsoft.cmdpal.home",
});
StartBands.Add(new DockBandSettings
},
new DockBandSettings
{
ProviderId = "WinGet",
CommandId = "com.microsoft.cmdpal.winget",
ShowLabels = false,
});
EndBands.Add(new DockBandSettings
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",
});
EndBands.Add(new DockBandSettings
},
new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
});
}
public bool ShowLabels { get; init; } = true;
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
}
/// <summary>
/// Settings for a specific dock band. These are per-band settings stored
/// within the overall <see cref="DockSettings"/>.
/// </summary>
public class DockBandSettings
public record DockBandSettings
{
public required string ProviderId { get; set; }
public required string ProviderId { get; init; }
public required string CommandId { get; set; }
public required string CommandId { get; init; }
/// <summary>
/// Gets or sets whether titles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting.
/// </summary>
public bool? ShowTitles { get; set; }
public bool? ShowTitles { get; init; }
/// <summary>
/// Gets or sets whether subtitles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting.
/// </summary>
public bool? ShowSubtitles { get; set; }
public bool? ShowSubtitles { get; init; }
/// <summary>
/// Gets or sets a value for backward compatibility. Maps to ShowTitles.
@@ -118,7 +109,7 @@ public class DockBandSettings
public bool? ShowLabels
{
get => ShowTitles;
set => ShowTitles = value;
init => ShowTitles = value;
}
/// <summary>
@@ -134,17 +125,6 @@ public class DockBandSettings
/// dock-wide setting (passed as <paramref name="defaultValue"/>).
/// </summary>
public bool ResolveShowSubtitles(bool defaultValue) => ShowSubtitles ?? defaultValue;
public DockBandSettings Clone()
{
return new()
{
ProviderId = this.ProviderId,
CommandId = this.CommandId,
ShowTitles = this.ShowTitles,
ShowSubtitles = this.ShowSubtitles,
};
}
}
public enum DockSide