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,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Messages;
@@ -14,26 +15,32 @@ public partial class AliasManager : ObservableObject
private readonly TopLevelCommandManager _topLevelCommandManager; private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
// REMEMBER, CommandAlias.SearchPrefix is what we use as keys private static readonly ImmutableList<CommandAlias> _defaultAliases = new List<CommandAlias>
private readonly Dictionary<string, CommandAlias> _aliases; {
new CommandAlias(":", "com.microsoft.cmdpal.registry", true),
new CommandAlias("$", "com.microsoft.cmdpal.windowsSettings", true),
new CommandAlias("=", "com.microsoft.cmdpal.calculator", true),
new CommandAlias(">", "com.microsoft.cmdpal.shell", true),
new CommandAlias("<", "com.microsoft.cmdpal.windowwalker", true),
new CommandAlias("??", "com.microsoft.cmdpal.websearch", true),
new CommandAlias("file", "com.microsoft.indexer.fileSearch", false),
new CommandAlias(")", "com.microsoft.cmdpal.timedate", true),
}.ToImmutableList();
public AliasManager(TopLevelCommandManager tlcManager, ISettingsService settingsService) public AliasManager(TopLevelCommandManager tlcManager, ISettingsService settingsService)
{ {
_topLevelCommandManager = tlcManager; _topLevelCommandManager = tlcManager;
_settingsService = settingsService; _settingsService = settingsService;
_aliases = _settingsService.Settings.Aliases;
if (_aliases.Count == 0) if (_settingsService.Settings.Aliases.Count == 0)
{ {
PopulateDefaultAliases(); PopulateDefaultAliases();
} }
} }
private void AddAlias(CommandAlias a) => _aliases.Add(a.SearchPrefix, a);
public bool CheckAlias(string searchText) public bool CheckAlias(string searchText)
{ {
if (_aliases.TryGetValue(searchText, out var alias)) if (_settingsService.Settings.Aliases.TryGetValue(searchText, out var alias))
{ {
try try
{ {
@@ -56,19 +63,18 @@ public partial class AliasManager : ObservableObject
private void PopulateDefaultAliases() private void PopulateDefaultAliases()
{ {
this.AddAlias(new CommandAlias(":", "com.microsoft.cmdpal.registry", true)); _settingsService.UpdateSettings(
this.AddAlias(new CommandAlias("$", "com.microsoft.cmdpal.windowsSettings", true)); s => s with
this.AddAlias(new CommandAlias("=", "com.microsoft.cmdpal.calculator", true)); {
this.AddAlias(new CommandAlias(">", "com.microsoft.cmdpal.shell", true)); Aliases = s.Aliases
this.AddAlias(new CommandAlias("<", "com.microsoft.cmdpal.windowwalker", true)); .AddRange(_defaultAliases.ToDictionary(a => a.SearchPrefix, a => a)),
this.AddAlias(new CommandAlias("??", "com.microsoft.cmdpal.websearch", true)); },
this.AddAlias(new CommandAlias("file", "com.microsoft.indexer.fileSearch", false)); hotReload: false);
this.AddAlias(new CommandAlias(")", "com.microsoft.cmdpal.timedate", true));
} }
public string? KeysFromId(string commandId) public string? KeysFromId(string commandId)
{ {
return _aliases return _settingsService.Settings.Aliases
.Where(kv => kv.Value.CommandId == commandId) .Where(kv => kv.Value.CommandId == commandId)
.Select(kv => kv.Value.Alias) .Select(kv => kv.Value.Alias)
.FirstOrDefault(); .FirstOrDefault();
@@ -76,7 +82,7 @@ public partial class AliasManager : ObservableObject
public CommandAlias? AliasFromId(string commandId) public CommandAlias? AliasFromId(string commandId)
{ {
return _aliases return _settingsService.Settings.Aliases
.Where(kv => kv.Value.CommandId == commandId) .Where(kv => kv.Value.CommandId == commandId)
.Select(kv => kv.Value) .Select(kv => kv.Value)
.FirstOrDefault(); .FirstOrDefault();
@@ -90,9 +96,11 @@ public partial class AliasManager : ObservableObject
return; return;
} }
var aliases = _settingsService.Settings.Aliases;
// If we already have _this exact alias_, do nothing // If we already have _this exact alias_, do nothing
if (newAlias is not null && if (newAlias is not null &&
_aliases.TryGetValue(newAlias.SearchPrefix, out var existingAlias)) aliases.TryGetValue(newAlias.SearchPrefix, out var existingAlias))
{ {
if (existingAlias.CommandId == commandId) if (existingAlias.CommandId == commandId)
{ {
@@ -100,19 +108,19 @@ public partial class AliasManager : ObservableObject
} }
} }
List<CommandAlias> toRemove = []; var keysToRemove = new List<string>();
foreach (var kv in _aliases) foreach (var kv in aliases)
{ {
// Look for the old aliases for the command, and remove it // Look for the old aliases for the command, and remove it
if (kv.Value.CommandId == commandId) if (kv.Value.CommandId == commandId)
{ {
toRemove.Add(kv.Value); keysToRemove.Add(kv.Key);
} }
// Look for the alias belonging to another command, and remove it // Look for the alias belonging to another command, and remove it
if (newAlias is not null && kv.Value.Alias == newAlias.Alias && kv.Value.CommandId != commandId) if (newAlias is not null && kv.Value.Alias == newAlias.Alias && kv.Value.CommandId != commandId)
{ {
toRemove.Add(kv.Value); keysToRemove.Add(kv.Key);
// Remove alias from other TopLevelViewModels it may be assigned to // Remove alias from other TopLevelViewModels it may be assigned to
var topLevelCommand = _topLevelCommandManager.LookupCommand(kv.Value.CommandId); var topLevelCommand = _topLevelCommandManager.LookupCommand(kv.Value.CommandId);
@@ -123,15 +131,16 @@ public partial class AliasManager : ObservableObject
} }
} }
foreach (var alias in toRemove) _settingsService.UpdateSettings(s =>
{ {
// REMEMBER, SearchPrefix is what we use as keys var updatedAliases = s.Aliases.RemoveRange(keysToRemove);
_aliases.Remove(alias.SearchPrefix);
}
if (newAlias is not null) if (newAlias is not null)
{ {
AddAlias(newAlias); updatedAliases = updatedAliases.Add(newAlias.SearchPrefix, newAlias);
} }
return s with { Aliases = updatedAliases };
});
} }
} }

View File

@@ -2,20 +2,18 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization; using System.Collections.Immutable;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public partial class AppStateModel : ObservableObject public record AppStateModel
{ {
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
// STATE HERE // STATE HERE
// Make sure that you make the setters public (JsonSerializer.Deserialize will fail silently otherwise)!
// Make sure that any new types you add are added to JsonSerializationContext! // Make sure that any new types you add are added to JsonSerializationContext!
public RecentCommandsManager RecentCommands { get; set; } = new(); public RecentCommandsManager RecentCommands { get; init; } = new();
public List<string> RunHistory { get; set; } = []; public ImmutableList<string> RunHistory { get; init; } = ImmutableList<string>.Empty;
// END SETTINGS // END SETTINGS
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////

View File

@@ -112,10 +112,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.Theme != value) if (_settingsService.Settings.Theme != value)
{ {
_settingsService.Settings.Theme = value; _settingsService.UpdateSettings(s => s with { Theme = value });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(ThemeIndex)); OnPropertyChanged(nameof(ThemeIndex));
Save(); DebouncedReapply();
} }
} }
} }
@@ -127,7 +127,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.ColorizationMode != value) if (_settingsService.Settings.ColorizationMode != value)
{ {
_settingsService.Settings.ColorizationMode = value; _settingsService.UpdateSettings(s => s with { ColorizationMode = value });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(ColorizationModeIndex)); OnPropertyChanged(nameof(ColorizationModeIndex));
OnPropertyChanged(nameof(IsCustomTintVisible)); OnPropertyChanged(nameof(IsCustomTintVisible));
@@ -146,7 +146,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
IsColorizationDetailsExpanded = value != ColorizationMode.None; IsColorizationDetailsExpanded = value != ColorizationMode.None;
Save(); DebouncedReapply();
} }
} }
} }
@@ -164,7 +164,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.CustomThemeColor != value) if (_settingsService.Settings.CustomThemeColor != value)
{ {
_settingsService.Settings.CustomThemeColor = value; _settingsService.UpdateSettings(s => s with { CustomThemeColor = value });
OnPropertyChanged(); OnPropertyChanged();
@@ -173,7 +173,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
ColorIntensity = 100; ColorIntensity = 100;
} }
Save(); DebouncedReapply();
} }
} }
} }
@@ -183,10 +183,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
get => _settingsService.Settings.CustomThemeColorIntensity; get => _settingsService.Settings.CustomThemeColorIntensity;
set set
{ {
_settingsService.Settings.CustomThemeColorIntensity = value; _settingsService.UpdateSettings(s => s with { CustomThemeColorIntensity = value });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(EffectiveTintIntensity)); OnPropertyChanged(nameof(EffectiveTintIntensity));
Save(); DebouncedReapply();
} }
} }
@@ -195,10 +195,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
get => _settingsService.Settings.BackgroundImageTintIntensity; get => _settingsService.Settings.BackgroundImageTintIntensity;
set set
{ {
_settingsService.Settings.BackgroundImageTintIntensity = value; _settingsService.UpdateSettings(s => s with { BackgroundImageTintIntensity = value });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(EffectiveTintIntensity)); OnPropertyChanged(nameof(EffectiveTintIntensity));
Save(); DebouncedReapply();
} }
} }
@@ -209,7 +209,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.BackgroundImagePath != value) if (_settingsService.Settings.BackgroundImagePath != value)
{ {
_settingsService.Settings.BackgroundImagePath = value; _settingsService.UpdateSettings(s => s with { BackgroundImagePath = value });
OnPropertyChanged(); OnPropertyChanged();
if (BackgroundImageOpacity == 0) if (BackgroundImageOpacity == 0)
@@ -217,7 +217,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
BackgroundImageOpacity = 100; BackgroundImageOpacity = 100;
} }
Save(); DebouncedReapply();
} }
} }
} }
@@ -229,9 +229,9 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.BackgroundImageOpacity != value) if (_settingsService.Settings.BackgroundImageOpacity != value)
{ {
_settingsService.Settings.BackgroundImageOpacity = value; _settingsService.UpdateSettings(s => s with { BackgroundImageOpacity = value });
OnPropertyChanged(); OnPropertyChanged();
Save(); DebouncedReapply();
} }
} }
} }
@@ -243,9 +243,9 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.BackgroundImageBrightness != value) if (_settingsService.Settings.BackgroundImageBrightness != value)
{ {
_settingsService.Settings.BackgroundImageBrightness = value; _settingsService.UpdateSettings(s => s with { BackgroundImageBrightness = value });
OnPropertyChanged(); OnPropertyChanged();
Save(); DebouncedReapply();
} }
} }
} }
@@ -257,9 +257,9 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.BackgroundImageBlurAmount != value) if (_settingsService.Settings.BackgroundImageBlurAmount != value)
{ {
_settingsService.Settings.BackgroundImageBlurAmount = value; _settingsService.UpdateSettings(s => s with { BackgroundImageBlurAmount = value });
OnPropertyChanged(); OnPropertyChanged();
Save(); DebouncedReapply();
} }
} }
} }
@@ -271,10 +271,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.BackgroundImageFit != value) if (_settingsService.Settings.BackgroundImageFit != value)
{ {
_settingsService.Settings.BackgroundImageFit = value; _settingsService.UpdateSettings(s => s with { BackgroundImageFit = value });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(BackgroundImageFitIndex)); OnPropertyChanged(nameof(BackgroundImageFitIndex));
Save(); DebouncedReapply();
} }
} }
} }
@@ -305,11 +305,11 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{ {
if (_settingsService.Settings.BackdropOpacity != value) if (_settingsService.Settings.BackdropOpacity != value)
{ {
_settingsService.Settings.BackdropOpacity = value; _settingsService.UpdateSettings(s => s with { BackdropOpacity = value });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(EffectiveBackdropStyle)); OnPropertyChanged(nameof(EffectiveBackdropStyle));
OnPropertyChanged(nameof(EffectiveImageOpacity)); OnPropertyChanged(nameof(EffectiveImageOpacity));
Save(); DebouncedReapply();
} }
} }
} }
@@ -322,7 +322,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
var newStyle = (BackdropStyle)value; var newStyle = (BackdropStyle)value;
if (_settingsService.Settings.BackdropStyle != newStyle) if (_settingsService.Settings.BackdropStyle != newStyle)
{ {
_settingsService.Settings.BackdropStyle = newStyle; _settingsService.UpdateSettings(s => s with { BackdropStyle = newStyle });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(IsBackdropOpacityVisible)); OnPropertyChanged(nameof(IsBackdropOpacityVisible));
@@ -335,7 +335,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
IsColorizationDetailsExpanded = false; IsColorizationDetailsExpanded = false;
} }
Save(); DebouncedReapply();
} }
} }
} }
@@ -468,9 +468,8 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
} }
private void Save() private void DebouncedReapply()
{ {
_settingsService.Save();
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
} }

