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

@@ -92,7 +92,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
{
var providerSettings = _settingsService.Settings.GetProviderSettings(provider);
var (_, providerSettings) = _settingsService.Settings.GetProviderSettings(provider);
var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId);
@@ -159,7 +159,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
{
var providerSettings = _settingsService.Settings.GetProviderSettings(provider);
var (_, providerSettings) = _settingsService.Settings.GetProviderSettings(provider);
var isPinnedSubCommand = providerSettings.PinnedCommandIds.Contains(itemId);
if (isPinnedSubCommand)

View File

@@ -186,7 +186,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
_ = _modifierKeysOnEntering.Remove(virtualKey);
}
internalSettings.Win = matchValue;
internalSettings = internalSettings with { Win = matchValue };
break;
case VirtualKey.Control:
case VirtualKey.LeftControl:
@@ -197,7 +197,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
_ = _modifierKeysOnEntering.Remove(VirtualKey.Control);
}
internalSettings.Ctrl = matchValue;
internalSettings = internalSettings with { Ctrl = matchValue };
break;
case VirtualKey.Menu:
case VirtualKey.LeftMenu:
@@ -208,7 +208,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
_ = _modifierKeysOnEntering.Remove(VirtualKey.Menu);
}
internalSettings.Alt = matchValue;
internalSettings = internalSettings with { Alt = matchValue };
break;
case VirtualKey.Shift:
case VirtualKey.LeftShift:
@@ -219,14 +219,14 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
_ = _modifierKeysOnEntering.Remove(VirtualKey.Shift);
}
internalSettings.Shift = matchValue;
internalSettings = internalSettings with { Shift = matchValue };
break;
case VirtualKey.Escape:
internalSettings = new HotkeySettings();
shortcutDialog.IsPrimaryButtonEnabled = false;
return;
default:
internalSettings.Code = matchValueCode;
internalSettings = internalSettings with { Code = matchValueCode };
break;
}
}
@@ -276,7 +276,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
else if (internalSettings.Shift && !_modifierKeysOnEntering.Contains(VirtualKey.Shift) && !internalSettings.Win && !internalSettings.Alt && !internalSettings.Ctrl)
{
// This is to reset the shift key press within the control as it was not used within the control but rather was used to leave the hotkey.
internalSettings.Shift = false;
internalSettings = internalSettings with { Shift = false };
SendSingleKeyboardInput((short)VirtualKey.Shift, (uint)NativeKeyboardHelper.KeyEventF.KeyDown);

View File

@@ -910,8 +910,7 @@ public sealed partial class MainWindow : WindowEx,
// the last non-dock placement because dock sessions intentionally skip updates.
if (_currentWindowPosition.IsSizeValid)
{
settings.LastWindowPosition = _currentWindowPosition;
settingsService.Save();
settingsService.UpdateSettings(s => s with { LastWindowPosition = _currentWindowPosition });
}
}

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 Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
@@ -19,10 +20,13 @@ internal sealed class RunHistoryService : IRunHistoryService
public IReadOnlyList<string> GetRunHistory()
{
if (_appStateService.State.RunHistory.Count == 0)
if (_appStateService.State.RunHistory.IsEmpty)
{
var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory();
_appStateService.State.RunHistory.AddRange(history);
_appStateService.UpdateState(state => state with
{
RunHistory = history.ToImmutableList(),
});
}
return _appStateService.State.RunHistory;
@@ -30,22 +34,24 @@ internal sealed class RunHistoryService : IRunHistoryService
public void ClearRunHistory()
{
_appStateService.State.RunHistory.Clear();
_appStateService.UpdateState(state => state with
{
RunHistory = ImmutableList<string>.Empty,
});
}
public void AddRunHistoryItem(string item)
{
// insert at the beginning of the list
if (string.IsNullOrWhiteSpace(item))
{
return; // Do not add empty or whitespace items
return;
}
_appStateService.State.RunHistory.Remove(item);
// Add the item to the front of the history
_appStateService.State.RunHistory.Insert(0, item);
_appStateService.Save();
_appStateService.UpdateState(state => state with
{
RunHistory = state.RunHistory
.Remove(item)
.Insert(0, item),
});
}
}