CmdPal: Handle CommandItem Title changes properly and raise notification every time it changes (#40513)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

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

- [x] **Closes:** #39167 
- [ ] **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-07-28 23:40:29 +02:00
committed by GitHub
parent 4489677b64
commit f81802430c
3 changed files with 121 additions and 4 deletions

View File

@@ -313,6 +313,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
Command = new(model.Command, PageContext);
Command.InitializeProperties();
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
_itemTitle = model.Title;
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Icon));
@@ -385,6 +389,14 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
switch (propertyName)
{
case nameof(Command.Name):
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
var model = _commandItemModel.Unsafe;
if (model != null)
{
_itemTitle = model.Title;
}
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Name));
break;

View File

@@ -7,6 +7,8 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class CommandItem : BaseObservable, ICommandItem
{
private ICommand? _command;
private WeakEventListener<CommandItem, object, IPropChangedEventArgs>? _commandListener;
private string _title = string.Empty;
public virtual IIconInfo? Icon
{
@@ -20,17 +22,15 @@ public partial class CommandItem : BaseObservable, ICommandItem
public virtual string Title
{
get => !string.IsNullOrEmpty(field) ? field : _command?.Name ?? string.Empty;
get => !string.IsNullOrEmpty(_title) ? _title : _command?.Name ?? string.Empty;
set
{
field = value;
_title = value;
OnPropertyChanged(nameof(Title));
}
}
= string.Empty;
public virtual string Subtitle
{
get;
@@ -48,8 +48,33 @@ public partial class CommandItem : BaseObservable, ICommandItem
get => _command;
set
{
if (_commandListener != null)
{
_commandListener.Detach();
_commandListener = null;
}
_command = value;
if (value != null)
{
_commandListener = new(this, OnCommandPropertyChanged, listener => value.PropChanged -= listener.OnEvent);
value.PropChanged += _commandListener.OnEvent;
}
OnPropertyChanged(nameof(Command));
if (string.IsNullOrWhiteSpace(_title))
{
OnPropertyChanged(nameof(Title));
}
}
}
private static void OnCommandPropertyChanged(CommandItem instance, object source, IPropChangedEventArgs args)
{
if (args.PropertyName == nameof(ICommand.Name))
{
instance.OnPropertyChanged(nameof(Title));
}
}

View File

@@ -0,0 +1,80 @@
// 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.ComponentModel;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
/// <summary>
/// Implements a weak event listener that allows the owner to be garbage
/// collected if its only remaining link is an event handler.
/// </summary>
/// <typeparam name="TInstance">Type of instance listening for the event.</typeparam>
/// <typeparam name="TSource">Type of source for the event.</typeparam>
/// <typeparam name="TEventArgs">Type of event arguments for the event.</typeparam>
[EditorBrowsable(EditorBrowsableState.Never)]
internal sealed class WeakEventListener<TInstance, TSource, TEventArgs>
where TInstance : class
{
/// <summary>
/// WeakReference to the instance listening for the event.
/// </summary>
private readonly WeakReference<TInstance> _weakInstance;
/// <summary>
/// Initializes a new instance of the <see cref="WeakEventListener{TInstance, TSource, TEventArgs}"/> class.
/// </summary>
/// <param name="instance">Instance subscribing to the event.</param>
/// <param name="onEventAction">Event handler executed when event is raised.</param>
/// <param name="onDetachAction">Action to execute when instance was collected.</param>
public WeakEventListener(
TInstance instance,
Action<TInstance, TSource, TEventArgs>? onEventAction = null,
Action<WeakEventListener<TInstance, TSource, TEventArgs>>? onDetachAction = null)
{
ArgumentNullException.ThrowIfNull(instance);
_weakInstance = new(instance);
OnEventAction = onEventAction;
OnDetachAction = onDetachAction;
}
/// <summary>
/// Gets or sets the method to call when the event fires.
/// </summary>
public Action<TInstance, TSource, TEventArgs>? OnEventAction { get; set; }
/// <summary>
/// Gets or sets the method to call when detaching from the event.
/// </summary>
public Action<WeakEventListener<TInstance, TSource, TEventArgs>>? OnDetachAction { get; set; }
/// <summary>
/// Handler for the subscribed event calls OnEventAction to handle it.
/// </summary>
/// <param name="source">Event source.</param>
/// <param name="eventArgs">Event arguments.</param>
public void OnEvent(TSource source, TEventArgs eventArgs)
{
if (_weakInstance.TryGetTarget(out var target))
{
// Call registered action
OnEventAction?.Invoke(target, source, eventArgs);
}
else
{
// Detach from event
Detach();
}
}
/// <summary>
/// Detaches from the subscribed event.
/// </summary>
public void Detach()
{
OnDetachAction?.Invoke(this);
OnDetachAction = null;
}
}