View File

@@ -6,13 +6,13 @@ using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public class CommandAlias public record CommandAlias
{ {
public string CommandId { get; set; } public string CommandId { get; init; }
public string Alias { get; set; } public string Alias { get; init; }
public bool IsDirect { get; set; } public bool IsDirect { get; init; }
[JsonIgnore] [JsonIgnore]
public string SearchPrefix => Alias + (IsDirect ? string.Empty : " "); public string SearchPrefix => Alias + (IsDirect ? string.Empty : " ");

View File

@@ -127,7 +127,12 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
private ProviderSettings GetProviderSettings(SettingsModel settings) private ProviderSettings GetProviderSettings(SettingsModel settings)
{ {
return settings.GetProviderSettings(this); if (!settings.ProviderSettings.TryGetValue(ProviderId, out var ps))
{
ps = new ProviderSettings();
}
return ps.WithConnection(this);
} }
public async Task LoadTopLevelCommands(IServiceProvider serviceProvider) public async Task LoadTopLevelCommands(IServiceProvider serviceProvider)
@@ -140,9 +145,26 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
} }
var settingsService = serviceProvider.GetRequiredService<ISettingsService>(); var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var settings = settingsService.Settings; var providerSettings = GetProviderSettings(settingsService.Settings);
// Persist the connected provider settings (fallback commands, etc.)
settingsService.UpdateSettings(
s =>
{
if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
{
ps = new ProviderSettings();
}
var newPs = ps.WithConnection(this);
return s with
{
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
};
},
hotReload: false);
var providerSettings = GetProviderSettings(settings);
IsActive = providerSettings.IsEnabled; IsActive = providerSettings.IsEnabled;
if (!IsActive) if (!IsActive)
{ {
@@ -419,32 +441,59 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
public void PinCommand(string commandId, IServiceProvider serviceProvider) public void PinCommand(string commandId, IServiceProvider serviceProvider)
{ {
var settingsService = serviceProvider.GetRequiredService<ISettingsService>(); var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var settings = settingsService.Settings; var providerSettings = GetProviderSettings(settingsService.Settings);
var providerSettings = GetProviderSettings(settings);
if (!providerSettings.PinnedCommandIds.Contains(commandId)) if (!providerSettings.PinnedCommandIds.Contains(commandId))
{ {
providerSettings.PinnedCommandIds.Add(commandId); settingsService.UpdateSettings(
s =>
{
if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
{
ps = new ProviderSettings();
}
var providerSettings = ps.WithConnection(this);
var newPinned = providerSettings.PinnedCommandIds.Add(commandId);
var newPs = providerSettings with { PinnedCommandIds = newPinned };
return s with
{
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
};
},
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands // Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
settingsService.Save(hotReload: false);
} }
} }
public void UnpinCommand(string commandId, IServiceProvider serviceProvider) public void UnpinCommand(string commandId, IServiceProvider serviceProvider)
{ {
var settingsService = serviceProvider.GetRequiredService<ISettingsService>(); var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var settings = settingsService.Settings;
var providerSettings = GetProviderSettings(settings);
if (providerSettings.PinnedCommandIds.Remove(commandId)) settingsService.UpdateSettings(
{ s =>
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands {
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
{
ps = new ProviderSettings();
}
settingsService.Save(hotReload: false); var providerSettings = ps.WithConnection(this);
} var newPinned = providerSettings.PinnedCommandIds.Remove(commandId);
var newPs = providerSettings with { PinnedCommandIds = newPinned };
return s with
{
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
};
},
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
} }
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null) public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null)
@@ -470,37 +519,47 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
ShowSubtitles = showSubtitles, ShowSubtitles = showSubtitles,
}; };
switch (side) settingsService.UpdateSettings(
{ s =>
case Dock.DockPinSide.Center: {
settings.DockSettings.CenterBands.Add(bandSettings); var dockSettings = s.DockSettings;
break; return s with
case Dock.DockPinSide.End: {
settings.DockSettings.EndBands.Add(bandSettings); DockSettings = side switch
break; {
case Dock.DockPinSide.Start: Dock.DockPinSide.Center => dockSettings with { CenterBands = dockSettings.CenterBands.Add(bandSettings) },
default: Dock.DockPinSide.End => dockSettings with { EndBands = dockSettings.EndBands.Add(bandSettings) },
settings.DockSettings.StartBands.Add(bandSettings); _ => dockSettings with { StartBands = dockSettings.StartBands.Add(bandSettings) },
break; },
} };
},
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands // Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
settingsService.Save(hotReload: false);
} }
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider) public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)
{ {
var settingsService = serviceProvider.GetRequiredService<ISettingsService>(); var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var settings = settingsService.Settings; settingsService.UpdateSettings(
settings.DockSettings.StartBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); s =>
settings.DockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); {
settings.DockSettings.EndBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); var dockSettings = s.DockSettings;
return s with
{
DockSettings = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId),
},
};
},
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands // Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
settingsService.Save(hotReload: false);
} }
public ICommandProviderContext GetProviderContext() => this; public ICommandProviderContext GetProviderContext() => this;

View File

@@ -679,9 +679,10 @@ public sealed partial class MainListPage : DynamicListPage,
public void UpdateHistory(IListItem topLevelOrAppItem) public void UpdateHistory(IListItem topLevelOrAppItem)
{ {
var id = IdForTopLevelOrAppItem(topLevelOrAppItem); var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
var history = _appStateService.State.RecentCommands; _appStateService.UpdateState(state => state with
history.AddHistoryItem(id); {
_appStateService.Save(); RecentCommands = state.RecentCommands.WithHistoryItem(id),
});
} }
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@@ -14,10 +15,11 @@ public partial class DockBandSettingsViewModel : ObservableObject
{ {
private static readonly CompositeFormat PluralItemsFormatString = CompositeFormat.Parse(Properties.Resources.dock_item_count_plural); private static readonly CompositeFormat PluralItemsFormatString = CompositeFormat.Parse(Properties.Resources.dock_item_count_plural);
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly DockBandSettings _dockSettingsModel;
private readonly TopLevelViewModel _adapter; private readonly TopLevelViewModel _adapter;
private readonly DockBandViewModel? _bandViewModel; private readonly DockBandViewModel? _bandViewModel;
private DockBandSettings _dockSettingsModel;
public string Title => _adapter.Title; public string Title => _adapter.Title;
public string Description public string Description
@@ -54,14 +56,14 @@ public partial class DockBandSettingsViewModel : ObservableObject
if (value != _showLabels) if (value != _showLabels)
{ {
_showLabels = value; _showLabels = value;
_dockSettingsModel.ShowLabels = value switch var newShowTitles = value switch
{ {
ShowLabelsOption.Default => null, ShowLabelsOption.Default => (bool?)null,
ShowLabelsOption.ShowLabels => true, ShowLabelsOption.ShowLabels => true,
ShowLabelsOption.HideLabels => false, ShowLabelsOption.HideLabels => false,
_ => null, _ => null,
}; };
Save(); UpdateModel(_dockSettingsModel with { ShowTitles = newShowTitles });
} }
} }
} }
@@ -174,9 +176,38 @@ public partial class DockBandSettingsViewModel : ObservableObject
return bandVm.Items.Count; return bandVm.Items.Count;
} }
private void Save() private void UpdateModel(DockBandSettings newModel)
{ {
_settingsService.Save(); var commandId = _dockSettingsModel.CommandId;
_settingsService.UpdateSettings(
s =>
{
var dockSettings = s.DockSettings;
return s with
{
DockSettings = dockSettings with
{
StartBands = ReplaceInList(dockSettings.StartBands, commandId, newModel),
CenterBands = ReplaceInList(dockSettings.CenterBands, commandId, newModel),
EndBands = ReplaceInList(dockSettings.EndBands, commandId, newModel),
},
};
},
hotReload: false);
_dockSettingsModel = newModel;
}
private static ImmutableList<DockBandSettings> ReplaceInList(ImmutableList<DockBandSettings> list, string commandId, DockBandSettings newModel)
{
for (var i = 0; i < list.Count; i++)
{
if (list[i].CommandId == commandId)
{
return list.SetItem(i, newModel);
}
}
return list;
} }
private void UpdatePinSide(DockPinSide value) private void UpdatePinSide(DockPinSide value)
@@ -189,44 +220,31 @@ public partial class DockBandSettingsViewModel : ObservableObject
public void SetBandPosition(DockPinSide side, int? index) public void SetBandPosition(DockPinSide side, int? index)
{ {
var dockSettings = _settingsService.Settings.DockSettings; var commandId = _dockSettingsModel.CommandId;
// Remove from all sides first _settingsService.UpdateSettings(s =>
dockSettings.StartBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
// Add to the selected side
switch (side)
{ {
case DockPinSide.Start: var dockSettings = s.DockSettings;
{
var insertIndex = index ?? dockSettings.StartBands.Count;
dockSettings.StartBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.Center: // Remove from all sides first
{ var newDock = dockSettings with
var insertIndex = index ?? dockSettings.CenterBands.Count; {
dockSettings.CenterBands.Insert(insertIndex, _dockSettingsModel); StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == commandId),
break; CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId),
} EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == commandId),
};
case DockPinSide.End: // Add to the selected side
{ newDock = side switch
var insertIndex = index ?? dockSettings.EndBands.Count; {
dockSettings.EndBands.Insert(insertIndex, _dockSettingsModel); DockPinSide.Start => newDock with { StartBands = newDock.StartBands.Insert(index ?? newDock.StartBands.Count, _dockSettingsModel) },
break; DockPinSide.Center => newDock with { CenterBands = newDock.CenterBands.Insert(index ?? newDock.CenterBands.Count, _dockSettingsModel) },
} DockPinSide.End => newDock with { EndBands = newDock.EndBands.Insert(index ?? newDock.EndBands.Count, _dockSettingsModel) },
_ => newDock,
};
case DockPinSide.None: return s with { DockSettings = newDock };
default: });
// Do nothing
break;
}
Save();
} }
private void OnPinSideChanged(DockPinSide value) private void OnPinSideChanged(DockPinSide value)

View File

