CmdPal: Add context commands for pinning nested commands (#45673)

_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.

<img width="540" height="181" alt="image"
src="https://github.com/user-attachments/assets/6c2cfe3c-4143-44d1-9308-bfc71db4c842"
/>
<img width="729" height="317" alt="image"
src="https://github.com/user-attachments/assets/4ff75c9f-1f35-4c1e-a03e-6fab5cbab423"
/>

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
This commit is contained in:
Mike Griese
2026-02-26 10:09:17 -06:00
committed by GitHub
parent cdeae7c854
commit 7a0e4ac891
30 changed files with 389 additions and 434 deletions

View File

@@ -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);
}
}

View File

@@ -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<AppItem> allApps = new();
var items = new List<AppListItem>();
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<AppListItem>();
var unpinned = new List<AppListItem>();
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<AppListItem>(pinnedApps);
var newUnpinned = new List<AppListItem>(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];
}
}

View File

@@ -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<Task<Details>>(BuildDetails);
_iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails).ConfigureAwait(false));
@@ -237,35 +237,6 @@ public sealed partial class AppListItem : ListItem, IPrecomputedListItem
return icon;
}
private IContextItem[] AddPinCommands(List<IContextItem> commands, bool isPinned)
{
var newCommands = new List<IContextItem>();
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<IconInfo?> TryLoadThumbnail(string path, bool jumbo, bool logOnFailure)
{
return await Task.Run(async () =>

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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<string>), TypeInfoPropertyName = "StringList")]
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
internal sealed partial class JsonSerializationContext : JsonSerializerContext

View File

@@ -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<string> 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<PinnedApps>(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
}
}
}

View File

@@ -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<PinnedAppsManager> _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<PinStateChangedEventArgs>? 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");
}
}