Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs
Jiří Polášek 3f35b11cee CmdPal: Fix missing primary context command for late-bound items (#46131)
This PR does fix a bug where an item that starts with a null or empty
primary command never adds that primary action to the context menu after
the extension later provides a real command.

- Creates the default primary context-menu item lazily when `Command` or
`Command.Name` becomes available after `SlowInitializeProperties()`
- Refreshes `AllCommands`, `SecondaryCommand`, and `HasMoreCommands`
notifications for late command materialization and Show Details updates.
- Adds unit tests to cover the fixed issue.


<!-- 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: #46129 
<!-- - [ ] 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-03-24 17:24:04 -05:00

326 lines
11 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 System.Diagnostics.CodeAnalysis;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListItemViewModel : CommandItemViewModel
{
public new ExtensionObject<IListItem> Model { get; }
public List<TagViewModel>? Tags { get; set; }
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public bool HasTags => (Tags?.Count ?? 0) > 0;
public string TextToSuggest { get; private set; } = string.Empty;
public string Section { get; private set; } = string.Empty;
public ListItemType Type { get; private set; }
public bool IsInteractive => Type == ListItemType.Item;
public DetailsViewModel? Details { get; private set; }
[MemberNotNullWhen(true, nameof(Details))]
public bool HasDetails => Details is not null;
public string AccessibleName { get; private set; } = string.Empty;
public bool ShowTitle { get; private set; }
public bool ShowSubtitle { get; private set; }
public bool LayoutShowsTitle
{
get;
set
{
if (SetProperty(ref field, value))
{
UpdateShowsTitle();
}
}
}
public bool LayoutShowsSubtitle
{
get;
set
{
if (SetProperty(ref field, value))
{
UpdateShowsSubtitle();
}
}
}
public ListItemViewModel(IListItem model, WeakReference<IPageContext> context, IContextMenuFactory contextMenuFactory)
: base(new(model), context, contextMenuFactory)
{
Model = new ExtensionObject<IListItem>(model);
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
// This sets IsInitialized = true
base.InitializeProperties();
var li = Model.Unsafe;
if (li is null)
{
return; // throw?
}
UpdateTags(li.Tags);
Section = li.Section ?? string.Empty;
Type = EvaluateType();
UpdateProperty(nameof(Section), nameof(Type), nameof(IsInteractive));
UpdateAccessibleName();
}
private ListItemType EvaluateType()
{
return Command.IsSet
? ListItemType.Item
: string.IsNullOrEmpty(Section) ? ListItemType.Separator : ListItemType.SectionHeader;
}
public override void SlowInitializeProperties()
{
base.SlowInitializeProperties();
var model = Model.Unsafe;
if (model is null)
{
return;
}
var extensionDetails = model.Details;
if (extensionDetails is not null)
{
Details = new(extensionDetails, PageContext);
Details.InitializeProperties();
UpdateProperty(nameof(Details), nameof(HasDetails));
}
AddShowDetailsCommands();
TextToSuggest = model.TextToSuggest;
UpdateProperty(nameof(TextToSuggest));
}
protected override void FetchProperty(string propertyName)
{
base.FetchProperty(propertyName);
var model = this.Model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(model.Tags):
UpdateTags(model.Tags);
break;
case nameof(model.TextToSuggest):
TextToSuggest = model.TextToSuggest ?? string.Empty;
UpdateProperty(nameof(TextToSuggest));
break;
case nameof(model.Section):
Section = model.Section ?? string.Empty;
Type = EvaluateType();
UpdateProperty(nameof(Section), nameof(Type), nameof(IsInteractive));
break;
case nameof(model.Command):
Type = EvaluateType();
UpdateProperty(nameof(Type), nameof(IsInteractive));
break;
case nameof(Details):
var extensionDetails = model.Details;
Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null;
Details?.InitializeProperties();
UpdateProperty(nameof(Details), nameof(HasDetails));
UpdateShowDetailsCommand();
break;
case nameof(model.MoreCommands):
AddShowDetailsCommands();
break;
case nameof(model.Title):
UpdateProperty(nameof(Title));
UpdateShowsTitle();
UpdateAccessibleName();
break;
case nameof(model.Subtitle):
UpdateProperty(nameof(Subtitle));
UpdateShowsSubtitle();
UpdateAccessibleName();
break;
default:
UpdateProperty(propertyName);
break;
}
}
// TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes?
// TODO: Do we want to save off the score here so we can sort by it in our ListViewModel?
public override string ToString() => $"{Name} ListItemViewModel";
public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model);
public override int GetHashCode() => Model.GetHashCode();
private void AddShowDetailsCommands()
{
// If the parent page has ShowDetails = false and we have details,
// then we should add a show details action in the context menu.
if (HasDetails &&
PageContext.TryGetTarget(out var pageContext) &&
pageContext is ListViewModel listViewModel &&
!listViewModel.ShowDetails)
{
var addedCommand = false;
lock (MoreCommandsLock)
{
// Check if "Show Details" action already exists to prevent duplicates
if (!UnsafeMoreCommands.Any(cmd => cmd is CommandContextItemViewModel contextItemViewModel &&
contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId))
{
var showDetailsCommand = new ShowDetailsCommand(Details);
var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
showDetailsContextItemViewModel.SlowInitializeProperties();
UnsafeMoreCommands.Add(showDetailsContextItemViewModel);
RefreshMoreCommandStateUnsafe();
addedCommand = true;
}
}
if (addedCommand)
{
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands));
}
}
}
// This method is called when the details change to make sure we
// have the latest details in the show details command.
private void UpdateShowDetailsCommand()
{
// If the parent page has ShowDetails = false and we have details,
// then we should add a show details action in the context menu.
if (HasDetails &&
PageContext.TryGetTarget(out var pageContext) &&
pageContext is ListViewModel listViewModel &&
!listViewModel.ShowDetails)
{
CommandContextItemViewModel? oldCommand = null;
lock (MoreCommandsLock)
{
oldCommand = UnsafeMoreCommands
.OfType<CommandContextItemViewModel>()
.FirstOrDefault(contextItemViewModel => contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId);
if (oldCommand is not null)
{
UnsafeMoreCommands.Remove(oldCommand);
}
var showDetailsCommand = new ShowDetailsCommand(Details);
var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
showDetailsContextItemViewModel.SlowInitializeProperties();
UnsafeMoreCommands.Add(showDetailsContextItemViewModel);
RefreshMoreCommandStateUnsafe();
}
oldCommand?.SafeCleanup();
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands));
}
}
private void UpdateTags(ITag[]? newTagsFromModel)
{
var newTags = newTagsFromModel?.Select(t =>
{
var vm = new TagViewModel(t, PageContext);
vm.InitializeProperties();
return vm;
})
.ToList() ?? [];
DoOnUiThread(
() =>
{
// Tags being an ObservableCollection instead of a List lead to
// many COM exception issues.
Tags = [.. newTags];
// We're already in UI thread, so just raise the events
OnPropertyChanged(nameof(Tags));
OnPropertyChanged(nameof(HasTags));
});
}
private void UpdateShowsTitle()
{
var oldShowTitle = ShowTitle;
ShowTitle = LayoutShowsTitle;
if (oldShowTitle != ShowTitle)
{
UpdateProperty(nameof(ShowTitle));
}
}
private void UpdateShowsSubtitle()
{
var oldShowSubtitle = ShowSubtitle;
ShowSubtitle = LayoutShowsSubtitle && !string.IsNullOrWhiteSpace(Subtitle);
if (oldShowSubtitle != ShowSubtitle)
{
UpdateProperty(nameof(ShowSubtitle));
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
// Tags don't have event handlers or anything to cleanup
Tags?.ForEach(t => t.SafeCleanup());
Details?.SafeCleanup();
var model = Model.Unsafe;
if (model is not null)
{
// We don't need to revoke the PropChanged event handler here,
// because we are just overriding CommandItem's FetchProperty and
// piggy-backing off their PropChanged
}
}
protected void UpdateAccessibleName()
{
AccessibleName = Title + ", " + Subtitle;
UpdateProperty(nameof(AccessibleName));
}
}