@@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -15,11 +17,11 @@ namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public sealed partial class DockBandViewModel : ExtensionObjectViewModel public sealed partial class DockBandViewModel : ExtensionObjectViewModel
{ {
private readonly CommandItemViewModel _rootItem; private readonly CommandItemViewModel _rootItem;
private readonly DockBandSettings _bandSettings; private readonly ISettingsService _settingsService;
private readonly DockSettings _dockSettings;
private readonly Action _saveSettings;
private readonly IContextMenuFactory _contextMenuFactory; private readonly IContextMenuFactory _contextMenuFactory;
private DockBandSettings _bandSettings;
public ObservableCollection<DockItemViewModel> Items { get; } = new(); public ObservableCollection<DockItemViewModel> Items { get; } = new();
private bool _showTitles = true; private bool _showTitles = true;
@@ -103,8 +105,7 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
/// </summary> /// </summary>
internal void SaveShowLabels() internal void SaveShowLabels()
{ {
_bandSettings.ShowTitles = _showTitles; ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
_bandSettings.ShowSubtitles = _showSubtitles;
_showTitlesSnapshot = null; _showTitlesSnapshot = null;
_showSubtitlesSnapshot = null; _showSubtitlesSnapshot = null;
} }
@@ -127,21 +128,54 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
} }
} }
private void ReplaceBandInSettings(DockBandSettings newSettings)
{
var commandId = _bandSettings.CommandId;
_settingsService.UpdateSettings(
s =>
{
var dockSettings = s.DockSettings;
return s with
{
DockSettings = dockSettings with
{
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
},
};
},
false);
_bandSettings = newSettings;
}
private static ImmutableList<DockBandSettings> ReplaceBandInList(ImmutableList<DockBandSettings> list, string commandId, DockBandSettings newSettings)
{
for (var i = 0; i < list.Count; i++)
{
if (list[i].CommandId == commandId)
{
return list.SetItem(i, newSettings);
}
}
return list;
}
internal DockBandViewModel( internal DockBandViewModel(
CommandItemViewModel commandItemViewModel, CommandItemViewModel commandItemViewModel,
WeakReference<IPageContext> errorContext, WeakReference<IPageContext> errorContext,
DockBandSettings settings, DockBandSettings settings,
DockSettings dockSettings, ISettingsService settingsService,
Action saveSettings,
IContextMenuFactory contextMenuFactory) IContextMenuFactory contextMenuFactory)
: base(errorContext) : base(errorContext)
{ {
_rootItem = commandItemViewModel; _rootItem = commandItemViewModel;
_bandSettings = settings; _bandSettings = settings;
_dockSettings = dockSettings; _settingsService = settingsService;
_saveSettings = saveSettings;
_contextMenuFactory = contextMenuFactory; _contextMenuFactory = contextMenuFactory;
var dockSettings = settingsService.Settings.DockSettings;
_showTitles = settings.ResolveShowTitles(dockSettings.ShowLabels); _showTitles = settings.ResolveShowTitles(dockSettings.ShowLabels);
_showSubtitles = settings.ResolveShowSubtitles(dockSettings.ShowLabels); _showSubtitles = settings.ResolveShowSubtitles(dockSettings.ShowLabels);
} }

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon; using ManagedCommon;
@@ -80,7 +81,7 @@ public sealed partial class DockViewModel
} }
private void SetupBands( private void SetupBands(
List<DockBandSettings> bands, ImmutableList<DockBandSettings> bands,
ObservableCollection<DockBandViewModel> target) ObservableCollection<DockBandViewModel> target)
{ {
List<DockBandViewModel> newBands = new(); List<DockBandViewModel> newBands = new();
@@ -148,7 +149,7 @@ public sealed partial class DockViewModel
DockBandSettings bandSettings, DockBandSettings bandSettings,
CommandItemViewModel commandItem) CommandItemViewModel commandItem)
{ {
DockBandViewModel band = new(commandItem, commandItem.PageContext, bandSettings, _settings, SaveSettings, _contextMenuFactory); DockBandViewModel band = new(commandItem, commandItem.PageContext, bandSettings, _settingsService, _contextMenuFactory);
// the band is NOT initialized here! // the band is NOT initialized here!
return band; return band;
@@ -156,7 +157,7 @@ public sealed partial class DockViewModel
private void SaveSettings() private void SaveSettings()
{ {
_settingsService.Save(); _settingsService.UpdateSettings(s => s with { DockSettings = _settings });
} }
public DockBandViewModel? FindBandByTopLevel(TopLevelViewModel tlc) public DockBandViewModel? FindBandByTopLevel(TopLevelViewModel tlc)
@@ -201,7 +202,7 @@ public sealed partial class DockViewModel
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex) public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{ {
var bandId = band.Id; var bandId = band.Id;
var dockSettings = _settingsService.Settings.DockSettings; var dockSettings = _settings;
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId) var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId) ?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
@@ -213,20 +214,30 @@ public sealed partial class DockViewModel
} }
// Remove from all settings lists // Remove from all settings lists
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId); var newDock = dockSettings with
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId); {
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId); StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
// Add to target settings list at the correct index // Add to target settings list at the correct index
var targetSettings = targetSide switch var targetList = targetSide switch
{ {
DockPinSide.Start => dockSettings.StartBands, DockPinSide.Start => newDock.StartBands,
DockPinSide.Center => dockSettings.CenterBands, DockPinSide.Center => newDock.CenterBands,
DockPinSide.End => dockSettings.EndBands, DockPinSide.End => newDock.EndBands,
_ => dockSettings.StartBands, _ => newDock.StartBands,
}; };
var insertIndex = Math.Min(targetIndex, targetSettings.Count); var insertIndex = Math.Min(targetIndex, targetList.Count);
targetSettings.Insert(insertIndex, bandSettings); newDock = targetSide switch
{
DockPinSide.Start => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
DockPinSide.Center => newDock with { CenterBands = targetList.Insert(insertIndex, bandSettings) },
DockPinSide.End => newDock with { EndBands = targetList.Insert(insertIndex, bandSettings) },
_ => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
};
_settings = newDock;
} }
/// <summary> /// <summary>
@@ -236,7 +247,7 @@ public sealed partial class DockViewModel
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex) public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{ {
var bandId = band.Id; var bandId = band.Id;
var dockSettings = _settingsService.Settings.DockSettings; var dockSettings = _settings;
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId) var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId) ?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
@@ -248,10 +259,15 @@ public sealed partial class DockViewModel
return; return;
} }
// Remove from all sides (settings and UI) // Remove from all sides (settings)
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId); var newDock = dockSettings with
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId); {
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId); StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
// Remove from UI collections
StartItems.Remove(band); StartItems.Remove(band);
CenterItems.Remove(band); CenterItems.Remove(band);
EndItems.Remove(band); EndItems.Remove(band);
@@ -261,8 +277,8 @@ public sealed partial class DockViewModel
{ {
case DockPinSide.Start: case DockPinSide.Start:
{ {
var settingsIndex = Math.Min(targetIndex, dockSettings.StartBands.Count); var settingsIndex = Math.Min(targetIndex, newDock.StartBands.Count);
dockSettings.StartBands.Insert(settingsIndex, bandSettings); newDock = newDock with { StartBands = newDock.StartBands.Insert(settingsIndex, bandSettings) };
var uiIndex = Math.Min(targetIndex, StartItems.Count); var uiIndex = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIndex, band); StartItems.Insert(uiIndex, band);
@@ -271,8 +287,8 @@ public sealed partial class DockViewModel
case DockPinSide.Center: case DockPinSide.Center:
{ {
var settingsIndex = Math.Min(targetIndex, dockSettings.CenterBands.Count); var settingsIndex = Math.Min(targetIndex, newDock.CenterBands.Count);
dockSettings.CenterBands.Insert(settingsIndex, bandSettings); newDock = newDock with { CenterBands = newDock.CenterBands.Insert(settingsIndex, bandSettings) };
var uiIndex = Math.Min(targetIndex, CenterItems.Count); var uiIndex = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIndex, band); CenterItems.Insert(uiIndex, band);
@@ -281,8 +297,8 @@ public sealed partial class DockViewModel
case DockPinSide.End: case DockPinSide.End:
{ {
var settingsIndex = Math.Min(targetIndex, dockSettings.EndBands.Count); var settingsIndex = Math.Min(targetIndex, newDock.EndBands.Count);
dockSettings.EndBands.Insert(settingsIndex, bandSettings); newDock = newDock with { EndBands = newDock.EndBands.Insert(settingsIndex, bandSettings) };
var uiIndex = Math.Min(targetIndex, EndItems.Count); var uiIndex = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIndex, band); EndItems.Insert(uiIndex, band);
@@ -290,6 +306,8 @@ public sealed partial class DockViewModel
} }
} }
_settings = newDock;
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)"); Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
} }
@@ -305,22 +323,57 @@ public sealed partial class DockViewModel
band.SaveShowLabels(); band.SaveShowLabels();
} }
_snapshotStartBands = null; // Preserve any per-band label edits made while in edit mode. Those edits are
_snapshotCenterBands = null; // saved independently of reorder, so merge the latest band settings back into
_snapshotEndBands = null; // the local reordered snapshot before we persist dock settings.
var latestBandSettings = BuildBandSettingsLookup(_settingsService.Settings.DockSettings);
_settings = _settings with
{
StartBands = MergeBandSettings(_settings.StartBands, latestBandSettings),
CenterBands = MergeBandSettings(_settings.CenterBands, latestBandSettings),
EndBands = MergeBandSettings(_settings.EndBands, latestBandSettings),
};
_snapshotDockSettings = null;
_snapshotBandViewModels = null; _snapshotBandViewModels = null;
// Save without hotReload to avoid triggering SettingsChanged → SetupBands, // Save without hotReload to avoid triggering SettingsChanged → SetupBands,
// which could race with stale DockBands_CollectionChanged work items and // which could race with stale DockBands_CollectionChanged work items and
// re-add bands that were just unpinned. // re-add bands that were just unpinned.
_settingsService.Save(hotReload: false); _settingsService.UpdateSettings(s => s with { DockSettings = _settings }, false);
_isEditing = false; _isEditing = false;
Logger.LogDebug("Saved band order to settings"); Logger.LogDebug("Saved band order to settings");
} }
private List<DockBandSettings>? _snapshotStartBands; private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(DockSettings dockSettings)
private List<DockBandSettings>? _snapshotCenterBands; {
private List<DockBandSettings>? _snapshotEndBands; var lookup = new Dictionary<string, DockBandSettings>(StringComparer.Ordinal);
foreach (var band in dockSettings.StartBands.Concat(dockSettings.CenterBands).Concat(dockSettings.EndBands))
{
lookup[band.CommandId] = band;
}
return lookup;
}
private static ImmutableList<DockBandSettings> MergeBandSettings(
ImmutableList<DockBandSettings> targetBands,
IReadOnlyDictionary<string, DockBandSettings> latestBandSettings)
{
var merged = targetBands;
for (var i = 0; i < merged.Count; i++)
{
var commandId = merged[i].CommandId;
if (latestBandSettings.TryGetValue(commandId, out var latestSettings))
{
merged = merged.SetItem(i, latestSettings);
}
}
return merged;
}
private DockSettings? _snapshotDockSettings;
private Dictionary<string, DockBandViewModel>? _snapshotBandViewModels; private Dictionary<string, DockBandViewModel>? _snapshotBandViewModels;
/// <summary> /// <summary>
@@ -332,12 +385,14 @@ public sealed partial class DockViewModel
_isEditing = true; _isEditing = true;
var dockSettings = _settingsService.Settings.DockSettings; var dockSettings = _settingsService.Settings.DockSettings;
_snapshotStartBands = dockSettings.StartBands.Select(b => b.Clone()).ToList();
_snapshotCenterBands = dockSettings.CenterBands.Select(b => b.Clone()).ToList(); var snapshotStartBandsCount = dockSettings.StartBands.Count;
_snapshotEndBands = dockSettings.EndBands.Select(b => b.Clone()).ToList(); var snapshotCenterBandsCount = dockSettings.CenterBands.Count;
var snapshotEndBandsCount = dockSettings.EndBands.Count;
// Snapshot band ViewModels so we can restore unpinned bands // Snapshot band ViewModels so we can restore unpinned bands
// Use a dictionary but handle potential duplicates gracefully // Use a dictionary but handle potential duplicates gracefully
_snapshotDockSettings = dockSettings;
_snapshotBandViewModels = new Dictionary<string, DockBandViewModel>(); _snapshotBandViewModels = new Dictionary<string, DockBandViewModel>();
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems)) foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
{ {
@@ -350,7 +405,7 @@ public sealed partial class DockViewModel
band.SnapshotShowLabels(); band.SnapshotShowLabels();
} }
Logger.LogDebug($"Snapshot taken: {_snapshotStartBands.Count} start bands, {_snapshotCenterBands.Count} center bands, {_snapshotEndBands.Count} end bands"); Logger.LogDebug($"Snapshot taken: {snapshotStartBandsCount} start bands, {snapshotCenterBandsCount} center bands, {snapshotEndBandsCount} end bands");
} }
/// <summary> /// <summary>
@@ -359,9 +414,7 @@ public sealed partial class DockViewModel
/// </summary> /// </summary>
public void RestoreBandOrder() public void RestoreBandOrder()
{ {
if (_snapshotStartBands == null || if (_snapshotDockSettings == null || _snapshotBandViewModels == null)
_snapshotCenterBands == null ||
_snapshotEndBands == null || _snapshotBandViewModels == null)
{ {
Logger.LogWarning("No snapshot to restore from"); Logger.LogWarning("No snapshot to restore from");
return; return;
@@ -373,37 +426,13 @@ public sealed partial class DockViewModel
band.RestoreShowLabels(); band.RestoreShowLabels();
} }
var dockSettings = _settingsService.Settings.DockSettings; // Restore settings from snapshot (immutable = just assign back)
_settings = _snapshotDockSettings;
// Restore settings from snapshot
dockSettings.StartBands.Clear();
dockSettings.CenterBands.Clear();
dockSettings.EndBands.Clear();
foreach (var bandSnapshot in _snapshotStartBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.StartBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotCenterBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.CenterBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotEndBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.EndBands.Add(bandSettings);
}
// Rebuild UI collections from restored settings using the snapshotted ViewModels // Rebuild UI collections from restored settings using the snapshotted ViewModels
RebuildUICollectionsFromSnapshot(); RebuildUICollectionsFromSnapshot();
_snapshotStartBands = null; _snapshotDockSettings = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotBandViewModels = null; _snapshotBandViewModels = null;
_isEditing = false; _isEditing = false;
Logger.LogDebug("Restored band order from snapshot"); Logger.LogDebug("Restored band order from snapshot");
@@ -416,7 +445,7 @@ public sealed partial class DockViewModel
return; return;
} }
var dockSettings = _settingsService.Settings.DockSettings; var dockSettings = _settings;
StartItems.Clear(); StartItems.Clear();
CenterItems.Clear(); CenterItems.Clear();
@@ -449,7 +478,7 @@ public sealed partial class DockViewModel
private void RebuildUICollections() private void RebuildUICollections()
{ {
var dockSettings = _settingsService.Settings.DockSettings; var dockSettings = _settings;
// Create a lookup of all current band ViewModels // Create a lookup of all current band ViewModels
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id); var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
@@ -526,7 +555,7 @@ public sealed partial class DockViewModel
// Create settings for the new band // Create settings for the new band
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId, ShowLabels = null }; var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId, ShowLabels = null };
var dockSettings = _settingsService.Settings.DockSettings; var dockSettings = _settings;
// Create the band view model // Create the band view model
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel); var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
@@ -535,15 +564,15 @@ public sealed partial class DockViewModel
switch (targetSide) switch (targetSide)
{ {
case DockPinSide.Start: case DockPinSide.Start:
dockSettings.StartBands.Add(bandSettings); _settings = dockSettings with { StartBands = dockSettings.StartBands.Add(bandSettings) };
StartItems.Add(bandVm); StartItems.Add(bandVm);
break; break;
case DockPinSide.Center: case DockPinSide.Center:
dockSettings.CenterBands.Add(bandSettings); _settings = dockSettings with { CenterBands = dockSettings.CenterBands.Add(bandSettings) };
CenterItems.Add(bandVm); CenterItems.Add(bandVm);
break; break;
case DockPinSide.End: case DockPinSide.End:
dockSettings.EndBands.Add(bandSettings); _settings = dockSettings with { EndBands = dockSettings.EndBands.Add(bandSettings) };
EndItems.Add(bandVm); EndItems.Add(bandVm);
break; break;
} }
@@ -566,12 +595,15 @@ public sealed partial class DockViewModel
public void UnpinBand(DockBandViewModel band) public void UnpinBand(DockBandViewModel band)
{ {
var bandId = band.Id; var bandId = band.Id;
var dockSettings = _settingsService.Settings.DockSettings; var dockSettings = _settings;
// Remove from settings // Remove from settings
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId); _settings = dockSettings with
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId); {
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId); StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
// Remove from UI collections // Remove from UI collections
StartItems.Remove(band); StartItems.Remove(band);
@@ -635,7 +667,7 @@ public sealed partial class DockViewModel
var isDockEnabled = _settingsService.Settings.EnableDock; var isDockEnabled = _settingsService.Settings.EnableDock;
var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none"; var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none";
static string FormatBands(List<DockBandSettings> bands) => static string FormatBands(ImmutableList<DockBandSettings> bands) =>
string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}")); string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}"));
var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty; var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty;

