From 7a0e4ac891fc540b147613d6df4ef53cfbad8816 Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Thu, 26 Feb 2026 10:09:17 -0600 Subject: [PATCH] CmdPal: Add context commands for pinning nested commands (#45673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _targets #45572_ This change allows our contact menu factory to actually create and add additional context menu commands for pinning commands to the top level. Now for any command provider built with the latest SDK that return subcommands with an ID, we will add additional context menu commands that allows you to pin that command to the top level. image image related to https://github.com/microsoft/PowerToys/issues/45191 related to https://github.com/microsoft/PowerToys/issues/45201 This PR notably does not remove pinning from the apps extension. I thought that made sense to do as a follow-up PR for the sake of reviewability. --- description from #45676 which was merged into this Removes the code that the apps provider was using to support pinning apps to the top level list of commands. Now the all apps provider just uses the global support for pinning commands to the top level. This does have the side effect of removing the separation of pinned apps from unpinned apps on the All Apps page. However, we all pretty much agree that wasn't a particularly widely used feature, and it's safe to remove. With this, we can finally call this issue done 🎉 closes https://github.com/microsoft/PowerToys/issues/45191 --- .../AppExtensionHost.cs | 2 +- .../CommandContextItemViewModel.cs | 2 +- .../CommandItemViewModel.cs | 26 ++- .../CommandPaletteContentPageViewModel.cs | 2 +- .../CommandPalettePageViewModelFactory.cs | 6 +- .../CommandProviderContext.cs | 18 +- .../CommandProviderWrapper.cs | 70 +++++--- .../Commands/MainListPage.cs | 11 +- .../ContentPageViewModel.cs | 2 +- .../GlobalLogPageContext.cs | 4 +- .../Microsoft.CmdPal.UI.ViewModels/Icons.cs | 18 ++ .../ListItemViewModel.cs | 2 +- .../ListViewModel.cs | 14 +- .../Messages/UnpinCommandItemMessage.cs | 9 + .../PageViewModel.cs | 8 +- .../TopLevelCommandManager.cs | 26 ++- .../TopLevelItemPageContext.cs | 36 ++++ .../TopLevelViewModel.cs | 6 +- .../CommandPaletteContextMenuFactory.cs | 165 +++++++++++++++++- .../PowerToysAppHostService.cs | 4 +- .../Strings/en-us/Resources.resw | 18 +- .../AllAppsPageTests.cs | 21 --- .../AllAppsCommandProvider.cs | 11 +- .../Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs | 125 ++----------- .../Microsoft.CmdPal.Ext.Apps/AppListItem.cs | 33 +--- .../Commands/PinAppCommand.cs | 28 --- .../Commands/UnpinAppCommand.cs | 27 --- .../JsonSerializationContext.cs | 2 - .../State/PinnedApps.cs | 47 ----- .../State/PinnedAppsManager.cs | 80 --------- 30 files changed, 389 insertions(+), 434 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Icons.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UnpinCommandItemMessage.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelItemPageContext.cs delete mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/PinAppCommand.cs delete mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UnpinAppCommand.cs delete mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedApps.cs delete mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs index c3405ff84b..edb12b6d4b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs @@ -166,5 +166,5 @@ public interface IAppHostService AppExtensionHost GetHostForCommand(object? context, AppExtensionHost? currentHost); - CommandProviderContext GetProviderContextForCommand(object? command, CommandProviderContext? currentContext); + ICommandProviderContext GetProviderContextForCommand(object? command, ICommandProviderContext? currentContext); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs index c919e3131f..dd22d57887 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs @@ -22,7 +22,7 @@ public partial class CommandContextItemViewModel : CommandItemViewModel, IContex public bool HasRequestedShortcut => RequestedShortcut is not null && (RequestedShortcut.Value != nullKeyChord); public CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference context) - : base(new(contextItem), context) + : base(new(contextItem), context, contextMenuFactory: null) { Model = new(contextItem); IsContextMenuItem = true; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs index 42d8272349..821c762a09 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs @@ -103,7 +103,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa public CommandItemViewModel( ExtensionObject item, WeakReference errorContext, - IContextMenuFactory? contextMenuFactory = null) + IContextMenuFactory? contextMenuFactory) : base(errorContext) { _commandItemModel = item; @@ -464,6 +464,30 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa .ForEach(c => c.SafeCleanup()); } + public void RefreshMoreCommands() + { + Task.Run(RefreshMoreCommandsSynchronous); + } + + private void RefreshMoreCommandsSynchronous() + { + try + { + BuildAndInitMoreCommands(); + UpdateProperty(nameof(MoreCommands)); + UpdateProperty(nameof(AllCommands)); + UpdateProperty(nameof(SecondaryCommand)); + UpdateProperty(nameof(SecondaryCommandName)); + UpdateProperty(nameof(HasMoreCommands)); + } + catch (Exception ex) + { + // Handle any exceptions that might occur during the refresh process + CoreLogger.LogError("Error refreshing MoreCommands in CommandItemViewModel", ex); + ShowException(ex, _commandItemModel?.Unsafe?.Title); + } + } + protected override void UnsafeCleanup() { base.UnsafeCleanup(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs index deae412657..b0b65e7f70 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs @@ -8,7 +8,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class CommandPaletteContentPageViewModel : ContentPageViewModel { - public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext) + public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext) : base(model, scheduler, host, providerContext) { } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs index a8bd6899e2..83f89b007c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs @@ -10,15 +10,15 @@ public class CommandPalettePageViewModelFactory : IPageViewModelFactoryService { private readonly TaskScheduler _scheduler; - private readonly IContextMenuFactory? _contextMenuFactory; + private readonly IContextMenuFactory _contextMenuFactory; - public CommandPalettePageViewModelFactory(TaskScheduler scheduler, IContextMenuFactory? contextMenuFactory) + public CommandPalettePageViewModelFactory(TaskScheduler scheduler, IContextMenuFactory contextMenuFactory) { _scheduler = scheduler; _contextMenuFactory = contextMenuFactory; } - public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext) + public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, ICommandProviderContext providerContext) { return page switch { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderContext.cs index 125d841a63..ce42487fe1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderContext.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderContext.cs @@ -4,9 +4,21 @@ namespace Microsoft.CmdPal.UI.ViewModels; -public sealed class CommandProviderContext +public static class CommandProviderContext { - public required string ProviderId { get; init; } + public static ICommandProviderContext Empty { get; } = new EmptyCommandProviderContext(); - public static CommandProviderContext Empty { get; } = new() { ProviderId = "" }; + private sealed class EmptyCommandProviderContext : ICommandProviderContext + { + public string ProviderId => ""; + + public bool SupportsPinning => false; + } +} + +public interface ICommandProviderContext +{ + string ProviderId { get; } + + bool SupportsPinning { get; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index 2785dc1d9e..9b31c83f26 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -13,7 +13,7 @@ using Windows.Foundation; namespace Microsoft.CmdPal.UI.ViewModels; -public sealed class CommandProviderWrapper +public sealed class CommandProviderWrapper : ICommandProviderContext { public bool IsExtension => Extension is not null; @@ -47,12 +47,17 @@ public sealed class CommandProviderWrapper public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId; + public bool SupportsPinning { get; private set; } + + public TopLevelItemPageContext TopLevelPageContext { get; } + public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread) { // This ctor is only used for in-proc builtin commands. So the Unsafe! // calls are pretty dang safe actually. _commandProvider = new(provider); _taskScheduler = mainThread; + TopLevelPageContext = new TopLevelItemPageContext(this, _taskScheduler); // Hook the extension back into us ExtensionHost = new CommandPaletteHost(provider); @@ -77,6 +82,7 @@ public sealed class CommandProviderWrapper { _taskScheduler = mainThread; _commandProviderCache = commandProviderCache; + TopLevelPageContext = new TopLevelItemPageContext(this, _taskScheduler); Extension = extension; ExtensionHost = new CommandPaletteHost(extension); @@ -121,7 +127,7 @@ public sealed class CommandProviderWrapper return settings.GetProviderSettings(this); } - public async Task LoadTopLevelCommands(IServiceProvider serviceProvider, WeakReference pageContext) + public async Task LoadTopLevelCommands(IServiceProvider serviceProvider) { if (!isValid) { @@ -157,8 +163,14 @@ public sealed class CommandProviderWrapper UnsafePreCacheApiAdditions(two); } - // Load pinned commands from saved settings - var pinnedCommands = LoadPinnedCommands(model, providerSettings); + ICommandItem[] pinnedCommands = []; + if (model is ICommandProvider4 four) + { + SupportsPinning = true; + + // Load pinned commands from saved settings + pinnedCommands = LoadPinnedCommands(four, providerSettings); + } Id = model.Id; DisplayName = model.DisplayName; @@ -177,7 +189,7 @@ public sealed class CommandProviderWrapper Settings = new(model.Settings, this, _taskScheduler); // We do need to explicitly initialize commands though - InitializeCommands(commands, fallbacks, pinnedCommands, serviceProvider, pageContext); + InitializeCommands(commands, fallbacks, pinnedCommands, serviceProvider); Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})"); } @@ -208,14 +220,20 @@ public sealed class CommandProviderWrapper } } - private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, ICommandItem[] pinnedCommands, IServiceProvider serviceProvider, WeakReference pageContext) + private void InitializeCommands( + ICommandItem[] commands, + IFallbackCommandItem[] fallbacks, + ICommandItem[] pinnedCommands, + IServiceProvider serviceProvider) { var settings = serviceProvider.GetService()!; + var contextMenuFactory = serviceProvider.GetService()!; var providerSettings = GetProviderSettings(settings); var ourContext = GetProviderContext(); + var pageContext = new WeakReference(TopLevelPageContext); var makeAndAdd = (ICommandItem? i, bool fallback) => { - CommandItemViewModel commandItemViewModel = new(new(i), pageContext); + CommandItemViewModel commandItemViewModel = new(new(i), pageContext, contextMenuFactory: contextMenuFactory); TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i); topLevelViewModel.InitializeProperties(); @@ -244,27 +262,24 @@ public sealed class CommandProviderWrapper } } - private ICommandItem[] LoadPinnedCommands(ICommandProvider model, ProviderSettings providerSettings) + private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, ProviderSettings providerSettings) { var pinnedItems = new List(); - if (model is ICommandProvider4 provider4) + foreach (var pinnedId in providerSettings.PinnedCommandIds) { - foreach (var pinnedId in providerSettings.PinnedCommandIds) + try { - try + var commandItem = model.GetCommandItem(pinnedId); + if (commandItem is not null) { - var commandItem = provider4.GetCommandItem(pinnedId); - if (commandItem is not null) - { - pinnedItems.Add(commandItem); - } - } - catch (Exception e) - { - Logger.LogError($"Failed to load pinned command {pinnedId}: {e.Message}"); + pinnedItems.Add(commandItem); } } + catch (Exception e) + { + Logger.LogError($"Failed to load pinned command {pinnedId}: {e.Message}"); + } } return pinnedItems.ToArray(); @@ -298,11 +313,22 @@ public sealed class CommandProviderWrapper } } - public CommandProviderContext GetProviderContext() + public void UnpinCommand(string commandId, IServiceProvider serviceProvider) { - return new() { ProviderId = ProviderId }; + var settings = serviceProvider.GetService()!; + var providerSettings = GetProviderSettings(settings); + + if (providerSettings.PinnedCommandIds.Remove(commandId)) + { + SettingsModel.SaveSettings(settings); + + // Raise CommandsChanged so the TopLevelCommandManager reloads our commands + this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); + } } + public ICommandProviderContext GetProviderContext() => this; + public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid; public override int GetHashCode() => _commandProvider.GetHashCode(); 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 5001dc2386..45bb9ac14a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// 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. @@ -11,7 +11,6 @@ using Microsoft.CmdPal.Common.Helpers; using Microsoft.CmdPal.Common.Text; using Microsoft.CmdPal.Ext.Apps; using Microsoft.CmdPal.Ext.Apps.Programs; -using Microsoft.CmdPal.Ext.Apps.State; using Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Properties; @@ -409,11 +408,13 @@ public sealed partial class MainListPage : DynamicListPage, var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast().ToList(); // We need to remove pinned apps from allNewApps so they don't show twice. - var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers(); + // Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds. + _settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings); + var pinnedCommandIds = providerSettings?.PinnedCommandIds; - if (pinnedApps.Length > 0) + if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0) { - newApps = allNewApps.Where(w => pinnedApps.IndexOf(w.AppIdentifier) < 0); + newApps = allNewApps.Where(li => li.Command != null && !pinnedCommandIds.Contains(li.Command.Id)); } else { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs index 9be0209144..da34915712 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs @@ -47,7 +47,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext // Remember - "observable" properties from the model (via PropChanged) // cannot be marked [ObservableProperty] - public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext) + public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext) : base(model, scheduler, host, providerContext) { _model = new(model); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs index fde1a36817..4425661d3a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// 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. @@ -8,6 +8,8 @@ public class GlobalLogPageContext : IPageContext { public TaskScheduler Scheduler { get; private init; } + ICommandProviderContext IPageContext.ProviderContext => CommandProviderContext.Empty; + public void ShowException(Exception ex, string? extensionHint) { /*do nothing*/ } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Icons.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Icons.cs new file mode 100644 index 0000000000..1950c62060 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Icons.cs @@ -0,0 +1,18 @@ +// 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. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public static class Icons +{ + public static IconInfo PinIcon => new("\uE718"); // Pin icon + + public static IconInfo UnpinIcon => new("\uE77A"); // Unpin 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/ListItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs index dd7e4a4096..21cdba9797 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs @@ -63,7 +63,7 @@ public partial class ListItemViewModel : CommandItemViewModel } } - public ListItemViewModel(IListItem model, WeakReference context, IContextMenuFactory? contextMenuFactory = null) + public ListItemViewModel(IListItem model, WeakReference context, IContextMenuFactory contextMenuFactory) : base(new(model), context, contextMenuFactory) { Model = new ExtensionObject(model); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index 9ee59ae7de..099a3490fc 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -36,7 +36,7 @@ public partial class ListViewModel : PageViewModel, IDisposable private readonly ExtensionObject _model; private readonly Lock _listLock = new(); - private readonly IContextMenuFactory? _contextMenuFactory; + private readonly IContextMenuFactory _contextMenuFactory; private InterlockedBoolean _isLoading; private bool _isFetching; @@ -96,12 +96,12 @@ public partial class ListViewModel : PageViewModel, IDisposable } } - public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext, IContextMenuFactory? contextMenuFactory) + public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext, IContextMenuFactory contextMenuFactory) : base(model, scheduler, host, providerContext) { _model = new(model); _contextMenuFactory = contextMenuFactory; - EmptyContent = new(new(null), PageContext, _contextMenuFactory); + EmptyContent = new(new(null), PageContext, contextMenuFactory: null); } private void FiltersPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) @@ -243,7 +243,7 @@ public partial class ListViewModel : PageViewModel, IDisposable continue; } - var viewModel = new ListItemViewModel(item, new(this)); + var viewModel = new ListItemViewModel(item, new(this), _contextMenuFactory); // If an item fails to load, silently ignore it. if (viewModel.SafeFastInit()) @@ -636,7 +636,7 @@ public partial class ListViewModel : PageViewModel, IDisposable UpdateProperty(nameof(SearchText)); UpdateProperty(nameof(InitialSearchText)); - EmptyContent = new(new(model.EmptyContent), PageContext); + EmptyContent = new(new(model.EmptyContent), PageContext, _contextMenuFactory); EmptyContent.SlowInitializeProperties(); Filters?.PropertyChanged -= FiltersPropertyChanged; @@ -732,7 +732,7 @@ public partial class ListViewModel : PageViewModel, IDisposable SearchText = model.SearchText; break; case nameof(EmptyContent): - EmptyContent = new(new(model.EmptyContent), PageContext); + EmptyContent = new(new(model.EmptyContent), PageContext, contextMenuFactory: null); EmptyContent.SlowInitializeProperties(); break; case nameof(Filters): @@ -806,7 +806,7 @@ public partial class ListViewModel : PageViewModel, IDisposable base.UnsafeCleanup(); EmptyContent?.SafeCleanup(); - EmptyContent = new(new(null), PageContext); // necessary? + EmptyContent = new(new(null), PageContext, contextMenuFactory: null); // necessary? _cancellationTokenSource?.Cancel(); filterCancellationTokenSource?.Cancel(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UnpinCommandItemMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UnpinCommandItemMessage.cs new file mode 100644 index 0000000000..25371516b3 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UnpinCommandItemMessage.cs @@ -0,0 +1,9 @@ +// 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.Messages; + +public record UnpinCommandItemMessage(string ProviderId, string CommandId) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs index df3090d51c..a56b9fcc76 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs @@ -76,9 +76,9 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public IconInfoViewModel Icon { get; protected set; } - public CommandProviderContext ProviderContext { get; protected set; } + public ICommandProviderContext ProviderContext { get; protected set; } - public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost, CommandProviderContext providerContext) + public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost, ICommandProviderContext providerContext) : base(scheduler) { InitializeSelfAsPageContext(); @@ -267,6 +267,8 @@ public interface IPageContext void ShowException(Exception ex, string? extensionHint = null); TaskScheduler Scheduler { get; } + + ICommandProviderContext ProviderContext { get; } } public interface IPageViewModelFactoryService @@ -278,5 +280,5 @@ public interface IPageViewModelFactoryService /// Indicates whether the page is not the top-level page. /// The command palette host that will host the page (for status messages) /// A new instance of the page view model. - PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext); + PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, ICommandProviderContext providerContext); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index 5b26bab952..a2526bd3f4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -22,7 +22,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class TopLevelCommandManager : ObservableObject, IRecipient, IRecipient, - IPageContext, + IRecipient, IDisposable { private readonly IServiceProvider _serviceProvider; @@ -34,8 +34,6 @@ public partial class TopLevelCommandManager : ObservableObject, private readonly Lock _commandProvidersLock = new(); private readonly SupersedingAsyncGate _reloadCommandsGate; - TaskScheduler IPageContext.Scheduler => _taskScheduler; - public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache) { _serviceProvider = serviceProvider; @@ -43,6 +41,7 @@ public partial class TopLevelCommandManager : ObservableObject, _taskScheduler = _serviceProvider.GetService()!; WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); _reloadCommandsGate = new(ReloadAllCommandsAsyncCore); } @@ -103,9 +102,7 @@ public partial class TopLevelCommandManager : ObservableObject, // May be called from a background thread private async Task> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider) { - WeakReference weakSelf = new(this); - - await commandProvider.LoadTopLevelCommands(_serviceProvider, weakSelf); + await commandProvider.LoadTopLevelCommands(_serviceProvider); var commands = await Task.Factory.StartNew( () => @@ -152,8 +149,7 @@ public partial class TopLevelCommandManager : ObservableObject, /// an awaitable task private async Task UpdateCommandsForProvider(CommandProviderWrapper sender, IItemsChangedEventArgs args) { - WeakReference weakSelf = new(this); - await sender.LoadTopLevelCommands(_serviceProvider, weakSelf); + await sender.LoadTopLevelCommands(_serviceProvider); List newItems = [.. sender.TopLevelItems]; foreach (var i in sender.FallbackItems) @@ -421,7 +417,13 @@ public partial class TopLevelCommandManager : ObservableObject, wrapper?.PinCommand(message.CommandId, _serviceProvider); } - private CommandProviderWrapper? LookupProvider(string providerId) + public void Receive(UnpinCommandItemMessage message) + { + var wrapper = LookupProvider(message.ProviderId); + wrapper?.UnpinCommand(message.CommandId, _serviceProvider); + } + + public CommandProviderWrapper? LookupProvider(string providerId) { lock (_commandProvidersLock) { @@ -430,12 +432,6 @@ public partial class TopLevelCommandManager : ObservableObject, } } - void IPageContext.ShowException(Exception ex, string? extensionHint) - { - var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? "TopLevelCommandManager"); - CommandPaletteHost.Instance.Log(message); - } - internal bool IsProviderActive(string id) { lock (_commandProvidersLock) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelItemPageContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelItemPageContext.cs new file mode 100644 index 0000000000..ef65fb336a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelItemPageContext.cs @@ -0,0 +1,36 @@ +// 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. + +using Microsoft.CmdPal.Common.Helpers; + +namespace Microsoft.CmdPal.UI.ViewModels; + +/// +/// Used as the PageContext for top-level items. Top level items are displayed +/// on the MainListPage, which _we_ own. We need to have a placeholder page +/// context for each provider that still connects those top-level items to the +/// CommandProvider they came from. +/// +public partial class TopLevelItemPageContext : IPageContext +{ + public TaskScheduler Scheduler { get; private set; } + + public ICommandProviderContext ProviderContext { get; private set; } + + TaskScheduler IPageContext.Scheduler => Scheduler; + + ICommandProviderContext IPageContext.ProviderContext => ProviderContext; + + internal TopLevelItemPageContext(CommandProviderWrapper provider, TaskScheduler scheduler) + { + ProviderContext = provider.GetProviderContext(); + Scheduler = scheduler; + } + + public void ShowException(Exception ex, string? extensionHint = null) + { + var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? $"TopLevelItemPageContext({ProviderContext.ProviderId})"); + CommandPaletteHost.Instance.Log(message); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index 650c62fc77..ee419364b2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// 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. @@ -26,7 +26,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx private readonly IServiceProvider _serviceProvider; private readonly CommandItemViewModel _commandItemViewModel; - public CommandProviderContext ProviderContext { get; private set; } + public ICommandProviderContext ProviderContext { get; private set; } private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id; @@ -189,7 +189,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx CommandItemViewModel item, bool isFallback, CommandPaletteHost extensionHost, - CommandProviderContext commandProviderContext, + ICommandProviderContext commandProviderContext, SettingsModel settings, ProviderSettings providerSettings, IServiceProvider serviceProvider, diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs index 684d39299a..36f1c1b8c8 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs @@ -2,8 +2,13 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using CommunityToolkit.Mvvm.Messaging; +using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; namespace Microsoft.CmdPal.UI; @@ -24,8 +29,164 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac { var results = DefaultContextMenuFactory.Instance.UnsafeBuildAndInitMoreCommands(items, commandItem); - // TODO: #45201 Here, we'll want to add pin/unpin commands for pinning - // items to the top-level or to the dock. + List moreCommands = []; + var itemId = commandItem.Command.Id; + + if (commandItem.PageContext.TryGetTarget(out var page) && + page.ProviderContext.SupportsPinning && + !string.IsNullOrEmpty(itemId)) + { + // Add pin/unpin commands for pinning items to the top-level or to + // the dock. + var providerId = page.ProviderContext.ProviderId; + if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider) + { + var providerSettings = _settingsModel.GetProviderSettings(provider); + + var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId); + + // Don't add pin/unpin commands for items displayed as + // TopLevelViewModels that aren't already pinned. + // + // We can't look up if this command item is in the top level + // items in the manager, because we are being called _before_ we + // get added to the manager's list of commands. + var isTopLevelItem = page is TopLevelItemPageContext; + + if (!isTopLevelItem || alreadyPinnedToTopLevel) + { + var pinToTopLevelCommand = new PinToCommand( + commandId: itemId, + providerId: providerId, + pin: !alreadyPinnedToTopLevel, + PinLocation.TopLevel, + _settingsModel, + _topLevelCommandManager); + + var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem); + moreCommands.Add(contextItem); + } + } + } + + if (moreCommands.Count > 0) + { + moreCommands.Insert(0, new Separator()); + var moreResults = DefaultContextMenuFactory.Instance.UnsafeBuildAndInitMoreCommands(moreCommands.ToArray(), commandItem); + results.AddRange(moreResults); + } + return results; } + + internal enum PinLocation + { + TopLevel, + Dock, + } + + private sealed partial class PinToContextItem : CommandContextItem + { + private readonly PinToCommand _command; + private readonly CommandItemViewModel _commandItem; + + public PinToContextItem(PinToCommand command, CommandItemViewModel commandItem) + : base(command) + { + _command = command; + _commandItem = commandItem; + command.PinStateChanged += this.OnPinStateChanged; + } + + private void OnPinStateChanged(object? sender, EventArgs e) + { + // update our MoreCommands + _commandItem.RefreshMoreCommands(); + } + + ~PinToContextItem() + { + _command.PinStateChanged -= this.OnPinStateChanged; + } + } + + private sealed partial class PinToCommand : InvokableCommand + { + private readonly string _commandId; + private readonly string _providerId; + private readonly SettingsModel _settings; + private readonly TopLevelCommandManager _topLevelCommandManager; + private readonly bool _pin; + private readonly PinLocation _pinLocation; + + public override IconInfo Icon => _pin ? Icons.PinIcon : Icons.UnpinIcon; + + public override string Name => _pin ? RS_.GetString("top_level_pin_command_name") : RS_.GetString("top_level_unpin_command_name"); + + internal event EventHandler? PinStateChanged; + + public PinToCommand( + string commandId, + string providerId, + bool pin, + PinLocation pinLocation, + SettingsModel settings, + TopLevelCommandManager topLevelCommandManager) + { + _commandId = commandId; + _providerId = providerId; + _pinLocation = pinLocation; + _settings = settings; + _topLevelCommandManager = topLevelCommandManager; + _pin = pin; + } + + public override CommandResult Invoke() + { + Logger.LogDebug($"PinTo{_pinLocation}Command.Invoke({_pin}): {_providerId}/{_commandId}"); + if (_pin) + { + switch (_pinLocation) + { + case PinLocation.TopLevel: + PinToTopLevel(); + break; + + // TODO: After dock is added: + // case PinLocation.Dock: + // PinToDock(); + // break; + } + } + else + { + switch (_pinLocation) + { + case PinLocation.TopLevel: + UnpinFromTopLevel(); + break; + + // case PinLocation.Dock: + // UnpinFromDock(); + // break; + } + } + + PinStateChanged?.Invoke(this, EventArgs.Empty); + + return CommandResult.KeepOpen(); + } + + private void PinToTopLevel() + { + PinCommandItemMessage message = new(_providerId, _commandId); + WeakReferenceMessenger.Default.Send(message); + } + + private void UnpinFromTopLevel() + { + UnpinCommandItemMessage message = new(_providerId, _commandId); + WeakReferenceMessenger.Default.Send(message); + } + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs index 1b4e617939..9d6a13d2db 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs @@ -26,9 +26,9 @@ internal sealed class PowerToysAppHostService : IAppHostService return topLevelHost ?? currentHost ?? CommandPaletteHost.Instance; } - public CommandProviderContext GetProviderContextForCommand(object? command, CommandProviderContext? currentContext) + public ICommandProviderContext GetProviderContextForCommand(object? command, ICommandProviderContext? currentContext) { - CommandProviderContext? topLevelId = null; + ICommandProviderContext? topLevelId = null; if (command is TopLevelViewModel topLevelViewModel) { topLevelId = topLevelViewModel.ProviderContext; 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 d82aa20b96..5c53fef5a7 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 @@ -801,10 +801,26 @@ Right-click to remove the key combination, thereby deactivating the shortcut.K Keyboard key - + Configure shortcut Assign shortcut + + Pin to home + Command name for pinning an item to the top level list of commands + + + Unpin from home + Command name for unpinning an item from the top level list of commands + + + Pin to dock + Command name for pinning an item to the dock + + + Unpin from dock + Command name for unpinning an item from the dock + \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs index 3ac1eaff68..3dcb802362 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs @@ -73,25 +73,4 @@ public class AllAppsPageTests : AppsTestBase Assert.IsTrue(items.Any(i => i.Title == "Notepad")); Assert.IsTrue(items.Any(i => i.Title == "Calculator")); } - - [TestMethod] - public async Task AllAppsPage_GetPinnedApps_ReturnsEmptyWhenNoAppsArePinned() - { - // Arrange - var mockCache = new MockAppCache(); - var app = TestDataHelper.CreateTestWin32Program("TestApp", "C:\\TestApp.exe"); - mockCache.AddWin32Program(app); - - var page = new AllAppsPage(mockCache); - - // Wait a bit for initialization to complete - await Task.Delay(100); - - // Act - var pinnedApps = page.GetPinnedApps(); - - // Assert - Assert.IsNotNull(pinnedApps); - Assert.AreEqual(0, pinnedApps.Length); - } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs index 6d46a3bb7b..22236428fe 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using Microsoft.CmdPal.Ext.Apps.Helpers; using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CmdPal.Ext.Apps.State; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -40,14 +39,11 @@ public partial class AllAppsCommandProvider : CommandProvider { MoreCommands = [new CommandContextItem(AllAppsSettings.Instance.Settings.SettingsPage)], }; - - // Subscribe to pin state changes to refresh the command provider - PinnedAppsManager.Instance.PinStateChanged += OnPinStateChanged; } public static int TopLevelResultLimit => AllAppsSettings.Instance.SearchResultLimit ?? DefaultResultLimit; - public override ICommandItem[] TopLevelCommands() => [_listItem, .. _page.GetPinnedApps()]; + public override ICommandItem[] TopLevelCommands() => [_listItem]; public ICommandItem? LookupAppByPackageFamilyName(string packageFamilyName, bool requireSingleMatch) { @@ -178,9 +174,4 @@ public partial class AllAppsCommandProvider : CommandProvider return null; } - - private void OnPinStateChanged(object? sender, System.EventArgs e) - { - RaiseItemsChanged(0); - } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs index 2a264f70c2..83ce3397ca 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs @@ -10,7 +10,6 @@ using System.Threading.Tasks; using ManagedCommon; using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CmdPal.Ext.Apps.State; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -21,9 +20,7 @@ public sealed partial class AllAppsPage : ListPage private readonly Lock _listLock = new(); private readonly IAppCache _appCache; - private AppItem[] allApps = []; - private AppListItem[] unpinnedApps = []; - private AppListItem[] pinnedApps = []; + private AppListItem[] allAppListItems = []; public AllAppsPage() : this(AppCache.Instance.Value) @@ -39,9 +36,6 @@ public sealed partial class AllAppsPage : ListPage this.IsLoading = true; this.PlaceholderText = Resources.search_installed_apps_placeholder; - // Subscribe to pin state changes to refresh the command provider - PinnedAppsManager.Instance.PinStateChanged += OnPinStateChanged; - Task.Run(() => { lock (_listLock) @@ -51,24 +45,17 @@ public sealed partial class AllAppsPage : ListPage }); } - internal AppListItem[] GetPinnedApps() - { - BuildListItems(); - return pinnedApps; - } - public override IListItem[] GetItems() { // Build or update the list if needed BuildListItems(); - AppListItem[] allApps = [.. pinnedApps, .. unpinnedApps]; - return allApps; + return allAppListItems; } private void BuildListItems() { - if (allApps.Length == 0 || _appCache.ShouldReload()) + if (allAppListItems.Length == 0 || _appCache.ShouldReload()) { lock (_listLock) { @@ -77,10 +64,7 @@ public sealed partial class AllAppsPage : ListPage Stopwatch stopwatch = new(); stopwatch.Start(); - var apps = GetPrograms(); - this.allApps = apps.AllApps; - this.pinnedApps = apps.PinnedItems; - this.unpinnedApps = apps.UnpinnedItems; + this.allAppListItems = GetPrograms(); this.IsLoading = false; @@ -92,15 +76,15 @@ public sealed partial class AllAppsPage : ListPage } } - private AppItem[] GetAllApps() + private AppListItem[] GetPrograms() { - List allApps = new(); + var items = new List(); foreach (var uwpApp in _appCache.UWPs) { if (uwpApp.Enabled) { - allApps.Add(uwpApp.ToAppItem()); + items.Add(new AppListItem(uwpApp.ToAppItem(), true)); } } @@ -108,101 +92,12 @@ public sealed partial class AllAppsPage : ListPage { if (win32App.Enabled && win32App.Valid) { - allApps.Add(win32App.ToAppItem()); + items.Add(new AppListItem(win32App.ToAppItem(), true)); } } - return [.. allApps]; - } + items.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal)); - internal (AppItem[] AllApps, AppListItem[] PinnedItems, AppListItem[] UnpinnedItems) GetPrograms() - { - var allApps = GetAllApps(); - var pinned = new List(); - var unpinned = new List(); - - foreach (var app in allApps) - { - var isPinned = PinnedAppsManager.Instance.IsAppPinned(app.AppIdentifier); - var appListItem = new AppListItem(app, true, isPinned); - - if (isPinned) - { - appListItem.Tags = [.. appListItem.Tags, new Tag() { Icon = Icons.PinIcon }]; - pinned.Add(appListItem); - } - else - { - unpinned.Add(appListItem); - } - } - - pinned.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal)); - unpinned.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal)); - - return ( - allApps, - pinned.ToArray(), - unpinned.ToArray() - ); - } - - private void OnPinStateChanged(object? sender, PinStateChangedEventArgs e) - { - /* - * Rebuilding all the lists is pretty expensive. - * So, instead, we'll just compare pinned items to move existing - * items between the two lists. - */ - AppItem? existingAppItem = null; - - foreach (var app in allApps) - { - if (app.AppIdentifier == e.AppIdentifier) - { - existingAppItem = app; - break; - } - } - - if (existingAppItem is not null) - { - var appListItem = new AppListItem(existingAppItem, true, e.IsPinned); - - var newPinned = new List(pinnedApps); - var newUnpinned = new List(unpinnedApps); - - if (e.IsPinned) - { - newPinned.Add(appListItem); - - foreach (var app in newUnpinned) - { - if (app.AppIdentifier == e.AppIdentifier) - { - newUnpinned.Remove(app); - break; - } - } - } - else - { - newUnpinned.Add(appListItem); - - foreach (var app in newPinned) - { - if (app.AppIdentifier == e.AppIdentifier) - { - newPinned.Remove(app); - break; - } - } - } - - pinnedApps = newPinned.ToArray(); - unpinnedApps = newUnpinned.ToArray(); - } - - RaiseItemsChanged(0); + return [.. items]; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs index 3e551d7b5b..426bfcccba 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs @@ -87,7 +87,7 @@ public sealed partial class AppListItem : ListItem, IPrecomputedListItem public AppItem App => _app; - public AppListItem(AppItem app, bool useThumbnails, bool isPinned) + public AppListItem(AppItem app, bool useThumbnails) { Command = _appCommand = new AppCommand(app); _app = app; @@ -95,7 +95,7 @@ public sealed partial class AppListItem : ListItem, IPrecomputedListItem Subtitle = app.Subtitle; Icon = Icons.GenericAppIcon; - MoreCommands = AddPinCommands(_app.Commands!, isPinned); + MoreCommands = _app.Commands?.ToArray() ?? []; _detailsLoadTask = new Lazy>(BuildDetails); _iconLoadTask = new Lazy>(async () => await FetchIcon(useThumbnails).ConfigureAwait(false)); @@ -237,35 +237,6 @@ public sealed partial class AppListItem : ListItem, IPrecomputedListItem return icon; } - private IContextItem[] AddPinCommands(List commands, bool isPinned) - { - var newCommands = new List(); - newCommands.AddRange(commands); - - newCommands.Add(new Separator()); - - if (isPinned) - { - newCommands.Add( - new CommandContextItem( - new UnpinAppCommand(this.AppIdentifier)) - { - RequestedShortcut = KeyChords.TogglePin, - }); - } - else - { - newCommands.Add( - new CommandContextItem( - new PinAppCommand(this.AppIdentifier)) - { - RequestedShortcut = KeyChords.TogglePin, - }); - } - - return newCommands.ToArray(); - } - private async Task TryLoadThumbnail(string path, bool jumbo, bool logOnFailure) { return await Task.Run(async () => diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/PinAppCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/PinAppCommand.cs deleted file mode 100644 index 8311e36cfc..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/PinAppCommand.cs +++ /dev/null @@ -1,28 +0,0 @@ -// 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. - -using System.Diagnostics.CodeAnalysis; -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CmdPal.Ext.Apps.State; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps.Commands; - -internal sealed partial class PinAppCommand : InvokableCommand -{ - private readonly string _appIdentifier; - - public PinAppCommand(string appIdentifier) - { - _appIdentifier = appIdentifier; - Name = Resources.pin_app; - Icon = Icons.PinIcon; - } - - public override CommandResult Invoke() - { - PinnedAppsManager.Instance.PinApp(_appIdentifier); - return CommandResult.KeepOpen(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UnpinAppCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UnpinAppCommand.cs deleted file mode 100644 index fcba03f3d3..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UnpinAppCommand.cs +++ /dev/null @@ -1,27 +0,0 @@ -// 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. - -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CmdPal.Ext.Apps.State; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps.Commands; - -internal sealed partial class UnpinAppCommand : InvokableCommand -{ - private readonly string _appIdentifier; - - public UnpinAppCommand(string appIdentifier) - { - _appIdentifier = appIdentifier; - Name = Resources.unpin_app; - Icon = Icons.UnpinIcon; - } - - public override CommandResult Invoke() - { - PinnedAppsManager.Instance.UnpinApp(_appIdentifier); - return CommandResult.KeepOpen(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs index 0db868222c..5cc4b98b3e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs @@ -4,12 +4,10 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -using Microsoft.CmdPal.Ext.Apps.State; namespace Microsoft.CmdPal.Ext.Apps; [JsonSerializable(typeof(string))] -[JsonSerializable(typeof(PinnedApps))] [JsonSerializable(typeof(List), TypeInfoPropertyName = "StringList")] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] internal sealed partial class JsonSerializationContext : JsonSerializerContext diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedApps.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedApps.cs deleted file mode 100644 index ff76043bf1..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedApps.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Text.Json; - -namespace Microsoft.CmdPal.Ext.Apps.State; - -public sealed class PinnedApps -{ - public List PinnedAppIdentifiers { get; set; } = []; - - public static PinnedApps ReadFromFile(string path) - { - if (!File.Exists(path)) - { - return new PinnedApps(); - } - - try - { - var jsonString = File.ReadAllText(path); - var result = JsonSerializer.Deserialize(jsonString, JsonSerializationContext.Default.PinnedApps); - return result ?? new PinnedApps(); - } - catch - { - return new PinnedApps(); - } - } - - public static void WriteToFile(string path, PinnedApps data) - { - try - { - var jsonString = JsonSerializer.Serialize(data, JsonSerializationContext.Default.PinnedApps); - File.WriteAllText(path, jsonString); - } - catch - { - // Silently fail - we don't want pinning issues to crash the extension - } - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs deleted file mode 100644 index 0fdc0a934c..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs +++ /dev/null @@ -1,80 +0,0 @@ -// 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. - -using System; -using System.IO; -using ManagedCommon; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps.State; - -public sealed class PinnedAppsManager -{ - private static readonly Lazy _instance = new(() => new PinnedAppsManager()); - private readonly string _pinnedAppsFilePath; - - public static PinnedAppsManager Instance => _instance.Value; - - private PinnedApps _pinnedApps = new(); - - // Add event for when pinning state changes - public event EventHandler? PinStateChanged; - - private PinnedAppsManager() - { - _pinnedAppsFilePath = GetPinnedAppsFilePath(); - LoadPinnedApps(); - } - - public bool IsAppPinned(string appIdentifier) - { - return _pinnedApps.PinnedAppIdentifiers.IndexOf(appIdentifier) >= 0; - } - - public void PinApp(string appIdentifier) - { - if (!IsAppPinned(appIdentifier)) - { - _pinnedApps.PinnedAppIdentifiers.Add(appIdentifier); - SavePinnedApps(); - Logger.LogTrace($"Pinned app: {appIdentifier}"); - PinStateChanged?.Invoke(this, new PinStateChangedEventArgs(appIdentifier, true)); - } - } - - public string[] GetPinnedAppIdentifiers() - { - return _pinnedApps.PinnedAppIdentifiers.ToArray(); - } - - public void UnpinApp(string appIdentifier) - { - var removed = _pinnedApps.PinnedAppIdentifiers.RemoveAll(id => - string.Equals(id, appIdentifier, StringComparison.OrdinalIgnoreCase)); - - if (removed > 0) - { - SavePinnedApps(); - Logger.LogTrace($"Unpinned app: {appIdentifier}"); - PinStateChanged?.Invoke(this, new PinStateChangedEventArgs(appIdentifier, false)); - } - } - - private void LoadPinnedApps() - { - _pinnedApps = PinnedApps.ReadFromFile(_pinnedAppsFilePath); - } - - private void SavePinnedApps() - { - PinnedApps.WriteToFile(_pinnedAppsFilePath, _pinnedApps); - } - - private static string GetPinnedAppsFilePath() - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - return Path.Combine(directory, "apps.pinned.json"); - } -}