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,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);
}