View File

@@ -47,10 +47,10 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.Theme != value) if (_settingsService.Settings.DockSettings.Theme != value)
{ {
_settingsService.Settings.DockSettings.Theme = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { Theme = value } });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(ThemeIndex)); OnPropertyChanged(nameof(ThemeIndex));
Save(); DebouncedReapply();
} }
} }
} }
@@ -68,10 +68,10 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.Backdrop != value) if (_settingsService.Settings.DockSettings.Backdrop != value)
{ {
_settingsService.Settings.DockSettings.Backdrop = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { Backdrop = value } });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(BackdropIndex)); OnPropertyChanged(nameof(BackdropIndex));
Save(); DebouncedReapply();
} }
} }
} }
@@ -83,7 +83,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.ColorizationMode != value) if (_settingsService.Settings.DockSettings.ColorizationMode != value)
{ {
_settingsService.Settings.DockSettings.ColorizationMode = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { ColorizationMode = value } });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(ColorizationModeIndex)); OnPropertyChanged(nameof(ColorizationModeIndex));
OnPropertyChanged(nameof(IsCustomTintVisible)); OnPropertyChanged(nameof(IsCustomTintVisible));
@@ -99,7 +99,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
IsColorizationDetailsExpanded = value != ColorizationMode.None; IsColorizationDetailsExpanded = value != ColorizationMode.None;
Save(); DebouncedReapply();
} }
} }
} }
@@ -117,7 +117,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.CustomThemeColor != value) if (_settingsService.Settings.DockSettings.CustomThemeColor != value)
{ {
_settingsService.Settings.DockSettings.CustomThemeColor = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { CustomThemeColor = value } });
OnPropertyChanged(); OnPropertyChanged();
@@ -126,7 +126,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
ColorIntensity = 100; ColorIntensity = 100;
} }
Save(); DebouncedReapply();
} }
} }
} }
@@ -136,9 +136,9 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
get => _settingsService.Settings.DockSettings.CustomThemeColorIntensity; get => _settingsService.Settings.DockSettings.CustomThemeColorIntensity;
set set
{ {
_settingsService.Settings.DockSettings.CustomThemeColorIntensity = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { CustomThemeColorIntensity = value } });
OnPropertyChanged(); OnPropertyChanged();
Save(); DebouncedReapply();
} }
} }
@@ -149,7 +149,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.BackgroundImagePath != value) if (_settingsService.Settings.DockSettings.BackgroundImagePath != value)
{ {
_settingsService.Settings.DockSettings.BackgroundImagePath = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImagePath = value } });
OnPropertyChanged(); OnPropertyChanged();
if (BackgroundImageOpacity == 0) if (BackgroundImageOpacity == 0)
@@ -157,7 +157,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
BackgroundImageOpacity = 100; BackgroundImageOpacity = 100;
} }
Save(); DebouncedReapply();
} }
} }
} }
@@ -169,9 +169,9 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.BackgroundImageOpacity != value) if (_settingsService.Settings.DockSettings.BackgroundImageOpacity != value)
{ {
_settingsService.Settings.DockSettings.BackgroundImageOpacity = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImageOpacity = value } });
OnPropertyChanged(); OnPropertyChanged();
Save(); DebouncedReapply();
} }
} }
} }
@@ -183,9 +183,9 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.BackgroundImageBrightness != value) if (_settingsService.Settings.DockSettings.BackgroundImageBrightness != value)
{ {
_settingsService.Settings.DockSettings.BackgroundImageBrightness = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImageBrightness = value } });
OnPropertyChanged(); OnPropertyChanged();
Save(); DebouncedReapply();
} }
} }
} }
@@ -197,9 +197,9 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.BackgroundImageBlurAmount != value) if (_settingsService.Settings.DockSettings.BackgroundImageBlurAmount != value)
{ {
_settingsService.Settings.DockSettings.BackgroundImageBlurAmount = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImageBlurAmount = value } });
OnPropertyChanged(); OnPropertyChanged();
Save(); DebouncedReapply();
} }
} }
} }
@@ -211,10 +211,10 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
{ {
if (_settingsService.Settings.DockSettings.BackgroundImageFit != value) if (_settingsService.Settings.DockSettings.BackgroundImageFit != value)
{ {
_settingsService.Settings.DockSettings.BackgroundImageFit = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImageFit = value } });
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(BackgroundImageFitIndex)); OnPropertyChanged(nameof(BackgroundImageFitIndex));
Save(); DebouncedReapply();
} }
} }
} }
@@ -298,9 +298,8 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
} }
private void Save() private void DebouncedReapply()
{ {
_settingsService.Save();
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
} }

View File

