CmdPal: Load pinned command items from anywhere (#45566)

This doesn't actually have a UX to expose this yet - we need to stack a
couple of PRs up to get to that.

But this adds plumbing such that we can now stash away a command ID, and
retrieve it later as a top-level command. Kinda like pinning for apps,
but for _anything_.

It works off of a new command provider interface `ICommandProvider4`,
which lets us look up Command**Item**s by ID. If we see a command ID
stored in that command provider's settings, we will try to look it up,
and then load it from the command provider.

e.g.

```json
    "com.microsoft.cmdpal.builtin.system": {
      "IsEnabled": true,
      "FallbackCommands": {
        "com.microsoft.cmdpal.builtin.system.fallback": {
          "IsEnabled": true,
          "IncludeInGlobalResults": true
        }
      },
      "PinnedCommandIds": [
        "com.microsoft.cmdpal.builtin.system.lock",
        "com.microsoft.cmdpal.builtin.system.restart_shell"
      ]
    },
```
will get us
<img width="840" height="197" alt="image"
src="https://github.com/user-attachments/assets/9ed19003-8361-4318-8dc9-055414456a51"
/>

Then it's just a matter of plumbing the command provider ID through the
layers, so that the command item knows who it is from. We'll need that
later for actually wiring this to the command's context menu.

related to #45191 
related to #45201
This commit is contained in:
Mike Griese
2026-02-19 16:20:05 -06:00
committed by GitHub
parent 39bfa86335
commit 0f87b61dad
23 changed files with 280 additions and 57 deletions

View File

@@ -9,8 +9,8 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandPaletteContentPageViewModel : ContentPageViewModel
{
public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host)
: base(model, scheduler, host)
public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext)
: base(model, scheduler, host, providerContext)
{
}

View File

@@ -17,12 +17,12 @@ public class CommandPalettePageViewModelFactory
_scheduler = scheduler;
}
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host)
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext)
{
return page switch
{
IListPage listPage => new ListViewModel(listPage, _scheduler, host) { IsNested = nested },
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host),
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext) { IsNested = nested },
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host, providerContext),
_ => null,
};
}

View File

@@ -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,7 @@ using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
using Windows.Foundation;
@@ -158,6 +159,9 @@ public sealed class CommandProviderWrapper
UnsafePreCacheApiAdditions(two);
}
// Load pinned commands from saved settings
var pinnedCommands = LoadPinnedCommands(model, providerSettings);
Id = model.Id;
DisplayName = model.DisplayName;
Icon = new(model.Icon);
@@ -175,7 +179,7 @@ public sealed class CommandProviderWrapper
Settings = new(model.Settings, this, _taskScheduler);
// We do need to explicitly initialize commands though
InitializeCommands(commands, fallbacks, serviceProvider, pageContext);
InitializeCommands(commands, fallbacks, pinnedCommands, serviceProvider, pageContext);
Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})");
}
@@ -206,27 +210,34 @@ public sealed class CommandProviderWrapper
}
}
private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, ICommandItem[] pinnedCommands, IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var providerSettings = GetProviderSettings(settings);
var ourContext = GetProviderContext();
var makeAndAdd = (ICommandItem? i, bool fallback) =>
{
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i);
topLevelViewModel.InitializeProperties();
return topLevelViewModel;
};
var topLevelList = new List<TopLevelViewModel>();
if (commands is not null)
{
TopLevelItems = commands
.Select(c => makeAndAdd(c, false))
.ToArray();
topLevelList.AddRange(commands.Select(c => makeAndAdd(c, false)));
}
if (pinnedCommands is not null)
{
topLevelList.AddRange(pinnedCommands.Select(c => makeAndAdd(c, false)));
}
TopLevelItems = topLevelList.ToArray();
if (fallbacks is not null)
{
FallbackItems = fallbacks
@@ -235,6 +246,32 @@ public sealed class CommandProviderWrapper
}
}
private ICommandItem[] LoadPinnedCommands(ICommandProvider model, ProviderSettings providerSettings)
{
var pinnedItems = new List<ICommandItem>();
if (model is ICommandProvider4 provider4)
{
foreach (var pinnedId in providerSettings.PinnedCommandIds)
{
try
{
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}");
}
}
}
return pinnedItems.ToArray();
}
private void UnsafePreCacheApiAdditions(ICommandProvider2 provider)
{
var apiExtensions = provider.GetApiExtensionStubs();
@@ -248,6 +285,26 @@ public sealed class CommandProviderWrapper
}
}
public void PinCommand(string commandId, IServiceProvider serviceProvider)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var providerSettings = GetProviderSettings(settings);
if (!providerSettings.PinnedCommandIds.Contains(commandId))
{
providerSettings.PinnedCommandIds.Add(commandId);
SettingsModel.SaveSettings(settings);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
}
public CommandProviderContext GetProviderContext()
{
return new() { ProviderId = ProviderId };
}
public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid;
public override int GetHashCode() => _commandProvider.GetHashCode();

