mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-04 10:16:24 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user