@@ -6,11 +6,11 @@ using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public class FallbackSettings public record FallbackSettings
{ {
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; init; } = true;
public bool IncludeInGlobalResults { get; set; } public bool IncludeInGlobalResults { get; init; }
public FallbackSettings() public FallbackSettings()
{ {

View File

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

View File

@@ -8,7 +8,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public record HistoryItem public record HistoryItem
{ {
public required string CommandId { get; set; } public required string CommandId { get; init; }
public required int Uses { get; set; } public required int Uses { get; init; }
} }

View File

@@ -11,36 +11,28 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class HotkeyManager : ObservableObject public partial class HotkeyManager : ObservableObject
{ {
private readonly TopLevelCommandManager _topLevelCommandManager; private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly List<TopLevelHotkey> _commandHotkeys; private readonly ISettingsService _settingsService;
public HotkeyManager(TopLevelCommandManager tlcManager, ISettingsService settingsService) public HotkeyManager(TopLevelCommandManager tlcManager, ISettingsService settingsService)
{ {
_topLevelCommandManager = tlcManager; _topLevelCommandManager = tlcManager;
_commandHotkeys = settingsService.Settings.CommandHotkeys; _settingsService = settingsService;
} }
public void UpdateHotkey(string commandId, HotkeySettings? hotkey) public void UpdateHotkey(string commandId, HotkeySettings? hotkey)
{ {
// If any of the commands were already bound to this hotkey, remove that _settingsService.UpdateSettings(s =>
foreach (var item in _commandHotkeys)
{ {
if (item.Hotkey == hotkey) // Remove any command already bound to this hotkey, and remove old binding for this command
var hotkeys = s.CommandHotkeys
.RemoveAll(item => item.Hotkey == hotkey || item.CommandId == commandId);
if (hotkey is not null)
{ {
item.Hotkey = null; hotkeys = hotkeys.Add(new(hotkey, commandId));
} }
}
_commandHotkeys.RemoveAll(item => item.Hotkey is null); return s with { CommandHotkeys = hotkeys };
});
foreach (var item in _commandHotkeys)
{
if (item.CommandId == commandId)
{
_commandHotkeys.Remove(item);
break;
}
}
_commandHotkeys.Add(new(hotkey, commandId));
} }
} }

View File

@@ -2,37 +2,39 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public class ProviderSettings public record ProviderSettings
{ {
// List of built-in fallbacks that should not have global results enabled by default // List of built-in fallbacks that should not have global results enabled by default
private readonly string[] _excludedBuiltInFallbacks = [ private static readonly string[] _excludedBuiltInFallbacks = [
"com.microsoft.cmdpal.builtin.indexer.fallback", "com.microsoft.cmdpal.builtin.indexer.fallback",
"com.microsoft.cmdpal.builtin.calculator.fallback", "com.microsoft.cmdpal.builtin.calculator.fallback",
"com.microsoft.cmdpal.builtin.remotedesktop.fallback", "com.microsoft.cmdpal.builtin.remotedesktop.fallback",
]; ];
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; init; } = true;
public Dictionary<string, FallbackSettings> FallbackCommands { get; set; } = new(); public ImmutableDictionary<string, FallbackSettings> FallbackCommands { get; init; }
= ImmutableDictionary<string, FallbackSettings>.Empty;
public List<string> PinnedCommandIds { get; set; } = []; public ImmutableList<string> PinnedCommandIds { get; init; }
= ImmutableList<string>.Empty;
[JsonIgnore] [JsonIgnore]
public string ProviderDisplayName { get; set; } = string.Empty; public string ProviderId { get; init; } = string.Empty;
[JsonIgnore] [JsonIgnore]
public string ProviderId { get; private set; } = string.Empty; public bool IsBuiltin { get; init; }
[JsonIgnore] [JsonIgnore]
public bool IsBuiltin { get; private set; } public string ProviderDisplayName { get; init; } = string.Empty;
public ProviderSettings(CommandProviderWrapper wrapper) public ProviderSettings()
{ {
Connect(wrapper);
} }
[JsonConstructor] [JsonConstructor]
@@ -41,28 +43,51 @@ public class ProviderSettings
IsEnabled = isEnabled; IsEnabled = isEnabled;
} }
public void Connect(CommandProviderWrapper wrapper) /// <summary>
/// Returns a new ProviderSettings connected to the given wrapper.
/// Returns <see langword="this"/> when the connection produces no changes.
/// Pure function — does not mutate this instance.
/// </summary>
public ProviderSettings WithConnection(CommandProviderWrapper wrapper)
{ {
ProviderId = wrapper.ProviderId; if (string.IsNullOrWhiteSpace(wrapper.ProviderId))
IsBuiltin = wrapper.Extension is null; {
throw new ArgumentException("ProviderId must not be null, empty, or whitespace.", nameof(wrapper));
ProviderDisplayName = wrapper.DisplayName; }
var changed = false;
var builder = FallbackCommands.ToBuilder();
if (wrapper.FallbackItems.Length > 0) if (wrapper.FallbackItems.Length > 0)
{ {
foreach (var fallback in wrapper.FallbackItems) foreach (var fallback in wrapper.FallbackItems)
{ {
if (!FallbackCommands.ContainsKey(fallback.Id)) if (!string.IsNullOrEmpty(fallback.Id) && !builder.ContainsKey(fallback.Id))
{ {
var enableGlobalResults = IsBuiltin && !_excludedBuiltInFallbacks.Contains(fallback.Id); var enableGlobalResults = (wrapper.Extension is null)
FallbackCommands[fallback.Id] = new FallbackSettings(enableGlobalResults); && !_excludedBuiltInFallbacks.Contains(fallback.Id);
builder[fallback.Id] = new FallbackSettings(enableGlobalResults);
changed = true;
} }
} }
} }
if (string.IsNullOrEmpty(ProviderId)) var isBuiltin = wrapper.Extension is null;
// If nothing changed, return the same instance to avoid unnecessary allocations and saves
if (!changed
&& ProviderId == wrapper.ProviderId
&& IsBuiltin == isBuiltin
&& ProviderDisplayName == wrapper.DisplayName)
{ {
throw new InvalidDataException("Did you add a built-in command and forget to set the Id? Make sure you do that!"); return this;
} }
return this with
{
ProviderId = wrapper.ProviderId,
IsBuiltin = isBuiltin,
ProviderDisplayName = wrapper.DisplayName,
FallbackCommands = changed ? builder.ToImmutable() : FallbackCommands,
};
} }
} }

View File

@@ -22,10 +22,11 @@ public partial class ProviderSettingsViewModel : ObservableObject
private static readonly CompositeFormat ExtensionSubtextDisabledFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_disabled); private static readonly CompositeFormat ExtensionSubtextDisabledFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_disabled);
private readonly CommandProviderWrapper _provider; private readonly CommandProviderWrapper _provider;
private readonly ProviderSettings _providerSettings;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly Lock _initializeSettingsLock = new(); private readonly Lock _initializeSettingsLock = new();
private ProviderSettings _providerSettings;
private Task? _initializeSettingsTask; private Task? _initializeSettingsTask;
public ProviderSettingsViewModel( public ProviderSettingsViewModel(
@@ -71,8 +72,13 @@ public partial class ProviderSettingsViewModel : ObservableObject
{ {
if (value != _providerSettings.IsEnabled) if (value != _providerSettings.IsEnabled)
{ {
_providerSettings.IsEnabled = value; var newSettings = _providerSettings with { IsEnabled = value };
Save(); _settingsService.UpdateSettings(s => s with
{
ProviderSettings = s.ProviderSettings.SetItem(_provider.ProviderId, newSettings),
});
_providerSettings = newSettings;
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new()); WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(IsEnabled));
OnPropertyChanged(nameof(ExtensionSubtext)); OnPropertyChanged(nameof(ExtensionSubtext));
@@ -191,7 +197,20 @@ public partial class ProviderSettingsViewModel : ObservableObject
FallbackCommands = fallbackViewModels; FallbackCommands = fallbackViewModels;
} }
private void Save() => _settingsService.Save(); internal void UpdateFallbackSettings(string id, FallbackSettings settings)
{
var newProviderSettings = _providerSettings with
{
FallbackCommands = _providerSettings.FallbackCommands.SetItem(id, settings),
};
_providerSettings = newProviderSettings;
_settingsService.UpdateSettings(
s => s with
{
ProviderSettings = s.ProviderSettings.SetItem(_provider.ProviderId, newProviderSettings),
},
hotReload: false);
}
private void InitializeSettingsPage() private void InitializeSettingsPage()
{ {

View File

@@ -2,17 +2,15 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public partial class RecentCommandsManager : ObservableObject, IRecentCommandsManager public record RecentCommandsManager : IRecentCommandsManager
{ {
[JsonInclude] [JsonInclude]
internal List<HistoryItem> History { get; set; } = []; internal ImmutableList<HistoryItem> History { get; init; } = ImmutableList<HistoryItem>.Empty;
private readonly Lock _lock = new();
public RecentCommandsManager() public RecentCommandsManager()
{ {
@@ -20,64 +18,64 @@ public partial class RecentCommandsManager : ObservableObject, IRecentCommandsMa
public int GetCommandHistoryWeight(string commandId) public int GetCommandHistoryWeight(string commandId)
{ {
lock (_lock) var entry = History
{
var entry = History
.Index() .Index()
.Where(item => item.Item.CommandId == commandId) .Where(item => item.Item.CommandId == commandId)
.FirstOrDefault(); .FirstOrDefault();
// These numbers are vaguely scaled so that "VS" will make "Visual Studio" the // These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
// match after one use. // match after one use.
// Usually it has a weight of 84, compared to 109 for the VS cmd prompt // Usually it has a weight of 84, compared to 109 for the VS cmd prompt
if (entry.Item is not null) 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 // Then, add weight for how often this is used, but cap the weight from usage.
var bucket = index switch var uses = Math.Min(entry.Item.Uses * 5, 35);
{
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. return bucket + uses;
var uses = Math.Min(entry.Item.Uses * 5, 35);
return bucket + uses;
}
return 0;
} }
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 existing = History.FirstOrDefault(item => item.CommandId == commandId);
{ ImmutableList<HistoryItem> newHistory;
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);
}
if (History.Count > 50) if (existing is not null)
{ {
History.RemoveRange(50, History.Count - 50); 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); int GetCommandHistoryWeight(string commandId);
void AddHistoryItem(string commandId); RecentCommandsManager WithHistoryItem(string commandId);
} }

View File

@@ -22,20 +22,35 @@ public sealed class AppStateService : IAppStateService
_persistence = persistence; _persistence = persistence;
_appInfoService = appInfoService; _appInfoService = appInfoService;
_filePath = StateJsonPath(); _filePath = StateJsonPath();
State = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel); _state = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel);
} }
private AppStateModel _state;
/// <inheritdoc/> /// <inheritdoc/>
public AppStateModel State { get; private set; } public AppStateModel State => Volatile.Read(ref _state);
/// <inheritdoc/> /// <inheritdoc/>
public event TypedEventHandler<IAppStateService, AppStateModel>? StateChanged; public event TypedEventHandler<IAppStateService, AppStateModel>? StateChanged;
/// <inheritdoc/> /// <inheritdoc/>
public void Save() public void Save() => UpdateState(s => s);
/// <inheritdoc/>
public void UpdateState(Func<AppStateModel, AppStateModel> transform)
{ {
_persistence.Save(State, _filePath, JsonSerializationContext.Default.AppStateModel); AppStateModel snapshot;
StateChanged?.Invoke(this, State); AppStateModel updated;
do
{
snapshot = Volatile.Read(ref _state);
updated = transform(snapshot);
}
while (Interlocked.CompareExchange(ref _state, updated, snapshot) != snapshot);
var newState = Volatile.Read(ref _state);
_persistence.Save(newState, _filePath, JsonSerializationContext.Default.AppStateModel);
StateChanged?.Invoke(this, newState);
} }
private string StateJsonPath() private string StateJsonPath()

View File

@@ -21,6 +21,12 @@ public interface IAppStateService
/// </summary> /// </summary>
void Save(); void Save();
/// <summary>
/// Atomically applies a transformation to the current state, persists the result,
/// and raises <see cref="StateChanged"/>.
/// </summary>
void UpdateState(Func<AppStateModel, AppStateModel> transform);
/// <summary> /// <summary>
/// Raised after state has been saved to disk. /// Raised after state has been saved to disk.
/// </summary> /// </summary>

View File

@@ -22,6 +22,12 @@ public interface ISettingsService
/// <param name="hotReload">When <see langword="true"/>, raises <see cref="SettingsChanged"/> after saving.</param> /// <param name="hotReload">When <see langword="true"/>, raises <see cref="SettingsChanged"/> after saving.</param>
void Save(bool hotReload = true); void Save(bool hotReload = true);
/// <summary>
/// Atomically applies a transformation to the current settings, persists the result,
/// and optionally raises <see cref="SettingsChanged"/>.
/// </summary>
void UpdateSettings(Func<SettingsModel, SettingsModel> transform, bool hotReload = true);
/// <summary> /// <summary>
/// Raised after settings are saved with <paramref name="hotReload"/> enabled, or after <see cref="Reload"/>. /// Raised after settings are saved with <paramref name="hotReload"/> enabled, or after <see cref="Reload"/>.
/// </summary> /// </summary>

View File

@@ -29,27 +29,38 @@ public sealed class SettingsService : ISettingsService
_persistence = persistence; _persistence = persistence;
_appInfoService = appInfoService; _appInfoService = appInfoService;
_filePath = SettingsJsonPath(); _filePath = SettingsJsonPath();
Settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel); _settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel);
ApplyMigrations(); ApplyMigrations();
} }
private SettingsModel _settings;
/// <inheritdoc/> /// <inheritdoc/>
public SettingsModel Settings { get; private set; } public SettingsModel Settings => Volatile.Read(ref _settings);
/// <inheritdoc/> /// <inheritdoc/>
public event TypedEventHandler<ISettingsService, SettingsModel>? SettingsChanged; public event TypedEventHandler<ISettingsService, SettingsModel>? SettingsChanged;
/// <inheritdoc/> /// <inheritdoc/>
public void Save(bool hotReload = true) public void Save(bool hotReload = true) => UpdateSettings(s => s, hotReload);
{
_persistence.Save(
Settings,
_filePath,
JsonSerializationContext.Default.SettingsModel);
/// <inheritdoc/>
public void UpdateSettings(Func<SettingsModel, SettingsModel> transform, bool hotReload = true)
{
SettingsModel snapshot;
SettingsModel updated;
do
{
snapshot = Volatile.Read(ref _settings);
updated = transform(snapshot);
}
while (Interlocked.CompareExchange(ref _settings, updated, snapshot) != snapshot);
var newSettings = Volatile.Read(ref _settings);
_persistence.Save(newSettings, _filePath, JsonSerializationContext.Default.SettingsModel);
if (hotReload) if (hotReload)
{ {
SettingsChanged?.Invoke(this, Settings); SettingsChanged?.Invoke(this, newSettings);
} }
} }
@@ -71,10 +82,10 @@ public sealed class SettingsService : ISettingsService
migratedAny |= TryMigrate( migratedAny |= TryMigrate(
"Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)", "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)",
root, root,
Settings, ref _settings,
nameof(SettingsModel.AutoGoHomeInterval), nameof(SettingsModel.AutoGoHomeInterval),
DeprecatedHotkeyGoesHomeKey, DeprecatedHotkeyGoesHomeKey,
(model, goesHome) => model.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, (ref SettingsModel model, bool goesHome) => model = model with { AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan },
JsonSerializationContext.Default.Boolean); JsonSerializationContext.Default.Boolean);
} }
} }
@@ -89,13 +100,15 @@ public sealed class SettingsService : ISettingsService
} }
} }
private delegate void MigrationApply<T>(ref SettingsModel model, T value);
private static bool TryMigrate<T>( private static bool TryMigrate<T>(
string migrationName, string migrationName,
JsonObject root, JsonObject root,
SettingsModel model, ref SettingsModel model,
string newKey, string newKey,
string oldKey, string oldKey,
Action<SettingsModel, T> apply, MigrationApply<T> apply,
JsonTypeInfo<T> jsonTypeInfo) JsonTypeInfo<T> jsonTypeInfo)
{ {
try try
@@ -108,7 +121,7 @@ public sealed class SettingsService : ISettingsService
if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null) if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null)
{ {
var value = oldNode.Deserialize(jsonTypeInfo); var value = oldNode.Deserialize(jsonTypeInfo);
apply(model, value!); apply(ref model, value!);
return true; return true;
} }
} }

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Windows.UI; using Windows.UI;
@@ -13,103 +14,93 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// Settings for the Dock. These are settings for _the whole dock_. Band-specific /// Settings for the Dock. These are settings for _the whole dock_. Band-specific
/// settings are in <see cref="DockBandSettings"/>. /// settings are in <see cref="DockBandSettings"/>.
/// </summary> /// </summary>
public class DockSettings public record DockSettings
{ {
public DockSide Side { get; set; } = DockSide.Top; public DockSide Side { get; init; } = DockSide.Top;
public DockSize DockSize { get; set; } = DockSize.Small; public DockSize DockSize { get; init; } = DockSize.Small;
public DockSize DockIconsSize { get; set; } = DockSize.Small; public DockSize DockIconsSize { get; init; } = DockSize.Small;
// <Theme settings> // <Theme settings>
public DockBackdrop Backdrop { get; set; } = DockBackdrop.Acrylic; public DockBackdrop Backdrop { get; init; } = DockBackdrop.Acrylic;
public UserTheme Theme { get; set; } = UserTheme.Default; public UserTheme Theme { get; init; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; } public ColorizationMode ColorizationMode { get; init; }
public Color CustomThemeColor { get; set; } = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent and COM in class init public Color CustomThemeColor { get; init; } = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent and COM in class init
public int CustomThemeColorIntensity { get; set; } = 100; public int CustomThemeColorIntensity { get; init; } = 100;
public int BackgroundImageOpacity { get; set; } = 20; public int BackgroundImageOpacity { get; init; } = 20;
public int BackgroundImageBlurAmount { get; set; } public int BackgroundImageBlurAmount { get; init; }
public int BackgroundImageBrightness { get; set; } public int BackgroundImageBrightness { get; init; }
public BackgroundImageFit BackgroundImageFit { get; set; } public BackgroundImageFit BackgroundImageFit { get; init; }
public string? BackgroundImagePath { get; set; } public string? BackgroundImagePath { get; init; }
// </Theme settings> // </Theme settings>
// public List<string> PinnedCommands { get; set; } = []; public ImmutableList<DockBandSettings> StartBands { get; init; } = ImmutableList.Create(
public List<DockBandSettings> StartBands { get; set; } = []; new DockBandSettings
public List<DockBandSettings> CenterBands { get; set; } = [];
public List<DockBandSettings> EndBands { get; set; } = [];
public bool ShowLabels { get; set; } = true;
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
public DockSettings()
{
// Initialize with default values
// PinnedCommands = [
// "com.microsoft.cmdpal.winget"
// ];
StartBands.Add(new DockBandSettings
{ {
ProviderId = "com.microsoft.cmdpal.builtin.core", ProviderId = "com.microsoft.cmdpal.builtin.core",
CommandId = "com.microsoft.cmdpal.home", CommandId = "com.microsoft.cmdpal.home",
}); },
StartBands.Add(new DockBandSettings new DockBandSettings
{ {
ProviderId = "WinGet", ProviderId = "WinGet",
CommandId = "com.microsoft.cmdpal.winget", CommandId = "com.microsoft.cmdpal.winget",
ShowLabels = false, ShowLabels = false,
}); });
EndBands.Add(new DockBandSettings public ImmutableList<DockBandSettings> CenterBands { get; init; } = ImmutableList<DockBandSettings>.Empty;
public ImmutableList<DockBandSettings> EndBands { get; init; } = ImmutableList.Create(
new DockBandSettings
{ {
ProviderId = "PerformanceMonitor", ProviderId = "PerformanceMonitor",
CommandId = "com.microsoft.cmdpal.performanceWidget", CommandId = "com.microsoft.cmdpal.performanceWidget",
}); },
EndBands.Add(new DockBandSettings new DockBandSettings
{ {
ProviderId = "com.microsoft.cmdpal.builtin.datetime", ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand", CommandId = "com.microsoft.cmdpal.timedate.dockBand",
}); });
}
public bool ShowLabels { get; init; } = true;
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
} }
/// <summary> /// <summary>
/// Settings for a specific dock band. These are per-band settings stored /// Settings for a specific dock band. These are per-band settings stored
/// within the overall <see cref="DockSettings"/>. /// within the overall <see cref="DockSettings"/>.
/// </summary> /// </summary>
public class DockBandSettings public record DockBandSettings
{ {
public required string ProviderId { get; set; } public required string ProviderId { get; init; }
public required string CommandId { get; set; } public required string CommandId { get; init; }
/// <summary> /// <summary>
/// Gets or sets whether titles are shown for items in this band. /// Gets or sets whether titles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting. /// If null, falls back to dock-wide ShowLabels setting.
/// </summary> /// </summary>
public bool? ShowTitles { get; set; } public bool? ShowTitles { get; init; }
/// <summary> /// <summary>
/// Gets or sets whether subtitles are shown for items in this band. /// Gets or sets whether subtitles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting. /// If null, falls back to dock-wide ShowLabels setting.
/// </summary> /// </summary>
public bool? ShowSubtitles { get; set; } public bool? ShowSubtitles { get; init; }
/// <summary> /// <summary>
/// Gets or sets a value for backward compatibility. Maps to ShowTitles. /// Gets or sets a value for backward compatibility. Maps to ShowTitles.
@@ -118,7 +109,7 @@ public class DockBandSettings
public bool? ShowLabels public bool? ShowLabels
{ {
get => ShowTitles; get => ShowTitles;
set => ShowTitles = value; init => ShowTitles = value;
} }
/// <summary> /// <summary>
@@ -134,17 +125,6 @@ public class DockBandSettings
/// dock-wide setting (passed as <paramref name="defaultValue"/>). /// dock-wide setting (passed as <paramref name="defaultValue"/>).
/// </summary> /// </summary>
public bool ResolveShowSubtitles(bool defaultValue) => ShowSubtitles ?? defaultValue; public bool ResolveShowSubtitles(bool defaultValue) => ShowSubtitles ?? defaultValue;
public DockBandSettings Clone()
{
return new()
{
ProviderId = this.ProviderId,
CommandId = this.CommandId,
ShowTitles = this.ShowTitles,
ShowSubtitles = this.ShowSubtitles,
};
}
} }
public enum DockSide public enum DockSide

