Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs
Jiří Polášek 5f124cec55 CmdPal: Cache and show information for disabled command providers (#44278)
## Summary of the Pull Request

This PR adds a cache of command provider information so we can show
providers even when the command provider isn’t loaded.

It also updates the description for disabled extensions on the
Extensions page to always include the extension name.

Finally, it adds a placeholder icon for cases where an extension icon
isn’t loaded. Note that this doesn’t address fully transparent icons
that some extensions may inherit from the default template.

Before:

<img width="1883" height="167" alt="image"
src="https://github.com/user-attachments/assets/7ccaa669-9516-4b57-9646-4e755d29d75c"
/>


After:

<img width="1873" height="190" alt="image"
src="https://github.com/user-attachments/assets/f29549c2-ddd5-4688-ba9c-d1abd4b523a0"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:19:25 -06:00

265 lines
9.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.CmdPal.UI.ViewModels.Services;
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;
private readonly ICommandProviderCache? _commandProviderCache;
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 => 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, ICommandProviderCache commandProviderCache)
{
_taskScheduler = mainThread;
_commandProviderCache = commandProviderCache;
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;
RecallFromCache();
return;
}
var settings = serviceProvider.GetService<SettingsModel>()!;
var providerSettings = GetProviderSettings(settings);
IsActive = providerSettings.IsEnabled;
if (!IsActive)
{
RecallFromCache();
return;
}
var displayInfoInitialized = false;
try
{
var model = _commandProvider.Unsafe!;
Task<ICommandItem[]> loadTopLevelCommandsTask = new(model.TopLevelCommands);
loadTopLevelCommandsTask.Start();
var commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
// On a BG thread here
var fallbacks = model.FallbackCommands();
if (model is ICommandProvider2 two)
{
UnsafePreCacheApiAdditions(two);
}
Id = model.Id;
DisplayName = model.DisplayName;
Icon = new(model.Icon);
Icon.InitializeProperties();
displayInfoInitialized = true;
// Update cached display name
if (_commandProviderCache is not null && Extension?.ExtensionUniqueId is not null)
{
_commandProviderCache.Memorize(Extension.ExtensionUniqueId, new CommandProviderCacheItem(model.DisplayName));
}
// 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());
if (!displayInfoInitialized)
{
RecallFromCache();
}
}
}
private void RecallFromCache()
{
var cached = _commandProviderCache?.Recall(ProviderId);
if (cached is not null)
{
DisplayName = cached.DisplayName;
}
if (string.IsNullOrWhiteSpace(DisplayName))
{
DisplayName = Extension?.PackageDisplayName ?? Extension?.PackageFamilyName ?? ProviderId;
}
}
private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var providerSettings = GetProviderSettings(settings);
var 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);
}