Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs
Michael Jolley f1e045751a CmdPal: Fallback ranking and global results (#43549)
> [!IMPORTANT]
> For extension developers, this release includes a new required `string
Id` property for `FallbackCommandItem`. While your existing extensions
will continue to work, without this `Id` being set, your fallbacks will
not display and will not be rankable.
> Before this is released, you will want to prepare your extension
fallbacks.
> 
> As an example, we are naming our built-in extensions as:
> - Calculator extension provider Id:
`com.microsoft.cmdpal.builtin.calculator`
> - Calculator extension fallback:
`com.microsoft.cmdpal.builtin.calculator.fallback`
> 
> While the content of the Id isn't important, what is important is that
it is unique to your extension and fallback to avoid conflicting with
other extensions.

Now the good stuff:

## What the heck does it do!?

### The backstory

In PowerToys 0.95, we released performance improvements to Command
Palette. One of the many ways we improved its speed is by no longer
ranking fallback commands with other "top level" commands. Instead, all
fallbacks would surface at the bottom of the results and be listed in
the order they were registered with Command Palette. But this was only a
temporary solution until the work included in this pull request was
ready.

In reality, not all fallbacks were treated equally. We marked the
calculator and run fallbacks as "special." Special fallbacks **were**
ranked like top-level commands and allowed to surface to the top of the
results.

### The new "hotness"

This PR brings the power of fallback management back to the people. In
the Command Palette settings, you, dear user, can specify what order you
want fallbacks to display in at the bottom of the results. This keeps
those fallbacks unranked by Command Palette but displays them in an
order that makes sense for you. But keep in mind, these will still live
at the bottom of search results.

But alas, we have also heard your cries that you'd like _some_ fallbacks
to be ranked by Command Palette and surface to the top of the results.
So, this PR allows you to mark any fallback as "special" by choosing to
include them in the global results. Special (Global) fallbacks are
treated like "top level" commands and appear in the search result based
on their title & description.

### Screenshots/video

<img width="1005" height="611" alt="image"
src="https://github.com/user-attachments/assets/8ba5d861-f887-47ed-8552-ba78937322d2"
/>

<img width="1501" height="973" alt="image"
src="https://github.com/user-attachments/assets/9edb7675-8084-4f14-8bdc-72d7d06d500e"
/>

<img width="706" height="744" alt="image"
src="https://github.com/user-attachments/assets/81ae0252-b87d-4172-a5ea-4d3102134baf"
/>

<img width="666" height="786" alt="image"
src="https://github.com/user-attachments/assets/acb76acf-531d-4e60-bb44-d1edeec77dce"
/>


### GitHub issue maintenance details

Closes #38312
Closes #38288
Closes #42524
Closes #41024
Closes #40351
Closes #41696
Closes #40193

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Jiří Polášek <me@jiripolasek.com>
2025-12-22 17:08:15 -06:00

239 lines
8.1 KiB
C#

// 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 ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed class CommandProviderWrapper
{
public bool IsExtension => Extension is not null;
private readonly bool isValid;
private readonly ExtensionObject<ICommandProvider> _commandProvider;
private readonly TaskScheduler _taskScheduler;
public TopLevelViewModel[] TopLevelItems { get; private set; } = [];
public TopLevelViewModel[] FallbackItems { get; private set; } = [];
public string DisplayName { get; private set; } = string.Empty;
public IExtensionWrapper? Extension { get; }
public CommandPaletteHost ExtensionHost { get; private set; }
public event TypedEventHandler<CommandProviderWrapper, IItemsChangedEventArgs>? CommandsChanged;
public string Id { get; private set; } = string.Empty;
public IconInfoViewModel Icon { get; private set; } = new(null);
public CommandSettingsViewModel? Settings { get; private set; }
public bool IsActive { get; private set; }
public string ProviderId
{
get
{
return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
}
}
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;
// Hook the extension back into us
ExtensionHost = new CommandPaletteHost(provider);
_commandProvider.Unsafe!.InitializeWithHost(ExtensionHost);
_commandProvider.Unsafe!.ItemsChanged += CommandProvider_ItemsChanged;
isValid = true;
Id = provider.Id;
DisplayName = provider.DisplayName;
Icon = new(provider.Icon);
Icon.InitializeProperties();
// Note: explicitly not InitializeProperties()ing the settings here. If
// we do that, then we'd regress GH #38321
Settings = new(provider.Settings, this, _taskScheduler);
Logger.LogDebug($"Initialized command provider {ProviderId}");
}
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread)
{
_taskScheduler = mainThread;
Extension = extension;
ExtensionHost = new CommandPaletteHost(extension);
if (!Extension.IsRunning())
{
throw new ArgumentException("You forgot to start the extension. This is a CmdPal error - we need to make sure to call StartExtensionAsync");
}
var extensionImpl = extension.GetExtensionObject();
var providerObject = extensionImpl?.GetProvider(ProviderType.Commands);
if (providerObject is not ICommandProvider provider)
{
throw new ArgumentException("extension didn't actually implement ICommandProvider");
}
_commandProvider = new(provider);
try
{
var model = _commandProvider.Unsafe!;
// Hook the extension back into us
model.InitializeWithHost(ExtensionHost);
model.ItemsChanged += CommandProvider_ItemsChanged;
isValid = true;
Logger.LogDebug($"Initialized extension command provider {Extension.PackageFamilyName}:{Extension.ExtensionUniqueId}");
}
catch (Exception e)
{
Logger.LogError("Failed to initialize CommandProvider for extension.");
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString());
}
isValid = true;
}
private ProviderSettings GetProviderSettings(SettingsModel settings)
{
return settings.GetProviderSettings(this);
}
public async Task LoadTopLevelCommands(IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
{
if (!isValid)
{
IsActive = false;
return;
}
var settings = serviceProvider.GetService<SettingsModel>()!;
IsActive = GetProviderSettings(settings).IsEnabled;
if (!IsActive)
{
return;
}
ICommandItem[]? commands = null;
IFallbackCommandItem[]? fallbacks = null;
try
{
var model = _commandProvider.Unsafe!;
Task<ICommandItem[]> t = new(model.TopLevelCommands);
t.Start();
commands = await t.ConfigureAwait(false);
// On a BG thread here
fallbacks = model.FallbackCommands();
if (model is ICommandProvider2 two)
{
UnsafePreCacheApiAdditions(two);
}
Id = model.Id;
DisplayName = model.DisplayName;
Icon = new(model.Icon);
Icon.InitializeProperties();
// Note: explicitly not InitializeProperties()ing the settings here. If
// we do that, then we'd regress GH #38321
Settings = new(model.Settings, this, _taskScheduler);
// We do need to explicitly initialize commands though
InitializeCommands(commands, fallbacks, serviceProvider, pageContext);
Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})");
}
catch (Exception e)
{
Logger.LogError("Failed to load commands from extension");
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString());
}
}
private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var providerSettings = GetProviderSettings(settings);
Func<ICommandItem?, bool, TopLevelViewModel> makeAndAdd = (ICommandItem? i, bool fallback) =>
{
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i);
topLevelViewModel.InitializeProperties();
return topLevelViewModel;
};
if (commands is not null)
{
TopLevelItems = commands
.Select(c => makeAndAdd(c, false))
.ToArray();
}
if (fallbacks is not null)
{
FallbackItems = fallbacks
.Select(c => makeAndAdd(c, true))
.ToArray();
}
}
private void UnsafePreCacheApiAdditions(ICommandProvider2 provider)
{
var apiExtensions = provider.GetApiExtensionStubs();
Logger.LogDebug($"Provider supports {apiExtensions.Length} extensions");
foreach (var a in apiExtensions)
{
if (a is IExtendedAttributesProvider command2)
{
Logger.LogDebug($"{ProviderId}: Found an IExtendedAttributesProvider");
}
}
}
public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid;
public override int GetHashCode() => _commandProvider.GetHashCode();
private void CommandProvider_ItemsChanged(object sender, IItemsChangedEventArgs args) =>
// We don't want to handle this ourselves - we want the
// TopLevelCommandManager to know about this, so they can remove
// our old commands from their own list.
//
// In handling this, a call will be made to `LoadTopLevelCommands` to
// retrieve the new items.
this.CommandsChanged?.Invoke(this, args);
}