View File

@@ -39,24 +39,24 @@ public record HotkeySettings// : ICmdLineRepresentable
} }
[JsonPropertyName("win")] [JsonPropertyName("win")]
public bool Win { get; set; } public bool Win { get; init; }
[JsonPropertyName("ctrl")] [JsonPropertyName("ctrl")]
public bool Ctrl { get; set; } public bool Ctrl { get; init; }
[JsonPropertyName("alt")] [JsonPropertyName("alt")]
public bool Alt { get; set; } public bool Alt { get; init; }
[JsonPropertyName("shift")] [JsonPropertyName("shift")]
public bool Shift { get; set; } public bool Shift { get; init; }
[JsonPropertyName("code")] [JsonPropertyName("code")]
public int Code { get; set; } public int Code { get; init; }
// This is currently needed for FancyZones, we need to unify these two objects // This is currently needed for FancyZones, we need to unify these two objects
// see src\common\settings_objects.h // see src\common\settings_objects.h
[JsonPropertyName("key")] [JsonPropertyName("key")]
public string Key { get; set; } = string.Empty; public string Key { get; init; } = string.Empty;
public override string ToString() public override string ToString()
{ {

View File

@@ -2,106 +2,114 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CmdPal.UI.ViewModels.Settings;
using Windows.UI; using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public partial class SettingsModel : ObservableObject public record SettingsModel
{ {
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
// SETTINGS HERE // SETTINGS HERE
public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space
public HotkeySettings? Hotkey { get; set; } = DefaultActivationShortcut; public HotkeySettings? Hotkey { get; init; } = DefaultActivationShortcut;
public bool UseLowLevelGlobalHotkey { get; set; } public bool UseLowLevelGlobalHotkey { get; init; }
public bool ShowAppDetails { get; set; } public bool ShowAppDetails { get; init; }
public bool BackspaceGoesBack { get; set; } public bool BackspaceGoesBack { get; init; }
public bool SingleClickActivates { get; set; } public bool SingleClickActivates { get; init; }
public bool HighlightSearchOnActivate { get; set; } = true; public bool HighlightSearchOnActivate { get; init; } = true;
public bool KeepPreviousQuery { get; set; } public bool KeepPreviousQuery { get; init; }
public bool ShowSystemTrayIcon { get; set; } = true; public bool ShowSystemTrayIcon { get; init; } = true;
public bool IgnoreShortcutWhenFullscreen { get; set; } public bool IgnoreShortcutWhenFullscreen { get; init; }
public bool AllowExternalReload { get; set; } public bool AllowExternalReload { get; init; }
public Dictionary<string, ProviderSettings> ProviderSettings { get; set; } = []; public ImmutableDictionary<string, ProviderSettings> ProviderSettings { get; init; }
= ImmutableDictionary<string, ProviderSettings>.Empty;
public string[] FallbackRanks { get; set; } = []; public string[] FallbackRanks { get; init; } = [];
public Dictionary<string, CommandAlias> Aliases { get; set; } = []; public ImmutableDictionary<string, CommandAlias> Aliases { get; init; }
= ImmutableDictionary<string, CommandAlias>.Empty;
public List<TopLevelHotkey> CommandHotkeys { get; set; } = []; public ImmutableList<TopLevelHotkey> CommandHotkeys { get; init; }
= ImmutableList<TopLevelHotkey>.Empty;
public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse; public MonitorBehavior SummonOn { get; init; } = MonitorBehavior.ToMouse;
public bool DisableAnimations { get; set; } = true; public bool DisableAnimations { get; init; } = true;
public WindowPosition? LastWindowPosition { get; set; } public WindowPosition? LastWindowPosition { get; init; }
public TimeSpan AutoGoHomeInterval { get; set; } = Timeout.InfiniteTimeSpan; public TimeSpan AutoGoHomeInterval { get; init; } = Timeout.InfiniteTimeSpan;
public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack; public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; init; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
public bool EnableDock { get; set; } public bool EnableDock { get; init; }
public DockSettings DockSettings { get; set; } = new(); public DockSettings DockSettings { get; init; } = new();
// Theme settings // Theme settings
public UserTheme Theme { get; set; } = UserTheme.Default; public UserTheme Theme { get; init; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; } public ColorizationMode ColorizationMode { get; init; }
public Color CustomThemeColor { get; set; } = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent public Color CustomThemeColor { get; init; } = new() { A = 0, R = 255, G = 255, B = 255 }; // Transparent — avoids WinUI3 COM dependency on Colors.Transparent
public int CustomThemeColorIntensity { get; set; } = 100; public int CustomThemeColorIntensity { get; init; } = 100;
public int BackgroundImageTintIntensity { get; set; } public int BackgroundImageTintIntensity { get; init; }
public int BackgroundImageOpacity { get; set; } = 20; public int BackgroundImageOpacity { get; init; } = 20;
public int BackgroundImageBlurAmount { get; set; } public int BackgroundImageBlurAmount { get; init; }
public int BackgroundImageBrightness { get; set; } public int BackgroundImageBrightness { get; init; }
public BackgroundImageFit BackgroundImageFit { get; set; } public BackgroundImageFit BackgroundImageFit { get; init; }
public string? BackgroundImagePath { get; set; } public string? BackgroundImagePath { get; init; }
public BackdropStyle BackdropStyle { get; set; } public BackdropStyle BackdropStyle { get; init; }
public int BackdropOpacity { get; set; } = 100; public int BackdropOpacity { get; init; } = 100;
// </Theme settings> // </Theme settings>
// END SETTINGS // END SETTINGS
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
public ProviderSettings GetProviderSettings(CommandProviderWrapper provider) public (SettingsModel Model, ProviderSettings Settings) GetProviderSettings(CommandProviderWrapper provider)
{ {
ProviderSettings? settings; if (!ProviderSettings.TryGetValue(provider.ProviderId, out var settings))
if (!ProviderSettings.TryGetValue(provider.ProviderId, out settings))
{ {
settings = new ProviderSettings(provider); settings = new ProviderSettings();
settings.Connect(provider);
ProviderSettings[provider.ProviderId] = settings;
}
else
{
settings.Connect(provider);
} }
return settings; var connected = settings.WithConnection(provider);
// If WithConnection returned the same instance, nothing changed — skip SetItem
if (ReferenceEquals(connected, settings))
{
return (this, connected);
}
var newModel = this with
{
ProviderSettings = ProviderSettings.SetItem(provider.ProviderId, connected),
};
return (newModel, connected);
} }
public string[] GetGlobalFallbacks() public string[] GetGlobalFallbacks()
@@ -150,6 +158,13 @@ public partial class SettingsModel : ObservableObject
[JsonSerializable(typeof(RecentCommandsManager))] [JsonSerializable(typeof(RecentCommandsManager))]
[JsonSerializable(typeof(List<string>), TypeInfoPropertyName = "StringList")] [JsonSerializable(typeof(List<string>), TypeInfoPropertyName = "StringList")]
[JsonSerializable(typeof(List<HistoryItem>), TypeInfoPropertyName = "HistoryList")] [JsonSerializable(typeof(List<HistoryItem>), TypeInfoPropertyName = "HistoryList")]
[JsonSerializable(typeof(ImmutableList<HistoryItem>), TypeInfoPropertyName = "ImmutableHistoryList")]
[JsonSerializable(typeof(ImmutableDictionary<string, FallbackSettings>), TypeInfoPropertyName = "ImmutableFallbackDictionary")]
[JsonSerializable(typeof(ImmutableList<string>), TypeInfoPropertyName = "ImmutableStringList")]
[JsonSerializable(typeof(ImmutableList<DockBandSettings>), TypeInfoPropertyName = "ImmutableDockBandSettingsList")]
[JsonSerializable(typeof(ImmutableDictionary<string, ProviderSettings>), TypeInfoPropertyName = "ImmutableProviderSettingsDictionary")]
[JsonSerializable(typeof(ImmutableDictionary<string, CommandAlias>), TypeInfoPropertyName = "ImmutableAliasDictionary")]
[JsonSerializable(typeof(ImmutableList<TopLevelHotkey>), TypeInfoPropertyName = "ImmutableTopLevelHotkeyList")]
[JsonSerializable(typeof(Dictionary<string, object>), TypeInfoPropertyName = "Dictionary")] [JsonSerializable(typeof(Dictionary<string, object>), TypeInfoPropertyName = "Dictionary")]
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")] [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")]

View File

@@ -41,9 +41,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.Hotkey; get => _settingsService.Settings.Hotkey;
set set
{ {
_settingsService.Settings.Hotkey = value ?? SettingsModel.DefaultActivationShortcut; _settingsService.UpdateSettings(s => s with { Hotkey = value ?? SettingsModel.DefaultActivationShortcut });
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey)));
Save();
} }
} }
@@ -52,9 +51,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.UseLowLevelGlobalHotkey; get => _settingsService.Settings.UseLowLevelGlobalHotkey;
set set
{ {
_settingsService.Settings.UseLowLevelGlobalHotkey = value; _settingsService.UpdateSettings(s => s with { UseLowLevelGlobalHotkey = value });
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey)));
Save();
} }
} }
@@ -63,8 +61,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.AllowExternalReload; get => _settingsService.Settings.AllowExternalReload;
set set
{ {
_settingsService.Settings.AllowExternalReload = value; _settingsService.UpdateSettings(s => s with { AllowExternalReload = value });
Save();
} }
} }
@@ -73,8 +70,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.ShowAppDetails; get => _settingsService.Settings.ShowAppDetails;
set set
{ {
_settingsService.Settings.ShowAppDetails = value; _settingsService.UpdateSettings(s => s with { ShowAppDetails = value });
Save();
} }
} }
@@ -83,8 +79,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.BackspaceGoesBack; get => _settingsService.Settings.BackspaceGoesBack;
set set
{ {
_settingsService.Settings.BackspaceGoesBack = value; _settingsService.UpdateSettings(s => s with { BackspaceGoesBack = value });
Save();
} }
} }
@@ -93,8 +88,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.SingleClickActivates; get => _settingsService.Settings.SingleClickActivates;
set set
{ {
_settingsService.Settings.SingleClickActivates = value; _settingsService.UpdateSettings(s => s with { SingleClickActivates = value });
Save();
} }
} }
@@ -103,8 +97,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.HighlightSearchOnActivate; get => _settingsService.Settings.HighlightSearchOnActivate;
set set
{ {
_settingsService.Settings.HighlightSearchOnActivate = value; _settingsService.UpdateSettings(s => s with { HighlightSearchOnActivate = value });
Save();
} }
} }
@@ -113,8 +106,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.KeepPreviousQuery; get => _settingsService.Settings.KeepPreviousQuery;
set set
{ {
_settingsService.Settings.KeepPreviousQuery = value; _settingsService.UpdateSettings(s => s with { KeepPreviousQuery = value });
Save();
} }
} }
@@ -123,8 +115,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => (int)_settingsService.Settings.SummonOn; get => (int)_settingsService.Settings.SummonOn;
set set
{ {
_settingsService.Settings.SummonOn = (MonitorBehavior)value; _settingsService.UpdateSettings(s => s with { SummonOn = (MonitorBehavior)value });
Save();
} }
} }
@@ -133,8 +124,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.ShowSystemTrayIcon; get => _settingsService.Settings.ShowSystemTrayIcon;
set set
{ {
_settingsService.Settings.ShowSystemTrayIcon = value; _settingsService.UpdateSettings(s => s with { ShowSystemTrayIcon = value });
Save();
} }
} }
@@ -143,8 +133,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.IgnoreShortcutWhenFullscreen; get => _settingsService.Settings.IgnoreShortcutWhenFullscreen;
set set
{ {
_settingsService.Settings.IgnoreShortcutWhenFullscreen = value; _settingsService.UpdateSettings(s => s with { IgnoreShortcutWhenFullscreen = value });
Save();
} }
} }
@@ -153,8 +142,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.DisableAnimations; get => _settingsService.Settings.DisableAnimations;
set set
{ {
_settingsService.Settings.DisableAnimations = value; _settingsService.UpdateSettings(s => s with { DisableAnimations = value });
Save();
} }
} }
@@ -170,10 +158,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
{ {
if (value >= 0 && value < AutoGoHomeIntervals.Count) if (value >= 0 && value < AutoGoHomeIntervals.Count)
{ {
_settingsService.Settings.AutoGoHomeInterval = AutoGoHomeIntervals[value]; _settingsService.UpdateSettings(s => s with { AutoGoHomeInterval = AutoGoHomeIntervals[value] });
} }
Save();
} }
} }
@@ -182,8 +168,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => (int)_settingsService.Settings.EscapeKeyBehaviorSetting; get => (int)_settingsService.Settings.EscapeKeyBehaviorSetting;
set set
{ {
_settingsService.Settings.EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value; _settingsService.UpdateSettings(s => s with { EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value });
Save();
} }
} }
@@ -192,8 +177,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.DockSettings.Side; get => _settingsService.Settings.DockSettings.Side;
set set
{ {
_settingsService.Settings.DockSettings.Side = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { Side = value } });
Save();
} }
} }
@@ -202,8 +186,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.DockSettings.DockSize; get => _settingsService.Settings.DockSettings.DockSize;
set set
{ {
_settingsService.Settings.DockSettings.DockSize = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { DockSize = value } });
Save();
} }
} }
@@ -212,8 +195,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.DockSettings.Backdrop; get => _settingsService.Settings.DockSettings.Backdrop;
set set
{ {
_settingsService.Settings.DockSettings.Backdrop = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { Backdrop = value } });
Save();
} }
} }
@@ -222,8 +204,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.DockSettings.ShowLabels; get => _settingsService.Settings.DockSettings.ShowLabels;
set set
{ {
_settingsService.Settings.DockSettings.ShowLabels = value; _settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { ShowLabels = value } });
Save();
} }
} }
@@ -232,8 +213,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
get => _settingsService.Settings.EnableDock; get => _settingsService.Settings.EnableDock;
set set
{ {
_settingsService.Settings.EnableDock = value; _settingsService.UpdateSettings(s => s with { EnableDock = value });
Save();
WeakReferenceMessenger.Default.Send(new ShowHideDockMessage(value)); WeakReferenceMessenger.Default.Send(new ShowHideDockMessage(value));
WeakReferenceMessenger.Default.Send(new ReloadCommandsMessage()); // TODO! we need to update the MoreCommands of all top level items, but we don't _really_ want to reload WeakReferenceMessenger.Default.Send(new ReloadCommandsMessage()); // TODO! we need to update the MoreCommands of all top level items, but we don't _really_ want to reload
} }
@@ -245,7 +225,11 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public SettingsExtensionsViewModel Extensions { get; } public SettingsExtensionsViewModel Extensions { get; }
public SettingsViewModel(TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler, IThemeService themeService, ISettingsService settingsService) public SettingsViewModel(
TopLevelCommandManager topLevelCommandManager,
TaskScheduler scheduler,
IThemeService themeService,
ISettingsService settingsService)
{ {
_settingsService = settingsService; _settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager; _topLevelCommandManager = topLevelCommandManager;
@@ -259,15 +243,27 @@ public partial class SettingsViewModel : INotifyPropertyChanged
var fallbacks = new List<FallbackSettingsViewModel>(); var fallbacks = new List<FallbackSettingsViewModel>();
var currentRankings = _settingsService.Settings.FallbackRanks; var currentRankings = _settingsService.Settings.FallbackRanks;
var needsSave = false; var needsSave = false;
var currentSettingsModel = _settingsService.Settings;
foreach (var item in activeProviders) foreach (var item in activeProviders)
{ {
var providerSettings = _settingsService.Settings.GetProviderSettings(item); var (newModel, providerSettings) = currentSettingsModel.GetProviderSettings(item);
currentSettingsModel = newModel;
var settingsModel = new ProviderSettingsViewModel(item, providerSettings, settingsService); var providerSettingsModel = new ProviderSettingsViewModel(item, providerSettings, settingsService);
CommandProviders.Add(settingsModel); CommandProviders.Add(providerSettingsModel);
fallbacks.AddRange(settingsModel.FallbackCommands); fallbacks.AddRange(providerSettingsModel.FallbackCommands);
}
// Only persist if provider enumeration actually changed the model
// Smelly? Yes, but it avoids an unnecessary write to disk.
// I don't love it, but it seems better than the alternatives.
// Open to suggestions.
if (!ReferenceEquals(currentSettingsModel, _settingsService.Settings))
{
var finalModel = currentSettingsModel;
_settingsService.UpdateSettings(_ => finalModel, hotReload: false);
} }
var fallbackRankings = new List<Scored<FallbackSettingsViewModel>>(fallbacks.Count); var fallbackRankings = new List<Scored<FallbackSettingsViewModel>>(fallbacks.Count);
@@ -306,10 +302,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public void ApplyFallbackSort() public void ApplyFallbackSort()
{ {
_settingsService.Settings.FallbackRanks = FallbackRankings.Select(s => s.Id).ToArray(); _settingsService.UpdateSettings(s => s with { FallbackRanks = FallbackRankings.Select(s2 => s2.Id).ToArray() });
Save();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings)));
} }
private void Save() => _settingsService.Save();
} }

