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

@@ -12,7 +12,9 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class FallbackSettingsViewModel : ObservableObject
{
private readonly ISettingsService _settingsService;
private readonly FallbackSettings _fallbackSettings;
private readonly ProviderSettingsViewModel _providerSettingsViewModel;
private FallbackSettings _fallbackSettings;
public string DisplayName { get; private set; } = string.Empty;
@@ -27,15 +29,18 @@ public partial class FallbackSettingsViewModel : ObservableObject
{
if (value != _fallbackSettings.IsEnabled)
{
_fallbackSettings.IsEnabled = value;
var newSettings = _fallbackSettings with { IsEnabled = value };
if (!_fallbackSettings.IsEnabled)
if (!newSettings.IsEnabled)
{
_fallbackSettings.IncludeInGlobalResults = false;
newSettings = newSettings with { IncludeInGlobalResults = false };
}
Save();
_fallbackSettings = newSettings;
_providerSettingsViewModel.UpdateFallbackSettings(Id, _fallbackSettings);
OnPropertyChanged(nameof(IsEnabled));
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
}
}
}
@@ -47,15 +52,18 @@ public partial class FallbackSettingsViewModel : ObservableObject
{
if (value != _fallbackSettings.IncludeInGlobalResults)
{
_fallbackSettings.IncludeInGlobalResults = value;
var newSettings = _fallbackSettings with { IncludeInGlobalResults = value };
if (!_fallbackSettings.IsEnabled)
if (!newSettings.IsEnabled)
{
_fallbackSettings.IsEnabled = true;
newSettings = newSettings with { IsEnabled = true };
}
Save();
_fallbackSettings = newSettings;
_providerSettingsViewModel.UpdateFallbackSettings(Id, _fallbackSettings);
OnPropertyChanged(nameof(IncludeInGlobalResults));
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
}
}
}
@@ -67,6 +75,7 @@ public partial class FallbackSettingsViewModel : ObservableObject
ISettingsService settingsService)
{
_settingsService = settingsService;
_providerSettingsViewModel = providerSettings;
_fallbackSettings = fallbackSettings;
Id = fallback.Id;
@@ -77,10 +86,4 @@ public partial class FallbackSettingsViewModel : ObservableObject
Icon = new(fallback.InitialIcon);
Icon.InitializeProperties();
}
private void Save()
{
_settingsService.Save();
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
}
}