diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index fbe419e34e9f..5fde18835079 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -188,7 +188,7 @@ public async Task LoadTopLevelCommands(IServiceProvider serviceProvider) SupportsPinning = true; // Load pinned commands from saved settings - pinnedCommands = LoadPinnedCommands(four, providerSettings); + pinnedCommands = LoadPinnedCommands(four, settings); } Id = model.Id; @@ -240,12 +240,6 @@ private void RecallFromCache() } } - private record TopLevelObjects( - ICommandItem[]? Commands, - IFallbackCommandItem[]? Fallbacks, - ICommandItem[]? PinnedCommands, - ICommandItem[]? DockBands); - private void InitializeCommands( TopLevelObjects objects, IServiceProvider serviceProvider, @@ -275,7 +269,24 @@ private void InitializeCommands( if (objects.PinnedCommands is not null) { - topLevelList.AddRange(objects.PinnedCommands.Select(c => make(c, TopLevelType.Normal))); + foreach (var pinnedCommand in objects.PinnedCommands) + { + var pinnedItem = make(pinnedCommand, TopLevelType.Normal); + var alreadyExists = false; + foreach (var existingItem in topLevelList) + { + if (existingItem.Id == pinnedItem.Id) + { + alreadyExists = true; + break; + } + } + + if (!alreadyExists) + { + topLevelList.Add(pinnedItem); + } + } } TopLevelItems = topLevelList.ToArray(); @@ -367,11 +378,11 @@ private void InitializeCommands( return null; } - private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, ProviderSettings providerSettings) + private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, SettingsModel settings) { var pinnedItems = new List(); - foreach (var pinnedId in providerSettings.PinnedCommandIds) + foreach (var pinnedId in settings.GetPinnedCommandIds(ProviderId)) { try { @@ -410,12 +421,9 @@ private void UnsafePreCacheApiAdditions(ICommandProvider2 provider) public void PinCommand(string commandId, IServiceProvider serviceProvider) { var settings = serviceProvider.GetService()!; - var providerSettings = GetProviderSettings(settings); - if (!providerSettings.PinnedCommandIds.Contains(commandId)) + if (settings.TryPinCommand(ProviderId, commandId)) { - providerSettings.PinnedCommandIds.Add(commandId); - // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); SettingsModel.SaveSettings(settings, false); @@ -425,9 +433,8 @@ public void PinCommand(string commandId, IServiceProvider serviceProvider) public void UnpinCommand(string commandId, IServiceProvider serviceProvider) { var settings = serviceProvider.GetService()!; - var providerSettings = GetProviderSettings(settings); - if (providerSettings.PinnedCommandIds.Remove(commandId)) + if (settings.TryUnpinCommand(ProviderId, commandId)) { // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); @@ -489,4 +496,10 @@ internal void PinDockBand(TopLevelViewModel bandVm) this.DockBandItems = bands.ToArray(); this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs()); } + + private record TopLevelObjects( + ICommandItem[]? Commands, + IFallbackCommandItem[]? Fallbacks, + ICommandItem[]? PinnedCommands, + ICommandItem[]? DockBands); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 6c06c7e39a59..ebbbf93790d2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -37,9 +37,14 @@ public sealed partial class MainListPage : DynamicListPage, // Stable separator instances so that the VM cache and InPlaceUpdateList // recognise them across successive GetItems() calls + private readonly Separator _pinnedSeparator = new(Resources.home_sections_pinned_title); private readonly Separator _resultsSeparator = new(Resources.results); private readonly Separator _fallbacksSeparator = new(Resources.fallbacks); + private TopLevelViewModel[]? _cachedPinnedViewModels; + private TopLevelViewModel[]? _cachedRegularViewModels; + private bool _defaultViewDirty = true; + private RoScored[]? _filteredItems; private RoScored[]? _filteredApps; @@ -80,6 +85,7 @@ public MainListPage( _tlcManager.PropertyChanged += TlcManager_PropertyChanged; _tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged; + _tlcManager.PinnedCommands.CollectionChanged += PinnedCommands_CollectionChanged; // The all apps page will kick off a BG thread to start loading apps. // We just want to know when it is done. @@ -110,8 +116,15 @@ private void TlcManager_PropertyChanged(object? sender, System.ComponentModel.Pr } } + private void PinnedCommands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + _defaultViewDirty = true; + RaiseItemsChanged(); + } + private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + _defaultViewDirty = true; _includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId); if (_includeApps != _filteredItemsIncludesApps) { @@ -172,65 +185,108 @@ public override IListItem[] GetItems() { lock (_tlcManager.TopLevelCommands) { - // Either return the top-level commands (no search text), or the merged and - // filtered results. - if (string.IsNullOrWhiteSpace(SearchText)) - { - var allCommands = _tlcManager.TopLevelCommands; + return string.IsNullOrWhiteSpace(SearchText) ? GetDefaultViewItems() : GetSearchViewItems(); + } + } - // First pass: count eligible commands - var eligibleCount = 0; - for (var i = 0; i < allCommands.Count; i++) - { - var cmd = allCommands[i]; - if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title)) - { - eligibleCount++; - } - } + private IListItem[] GetSearchViewItems() + { + var validScoredFallbacks = _scoredFallbackItems? + .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title)) + .ToList(); + + var validFallbacks = _fallbackItems? + .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title)) + .ToList(); + + return MainListPageResultFactory.Create( + _filteredItems, + validScoredFallbacks, + _filteredApps, + validFallbacks, + _resultsSeparator, + _fallbacksSeparator, + AppResultLimit); + } - if (eligibleCount == 0) - { - return []; - } + private IListItem[] GetDefaultViewItems() + { + if (_defaultViewDirty) + { + RebuildDefaultViewCache(); + } - // +1 for the separator - var result = new IListItem[eligibleCount + 1]; - result[0] = _resultsSeparator; + var pinned = _cachedPinnedViewModels!; + var regular = _cachedRegularViewModels!; + var pinnedCount = pinned.Length; + var regularCount = regular.Length; - // Second pass: populate - var writeIndex = 1; - for (var i = 0; i < allCommands.Count; i++) + var sectionCount = (pinnedCount > 0 ? 1 : 0) + (regularCount > 0 ? 1 : 0); + if (sectionCount == 0) + { + return []; + } + + var result = new IListItem[pinnedCount + regularCount + sectionCount]; + var writeIndex = 0; + if (pinnedCount > 0) + { + result[writeIndex++] = _pinnedSeparator; + Array.Copy(pinned, 0, result, writeIndex, pinnedCount); + writeIndex += pinnedCount; + } + + if (regularCount > 0) + { + result[writeIndex++] = _resultsSeparator; + Array.Copy(regular, 0, result, writeIndex, regularCount); + } + + return result; + } + + private void RebuildDefaultViewCache() + { + var allCommands = _tlcManager.TopLevelCommands; + var pinnedSettings = _tlcManager.PinnedCommands; + + // Resolve pinned VMs in settings order + var pinned = new List(pinnedSettings.Count); + for (var i = 0; i < pinnedSettings.Count; i++) + { + var s = pinnedSettings[i]; + for (var j = 0; j < allCommands.Count; j++) + { + var cmd = allCommands[j]; + if (IsEligibleTopLevelCommand(cmd) && + cmd.CommandProviderId == s.ProviderId && + cmd.Id == s.CommandId) { - var cmd = allCommands[i]; - if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title)) - { - result[writeIndex++] = cmd; - } + pinned.Add(cmd); + break; } - - return result; } - else + } + + // Single pass for regular items + var regular = new List(allCommands.Count); + for (var i = 0; i < allCommands.Count; i++) + { + var candidate = allCommands[i]; + if (IsEligibleTopLevelCommand(candidate) && !_tlcManager.IsPinned(candidate.CommandProviderId, candidate.Id)) { - var validScoredFallbacks = _scoredFallbackItems? - .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title)) - .ToList(); - - var validFallbacks = _fallbackItems? - .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title)) - .ToList(); - - return MainListPageResultFactory.Create( - _filteredItems, - validScoredFallbacks, - _filteredApps, - validFallbacks, - _resultsSeparator, - _fallbacksSeparator, - AppResultLimit); + regular.Add(candidate); } } + + _cachedPinnedViewModels = [.. pinned]; + _cachedRegularViewModels = [.. regular]; + _defaultViewDirty = false; + } + + private static bool IsEligibleTopLevelCommand(TopLevelViewModel command) + { + return !command.IsFallback && !string.IsNullOrEmpty(command.Title); } private void ClearResults() @@ -409,11 +465,10 @@ public override void UpdateSearchText(string oldSearch, string newSearch) var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast().ToList(); // We need to remove pinned apps from allNewApps so they don't show twice. - // Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds. - _settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings); - var pinnedCommandIds = providerSettings?.PinnedCommandIds; + // Pinned app command IDs are stored in SettingsModel.PinnedCommands. + var pinnedCommandIds = _settings.GetPinnedCommandIds(AllAppsCommandProvider.WellKnownId); - if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0) + if (pinnedCommandIds.Count > 0) { newApps = allNewApps.Where(li => li.Command != null && !pinnedCommandIds.Contains(li.Command.Id)); } @@ -643,7 +698,15 @@ private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) public void Receive(ClearSearchMessage message) => SearchText = string.Empty; - public void Receive(UpdateFallbackItemsMessage message) => RaiseItemsChanged(_tlcManager.TopLevelCommands.Count); + public void Receive(UpdateFallbackItemsMessage message) + { + // MovePinnedCommand modifies SettingsModel directly without going + // through Pin/UnpinCommandItemMessage, so refresh the cached pinned + // state to pick up reorder changes. + _tlcManager.RebuildPinnedCache(); + _defaultViewDirty = true; + RaiseItemsChanged(); + } private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender); @@ -656,6 +719,7 @@ public void Dispose() _tlcManager.PropertyChanged -= TlcManager_PropertyChanged; _tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged; + _tlcManager.PinnedCommands.CollectionChanged -= PinnedCommands_CollectionChanged; if (_settings is not null) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Icons.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Icons.cs index 1950c620604f..8fa3829103bd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Icons.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Icons.cs @@ -12,6 +12,12 @@ public static class Icons public static IconInfo UnpinIcon => new("\uE77A"); // Unpin icon + public static IconInfo MoveUpIcon => new("\uE74A"); // Up icon + + public static IconInfo MoveDownIcon => new("\uE74B"); // Down icon + + public static IconInfo MoveToTopIcon => new("\uE898"); // Down icon + public static IconInfo SettingsIcon => new("\uE713"); // Settings icon public static IconInfo EditIcon => new("\uE70F"); // Edit icon diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PinnedCommandSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PinnedCommandSettings.cs new file mode 100644 index 000000000000..477b723af785 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PinnedCommandSettings.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public record PinnedCommandSettings(string ProviderId, string CommandId); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs index da68f79f6c51..603a25117790 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs @@ -528,6 +528,15 @@ public static string fallbacks { } } + /// + /// Looks up a localized string similar to Pinned. + /// + public static string home_sections_pinned_title { + get { + return ResourceManager.GetString("home_sections_pinned_title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Pinned. /// @@ -536,7 +545,8 @@ public static string PinnedItemSuffix { return ResourceManager.GetString("PinnedItemSuffix", resourceCulture); } } - + + /// /// Looks up a localized string similar to Results. /// public static string results { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx index 48c178dd031e..559bbdb21b04 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx @@ -283,7 +283,7 @@ Dock settings Command name for opening dock settings - + Show details Name for the command that shows details of an item @@ -296,4 +296,7 @@ Results Section title for list of all search results that doesn't fall into any other category + + Pinned + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs index 419d6fbbe5fb..3bb9a43360f1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs @@ -18,8 +18,6 @@ public class ProviderSettings public Dictionary FallbackCommands { get; set; } = new(); - public List PinnedCommandIds { get; set; } = []; - [JsonIgnore] public string ProviderDisplayName { get; set; } = string.Empty; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index 97a8713e0766..d960fe98fe60 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -52,6 +52,8 @@ public partial class SettingsModel : ObservableObject public Dictionary ProviderSettings { get; set; } = []; + public List PinnedCommands { get; set; } = []; + public string[] FallbackRanks { get; set; } = []; public Dictionary Aliases { get; set; } = []; @@ -124,6 +126,121 @@ public ProviderSettings GetProviderSettings(CommandProviderWrapper provider) return settings; } + public bool IsCommandPinned(string providerId, string commandId) + { + foreach (var pinnedCommand in PinnedCommands) + { + if (pinnedCommand.ProviderId == providerId && + pinnedCommand.CommandId == commandId) + { + return true; + } + } + + return false; + } + + public List GetPinnedCommandIds(string providerId) + { + List pinnedCommandIds = []; + foreach (var pinnedCommand in PinnedCommands) + { + if (pinnedCommand.ProviderId == providerId) + { + pinnedCommandIds.Add(pinnedCommand.CommandId); + } + } + + return pinnedCommandIds; + } + + public bool TryPinCommand(string providerId, string commandId) + { + if (IsCommandPinned(providerId, commandId)) + { + return false; + } + + PinnedCommands.Add(new PinnedCommandSettings(providerId, commandId)); + return true; + } + + public bool TryUnpinCommand(string providerId, string commandId) + { + for (var i = 0; i < PinnedCommands.Count; i++) + { + var pinnedCommand = PinnedCommands[i]; + if (pinnedCommand.ProviderId == providerId && + pinnedCommand.CommandId == commandId) + { + PinnedCommands.RemoveAt(i); + return true; + } + } + + return false; + } + + public bool TryMovePinnedCommand(string providerId, string commandId, bool moveUp, Func? isVisible = null) + { + var index = FindPinnedCommandIndex(providerId, commandId); + if (index < 0) + { + return false; + } + + // Find the next visible neighbor in the move direction, skipping + // stale entries (removed/disabled/failed extensions). + var direction = moveUp ? -1 : 1; + var targetIndex = index + direction; + while (targetIndex >= 0 && targetIndex < PinnedCommands.Count && + isVisible != null && !isVisible(PinnedCommands[targetIndex])) + { + targetIndex += direction; + } + + if (targetIndex < 0 || targetIndex >= PinnedCommands.Count) + { + return false; + } + + // Remove and re-insert rather than swap so that stale entries + // between index and targetIndex keep their relative positions. + var pinnedCommand = PinnedCommands[index]; + PinnedCommands.RemoveAt(index); + PinnedCommands.Insert(targetIndex, pinnedCommand); + return true; + } + + public bool TryMovePinnedCommandToTop(string providerId, string commandId) + { + var index = FindPinnedCommandIndex(providerId, commandId); + if (index <= 0) + { + return false; + } + + var pinnedCommand = PinnedCommands[index]; + PinnedCommands.RemoveAt(index); + PinnedCommands.Insert(0, pinnedCommand); + return true; + } + + private int FindPinnedCommandIndex(string providerId, string commandId) + { + for (var i = 0; i < PinnedCommands.Count; i++) + { + var pinnedCommand = PinnedCommands[i]; + if (pinnedCommand.ProviderId == providerId && + pinnedCommand.CommandId == commandId) + { + return i; + } + } + + return -1; + } + public string[] GetGlobalFallbacks() { var globalFallbacks = new HashSet(); @@ -330,6 +447,7 @@ internal static string SettingsJsonPath() [JsonSerializable(typeof(List), TypeInfoPropertyName = "StringList")] [JsonSerializable(typeof(List), TypeInfoPropertyName = "HistoryList")] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "Dictionary")] +[JsonSerializable(typeof(PinnedCommandSettings))] [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")] internal sealed partial class JsonSerializationContext : JsonSerializerContext diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index f30f9606659b..c03b2f764be9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; -public partial class TopLevelCommandManager : ObservableObject, +public sealed partial class TopLevelCommandManager : ObservableObject, IRecipient, IRecipient, IRecipient, @@ -40,6 +40,8 @@ public partial class TopLevelCommandManager : ObservableObject, private readonly Lock _dockBandsLock = new(); private readonly SupersedingAsyncGate _reloadCommandsGate; + private HashSet _pinnedCommandSet = []; + public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache) { _serviceProvider = serviceProvider; @@ -50,8 +52,11 @@ public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProvider WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); _reloadCommandsGate = new(ReloadAllCommandsAsyncCore); + RebuildPinnedCache(); } + public ObservableCollection PinnedCommands { get; } = []; + public ObservableCollection TopLevelCommands { get; set; } = []; public ObservableCollection DockBands { get; set; } = []; @@ -70,6 +75,18 @@ public IEnumerable CommandProviders } } + internal bool IsPinned(string providerId, string commandId) + { + return _pinnedCommandSet.Contains(new PinnedCommandSettings(providerId, commandId)); + } + + internal void RebuildPinnedCache() + { + var settings = _serviceProvider.GetService()!; + _pinnedCommandSet = new(settings.PinnedCommands); + ListHelpers.InPlaceUpdateList(PinnedCommands, settings.PinnedCommands); + } + public async Task LoadBuiltinsAsync() { var s = new Stopwatch(); @@ -521,12 +538,14 @@ public void Receive(PinCommandItemMessage message) { var wrapper = LookupProvider(message.ProviderId); wrapper?.PinCommand(message.CommandId, _serviceProvider); + RebuildPinnedCache(); } public void Receive(UnpinCommandItemMessage message) { var wrapper = LookupProvider(message.ProviderId); wrapper?.UnpinCommand(message.CommandId, _serviceProvider); + RebuildPinnedCache(); } public void Receive(PinToDockMessage message) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs index 2f15e4b2135f..6f17291bf802 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs @@ -63,11 +63,9 @@ public List UnsafeBuildAndInitMoreCommands( // Add pin/unpin commands for pinning items to the top-level or to // the dock. var providerId = providerContext.ProviderId; - if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider) + if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper) { - var providerSettings = _settingsModel.GetProviderSettings(provider); - - var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId); + var alreadyPinnedToTopLevel = _settingsModel.IsCommandPinned(providerId, itemId); // Don't add pin/unpin commands for items displayed as // TopLevelViewModels that aren't already pinned. @@ -89,7 +87,7 @@ public List UnsafeBuildAndInitMoreCommands( moreCommands.Add(contextItem); } - TryAddPinToDockCommand(providerSettings, itemId, providerId, moreCommands, commandItem); + TryAddPinToDockCommand(itemId, providerId, moreCommands, commandItem); } } @@ -123,33 +121,20 @@ public void AddMoreCommandsToTopLevel( List contextItems) { var itemId = topLevelItem.Id; - var supportsPinning = providerContext.SupportsPinning; List moreCommands = []; var commandItem = topLevelItem.ItemViewModel; // Add pin/unpin commands for pinning items to the top-level or to // the dock. var providerId = providerContext.ProviderId; - if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider) + var commandProvider = _topLevelCommandManager.LookupProvider(providerId); + if (commandProvider is CommandProviderWrapper) { - var providerSettings = _settingsModel.GetProviderSettings(provider); - - var isPinnedSubCommand = providerSettings.PinnedCommandIds.Contains(itemId); - if (isPinnedSubCommand) - { - var pinToTopLevelCommand = new PinToCommand( - commandId: itemId, - providerId: providerId, - pin: !isPinnedSubCommand, - PinLocation.TopLevel, - _settingsModel, - _topLevelCommandManager); - - var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem); - moreCommands.Add(contextItem); - } + TryAddMovePinnedCommands(itemId, providerId, commandItem, moreCommands); + TryAddUnpinFromHomeCommand(itemId, providerId, commandItem, moreCommands); + TryAddPinToHomeCommand(itemId, providerId, commandItem, moreCommands); - TryAddPinToDockCommand(providerSettings, itemId, providerId, moreCommands, commandItem); + TryAddPinToDockCommand(itemId, providerId, moreCommands, commandItem); } if (moreCommands.Count > 0) @@ -161,8 +146,73 @@ public void AddMoreCommandsToTopLevel( } } + private void TryAddPinToHomeCommand( + string itemId, + string providerId, + CommandItemViewModel commandItem, + List moreCommands) + { + if (_settingsModel.IsCommandPinned(providerId, itemId)) + { + return; + } + + var pinToTopLevelCommand = new PinToCommand( + commandId: itemId, + providerId: providerId, + pin: true, + PinLocation.TopLevel, + _settingsModel, + _topLevelCommandManager); + + var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem); + moreCommands.Add(contextItem); + } + + private void TryAddUnpinFromHomeCommand( + string itemId, + string providerId, + CommandItemViewModel commandItem, + List moreCommands) + { + var isPinnedSubCommand = _settingsModel.IsCommandPinned(providerId, itemId); + if (isPinnedSubCommand) + { + var pinToTopLevelCommand = new PinToCommand( + commandId: itemId, + providerId: providerId, + pin: !isPinnedSubCommand, + PinLocation.TopLevel, + _settingsModel, + _topLevelCommandManager); + + var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem); + moreCommands.Add(contextItem); + } + } + + private void TryAddMovePinnedCommands( + string itemId, + string providerId, + CommandItemViewModel commandItem, + List moreCommands) + { + if (!_settingsModel.IsCommandPinned(providerId, itemId)) + { + return; + } + + var moveToTopCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.ToTop, _settingsModel, _topLevelCommandManager); + moreCommands.Add(new MovePinnedContextItem(moveToTopCommand, commandItem)); + + var moveUpCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.Up, _settingsModel, _topLevelCommandManager); + moreCommands.Add(new MovePinnedContextItem(moveUpCommand, commandItem)); + + var moveDownCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.Down, _settingsModel, _topLevelCommandManager); + moreCommands.Add(new MovePinnedContextItem(moveDownCommand, commandItem)); + } + private void TryAddPinToDockCommand( - ProviderSettings providerSettings, string itemId, string providerId, List moreCommands, @@ -227,6 +277,30 @@ private void OnPinStateChanged(object? sender, EventArgs e) } } + private sealed partial class MovePinnedContextItem : CommandContextItem + { + private readonly MovePinnedCommand _command; + private readonly CommandItemViewModel _commandItem; + + public MovePinnedContextItem(MovePinnedCommand command, CommandItemViewModel commandItem) + : base(command) + { + _command = command; + _commandItem = commandItem; + command.MoveStateChanged += this.OnMoveStateChanged; + } + + private void OnMoveStateChanged(object? sender, EventArgs e) + { + _commandItem.RefreshMoreCommands(); + } + + ~MovePinnedContextItem() + { + _command.MoveStateChanged -= this.OnMoveStateChanged; + } + } + private sealed partial class PinToCommand : InvokableCommand { private readonly string _commandId; @@ -321,4 +395,77 @@ private void UnpinFromDock() WeakReferenceMessenger.Default.Send(message); } } + + private sealed partial class MovePinnedCommand : InvokableCommand + { + private readonly string _providerId; + private readonly string _commandId; + private readonly MovePinnedDirection _moveDirection; + private readonly SettingsModel _settings; + private readonly TopLevelCommandManager _topLevelCommandManager; + + public override IconInfo Icon => _moveDirection switch + { + MovePinnedDirection.ToTop => Icons.MoveToTopIcon, + MovePinnedDirection.Up => Icons.MoveUpIcon, + _ => Icons.MoveDownIcon, + }; + + public override string Name => _moveDirection switch + { + MovePinnedDirection.ToTop => RS_.GetString("top_level_move_to_top_command_name"), + MovePinnedDirection.Up => RS_.GetString("top_level_move_up_command_name"), + _ => RS_.GetString("top_level_move_down_command_name"), + }; + + internal event EventHandler? MoveStateChanged; + + public MovePinnedCommand( + string providerId, + string commandId, + MovePinnedDirection moveDirection, + SettingsModel settings, + TopLevelCommandManager topLevelCommandManager) + { + _providerId = providerId; + _commandId = commandId; + _moveDirection = moveDirection; + _settings = settings; + _topLevelCommandManager = topLevelCommandManager; + } + + public override CommandResult Invoke() + { + var moved = _moveDirection switch + { + MovePinnedDirection.ToTop => _settings.TryMovePinnedCommandToTop(_providerId, _commandId), + MovePinnedDirection.Up => _settings.TryMovePinnedCommand(_providerId, _commandId, true, IsLoaded), + _ => _settings.TryMovePinnedCommand(_providerId, _commandId, false, IsLoaded), + }; + + if (moved) + { + SettingsModel.SaveSettings(_settings, false); + WeakReferenceMessenger.Default.Send(); + MoveStateChanged?.Invoke(this, EventArgs.Empty); + } + + return CommandResult.KeepOpen(); + + // Pass a visibility check so moves skip stale pinned entries + // (removed/disabled/failed extensions) that aren't shown on home. + bool IsLoaded(PinnedCommandSettings pin) + { + return _topLevelCommandManager.LookupCommand(pin.CommandId) is TopLevelViewModel cmd && + cmd.CommandProviderId == pin.ProviderId; + } + } + } + + private enum MovePinnedDirection + { + ToTop, + Up, + Down, + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index e40c0b05b061..f575c8c46487 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -934,4 +934,16 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Unpin from dock Command name for unpinning an item from the dock + + Move up + Command name for moving a pinned home item up in the pinned section order + + + Move to the top + Command name for moving a pinned home item to the top of the pinned section order + + + Move down + Command name for moving a pinned home item down in the pinned section order + \ No newline at end of file