View File

@@ -7,9 +7,15 @@ using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public class TopLevelHotkey(HotkeySettings? hotkey, string commandId) public record TopLevelHotkey
{ {
public string CommandId { get; set; } = commandId; public string CommandId { get; init; }
public HotkeySettings? Hotkey { get; set; } = hotkey; public HotkeySettings? Hotkey { get; init; }
public TopLevelHotkey(HotkeySettings? hotkey, string commandId)
{
Hotkey = hotkey;
CommandId = commandId;
}
} }

View File

@@ -121,7 +121,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
{ {
if (Alias is CommandAlias a) if (Alias is CommandAlias a)
{ {
a.Alias = value; Alias = a with { Alias = value };
} }
else else
{ {
@@ -146,7 +146,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
{ {
if (Alias is CommandAlias a) if (Alias is CommandAlias a)
{ {
a.IsDirect = value; Alias = a with { IsDirect = value };
} }
HandleChangeAlias(); HandleChangeAlias();

View File

@@ -92,7 +92,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var providerId = providerContext.ProviderId; var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider) 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); var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId);
@@ -159,7 +159,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var providerId = providerContext.ProviderId; var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider) 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); var isPinnedSubCommand = providerSettings.PinnedCommandIds.Contains(itemId);
if (isPinnedSubCommand) if (isPinnedSubCommand)

View File

@@ -186,7 +186,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
_ = _modifierKeysOnEntering.Remove(virtualKey); _ = _modifierKeysOnEntering.Remove(virtualKey);
} }
internalSettings.Win = matchValue; internalSettings = internalSettings with { Win = matchValue };
break; break;
case VirtualKey.Control: case VirtualKey.Control:
case VirtualKey.LeftControl: case VirtualKey.LeftControl:
@@ -197,7 +197,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
_ = _modifierKeysOnEntering.Remove(VirtualKey.Control); _ = _modifierKeysOnEntering.Remove(VirtualKey.Control);
} }
internalSettings.Ctrl = matchValue; internalSettings = internalSettings with { Ctrl = matchValue };
break; break;
case VirtualKey.Menu: case VirtualKey.Menu:
case VirtualKey.LeftMenu: case VirtualKey.LeftMenu:
@@ -208,7 +208,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
_ = _modifierKeysOnEntering.Remove(VirtualKey.Menu); _ = _modifierKeysOnEntering.Remove(VirtualKey.Menu);
} }
internalSettings.Alt = matchValue; internalSettings = internalSettings with { Alt = matchValue };
break; break;
case VirtualKey.Shift: case VirtualKey.Shift:
case VirtualKey.LeftShift: case VirtualKey.LeftShift:
@@ -219,14 +219,14 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
_ = _modifierKeysOnEntering.Remove(VirtualKey.Shift); _ = _modifierKeysOnEntering.Remove(VirtualKey.Shift);
} }
internalSettings.Shift = matchValue; internalSettings = internalSettings with { Shift = matchValue };
break; break;
case VirtualKey.Escape: case VirtualKey.Escape:
internalSettings = new HotkeySettings(); internalSettings = new HotkeySettings();
shortcutDialog.IsPrimaryButtonEnabled = false; shortcutDialog.IsPrimaryButtonEnabled = false;
return; return;
default: default:
internalSettings.Code = matchValueCode; internalSettings = internalSettings with { Code = matchValueCode };
break; 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) 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. // 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); 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. // the last non-dock placement because dock sessions intentionally skip updates.
if (_currentWindowPosition.IsSizeValid) if (_currentWindowPosition.IsSizeValid)
{ {
settings.LastWindowPosition = _currentWindowPosition; settingsService.UpdateSettings(s => s with { LastWindowPosition = _currentWindowPosition });
settingsService.Save();
} }
} }

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Services;
@@ -19,10 +20,13 @@ internal sealed class RunHistoryService : IRunHistoryService
public IReadOnlyList<string> GetRunHistory() public IReadOnlyList<string> GetRunHistory()
{ {
if (_appStateService.State.RunHistory.Count == 0) if (_appStateService.State.RunHistory.IsEmpty)
{ {
var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory(); var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory();
_appStateService.State.RunHistory.AddRange(history); _appStateService.UpdateState(state => state with
{
RunHistory = history.ToImmutableList(),
});
} }
return _appStateService.State.RunHistory; return _appStateService.State.RunHistory;
@@ -30,22 +34,24 @@ internal sealed class RunHistoryService : IRunHistoryService
public void ClearRunHistory() public void ClearRunHistory()
{ {
_appStateService.State.RunHistory.Clear(); _appStateService.UpdateState(state => state with
{
RunHistory = ImmutableList<string>.Empty,
});
} }
public void AddRunHistoryItem(string item) public void AddRunHistoryItem(string item)
{ {
// insert at the beginning of the list
if (string.IsNullOrWhiteSpace(item)) if (string.IsNullOrWhiteSpace(item))
{ {
return; // Do not add empty or whitespace items return;
} }
_appStateService.State.RunHistory.Remove(item); _appStateService.UpdateState(state => state with
{
// Add the item to the front of the history RunHistory = state.RunHistory
_appStateService.State.RunHistory.Insert(0, item); .Remove(item)
.Insert(0, item),
_appStateService.Save(); });
} }
} }

