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
This commit is contained in:
Jiří Polášek
2026-03-24 23:24:04 +01:00
committed by GitHub
parent 1a9fcdcd1f
commit 3f35b11cee
3 changed files with 124 additions and 20 deletions

View File

@@ -4,6 +4,7 @@
using System;
using System.Threading.Tasks;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -76,4 +77,64 @@ public class CommandItemViewModelTests
Assert.IsNotNull(viewModel.SecondaryCommand);
Assert.AreEqual("Secondary", viewModel.SecondaryCommand.Name);
}
[TestMethod]
public void LatePrimaryCommandCreation_AddsPrimaryToAllCommands()
{
// Reproduces issue where SlowInitializeProperties runs before a real primary command exists.
// The late-arriving command should still create the synthetic primary context item and prepend it to AllCommands.
var pageContext = new TestPageContext();
var item = new CommandItem()
{
Command = null,
MoreCommands =
[
new CommandContextItem(new NoOpCommand { Name = "Secondary" }),
],
};
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
viewModel.SlowInitializeProperties();
Assert.AreEqual(1, viewModel.AllCommands.Count);
Assert.AreEqual("Secondary", ((CommandContextItemViewModel)viewModel.AllCommands[0]).Name);
item.Command = new NoOpCommand { Name = "Primary" };
Assert.AreEqual(2, viewModel.AllCommands.Count);
Assert.AreEqual("Primary", ((CommandContextItemViewModel)viewModel.AllCommands[0]).Name);
Assert.AreEqual("Secondary", ((CommandContextItemViewModel)viewModel.AllCommands[1]).Name);
Assert.IsTrue(viewModel.HasMoreCommands);
Assert.AreEqual("Secondary", viewModel.SecondaryCommand?.Name);
}
[TestMethod]
public void SyntheticPrimaryContextItem_UpdatesSubtitleAndCachedSubtitleTarget()
{
// The synthetic primary context item copies subtitle state from the parent CommandItemViewModel.
// When subtitle changes later, both the exposed subtitle and its cached fuzzy-search target must refresh.
var pageContext = new TestPageContext();
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
{
Subtitle = "before",
MoreCommands =
[
new CommandContextItem(new NoOpCommand { Name = "Secondary" }),
],
};
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
viewModel.SlowInitializeProperties();
var primaryContextItem = (CommandContextItemViewModel)viewModel.AllCommands[0];
var matcher = new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
Assert.AreEqual("before", primaryContextItem.Subtitle);
Assert.AreEqual("before", primaryContextItem.GetSubtitleTarget(matcher).Original);
item.Subtitle = "after unique";
Assert.AreEqual("after unique", primaryContextItem.Subtitle);
Assert.AreEqual("after unique", primaryContextItem.GetSubtitleTarget(matcher).Original);
}
}