mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 17:56:44 +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:
@@ -2,17 +2,15 @@
|
||||
// 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 CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class RecentCommandsManager : ObservableObject, IRecentCommandsManager
|
||||
public record RecentCommandsManager : IRecentCommandsManager
|
||||
{
|
||||
[JsonInclude]
|
||||
internal List<HistoryItem> History { get; set; } = [];
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
internal ImmutableList<HistoryItem> History { get; init; } = ImmutableList<HistoryItem>.Empty;
|
||||
|
||||
public RecentCommandsManager()
|
||||
{
|
||||
@@ -20,64 +18,64 @@ public partial class RecentCommandsManager : ObservableObject, IRecentCommandsMa
|
||||
|
||||
public int GetCommandHistoryWeight(string commandId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = History
|
||||
var entry = History
|
||||
.Index()
|
||||
.Where(item => item.Item.CommandId == commandId)
|
||||
.FirstOrDefault();
|
||||
|
||||
// These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
|
||||
// match after one use.
|
||||
// Usually it has a weight of 84, compared to 109 for the VS cmd prompt
|
||||
if (entry.Item is not null)
|
||||
// These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
|
||||
// match after one use.
|
||||
// Usually it has a weight of 84, compared to 109 for the VS cmd prompt
|
||||
if (entry.Item is not null)
|
||||
{
|
||||
var index = entry.Index;
|
||||
|
||||
// First, add some weight based on how early in the list this appears
|
||||
var bucket = index switch
|
||||
{
|
||||
var index = entry.Index;
|
||||
_ when index <= 2 => 35,
|
||||
_ when index <= 10 => 25,
|
||||
_ when index <= 15 => 15,
|
||||
_ when index <= 35 => 10,
|
||||
_ => 5,
|
||||
};
|
||||
|
||||
// First, add some weight based on how early in the list this appears
|
||||
var bucket = index switch
|
||||
{
|
||||
var i when index <= 2 => 35,
|
||||
var i when index <= 10 => 25,
|
||||
var i when index <= 15 => 15,
|
||||
var i when index <= 35 => 10,
|
||||
_ => 5,
|
||||
};
|
||||
// Then, add weight for how often this is used, but cap the weight from usage.
|
||||
var uses = Math.Min(entry.Item.Uses * 5, 35);
|
||||
|
||||
// Then, add weight for how often this is used, but cap the weight from usage.
|
||||
var uses = Math.Min(entry.Item.Uses * 5, 35);
|
||||
|
||||
return bucket + uses;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return bucket + uses;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void AddHistoryItem(string commandId)
|
||||
/// <summary>
|
||||
/// Returns a new RecentCommandsManager with the given command added/promoted in history.
|
||||
/// Pure function — does not mutate this instance.
|
||||
/// </summary>
|
||||
public RecentCommandsManager WithHistoryItem(string commandId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = History
|
||||
.Where(item => item.CommandId == commandId)
|
||||
.FirstOrDefault();
|
||||
if (entry is null)
|
||||
{
|
||||
var newitem = new HistoryItem() { CommandId = commandId, Uses = 1 };
|
||||
History.Insert(0, newitem);
|
||||
}
|
||||
else
|
||||
{
|
||||
History.Remove(entry);
|
||||
entry.Uses++;
|
||||
History.Insert(0, entry);
|
||||
}
|
||||
var existing = History.FirstOrDefault(item => item.CommandId == commandId);
|
||||
ImmutableList<HistoryItem> newHistory;
|
||||
|
||||
if (History.Count > 50)
|
||||
{
|
||||
History.RemoveRange(50, History.Count - 50);
|
||||
}
|
||||
if (existing is not null)
|
||||
{
|
||||
newHistory = History.Remove(existing);
|
||||
var updated = existing with { Uses = existing.Uses + 1 };
|
||||
newHistory = newHistory.Insert(0, updated);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newItem = new HistoryItem { CommandId = commandId, Uses = 1 };
|
||||
newHistory = History.Insert(0, newItem);
|
||||
}
|
||||
|
||||
if (newHistory.Count > 50)
|
||||
{
|
||||
newHistory = newHistory.RemoveRange(50, newHistory.Count - 50);
|
||||
}
|
||||
|
||||
return this with { History = newHistory };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,5 +83,5 @@ public interface IRecentCommandsManager
|
||||
{
|
||||
int GetCommandHistoryWeight(string commandId);
|
||||
|
||||
void AddHistoryItem(string commandId);
|
||||
RecentCommandsManager WithHistoryItem(string commandId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user