CmdPal: Fix updating primary command and context menu and app icons (#42155)

## Summary of the Pull Request

This PR fixes three issues in one go:
- Restores missing icons in app context menus.
- Fixes propagation of changes from a command item to the context menu
item for the primary action.
- Ensures the context menus stay in sync when underlying command items
change.

Details:
- Correctly propagates updates of name, icon, and subtitle from a
command item to its primary command
(`CommandItemViewModel._defaultCommandContextItemViewModel`).
- Correctly propagate updates of command's name to title
(`CommandItem.ctor`).
- Fixes icon loading for application items: `AppCommand` no longer loads
an app icon by default but instead relies on the caller to provide one
(since `AppListItem` also handles icon loading).
- Adds a generic fallback icon for apps when an icon cannot be loaded.
- Updates bindings on context menu items to `OneWay`, ensuring the UI
properly reflects item changes.
- Adds a sample that showcases dynamically updated commands (with cats
and dolphins!) to _Samples → List Page Sample Command_.

⚠️ Toolkit changes:
- `CommandItem` won't capture assigned Command's name as its `Title`.
This will allow it to propagate future changes to `Command.Name`.

Pictures? Moving ones!


https://github.com/user-attachments/assets/1a482394-d222-4f7c-9922-bb67d47dc566

<img width="864" height="538" alt="image"
src="https://github.com/user-attachments/assets/12f07b3e-f41c-4c40-a4e5-315f40676c52"
/>


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

- [x] Closes: #40946
- [x] Related: #40991 
- [ ] **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
This commit is contained in:
Jiří Polášek
2025-10-06 16:45:10 +02:00
committed by GitHub
parent 26ec8c6bd5
commit 466a94eb40
7 changed files with 187 additions and 82 deletions

View File

@@ -16,24 +16,19 @@ using WyHash;
namespace Microsoft.CmdPal.Ext.Apps;
public sealed partial class AppCommand : InvokableCommand
internal sealed partial class AppCommand : InvokableCommand
{
private readonly AppItem _app;
public AppCommand(AppItem app)
{
_app = app;
Name = Resources.run_command_action;
Name = Resources.run_command_action!;
Id = GenerateId();
if (!string.IsNullOrEmpty(app.IcoPath))
{
Icon = new(app.IcoPath);
}
Icon = Icons.GenericAppIcon;
}
internal static async Task StartApp(string aumid)
private static async Task StartApp(string aumid)
{
await Task.Run(() =>
{
@@ -58,7 +53,7 @@ public sealed partial class AppCommand : InvokableCommand
}).ConfigureAwait(false);
}
internal static async Task StartExe(string path)
private static async Task StartExe(string path)
{
await Task.Run(() =>
{
@@ -73,7 +68,7 @@ public sealed partial class AppCommand : InvokableCommand
});
}
internal async Task Launch()
private async Task Launch()
{
if (_app.IsPackaged)
{

View File

@@ -5,34 +5,51 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Ext.Apps.Commands;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.Ext.Apps.Programs;
internal sealed partial class AppListItem : ListItem
{
private readonly AppItem _app;
private static readonly Tag _appTag = new("App");
private readonly AppCommand _appCommand;
private readonly AppItem _app;
private readonly Lazy<Details> _details;
private readonly Lazy<IconInfo> _icon;
private readonly Lazy<Task<IconInfo?>> _iconLoadTask;
private InterlockedBoolean _isLoadingIcon;
public override IDetails? Details { get => _details.Value; set => base.Details = value; }
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
public override IIconInfo? Icon
{
get
{
if (_isLoadingIcon.Set())
{
_ = LoadIconAsync();
}
return base.Icon;
}
set => base.Icon = value;
}
public string AppIdentifier => _app.AppIdentifier;
public AppListItem(AppItem app, bool useThumbnails, bool isPinned)
: base(new AppCommand(app))
{
Command = _appCommand = new AppCommand(app);
_app = app;
Title = app.Name;
Subtitle = app.Subtitle;
Tags = [_appTag];
Icon = Icons.GenericAppIcon;
MoreCommands = AddPinCommands(_app.Commands!, isPinned);
@@ -43,12 +60,19 @@ internal sealed partial class AppListItem : ListItem
return t.Result;
});
_icon = new Lazy<IconInfo>(() =>
_iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails));
}
private async Task LoadIconAsync()
{
try
{
var t = FetchIcon(useThumbnails);
t.Wait();
return t.Result;
});
Icon = _appCommand.Icon = await _iconLoadTask.Value ?? Icons.GenericAppIcon;
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to load icon for {AppIdentifier}\n{ex}");
}
}
private async Task<Details> BuildDetails()
@@ -87,12 +111,12 @@ internal sealed partial class AppListItem : ListItem
return new Details()
{
Title = this.Title,
HeroImage = heroImage ?? this.Icon ?? new IconInfo(string.Empty),
HeroImage = heroImage ?? this.Icon ?? Icons.GenericAppIcon,
Metadata = metadata.ToArray(),
};
}
public async Task<IconInfo> FetchIcon(bool useThumbnails)
private async Task<IconInfo> FetchIcon(bool useThumbnails)
{
IconInfo? icon = null;
if (_app.IsPackaged)
@@ -108,12 +132,12 @@ internal sealed partial class AppListItem : ListItem
var stream = await ThumbnailHelper.GetThumbnail(_app.ExePath);
if (stream is not null)
{
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
icon = new IconInfo(data, data);
icon = IconInfo.FromStream(stream);
}
}
catch
catch (Exception ex)
{
Logger.LogDebug($"Failed to load icon for {AppIdentifier}:\n{ex}");
}
icon = icon ?? new IconInfo(_app.IcoPath);