View File

@@ -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.
@@ -31,7 +31,7 @@ public partial class CommandSettingsViewModel(ICommandSettings? _unsafeSettings,
if (model.SettingsPage is not null)
{
SettingsPage = new CommandPaletteContentPageViewModel(model.SettingsPage, mainThread, provider.ExtensionHost);
SettingsPage = new CommandPaletteContentPageViewModel(model.SettingsPage, mainThread, provider.ExtensionHost, provider.GetProviderContext());
SettingsPage.InitializeProperties();
}
}

View File

@@ -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 PinCommandItemMessage(string ProviderId, string CommandId)
{
}

View File

@@ -18,6 +18,8 @@ public class ProviderSettings
public Dictionary<string, FallbackSettings> FallbackCommands { get; set; } = new();
public List<string> PinnedCommandIds { get; set; } = [];
[JsonIgnore]
public string ProviderDisplayName { get; set; } = string.Empty;

View File

@@ -22,6 +22,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>,
IRecipient<PinCommandItemMessage>,
IPageContext,
IDisposable
{
@@ -42,6 +43,7 @@ public partial class TopLevelCommandManager : ObservableObject,
_commandProviderCache = commandProviderCache;
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
WeakReferenceMessenger.Default.Register<PinCommandItemMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
}
@@ -414,6 +416,21 @@ public partial class TopLevelCommandManager : ObservableObject,
public void Receive(ReloadCommandsMessage message) =>
ReloadAllCommandsAsync().ConfigureAwait(false);
public void Receive(PinCommandItemMessage message)
{
var wrapper = LookupProvider(message.ProviderId);
wrapper?.PinCommand(message.CommandId, _serviceProvider);
}
private CommandProviderWrapper? LookupProvider(string providerId)
{
lock (_commandProvidersLock)
{
return _builtInCommands.FirstOrDefault(w => w.ProviderId == providerId)
?? _extensionCommandProviders.FirstOrDefault(w => w.ProviderId == providerId);
}
}
void IPageContext.ShowException(Exception ex, string? extensionHint)
{
var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? "TopLevelCommandManager");

View File

@@ -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.
@@ -27,7 +27,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
private readonly IServiceProvider _serviceProvider;
private readonly CommandItemViewModel _commandItemViewModel;
private readonly string _commandProviderId;
public CommandProviderContext ProviderContext { get; private set; }
private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id;
@@ -57,7 +57,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
public CommandItemViewModel ItemViewModel => _commandItemViewModel;
public string CommandProviderId => _commandProviderId;
public string CommandProviderId => ProviderContext.ProviderId;
////// ICommandItem
public string Title => _commandItemViewModel.Title;
@@ -190,7 +190,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
CommandItemViewModel item,
bool isFallback,
CommandPaletteHost extensionHost,
string commandProviderId,
CommandProviderContext commandProviderContext,
SettingsModel settings,
ProviderSettings providerSettings,
IServiceProvider serviceProvider,
@@ -199,7 +199,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
_serviceProvider = serviceProvider;
_settings = settings;
_providerSettings = providerSettings;
_commandProviderId = commandProviderId;
ProviderContext = commandProviderContext;
_commandItemViewModel = item;
IsFallback = isFallback;
@@ -358,8 +358,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
{
// Use WyHash64 to generate stable ID hashes.
// manually seeding with 0, so that the hash is stable across launches
var result = WyHash64.ComputeHash64(_commandProviderId + DisplayTitle + Title + Subtitle, seed: 0);
_generatedId = $"{_commandProviderId}{result}";
var result = WyHash64.ComputeHash64(CommandProviderId + DisplayTitle + Title + Subtitle, seed: 0);
_generatedId = $"{CommandProviderId}{result}";
}
private void DoOnUiThread(Action action)