View File

@@ -4,6 +4,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO; using System.IO;
using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Services;
@@ -41,7 +42,7 @@ public class AppStateServiceTests
// Arrange // Arrange
var expectedState = new AppStateModel var expectedState = new AppStateModel
{ {
RunHistory = new List<string> { "command1", "command2" }, RunHistory = ImmutableList.Create("command1", "command2"),
}; };
_mockPersistence _mockPersistence
.Setup(p => p.Load( .Setup(p => p.Load(
@@ -86,7 +87,8 @@ public class AppStateServiceTests
{ {
// Arrange // Arrange
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object); var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
service.State.RunHistory.Add("test-command"); service.UpdateState(s => s with { RunHistory = s.RunHistory.Add("test-command") });
_mockPersistence.Invocations.Clear(); // Reset after Arrange — UpdateState also persists
// Act // Act
service.Save(); service.Save();
@@ -160,4 +162,44 @@ public class AppStateServiceTests
// Assert // Assert
Assert.AreEqual(2, eventCount); Assert.AreEqual(2, eventCount);
} }
[TestMethod]
public void UpdateState_ConcurrentUpdates_NoLostUpdates()
{
// Arrange — two threads each add items to RunHistory concurrently.
// With the CAS loop, every add must land (no lost updates).
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
const int iterations = 50;
var barrier = new System.Threading.Barrier(2);
// Act
var t1 = System.Threading.Tasks.Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < iterations; i++)
{
service.UpdateState(s => s with { RunHistory = s.RunHistory.Add($"t1-{i}") });
}
});
var t2 = System.Threading.Tasks.Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < iterations; i++)
{
service.UpdateState(s => s with { RunHistory = s.RunHistory.Add($"t2-{i}") });
}
});
System.Threading.Tasks.Task.WaitAll(t1, t2);
// Assert — all 100 items must be present (no lost updates)
Assert.AreEqual(iterations * 2, service.State.RunHistory.Count, "All concurrent updates should be preserved");
for (var i = 0; i < iterations; i++)
{
Assert.IsTrue(service.State.RunHistory.Contains($"t1-{i}"), $"Missing t1-{i}");
Assert.IsTrue(service.State.RunHistory.Contains($"t2-{i}"), $"Missing t2-{i}");
}
}
} }

View File

@@ -26,7 +26,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
{ {
foreach (var item in commandIds) foreach (var item in commandIds)
{ {
history.AddHistoryItem(item); history = history.WithHistoryItem(item);
} }
} }
@@ -54,7 +54,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var history = CreateHistory(); var history = CreateHistory();
// Act // Act
history.AddHistoryItem("com.microsoft.cmdpal.shell"); history = history.WithHistoryItem("com.microsoft.cmdpal.shell");
// Assert // Assert
Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0); Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0);
@@ -121,7 +121,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var history = new RecentCommandsManager(); var history = new RecentCommandsManager();
foreach (var item in items) foreach (var item in items)
{ {
history.AddHistoryItem(item.Id); history = history.WithHistoryItem(item.Id);
} }
return history; return history;
@@ -417,7 +417,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
// Add extra uses of VS Code to try and push it above Terminal // Add extra uses of VS Code to try and push it above Terminal
for (var i = 0; i < 10; i++) for (var i = 0; i < 10; i++)
{ {
history.AddHistoryItem(items[1].Id); history = history.WithHistoryItem(items[1].Id);
} }
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList(); var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
@@ -446,7 +446,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var vsCodeId = items[1].Id; var vsCodeId = items[1].Id;
for (var i = 0; i < 10; i++) for (var i = 0; i < 10; i++)
{ {
history.AddHistoryItem(vsCodeId); history = history.WithHistoryItem(vsCodeId);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList(); var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList(); var weightedMatches = GetMatches(items, weightedScores).ToList();

View File

@@ -75,7 +75,14 @@ public class SettingsServiceTests
public void Settings_ReturnsLoadedModel() public void Settings_ReturnsLoadedModel()
{ {
// Arrange // Arrange
_testSettings.ShowAppDetails = true; _testSettings = _testSettings with { ShowAppDetails = true };
// Reset mock to return updated settings
_mockPersistence
.Setup(p => p.Load(
It.IsAny<string>(),
It.IsAny<System.Text.Json.Serialization.Metadata.JsonTypeInfo<SettingsModel>>()))
.Returns(_testSettings);
// Act // Act
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
@@ -89,7 +96,10 @@ public class SettingsServiceTests
{ {
// Arrange // Arrange
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object); var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
service.Settings.SingleClickActivates = true; service.UpdateSettings(
s => s with { SingleClickActivates = true },
hotReload: false);
_mockPersistence.Invocations.Clear(); // Reset after Arrange — UpdateSettings also persists
// Act // Act
service.Save(hotReload: false); service.Save(hotReload: false);
@@ -178,4 +188,40 @@ public class SettingsServiceTests
Assert.AreSame(service, receivedSender); Assert.AreSame(service, receivedSender);
Assert.AreSame(service.Settings, receivedSettings); Assert.AreSame(service.Settings, receivedSettings);
} }
[TestMethod]
public void UpdateSettings_ConcurrentUpdates_NoLostUpdates()
{
// Arrange — two threads each set a different property to true, 100 times.
// Without a CAS loop, one thread's Exchange can overwrite the other's
// property back to false from a stale snapshot. With CAS, both survive.
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
const int iterations = 100;
var barrier = new System.Threading.Barrier(2);
// Act — t1 sets ShowAppDetails=true, t2 sets SingleClickActivates=true
var t1 = System.Threading.Tasks.Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < iterations; i++)
{
service.UpdateSettings(s => s with { ShowAppDetails = true }, hotReload: false);
}
});
var t2 = System.Threading.Tasks.Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < iterations; i++)
{
service.UpdateSettings(s => s with { SingleClickActivates = true }, hotReload: false);
}
});
System.Threading.Tasks.Task.WaitAll(t1, t2);
// Assert — both properties must be true; neither should have been overwritten
Assert.IsTrue(service.Settings.ShowAppDetails, "ShowAppDetails lost — a stale snapshot overwrote it");
Assert.IsTrue(service.Settings.SingleClickActivates, "SingleClickActivates lost — a stale snapshot overwrote it");
}
} }