View File

@@ -6,21 +6,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Apps;
internal sealed class Icons
internal static class Icons
{
internal static IconInfo AllAppsIcon => IconHelpers.FromRelativePath("Assets\\AllApps.svg");
internal static IconInfo AllAppsIcon { get; } = IconHelpers.FromRelativePath("Assets\\AllApps.svg");
internal static IconInfo RunAsUserIcon => new("\uE7EE"); // OtherUser icon
internal static IconInfo RunAsUserIcon { get; } = new("\uE7EE"); // OtherUser icon
internal static IconInfo RunAsAdminIcon => new("\uE7EF"); // Admin icon
internal static IconInfo RunAsAdminIcon { get; } = new("\uE7EF"); // Admin icon
internal static IconInfo OpenPathIcon => new("\ue838"); // Folder Open icon
internal static IconInfo OpenPathIcon { get; } = new("\ue838"); // Folder Open icon
internal static IconInfo CopyIcon => new("\ue8c8"); // Copy icon
internal static IconInfo CopyIcon { get; } = new("\ue8c8"); // Copy icon
public static IconInfo UnpinIcon { get; } = new("\uE77A"); // Unpin icon
public static IconInfo PinIcon { get; } = new("\uE840"); // Pin icon
public static IconInfo UninstallApplicationIcon { get; } = new("\uE74D"); // Uninstall icon
public static IconInfo GenericAppIcon { get; } = new("\uE737"); // Favicon
}

View File

@@ -2,8 +2,9 @@
// 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.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
@@ -181,6 +182,15 @@ internal sealed partial class SampleListPage : ListPage
{
Title = "I also have properties",
},
new ListItem(new EverChangingCommand("Cat", "🐈‍⬛", "🐈"))
{
Title = "And I have a commands with changing name and icon",
MoreCommands = [
new CommandContextItem(new EverChangingCommand("Water", "🐬", "🐳", "🐟", "🦈")),
new CommandContextItem(new EverChangingCommand("Faces", "😁", "🥺", "😍")),
new CommandContextItem(new EverChangingCommand("Hearts", "♥️", "💚", "💜", "🧡", "💛", "💙")),
],
}
];
}
@@ -229,4 +239,47 @@ internal sealed partial class SampleListPage : ListPage
{ "hmm?", null },
};
}
internal sealed partial class EverChangingCommand : InvokableCommand, IDisposable
{
private readonly string[] _icons;
private readonly Timer _timer;
private readonly string _name;
private int _currentIndex;
public EverChangingCommand(string name, params string[] icons)
{
_icons = icons ?? throw new ArgumentNullException(nameof(icons));
if (_icons.Length == 0)
{
throw new ArgumentException("Icons array cannot be empty", nameof(icons));
}
_name = name;
Name = $"{_name} {DateTimeOffset.UtcNow:hh:mm:ss}";
Icon = new IconInfo(_icons[_currentIndex]);
// Start timer to change icon and name every 5 seconds
_timer = new Timer(OnTimerElapsed, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
private void OnTimerElapsed(object state)
{
var nextIndex = (_currentIndex + 1) % _icons.Length;
if (nextIndex == _currentIndex && _icons.Length > 1)
{
nextIndex = (_currentIndex + 1) % _icons.Length;
}
_currentIndex = nextIndex;
Name = $"{_name} {DateTimeOffset.UtcNow:hh:mm:ss}";
Icon = new IconInfo(_icons[_currentIndex]);
}
public void Dispose()
{
_timer?.Dispose();
}
}
}