diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs index 068748d58540..e85d3b1fb1c3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs @@ -213,22 +213,7 @@ public virtual void SlowInitializeProperties() BuildAndInitMoreCommands(); - if (!string.IsNullOrEmpty(model.Command?.Name)) - { - _defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext) - { - _itemTitle = Name, - Subtitle = Subtitle, - Command = Command, - - // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever - // Anything we set manually here must stay in sync with the corresponding properties on CommandItemViewModel. - }; - - // Only set the icon on the context item for us if our command didn't - // have its own icon - UpdateDefaultContextItemIcon(); - } + TryCreateDefaultCommandContextItem(model); lock (_moreCommandsLock) { @@ -238,6 +223,7 @@ public virtual void SlowInitializeProperties() Initialized |= InitializedState.SelectionInitialized; UpdateProperty(nameof(MoreCommands)); UpdateProperty(nameof(AllCommands)); + UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands)); UpdateProperty(nameof(IsSelectedInitialized)); } @@ -335,9 +321,16 @@ protected virtual void FetchProperty(string propertyName) // 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; - _defaultCommandContextItemViewModel?.Command = Command; - _defaultCommandContextItemViewModel?.UpdateTitle(_itemTitle); - UpdateDefaultContextItemIcon(); + if (_defaultCommandContextItemViewModel is not null) + { + _defaultCommandContextItemViewModel.Command = Command; + _defaultCommandContextItemViewModel.UpdateTitle(_itemTitle); + UpdateDefaultContextItemIcon(); + } + else + { + TryCreateDefaultCommandContextItem(model); + } UpdateProperty(nameof(Name)); UpdateProperty(nameof(Title)); @@ -403,7 +396,15 @@ private void Command_PropertyChanged(object? sender, System.ComponentModel.Prope _titleCache.Invalidate(); UpdateProperty(nameof(Title), nameof(Name)); - _defaultCommandContextItemViewModel?.UpdateTitle(model.Command.Name); + if (_defaultCommandContextItemViewModel is not null) + { + _defaultCommandContextItemViewModel.UpdateTitle(model.Command.Name); + } + else + { + TryCreateDefaultCommandContextItem(model); + } + break; case nameof(Command.Icon): @@ -413,6 +414,46 @@ private void Command_PropertyChanged(object? sender, System.ComponentModel.Prope } } + /// + /// Creates when it does not exist + /// yet and the current command has a non-empty name. This covers the case + /// where an extension initially exposes a NoOpCommand (empty name) + /// and later switches to a concrete command after has already run. + /// When a new instance is created, the snapshot is refreshed and + /// is notified. + /// + private void TryCreateDefaultCommandContextItem(ICommandItem model) + { + if (_defaultCommandContextItemViewModel is not null) + { + return; + } + + if (string.IsNullOrEmpty(model.Command?.Name)) + { + return; + } + + _defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext) + { + _itemTitle = Name, + Subtitle = Subtitle, + Command = Command, + + // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever + // Anything we set manually here must stay in sync with the corresponding properties on CommandItemViewModel. + }; + + UpdateDefaultContextItemIcon(); + + lock (_moreCommandsLock) + { + RefreshMoreCommandStateUnsafe(); + } + + UpdateProperty(nameof(AllCommands)); + } + private void UpdateDefaultContextItemIcon() => // Command icon takes precedence over our icon on the primary command diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs index 0cda3821912d..518dd0bc634e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs @@ -214,6 +214,7 @@ pageContext is ListViewModel listViewModel && if (addedCommand) { UpdateProperty(nameof(MoreCommands), nameof(AllCommands)); + UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands)); } } } @@ -252,6 +253,7 @@ pageContext is ListViewModel listViewModel && oldCommand?.SafeCleanup(); UpdateProperty(nameof(MoreCommands), nameof(AllCommands)); + UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands)); } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/CommandItemViewModelTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/CommandItemViewModelTests.cs index e4c65ecfafe1..882352f01a7d 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/CommandItemViewModelTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/CommandItemViewModelTests.cs @@ -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 void SecondaryCommand_IgnoresLeadingSeparators() 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); + } }