mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 17:56:44 +02:00
CmdPal: Make settings and app state immutable (#46451)
## Summary This PR refactors CmdPal settings/state to be immutable end-to-end. ### Core changes - Convert model types to immutable records / init-only properties: - `SettingsModel` - `AppStateModel` - `ProviderSettings` - `DockSettings` - `RecentCommandsManager` - supporting settings types (fallback/hotkey/alias/top-level hotkey/history items, etc.) - Replace mutable collections with immutable equivalents where appropriate: - `ImmutableDictionary<,>` - `ImmutableList<>` - Move mutation flow to atomic service updates: - `ISettingsService.UpdateSettings(Func<SettingsModel, SettingsModel>)` - `IAppStateService.UpdateState(Func<AppStateModel, AppStateModel>)` - Update ViewModels/managers/services to use copy-on-write (`with`) patterns instead of in-place mutation. - Update serialization context + tests for immutable model graph compatibility. ## Why Issue #46437 is caused by mutable shared state being updated from different execution paths/threads, leading to race-prone behavior during persistence/serialization. By making settings/app state immutable and using atomic swap/update patterns, we remove in-place mutation and eliminate this class of concurrency bug. ## Validation - Built successfully: - `Microsoft.CmdPal.UI.ViewModels` - `Microsoft.CmdPal.UI` - `Microsoft.CmdPal.UI.ViewModels.UnitTests` - Updated unit tests for immutable update patterns. Fixes #46437 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
@@ -14,26 +15,32 @@ public partial class AliasManager : ObservableObject
|
||||
private readonly TopLevelCommandManager _topLevelCommandManager;
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
// REMEMBER, CommandAlias.SearchPrefix is what we use as keys
|
||||
private readonly Dictionary<string, CommandAlias> _aliases;
|
||||
private static readonly ImmutableList<CommandAlias> _defaultAliases = new List<CommandAlias>
|
||||
{
|
||||
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)
|
||||
{
|
||||
_topLevelCommandManager = tlcManager;
|
||||
_settingsService = settingsService;
|
||||
_aliases = _settingsService.Settings.Aliases;
|
||||
|
||||
if (_aliases.Count == 0)
|
||||
if (_settingsService.Settings.Aliases.Count == 0)
|
||||
{
|
||||
PopulateDefaultAliases();
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAlias(CommandAlias a) => _aliases.Add(a.SearchPrefix, a);
|
||||
|
||||
public bool CheckAlias(string searchText)
|
||||
{
|
||||
if (_aliases.TryGetValue(searchText, out var alias))
|
||||
if (_settingsService.Settings.Aliases.TryGetValue(searchText, out var alias))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -56,19 +63,18 @@ public partial class AliasManager : ObservableObject
|
||||
|
||||
private void PopulateDefaultAliases()
|
||||
{
|
||||
this.AddAlias(new CommandAlias(":", "com.microsoft.cmdpal.registry", true));
|
||||
this.AddAlias(new CommandAlias("$", "com.microsoft.cmdpal.windowsSettings", true));
|
||||
this.AddAlias(new CommandAlias("=", "com.microsoft.cmdpal.calculator", true));
|
||||
this.AddAlias(new CommandAlias(">", "com.microsoft.cmdpal.shell", true));
|
||||
this.AddAlias(new CommandAlias("<", "com.microsoft.cmdpal.windowwalker", true));
|
||||
this.AddAlias(new CommandAlias("??", "com.microsoft.cmdpal.websearch", true));
|
||||
this.AddAlias(new CommandAlias("file", "com.microsoft.indexer.fileSearch", false));
|
||||
this.AddAlias(new CommandAlias(")", "com.microsoft.cmdpal.timedate", true));
|
||||
_settingsService.UpdateSettings(
|
||||
s => s with
|
||||
{
|
||||
Aliases = s.Aliases
|
||||
.AddRange(_defaultAliases.ToDictionary(a => a.SearchPrefix, a => a)),
|
||||
},
|
||||
hotReload: false);
|
||||
}
|
||||
|
||||
public string? KeysFromId(string commandId)
|
||||
{
|
||||
return _aliases
|
||||
return _settingsService.Settings.Aliases
|
||||
.Where(kv => kv.Value.CommandId == commandId)
|
||||
.Select(kv => kv.Value.Alias)
|
||||
.FirstOrDefault();
|
||||
@@ -76,7 +82,7 @@ public partial class AliasManager : ObservableObject
|
||||
|
||||
public CommandAlias? AliasFromId(string commandId)
|
||||
{
|
||||
return _aliases
|
||||
return _settingsService.Settings.Aliases
|
||||
.Where(kv => kv.Value.CommandId == commandId)
|
||||
.Select(kv => kv.Value)
|
||||
.FirstOrDefault();
|
||||
@@ -90,9 +96,11 @@ public partial class AliasManager : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
var aliases = _settingsService.Settings.Aliases;
|
||||
|
||||
// If we already have _this exact alias_, do nothing
|
||||
if (newAlias is not null &&
|
||||
_aliases.TryGetValue(newAlias.SearchPrefix, out var existingAlias))
|
||||
aliases.TryGetValue(newAlias.SearchPrefix, out var existingAlias))
|
||||
{
|
||||
if (existingAlias.CommandId == commandId)
|
||||
{
|
||||
@@ -100,19 +108,19 @@ public partial class AliasManager : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
List<CommandAlias> toRemove = [];
|
||||
foreach (var kv in _aliases)
|
||||
var keysToRemove = new List<string>();
|
||||
foreach (var kv in aliases)
|
||||
{
|
||||
// Look for the old aliases for the command, and remove it
|
||||
if (kv.Value.CommandId == commandId)
|
||||
{
|
||||
toRemove.Add(kv.Value);
|
||||
keysToRemove.Add(kv.Key);
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
toRemove.Add(kv.Value);
|
||||
keysToRemove.Add(kv.Key);
|
||||
|
||||
// Remove alias from other TopLevelViewModels it may be assigned to
|
||||
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
|
||||
_aliases.Remove(alias.SearchPrefix);
|
||||
}
|
||||
var updatedAliases = s.Aliases.RemoveRange(keysToRemove);
|
||||
|
||||
if (newAlias is not null)
|
||||
{
|
||||
AddAlias(newAlias);
|
||||
}
|
||||
if (newAlias is not null)
|
||||
{
|
||||
updatedAliases = updatedAliases.Add(newAlias.SearchPrefix, newAlias);
|
||||
}
|
||||
|
||||
return s with { Aliases = updatedAliases };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class AppStateModel : ObservableObject
|
||||
public record AppStateModel
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// 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!
|
||||
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
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -112,10 +112,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.Theme != value)
|
||||
{
|
||||
_settingsService.Settings.Theme = value;
|
||||
_settingsService.UpdateSettings(s => s with { Theme = value });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(ThemeIndex));
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.ColorizationMode != value)
|
||||
{
|
||||
_settingsService.Settings.ColorizationMode = value;
|
||||
_settingsService.UpdateSettings(s => s with { ColorizationMode = value });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(ColorizationModeIndex));
|
||||
OnPropertyChanged(nameof(IsCustomTintVisible));
|
||||
@@ -146,7 +146,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
|
||||
IsColorizationDetailsExpanded = value != ColorizationMode.None;
|
||||
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.CustomThemeColor != value)
|
||||
{
|
||||
_settingsService.Settings.CustomThemeColor = value;
|
||||
_settingsService.UpdateSettings(s => s with { CustomThemeColor = value });
|
||||
|
||||
OnPropertyChanged();
|
||||
|
||||
@@ -173,7 +173,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
ColorIntensity = 100;
|
||||
}
|
||||
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,10 +183,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
get => _settingsService.Settings.CustomThemeColorIntensity;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.CustomThemeColorIntensity = value;
|
||||
_settingsService.UpdateSettings(s => s with { CustomThemeColorIntensity = value });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(EffectiveTintIntensity));
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,10 +195,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
get => _settingsService.Settings.BackgroundImageTintIntensity;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.BackgroundImageTintIntensity = value;
|
||||
_settingsService.UpdateSettings(s => s with { BackgroundImageTintIntensity = value });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(EffectiveTintIntensity));
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.BackgroundImagePath != value)
|
||||
{
|
||||
_settingsService.Settings.BackgroundImagePath = value;
|
||||
_settingsService.UpdateSettings(s => s with { BackgroundImagePath = value });
|
||||
OnPropertyChanged();
|
||||
|
||||
if (BackgroundImageOpacity == 0)
|
||||
@@ -217,7 +217,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
BackgroundImageOpacity = 100;
|
||||
}
|
||||
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,9 +229,9 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.BackgroundImageOpacity != value)
|
||||
{
|
||||
_settingsService.Settings.BackgroundImageOpacity = value;
|
||||
_settingsService.UpdateSettings(s => s with { BackgroundImageOpacity = value });
|
||||
OnPropertyChanged();
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,9 +243,9 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.BackgroundImageBrightness != value)
|
||||
{
|
||||
_settingsService.Settings.BackgroundImageBrightness = value;
|
||||
_settingsService.UpdateSettings(s => s with { BackgroundImageBrightness = value });
|
||||
OnPropertyChanged();
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,9 +257,9 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.BackgroundImageBlurAmount != value)
|
||||
{
|
||||
_settingsService.Settings.BackgroundImageBlurAmount = value;
|
||||
_settingsService.UpdateSettings(s => s with { BackgroundImageBlurAmount = value });
|
||||
OnPropertyChanged();
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,10 +271,10 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.BackgroundImageFit != value)
|
||||
{
|
||||
_settingsService.Settings.BackgroundImageFit = value;
|
||||
_settingsService.UpdateSettings(s => s with { BackgroundImageFit = value });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(BackgroundImageFitIndex));
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,11 +305,11 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
{
|
||||
if (_settingsService.Settings.BackdropOpacity != value)
|
||||
{
|
||||
_settingsService.Settings.BackdropOpacity = value;
|
||||
_settingsService.UpdateSettings(s => s with { BackdropOpacity = value });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(EffectiveBackdropStyle));
|
||||
OnPropertyChanged(nameof(EffectiveImageOpacity));
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
var newStyle = (BackdropStyle)value;
|
||||
if (_settingsService.Settings.BackdropStyle != newStyle)
|
||||
{
|
||||
_settingsService.Settings.BackdropStyle = newStyle;
|
||||
_settingsService.UpdateSettings(s => s with { BackdropStyle = newStyle });
|
||||
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(IsBackdropOpacityVisible));
|
||||
@@ -335,7 +335,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
IsColorizationDetailsExpanded = false;
|
||||
}
|
||||
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,9 +468,8 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
|
||||
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
private void Save()
|
||||
private void DebouncedReapply()
|
||||
{
|
||||
_settingsService.Save();
|
||||
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ using System.Text.Json.Serialization;
|
||||
|
||||
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]
|
||||
public string SearchPrefix => Alias + (IsDirect ? string.Empty : " ");
|
||||
|
||||
@@ -127,7 +127,12 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
|
||||
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)
|
||||
@@ -140,9 +145,26 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
}
|
||||
|
||||
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;
|
||||
if (!IsActive)
|
||||
{
|
||||
@@ -419,32 +441,59 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
public void PinCommand(string commandId, IServiceProvider serviceProvider)
|
||||
{
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
var settings = settingsService.Settings;
|
||||
var providerSettings = GetProviderSettings(settings);
|
||||
var providerSettings = GetProviderSettings(settingsService.Settings);
|
||||
|
||||
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
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
settingsService.Save(hotReload: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void UnpinCommand(string commandId, IServiceProvider serviceProvider)
|
||||
{
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
var settings = settingsService.Settings;
|
||||
var providerSettings = GetProviderSettings(settings);
|
||||
|
||||
if (providerSettings.PinnedCommandIds.Remove(commandId))
|
||||
{
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
settingsService.UpdateSettings(
|
||||
s =>
|
||||
{
|
||||
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)
|
||||
@@ -470,37 +519,47 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
ShowSubtitles = showSubtitles,
|
||||
};
|
||||
|
||||
switch (side)
|
||||
{
|
||||
case Dock.DockPinSide.Center:
|
||||
settings.DockSettings.CenterBands.Add(bandSettings);
|
||||
break;
|
||||
case Dock.DockPinSide.End:
|
||||
settings.DockSettings.EndBands.Add(bandSettings);
|
||||
break;
|
||||
case Dock.DockPinSide.Start:
|
||||
default:
|
||||
settings.DockSettings.StartBands.Add(bandSettings);
|
||||
break;
|
||||
}
|
||||
settingsService.UpdateSettings(
|
||||
s =>
|
||||
{
|
||||
var dockSettings = s.DockSettings;
|
||||
return s with
|
||||
{
|
||||
DockSettings = side switch
|
||||
{
|
||||
Dock.DockPinSide.Center => dockSettings with { CenterBands = dockSettings.CenterBands.Add(bandSettings) },
|
||||
Dock.DockPinSide.End => dockSettings with { EndBands = dockSettings.EndBands.Add(bandSettings) },
|
||||
_ => dockSettings with { StartBands = dockSettings.StartBands.Add(bandSettings) },
|
||||
},
|
||||
};
|
||||
},
|
||||
hotReload: false);
|
||||
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
|
||||
settingsService.Save(hotReload: false);
|
||||
}
|
||||
|
||||
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)
|
||||
{
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
var settings = settingsService.Settings;
|
||||
settings.DockSettings.StartBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
|
||||
settings.DockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
|
||||
settings.DockSettings.EndBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
|
||||
settingsService.UpdateSettings(
|
||||
s =>
|
||||
{
|
||||
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
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
settingsService.Save(hotReload: false);
|
||||
}
|
||||
|
||||
public ICommandProviderContext GetProviderContext() => this;
|
||||
|
||||
@@ -679,9 +679,10 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
public void UpdateHistory(IListItem topLevelOrAppItem)
|
||||
{
|
||||
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
|
||||
var history = _appStateService.State.RecentCommands;
|
||||
history.AddHistoryItem(id);
|
||||
_appStateService.Save();
|
||||
_appStateService.UpdateState(state => state with
|
||||
{
|
||||
RecentCommands = state.RecentCommands.WithHistoryItem(id),
|
||||
});
|
||||
}
|
||||
|
||||
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
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 readonly ISettingsService _settingsService;
|
||||
private readonly DockBandSettings _dockSettingsModel;
|
||||
private readonly TopLevelViewModel _adapter;
|
||||
private readonly DockBandViewModel? _bandViewModel;
|
||||
|
||||
private DockBandSettings _dockSettingsModel;
|
||||
|
||||
public string Title => _adapter.Title;
|
||||
|
||||
public string Description
|
||||
@@ -54,14 +56,14 @@ public partial class DockBandSettingsViewModel : ObservableObject
|
||||
if (value != _showLabels)
|
||||
{
|
||||
_showLabels = value;
|
||||
_dockSettingsModel.ShowLabels = value switch
|
||||
var newShowTitles = value switch
|
||||
{
|
||||
ShowLabelsOption.Default => null,
|
||||
ShowLabelsOption.Default => (bool?)null,
|
||||
ShowLabelsOption.ShowLabels => true,
|
||||
ShowLabelsOption.HideLabels => false,
|
||||
_ => null,
|
||||
};
|
||||
Save();
|
||||
UpdateModel(_dockSettingsModel with { ShowTitles = newShowTitles });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,9 +176,38 @@ public partial class DockBandSettingsViewModel : ObservableObject
|
||||
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)
|
||||
@@ -189,44 +220,31 @@ public partial class DockBandSettingsViewModel : ObservableObject
|
||||
|
||||
public void SetBandPosition(DockPinSide side, int? index)
|
||||
{
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var commandId = _dockSettingsModel.CommandId;
|
||||
|
||||
// Remove from all sides first
|
||||
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)
|
||||
_settingsService.UpdateSettings(s =>
|
||||
{
|
||||
case DockPinSide.Start:
|
||||
{
|
||||
var insertIndex = index ?? dockSettings.StartBands.Count;
|
||||
dockSettings.StartBands.Insert(insertIndex, _dockSettingsModel);
|
||||
break;
|
||||
}
|
||||
var dockSettings = s.DockSettings;
|
||||
|
||||
case DockPinSide.Center:
|
||||
{
|
||||
var insertIndex = index ?? dockSettings.CenterBands.Count;
|
||||
dockSettings.CenterBands.Insert(insertIndex, _dockSettingsModel);
|
||||
break;
|
||||
}
|
||||
// Remove from all sides first
|
||||
var newDock = dockSettings with
|
||||
{
|
||||
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == commandId),
|
||||
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId),
|
||||
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == commandId),
|
||||
};
|
||||
|
||||
case DockPinSide.End:
|
||||
{
|
||||
var insertIndex = index ?? dockSettings.EndBands.Count;
|
||||
dockSettings.EndBands.Insert(insertIndex, _dockSettingsModel);
|
||||
break;
|
||||
}
|
||||
// Add to the selected side
|
||||
newDock = side switch
|
||||
{
|
||||
DockPinSide.Start => newDock with { StartBands = newDock.StartBands.Insert(index ?? newDock.StartBands.Count, _dockSettingsModel) },
|
||||
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:
|
||||
default:
|
||||
// Do nothing
|
||||
break;
|
||||
}
|
||||
|
||||
Save();
|
||||
return s with { DockSettings = newDock };
|
||||
});
|
||||
}
|
||||
|
||||
private void OnPinSideChanged(DockPinSide value)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Models;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
@@ -15,11 +17,11 @@ namespace Microsoft.CmdPal.UI.ViewModels.Dock;
|
||||
public sealed partial class DockBandViewModel : ExtensionObjectViewModel
|
||||
{
|
||||
private readonly CommandItemViewModel _rootItem;
|
||||
private readonly DockBandSettings _bandSettings;
|
||||
private readonly DockSettings _dockSettings;
|
||||
private readonly Action _saveSettings;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IContextMenuFactory _contextMenuFactory;
|
||||
|
||||
private DockBandSettings _bandSettings;
|
||||
|
||||
public ObservableCollection<DockItemViewModel> Items { get; } = new();
|
||||
|
||||
private bool _showTitles = true;
|
||||
@@ -103,8 +105,7 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
|
||||
/// </summary>
|
||||
internal void SaveShowLabels()
|
||||
{
|
||||
_bandSettings.ShowTitles = _showTitles;
|
||||
_bandSettings.ShowSubtitles = _showSubtitles;
|
||||
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
|
||||
_showTitlesSnapshot = 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(
|
||||
CommandItemViewModel commandItemViewModel,
|
||||
WeakReference<IPageContext> errorContext,
|
||||
DockBandSettings settings,
|
||||
DockSettings dockSettings,
|
||||
Action saveSettings,
|
||||
ISettingsService settingsService,
|
||||
IContextMenuFactory contextMenuFactory)
|
||||
: base(errorContext)
|
||||
{
|
||||
_rootItem = commandItemViewModel;
|
||||
_bandSettings = settings;
|
||||
_dockSettings = dockSettings;
|
||||
_saveSettings = saveSettings;
|
||||
_settingsService = settingsService;
|
||||
_contextMenuFactory = contextMenuFactory;
|
||||
|
||||
var dockSettings = settingsService.Settings.DockSettings;
|
||||
_showTitles = settings.ResolveShowTitles(dockSettings.ShowLabels);
|
||||
_showSubtitles = settings.ResolveShowSubtitles(dockSettings.ShowLabels);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
@@ -80,7 +81,7 @@ public sealed partial class DockViewModel
|
||||
}
|
||||
|
||||
private void SetupBands(
|
||||
List<DockBandSettings> bands,
|
||||
ImmutableList<DockBandSettings> bands,
|
||||
ObservableCollection<DockBandViewModel> target)
|
||||
{
|
||||
List<DockBandViewModel> newBands = new();
|
||||
@@ -148,7 +149,7 @@ public sealed partial class DockViewModel
|
||||
DockBandSettings bandSettings,
|
||||
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!
|
||||
return band;
|
||||
@@ -156,7 +157,7 @@ public sealed partial class DockViewModel
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
_settingsService.Save();
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = _settings });
|
||||
}
|
||||
|
||||
public DockBandViewModel? FindBandByTopLevel(TopLevelViewModel tlc)
|
||||
@@ -201,7 +202,7 @@ public sealed partial class DockViewModel
|
||||
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
|
||||
{
|
||||
var bandId = band.Id;
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var dockSettings = _settings;
|
||||
|
||||
var bandSettings = dockSettings.StartBands.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
|
||||
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
|
||||
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
|
||||
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
|
||||
var newDock = dockSettings with
|
||||
{
|
||||
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
|
||||
var targetSettings = targetSide switch
|
||||
var targetList = targetSide switch
|
||||
{
|
||||
DockPinSide.Start => dockSettings.StartBands,
|
||||
DockPinSide.Center => dockSettings.CenterBands,
|
||||
DockPinSide.End => dockSettings.EndBands,
|
||||
_ => dockSettings.StartBands,
|
||||
DockPinSide.Start => newDock.StartBands,
|
||||
DockPinSide.Center => newDock.CenterBands,
|
||||
DockPinSide.End => newDock.EndBands,
|
||||
_ => newDock.StartBands,
|
||||
};
|
||||
var insertIndex = Math.Min(targetIndex, targetSettings.Count);
|
||||
targetSettings.Insert(insertIndex, bandSettings);
|
||||
var insertIndex = Math.Min(targetIndex, targetList.Count);
|
||||
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>
|
||||
@@ -236,7 +247,7 @@ public sealed partial class DockViewModel
|
||||
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
|
||||
{
|
||||
var bandId = band.Id;
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var dockSettings = _settings;
|
||||
|
||||
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
|
||||
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
|
||||
@@ -248,10 +259,15 @@ public sealed partial class DockViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from all sides (settings and UI)
|
||||
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
|
||||
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
|
||||
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
|
||||
// Remove from all sides (settings)
|
||||
var newDock = dockSettings with
|
||||
{
|
||||
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);
|
||||
CenterItems.Remove(band);
|
||||
EndItems.Remove(band);
|
||||
@@ -261,8 +277,8 @@ public sealed partial class DockViewModel
|
||||
{
|
||||
case DockPinSide.Start:
|
||||
{
|
||||
var settingsIndex = Math.Min(targetIndex, dockSettings.StartBands.Count);
|
||||
dockSettings.StartBands.Insert(settingsIndex, bandSettings);
|
||||
var settingsIndex = Math.Min(targetIndex, newDock.StartBands.Count);
|
||||
newDock = newDock with { StartBands = newDock.StartBands.Insert(settingsIndex, bandSettings) };
|
||||
|
||||
var uiIndex = Math.Min(targetIndex, StartItems.Count);
|
||||
StartItems.Insert(uiIndex, band);
|
||||
@@ -271,8 +287,8 @@ public sealed partial class DockViewModel
|
||||
|
||||
case DockPinSide.Center:
|
||||
{
|
||||
var settingsIndex = Math.Min(targetIndex, dockSettings.CenterBands.Count);
|
||||
dockSettings.CenterBands.Insert(settingsIndex, bandSettings);
|
||||
var settingsIndex = Math.Min(targetIndex, newDock.CenterBands.Count);
|
||||
newDock = newDock with { CenterBands = newDock.CenterBands.Insert(settingsIndex, bandSettings) };
|
||||
|
||||
var uiIndex = Math.Min(targetIndex, CenterItems.Count);
|
||||
CenterItems.Insert(uiIndex, band);
|
||||
@@ -281,8 +297,8 @@ public sealed partial class DockViewModel
|
||||
|
||||
case DockPinSide.End:
|
||||
{
|
||||
var settingsIndex = Math.Min(targetIndex, dockSettings.EndBands.Count);
|
||||
dockSettings.EndBands.Insert(settingsIndex, bandSettings);
|
||||
var settingsIndex = Math.Min(targetIndex, newDock.EndBands.Count);
|
||||
newDock = newDock with { EndBands = newDock.EndBands.Insert(settingsIndex, bandSettings) };
|
||||
|
||||
var uiIndex = Math.Min(targetIndex, EndItems.Count);
|
||||
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)");
|
||||
}
|
||||
|
||||
@@ -305,22 +323,57 @@ public sealed partial class DockViewModel
|
||||
band.SaveShowLabels();
|
||||
}
|
||||
|
||||
_snapshotStartBands = null;
|
||||
_snapshotCenterBands = null;
|
||||
_snapshotEndBands = null;
|
||||
// Preserve any per-band label edits made while in edit mode. Those edits are
|
||||
// saved independently of reorder, so merge the latest band settings back into
|
||||
// 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;
|
||||
|
||||
// Save without hotReload to avoid triggering SettingsChanged → SetupBands,
|
||||
// which could race with stale DockBands_CollectionChanged work items and
|
||||
// re-add bands that were just unpinned.
|
||||
_settingsService.Save(hotReload: false);
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = _settings }, false);
|
||||
_isEditing = false;
|
||||
Logger.LogDebug("Saved band order to settings");
|
||||
}
|
||||
|
||||
private List<DockBandSettings>? _snapshotStartBands;
|
||||
private List<DockBandSettings>? _snapshotCenterBands;
|
||||
private List<DockBandSettings>? _snapshotEndBands;
|
||||
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(DockSettings dockSettings)
|
||||
{
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
@@ -332,12 +385,14 @@ public sealed partial class DockViewModel
|
||||
_isEditing = true;
|
||||
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
_snapshotStartBands = dockSettings.StartBands.Select(b => b.Clone()).ToList();
|
||||
_snapshotCenterBands = dockSettings.CenterBands.Select(b => b.Clone()).ToList();
|
||||
_snapshotEndBands = dockSettings.EndBands.Select(b => b.Clone()).ToList();
|
||||
|
||||
var snapshotStartBandsCount = dockSettings.StartBands.Count;
|
||||
var snapshotCenterBandsCount = dockSettings.CenterBands.Count;
|
||||
var snapshotEndBandsCount = dockSettings.EndBands.Count;
|
||||
|
||||
// Snapshot band ViewModels so we can restore unpinned bands
|
||||
// Use a dictionary but handle potential duplicates gracefully
|
||||
_snapshotDockSettings = dockSettings;
|
||||
_snapshotBandViewModels = new Dictionary<string, DockBandViewModel>();
|
||||
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
|
||||
{
|
||||
@@ -350,7 +405,7 @@ public sealed partial class DockViewModel
|
||||
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>
|
||||
@@ -359,9 +414,7 @@ public sealed partial class DockViewModel
|
||||
/// </summary>
|
||||
public void RestoreBandOrder()
|
||||
{
|
||||
if (_snapshotStartBands == null ||
|
||||
_snapshotCenterBands == null ||
|
||||
_snapshotEndBands == null || _snapshotBandViewModels == null)
|
||||
if (_snapshotDockSettings == null || _snapshotBandViewModels == null)
|
||||
{
|
||||
Logger.LogWarning("No snapshot to restore from");
|
||||
return;
|
||||
@@ -373,37 +426,13 @@ public sealed partial class DockViewModel
|
||||
band.RestoreShowLabels();
|
||||
}
|
||||
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Restore settings from snapshot (immutable = just assign back)
|
||||
_settings = _snapshotDockSettings;
|
||||
|
||||
// Rebuild UI collections from restored settings using the snapshotted ViewModels
|
||||
RebuildUICollectionsFromSnapshot();
|
||||
|
||||
_snapshotStartBands = null;
|
||||
_snapshotCenterBands = null;
|
||||
_snapshotEndBands = null;
|
||||
_snapshotDockSettings = null;
|
||||
_snapshotBandViewModels = null;
|
||||
_isEditing = false;
|
||||
Logger.LogDebug("Restored band order from snapshot");
|
||||
@@ -416,7 +445,7 @@ public sealed partial class DockViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var dockSettings = _settings;
|
||||
|
||||
StartItems.Clear();
|
||||
CenterItems.Clear();
|
||||
@@ -449,7 +478,7 @@ public sealed partial class DockViewModel
|
||||
|
||||
private void RebuildUICollections()
|
||||
{
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var dockSettings = _settings;
|
||||
|
||||
// Create a lookup of all current band ViewModels
|
||||
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
|
||||
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId, ShowLabels = null };
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var dockSettings = _settings;
|
||||
|
||||
// Create the band view model
|
||||
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
|
||||
@@ -535,15 +564,15 @@ public sealed partial class DockViewModel
|
||||
switch (targetSide)
|
||||
{
|
||||
case DockPinSide.Start:
|
||||
dockSettings.StartBands.Add(bandSettings);
|
||||
_settings = dockSettings with { StartBands = dockSettings.StartBands.Add(bandSettings) };
|
||||
StartItems.Add(bandVm);
|
||||
break;
|
||||
case DockPinSide.Center:
|
||||
dockSettings.CenterBands.Add(bandSettings);
|
||||
_settings = dockSettings with { CenterBands = dockSettings.CenterBands.Add(bandSettings) };
|
||||
CenterItems.Add(bandVm);
|
||||
break;
|
||||
case DockPinSide.End:
|
||||
dockSettings.EndBands.Add(bandSettings);
|
||||
_settings = dockSettings with { EndBands = dockSettings.EndBands.Add(bandSettings) };
|
||||
EndItems.Add(bandVm);
|
||||
break;
|
||||
}
|
||||
@@ -566,12 +595,15 @@ public sealed partial class DockViewModel
|
||||
public void UnpinBand(DockBandViewModel band)
|
||||
{
|
||||
var bandId = band.Id;
|
||||
var dockSettings = _settingsService.Settings.DockSettings;
|
||||
var dockSettings = _settings;
|
||||
|
||||
// Remove from settings
|
||||
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
|
||||
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
|
||||
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
|
||||
_settings = dockSettings with
|
||||
{
|
||||
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);
|
||||
@@ -635,7 +667,7 @@ public sealed partial class DockViewModel
|
||||
var isDockEnabled = _settingsService.Settings.EnableDock;
|
||||
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}"));
|
||||
|
||||
var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty;
|
||||
|
||||
@@ -47,10 +47,10 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.Theme != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.Theme = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { Theme = value } });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(ThemeIndex));
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,10 +68,10 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.Backdrop != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.Backdrop = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { Backdrop = value } });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(BackdropIndex));
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.ColorizationMode != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.ColorizationMode = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { ColorizationMode = value } });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(ColorizationModeIndex));
|
||||
OnPropertyChanged(nameof(IsCustomTintVisible));
|
||||
@@ -99,7 +99,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
|
||||
IsColorizationDetailsExpanded = value != ColorizationMode.None;
|
||||
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.CustomThemeColor != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.CustomThemeColor = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { CustomThemeColor = value } });
|
||||
|
||||
OnPropertyChanged();
|
||||
|
||||
@@ -126,7 +126,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
ColorIntensity = 100;
|
||||
}
|
||||
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,9 +136,9 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
get => _settingsService.Settings.DockSettings.CustomThemeColorIntensity;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.DockSettings.CustomThemeColorIntensity = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { CustomThemeColorIntensity = value } });
|
||||
OnPropertyChanged();
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.BackgroundImagePath != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.BackgroundImagePath = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImagePath = value } });
|
||||
OnPropertyChanged();
|
||||
|
||||
if (BackgroundImageOpacity == 0)
|
||||
@@ -157,7 +157,7 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
BackgroundImageOpacity = 100;
|
||||
}
|
||||
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,9 +169,9 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.BackgroundImageOpacity != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.BackgroundImageOpacity = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImageOpacity = value } });
|
||||
OnPropertyChanged();
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,9 +183,9 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.BackgroundImageBrightness != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.BackgroundImageBrightness = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImageBrightness = value } });
|
||||
OnPropertyChanged();
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,9 +197,9 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.BackgroundImageBlurAmount != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.BackgroundImageBlurAmount = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImageBlurAmount = value } });
|
||||
OnPropertyChanged();
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,10 +211,10 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
{
|
||||
if (_settingsService.Settings.DockSettings.BackgroundImageFit != value)
|
||||
{
|
||||
_settingsService.Settings.DockSettings.BackgroundImageFit = value;
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { BackgroundImageFit = value } });
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(BackgroundImageFitIndex));
|
||||
Save();
|
||||
DebouncedReapply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,9 +298,8 @@ public sealed partial class DockAppearanceSettingsViewModel : ObservableObject,
|
||||
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
private void Save()
|
||||
private void DebouncedReapply()
|
||||
{
|
||||
_settingsService.Save();
|
||||
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ using System.Text.Json.Serialization;
|
||||
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -12,7 +12,9 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
public partial class FallbackSettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly FallbackSettings _fallbackSettings;
|
||||
private readonly ProviderSettingsViewModel _providerSettingsViewModel;
|
||||
|
||||
private FallbackSettings _fallbackSettings;
|
||||
|
||||
public string DisplayName { get; private set; } = string.Empty;
|
||||
|
||||
@@ -27,15 +29,18 @@ public partial class FallbackSettingsViewModel : ObservableObject
|
||||
{
|
||||
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));
|
||||
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,15 +52,18 @@ public partial class FallbackSettingsViewModel : ObservableObject
|
||||
{
|
||||
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));
|
||||
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +75,7 @@ public partial class FallbackSettingsViewModel : ObservableObject
|
||||
ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_providerSettingsViewModel = providerSettings;
|
||||
_fallbackSettings = fallbackSettings;
|
||||
|
||||
Id = fallback.Id;
|
||||
@@ -77,10 +86,4 @@ public partial class FallbackSettingsViewModel : ObservableObject
|
||||
Icon = new(fallback.InitialIcon);
|
||||
Icon.InitializeProperties();
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
_settingsService.Save();
|
||||
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -11,36 +11,28 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
public partial class HotkeyManager : ObservableObject
|
||||
{
|
||||
private readonly TopLevelCommandManager _topLevelCommandManager;
|
||||
private readonly List<TopLevelHotkey> _commandHotkeys;
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public HotkeyManager(TopLevelCommandManager tlcManager, ISettingsService settingsService)
|
||||
{
|
||||
_topLevelCommandManager = tlcManager;
|
||||
_commandHotkeys = settingsService.Settings.CommandHotkeys;
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public void UpdateHotkey(string commandId, HotkeySettings? hotkey)
|
||||
{
|
||||
// If any of the commands were already bound to this hotkey, remove that
|
||||
foreach (var item in _commandHotkeys)
|
||||
_settingsService.UpdateSettings(s =>
|
||||
{
|
||||
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);
|
||||
|
||||
foreach (var item in _commandHotkeys)
|
||||
{
|
||||
if (item.CommandId == commandId)
|
||||
{
|
||||
_commandHotkeys.Remove(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_commandHotkeys.Add(new(hotkey, commandId));
|
||||
return s with { CommandHotkeys = hotkeys };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,37 +2,39 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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
|
||||
private readonly string[] _excludedBuiltInFallbacks = [
|
||||
private static readonly string[] _excludedBuiltInFallbacks = [
|
||||
"com.microsoft.cmdpal.builtin.indexer.fallback",
|
||||
"com.microsoft.cmdpal.builtin.calculator.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]
|
||||
public string ProviderDisplayName { get; set; } = string.Empty;
|
||||
public string ProviderId { get; init; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public string ProviderId { get; private set; } = string.Empty;
|
||||
public bool IsBuiltin { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsBuiltin { get; private set; }
|
||||
public string ProviderDisplayName { get; init; } = string.Empty;
|
||||
|
||||
public ProviderSettings(CommandProviderWrapper wrapper)
|
||||
public ProviderSettings()
|
||||
{
|
||||
Connect(wrapper);
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
@@ -41,28 +43,51 @@ public class ProviderSettings
|
||||
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;
|
||||
IsBuiltin = wrapper.Extension is null;
|
||||
|
||||
ProviderDisplayName = wrapper.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(wrapper.ProviderId))
|
||||
{
|
||||
throw new ArgumentException("ProviderId must not be null, empty, or whitespace.", nameof(wrapper));
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
var builder = FallbackCommands.ToBuilder();
|
||||
if (wrapper.FallbackItems.Length > 0)
|
||||
{
|
||||
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);
|
||||
FallbackCommands[fallback.Id] = new FallbackSettings(enableGlobalResults);
|
||||
var enableGlobalResults = (wrapper.Extension is null)
|
||||
&& !_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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
private static readonly CompositeFormat ExtensionSubtextDisabledFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_disabled);
|
||||
|
||||
private readonly CommandProviderWrapper _provider;
|
||||
private readonly ProviderSettings _providerSettings;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly Lock _initializeSettingsLock = new();
|
||||
|
||||
private ProviderSettings _providerSettings;
|
||||
|
||||
private Task? _initializeSettingsTask;
|
||||
|
||||
public ProviderSettingsViewModel(
|
||||
@@ -71,8 +72,13 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
{
|
||||
if (value != _providerSettings.IsEnabled)
|
||||
{
|
||||
_providerSettings.IsEnabled = value;
|
||||
Save();
|
||||
var newSettings = _providerSettings with { IsEnabled = value };
|
||||
_settingsService.UpdateSettings(s => s with
|
||||
{
|
||||
|
||||
ProviderSettings = s.ProviderSettings.SetItem(_provider.ProviderId, newSettings),
|
||||
});
|
||||
_providerSettings = newSettings;
|
||||
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
||||
OnPropertyChanged(nameof(IsEnabled));
|
||||
OnPropertyChanged(nameof(ExtensionSubtext));
|
||||
@@ -191,7 +197,20 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class RecentCommandsManager : ObservableObject, IRecentCommandsManager
|
||||
public record RecentCommandsManager : IRecentCommandsManager
|
||||
{
|
||||
[JsonInclude]
|
||||
internal List<HistoryItem> History { get; set; } = [];
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
internal ImmutableList<HistoryItem> History { get; init; } = ImmutableList<HistoryItem>.Empty;
|
||||
|
||||
public RecentCommandsManager()
|
||||
{
|
||||
@@ -20,64 +18,64 @@ public partial class RecentCommandsManager : ObservableObject, IRecentCommandsMa
|
||||
|
||||
public int GetCommandHistoryWeight(string commandId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = History
|
||||
var entry = History
|
||||
.Index()
|
||||
.Where(item => item.Item.CommandId == commandId)
|
||||
.FirstOrDefault();
|
||||
|
||||
// These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
|
||||
// match after one use.
|
||||
// Usually it has a weight of 84, compared to 109 for the VS cmd prompt
|
||||
if (entry.Item is not null)
|
||||
// These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
|
||||
// match after one use.
|
||||
// Usually it has a weight of 84, compared to 109 for the VS cmd prompt
|
||||
if (entry.Item is not null)
|
||||
{
|
||||
var index = entry.Index;
|
||||
|
||||
// First, add some weight based on how early in the list this appears
|
||||
var bucket = index switch
|
||||
{
|
||||
var index = entry.Index;
|
||||
_ when index <= 2 => 35,
|
||||
_ when index <= 10 => 25,
|
||||
_ when index <= 15 => 15,
|
||||
_ when index <= 35 => 10,
|
||||
_ => 5,
|
||||
};
|
||||
|
||||
// First, add some weight based on how early in the list this appears
|
||||
var bucket = index switch
|
||||
{
|
||||
var i when index <= 2 => 35,
|
||||
var i when index <= 10 => 25,
|
||||
var i when index <= 15 => 15,
|
||||
var i when index <= 35 => 10,
|
||||
_ => 5,
|
||||
};
|
||||
// Then, add weight for how often this is used, but cap the weight from usage.
|
||||
var uses = Math.Min(entry.Item.Uses * 5, 35);
|
||||
|
||||
// Then, add weight for how often this is used, but cap the weight from usage.
|
||||
var uses = Math.Min(entry.Item.Uses * 5, 35);
|
||||
|
||||
return bucket + uses;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return bucket + uses;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void AddHistoryItem(string commandId)
|
||||
/// <summary>
|
||||
/// Returns a new RecentCommandsManager with the given command added/promoted in history.
|
||||
/// Pure function — does not mutate this instance.
|
||||
/// </summary>
|
||||
public RecentCommandsManager WithHistoryItem(string commandId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = History
|
||||
.Where(item => item.CommandId == commandId)
|
||||
.FirstOrDefault();
|
||||
if (entry is null)
|
||||
{
|
||||
var newitem = new HistoryItem() { CommandId = commandId, Uses = 1 };
|
||||
History.Insert(0, newitem);
|
||||
}
|
||||
else
|
||||
{
|
||||
History.Remove(entry);
|
||||
entry.Uses++;
|
||||
History.Insert(0, entry);
|
||||
}
|
||||
var existing = History.FirstOrDefault(item => item.CommandId == commandId);
|
||||
ImmutableList<HistoryItem> newHistory;
|
||||
|
||||
if (History.Count > 50)
|
||||
{
|
||||
History.RemoveRange(50, History.Count - 50);
|
||||
}
|
||||
if (existing is not null)
|
||||
{
|
||||
newHistory = History.Remove(existing);
|
||||
var updated = existing with { Uses = existing.Uses + 1 };
|
||||
newHistory = newHistory.Insert(0, updated);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newItem = new HistoryItem { CommandId = commandId, Uses = 1 };
|
||||
newHistory = History.Insert(0, newItem);
|
||||
}
|
||||
|
||||
if (newHistory.Count > 50)
|
||||
{
|
||||
newHistory = newHistory.RemoveRange(50, newHistory.Count - 50);
|
||||
}
|
||||
|
||||
return this with { History = newHistory };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,5 +83,5 @@ public interface IRecentCommandsManager
|
||||
{
|
||||
int GetCommandHistoryWeight(string commandId);
|
||||
|
||||
void AddHistoryItem(string commandId);
|
||||
RecentCommandsManager WithHistoryItem(string commandId);
|
||||
}
|
||||
|
||||
@@ -22,20 +22,35 @@ public sealed class AppStateService : IAppStateService
|
||||
_persistence = persistence;
|
||||
_appInfoService = appInfoService;
|
||||
_filePath = StateJsonPath();
|
||||
State = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel);
|
||||
_state = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel);
|
||||
}
|
||||
|
||||
private AppStateModel _state;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AppStateModel State { get; private set; }
|
||||
public AppStateModel State => Volatile.Read(ref _state);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event TypedEventHandler<IAppStateService, AppStateModel>? StateChanged;
|
||||
|
||||
/// <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);
|
||||
StateChanged?.Invoke(this, State);
|
||||
AppStateModel snapshot;
|
||||
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()
|
||||
|
||||
@@ -21,6 +21,12 @@ public interface IAppStateService
|
||||
/// </summary>
|
||||
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>
|
||||
/// Raised after state has been saved to disk.
|
||||
/// </summary>
|
||||
|
||||
@@ -22,6 +22,12 @@ public interface ISettingsService
|
||||
/// <param name="hotReload">When <see langword="true"/>, raises <see cref="SettingsChanged"/> after saving.</param>
|
||||
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>
|
||||
/// Raised after settings are saved with <paramref name="hotReload"/> enabled, or after <see cref="Reload"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -29,27 +29,38 @@ public sealed class SettingsService : ISettingsService
|
||||
_persistence = persistence;
|
||||
_appInfoService = appInfoService;
|
||||
_filePath = SettingsJsonPath();
|
||||
Settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel);
|
||||
_settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel);
|
||||
ApplyMigrations();
|
||||
}
|
||||
|
||||
private SettingsModel _settings;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SettingsModel Settings { get; private set; }
|
||||
public SettingsModel Settings => Volatile.Read(ref _settings);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event TypedEventHandler<ISettingsService, SettingsModel>? SettingsChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Save(bool hotReload = true)
|
||||
{
|
||||
_persistence.Save(
|
||||
Settings,
|
||||
_filePath,
|
||||
JsonSerializationContext.Default.SettingsModel);
|
||||
public void Save(bool hotReload = true) => UpdateSettings(s => s, hotReload);
|
||||
|
||||
/// <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)
|
||||
{
|
||||
SettingsChanged?.Invoke(this, Settings);
|
||||
SettingsChanged?.Invoke(this, newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +82,10 @@ public sealed class SettingsService : ISettingsService
|
||||
migratedAny |= TryMigrate(
|
||||
"Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)",
|
||||
root,
|
||||
Settings,
|
||||
ref _settings,
|
||||
nameof(SettingsModel.AutoGoHomeInterval),
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -89,13 +100,15 @@ public sealed class SettingsService : ISettingsService
|
||||
}
|
||||
}
|
||||
|
||||
private delegate void MigrationApply<T>(ref SettingsModel model, T value);
|
||||
|
||||
private static bool TryMigrate<T>(
|
||||
string migrationName,
|
||||
JsonObject root,
|
||||
SettingsModel model,
|
||||
ref SettingsModel model,
|
||||
string newKey,
|
||||
string oldKey,
|
||||
Action<SettingsModel, T> apply,
|
||||
MigrationApply<T> apply,
|
||||
JsonTypeInfo<T> jsonTypeInfo)
|
||||
{
|
||||
try
|
||||
@@ -108,7 +121,7 @@ public sealed class SettingsService : ISettingsService
|
||||
if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null)
|
||||
{
|
||||
var value = oldNode.Deserialize(jsonTypeInfo);
|
||||
apply(model, value!);
|
||||
apply(ref model, value!);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
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 are in <see cref="DockBandSettings"/>.
|
||||
/// </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>
|
||||
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>
|
||||
// public List<string> PinnedCommands { get; set; } = [];
|
||||
public List<DockBandSettings> StartBands { get; set; } = [];
|
||||
|
||||
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
|
||||
public ImmutableList<DockBandSettings> StartBands { get; init; } = ImmutableList.Create(
|
||||
new DockBandSettings
|
||||
{
|
||||
ProviderId = "com.microsoft.cmdpal.builtin.core",
|
||||
CommandId = "com.microsoft.cmdpal.home",
|
||||
});
|
||||
StartBands.Add(new DockBandSettings
|
||||
},
|
||||
new DockBandSettings
|
||||
{
|
||||
ProviderId = "WinGet",
|
||||
CommandId = "com.microsoft.cmdpal.winget",
|
||||
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",
|
||||
CommandId = "com.microsoft.cmdpal.performanceWidget",
|
||||
});
|
||||
EndBands.Add(new DockBandSettings
|
||||
},
|
||||
new DockBandSettings
|
||||
{
|
||||
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
|
||||
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>
|
||||
/// Settings for a specific dock band. These are per-band settings stored
|
||||
/// within the overall <see cref="DockSettings"/>.
|
||||
/// </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>
|
||||
/// Gets or sets whether titles are shown for items in this band.
|
||||
/// If null, falls back to dock-wide ShowLabels setting.
|
||||
/// </summary>
|
||||
public bool? ShowTitles { get; set; }
|
||||
public bool? ShowTitles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether subtitles are shown for items in this band.
|
||||
/// If null, falls back to dock-wide ShowLabels setting.
|
||||
/// </summary>
|
||||
public bool? ShowSubtitles { get; set; }
|
||||
public bool? ShowSubtitles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for backward compatibility. Maps to ShowTitles.
|
||||
@@ -118,7 +109,7 @@ public class DockBandSettings
|
||||
public bool? ShowLabels
|
||||
{
|
||||
get => ShowTitles;
|
||||
set => ShowTitles = value;
|
||||
init => ShowTitles = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -134,17 +125,6 @@ public class DockBandSettings
|
||||
/// dock-wide setting (passed as <paramref name="defaultValue"/>).
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
@@ -39,24 +39,24 @@ public record HotkeySettings// : ICmdLineRepresentable
|
||||
}
|
||||
|
||||
[JsonPropertyName("win")]
|
||||
public bool Win { get; set; }
|
||||
public bool Win { get; init; }
|
||||
|
||||
[JsonPropertyName("ctrl")]
|
||||
public bool Ctrl { get; set; }
|
||||
public bool Ctrl { get; init; }
|
||||
|
||||
[JsonPropertyName("alt")]
|
||||
public bool Alt { get; set; }
|
||||
public bool Alt { get; init; }
|
||||
|
||||
[JsonPropertyName("shift")]
|
||||
public bool Shift { get; set; }
|
||||
public bool Shift { get; init; }
|
||||
|
||||
[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
|
||||
// see src\common\settings_objects.h
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
|
||||
@@ -2,106 +2,114 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
using Windows.UI;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class SettingsModel : ObservableObject
|
||||
public record SettingsModel
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// SETTINGS HERE
|
||||
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
|
||||
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>
|
||||
|
||||
// END SETTINGS
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public ProviderSettings GetProviderSettings(CommandProviderWrapper provider)
|
||||
public (SettingsModel Model, ProviderSettings Settings) GetProviderSettings(CommandProviderWrapper provider)
|
||||
{
|
||||
ProviderSettings? settings;
|
||||
if (!ProviderSettings.TryGetValue(provider.ProviderId, out settings))
|
||||
if (!ProviderSettings.TryGetValue(provider.ProviderId, out var settings))
|
||||
{
|
||||
settings = new ProviderSettings(provider);
|
||||
settings.Connect(provider);
|
||||
ProviderSettings[provider.ProviderId] = settings;
|
||||
}
|
||||
else
|
||||
{
|
||||
settings.Connect(provider);
|
||||
settings = new ProviderSettings();
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -150,6 +158,13 @@ public partial class SettingsModel : ObservableObject
|
||||
[JsonSerializable(typeof(RecentCommandsManager))]
|
||||
[JsonSerializable(typeof(List<string>), TypeInfoPropertyName = "StringList")]
|
||||
[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")]
|
||||
[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")]
|
||||
|
||||
@@ -41,9 +41,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.Hotkey;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.Hotkey = value ?? SettingsModel.DefaultActivationShortcut;
|
||||
_settingsService.UpdateSettings(s => s with { Hotkey = value ?? SettingsModel.DefaultActivationShortcut });
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey)));
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +51,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.UseLowLevelGlobalHotkey;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.UseLowLevelGlobalHotkey = value;
|
||||
_settingsService.UpdateSettings(s => s with { UseLowLevelGlobalHotkey = value });
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey)));
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +61,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.AllowExternalReload;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.AllowExternalReload = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { AllowExternalReload = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +70,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.ShowAppDetails;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.ShowAppDetails = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { ShowAppDetails = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +79,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.BackspaceGoesBack;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.BackspaceGoesBack = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { BackspaceGoesBack = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +88,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.SingleClickActivates;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.SingleClickActivates = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { SingleClickActivates = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +97,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.HighlightSearchOnActivate;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.HighlightSearchOnActivate = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { HighlightSearchOnActivate = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +106,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.KeepPreviousQuery;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.KeepPreviousQuery = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { KeepPreviousQuery = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +115,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => (int)_settingsService.Settings.SummonOn;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.SummonOn = (MonitorBehavior)value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { SummonOn = (MonitorBehavior)value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +124,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.ShowSystemTrayIcon;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.ShowSystemTrayIcon = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { ShowSystemTrayIcon = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +133,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.IgnoreShortcutWhenFullscreen;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.IgnoreShortcutWhenFullscreen = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { IgnoreShortcutWhenFullscreen = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +142,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.DisableAnimations;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.DisableAnimations = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { DisableAnimations = value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,10 +158,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
{
|
||||
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;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,8 +177,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.DockSettings.Side;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.DockSettings.Side = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { Side = value } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +186,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.DockSettings.DockSize;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.DockSettings.DockSize = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { DockSize = value } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +195,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.DockSettings.Backdrop;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.DockSettings.Backdrop = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { Backdrop = value } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,8 +204,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.DockSettings.ShowLabels;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.DockSettings.ShowLabels = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { ShowLabels = value } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,8 +213,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
get => _settingsService.Settings.EnableDock;
|
||||
set
|
||||
{
|
||||
_settingsService.Settings.EnableDock = value;
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { EnableDock = 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
|
||||
}
|
||||
@@ -245,7 +225,11 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
|
||||
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;
|
||||
_topLevelCommandManager = topLevelCommandManager;
|
||||
@@ -259,15 +243,27 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
var fallbacks = new List<FallbackSettingsViewModel>();
|
||||
var currentRankings = _settingsService.Settings.FallbackRanks;
|
||||
var needsSave = false;
|
||||
var currentSettingsModel = _settingsService.Settings;
|
||||
|
||||
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);
|
||||
CommandProviders.Add(settingsModel);
|
||||
var providerSettingsModel = new ProviderSettingsViewModel(item, providerSettings, settingsService);
|
||||
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);
|
||||
@@ -306,10 +302,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
|
||||
public void ApplyFallbackSort()
|
||||
{
|
||||
_settingsService.Settings.FallbackRanks = FallbackRankings.Select(s => s.Id).ToArray();
|
||||
Save();
|
||||
_settingsService.UpdateSettings(s => s with { FallbackRanks = FallbackRankings.Select(s2 => s2.Id).ToArray() });
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings)));
|
||||
}
|
||||
|
||||
private void Save() => _settingsService.Save();
|
||||
}
|
||||
|
||||
@@ -7,9 +7,15 @@ using Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
|
||||
{
|
||||
if (Alias is CommandAlias a)
|
||||
{
|
||||
a.Alias = value;
|
||||
Alias = a with { Alias = value };
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -146,7 +146,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
|
||||
{
|
||||
if (Alias is CommandAlias a)
|
||||
{
|
||||
a.IsDirect = value;
|
||||
Alias = a with { IsDirect = value };
|
||||
}
|
||||
|
||||
HandleChangeAlias();
|
||||
|
||||
@@ -92,7 +92,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
|
||||
var providerId = providerContext.ProviderId;
|
||||
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
|
||||
{
|
||||
var providerSettings = _settingsService.Settings.GetProviderSettings(provider);
|
||||
var (_, providerSettings) = _settingsService.Settings.GetProviderSettings(provider);
|
||||
|
||||
var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId);
|
||||
|
||||
@@ -159,7 +159,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
|
||||
var providerId = providerContext.ProviderId;
|
||||
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
|
||||
{
|
||||
var providerSettings = _settingsService.Settings.GetProviderSettings(provider);
|
||||
var (_, providerSettings) = _settingsService.Settings.GetProviderSettings(provider);
|
||||
|
||||
var isPinnedSubCommand = providerSettings.PinnedCommandIds.Contains(itemId);
|
||||
if (isPinnedSubCommand)
|
||||
|
||||
@@ -186,7 +186,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
|
||||
_ = _modifierKeysOnEntering.Remove(virtualKey);
|
||||
}
|
||||
|
||||
internalSettings.Win = matchValue;
|
||||
internalSettings = internalSettings with { Win = matchValue };
|
||||
break;
|
||||
case VirtualKey.Control:
|
||||
case VirtualKey.LeftControl:
|
||||
@@ -197,7 +197,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
|
||||
_ = _modifierKeysOnEntering.Remove(VirtualKey.Control);
|
||||
}
|
||||
|
||||
internalSettings.Ctrl = matchValue;
|
||||
internalSettings = internalSettings with { Ctrl = matchValue };
|
||||
break;
|
||||
case VirtualKey.Menu:
|
||||
case VirtualKey.LeftMenu:
|
||||
@@ -208,7 +208,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
|
||||
_ = _modifierKeysOnEntering.Remove(VirtualKey.Menu);
|
||||
}
|
||||
|
||||
internalSettings.Alt = matchValue;
|
||||
internalSettings = internalSettings with { Alt = matchValue };
|
||||
break;
|
||||
case VirtualKey.Shift:
|
||||
case VirtualKey.LeftShift:
|
||||
@@ -219,14 +219,14 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
|
||||
_ = _modifierKeysOnEntering.Remove(VirtualKey.Shift);
|
||||
}
|
||||
|
||||
internalSettings.Shift = matchValue;
|
||||
internalSettings = internalSettings with { Shift = matchValue };
|
||||
break;
|
||||
case VirtualKey.Escape:
|
||||
internalSettings = new HotkeySettings();
|
||||
shortcutDialog.IsPrimaryButtonEnabled = false;
|
||||
return;
|
||||
default:
|
||||
internalSettings.Code = matchValueCode;
|
||||
internalSettings = internalSettings with { Code = matchValueCode };
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie
|
||||
else if (internalSettings.Shift && !_modifierKeysOnEntering.Contains(VirtualKey.Shift) && !internalSettings.Win && !internalSettings.Alt && !internalSettings.Ctrl)
|
||||
{
|
||||
// This is to reset the shift key press within the control as it was not used within the control but rather was used to leave the hotkey.
|
||||
internalSettings.Shift = false;
|
||||
internalSettings = internalSettings with { Shift = false };
|
||||
|
||||
SendSingleKeyboardInput((short)VirtualKey.Shift, (uint)NativeKeyboardHelper.KeyEventF.KeyDown);
|
||||
|
||||
|
||||
@@ -910,8 +910,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
// the last non-dock placement because dock sessions intentionally skip updates.
|
||||
if (_currentWindowPosition.IsSizeValid)
|
||||
{
|
||||
settings.LastWindowPosition = _currentWindowPosition;
|
||||
settingsService.Save();
|
||||
settingsService.UpdateSettings(s => s with { LastWindowPosition = _currentWindowPosition });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CmdPal.Common.Services;
|
||||
using Microsoft.CmdPal.UI.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
@@ -19,10 +20,13 @@ internal sealed class RunHistoryService : IRunHistoryService
|
||||
|
||||
public IReadOnlyList<string> GetRunHistory()
|
||||
{
|
||||
if (_appStateService.State.RunHistory.Count == 0)
|
||||
if (_appStateService.State.RunHistory.IsEmpty)
|
||||
{
|
||||
var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory();
|
||||
_appStateService.State.RunHistory.AddRange(history);
|
||||
_appStateService.UpdateState(state => state with
|
||||
{
|
||||
RunHistory = history.ToImmutableList(),
|
||||
});
|
||||
}
|
||||
|
||||
return _appStateService.State.RunHistory;
|
||||
@@ -30,22 +34,24 @@ internal sealed class RunHistoryService : IRunHistoryService
|
||||
|
||||
public void ClearRunHistory()
|
||||
{
|
||||
_appStateService.State.RunHistory.Clear();
|
||||
_appStateService.UpdateState(state => state with
|
||||
{
|
||||
RunHistory = ImmutableList<string>.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
public void AddRunHistoryItem(string item)
|
||||
{
|
||||
// insert at the beginning of the list
|
||||
if (string.IsNullOrWhiteSpace(item))
|
||||
{
|
||||
return; // Do not add empty or whitespace items
|
||||
return;
|
||||
}
|
||||
|
||||
_appStateService.State.RunHistory.Remove(item);
|
||||
|
||||
// Add the item to the front of the history
|
||||
_appStateService.State.RunHistory.Insert(0, item);
|
||||
|
||||
_appStateService.Save();
|
||||
_appStateService.UpdateState(state => state with
|
||||
{
|
||||
RunHistory = state.RunHistory
|
||||
.Remove(item)
|
||||
.Insert(0, item),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using Microsoft.CmdPal.Common.Services;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
@@ -41,7 +42,7 @@ public class AppStateServiceTests
|
||||
// Arrange
|
||||
var expectedState = new AppStateModel
|
||||
{
|
||||
RunHistory = new List<string> { "command1", "command2" },
|
||||
RunHistory = ImmutableList.Create("command1", "command2"),
|
||||
};
|
||||
_mockPersistence
|
||||
.Setup(p => p.Load(
|
||||
@@ -86,7 +87,8 @@ public class AppStateServiceTests
|
||||
{
|
||||
// Arrange
|
||||
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
|
||||
service.Save();
|
||||
@@ -160,4 +162,44 @@ public class AppStateServiceTests
|
||||
// Assert
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
{
|
||||
foreach (var item in commandIds)
|
||||
{
|
||||
history.AddHistoryItem(item);
|
||||
history = history.WithHistoryItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
var history = CreateHistory();
|
||||
|
||||
// Act
|
||||
history.AddHistoryItem("com.microsoft.cmdpal.shell");
|
||||
history = history.WithHistoryItem("com.microsoft.cmdpal.shell");
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0);
|
||||
@@ -121,7 +121,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
var history = new RecentCommandsManager();
|
||||
foreach (var item in items)
|
||||
{
|
||||
history.AddHistoryItem(item.Id);
|
||||
history = history.WithHistoryItem(item.Id);
|
||||
}
|
||||
|
||||
return history;
|
||||
@@ -417,7 +417,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
// Add extra uses of VS Code to try and push it above Terminal
|
||||
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();
|
||||
@@ -446,7 +446,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
var vsCodeId = items[1].Id;
|
||||
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 weightedMatches = GetMatches(items, weightedScores).ToList();
|
||||
|
||||
@@ -75,7 +75,14 @@ public class SettingsServiceTests
|
||||
public void Settings_ReturnsLoadedModel()
|
||||
{
|
||||
// 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
|
||||
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
|
||||
@@ -89,7 +96,10 @@ public class SettingsServiceTests
|
||||
{
|
||||
// Arrange
|
||||
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
|
||||
service.Save(hotReload: false);
|
||||
@@ -178,4 +188,40 @@ public class SettingsServiceTests
|
||||
Assert.AreSame(service, receivedSender);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user