From 47c7b60586fcf2b242ffa9ad0d26278227bd6331 Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Wed, 15 Jul 2026 16:30:55 -0500 Subject: [PATCH] Parse nested moreCommands on context items in JS host mapper ParseContextItem only read title/command/icon and dropped any nested moreCommands array, so JS/TS extensions could not surface nested context-menu commands even when the wire carried them. Recursively parse the moreCommands array (reusing ParseContextItems) and assign the children onto the existing CommandContextItem.MoreCommands, leaving the default empty array when the field is absent. Pairs with the Phase 1 SDK serialization change (8f123a8616). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e96286c-f6eb-42d5-bcd3-68fc6a76622f --- .../Models/JSModelMapper.cs | 9 ++++ .../JSAdapterProxyTests.cs | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSModelMapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSModelMapper.cs index 77fc0f6868..e555fe738b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSModelMapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/JSModelMapper.cs @@ -281,6 +281,15 @@ internal static class JSModelMapper item.Subtitle = subtitle; } + // Context items can carry their own nested context menu via "moreCommands". + // The wire omits the field entirely when empty, so only assign when the + // recursive parse yields children and otherwise leave the default. + var moreCommands = ParseContextItems(element, "moreCommands", "MoreCommands", connection); + if (moreCommands.Length > 0) + { + item.MoreCommands = moreCommands; + } + return item; } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/JSAdapterProxyTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/JSAdapterProxyTests.cs index 0e32bfce29..1cad30c889 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/JSAdapterProxyTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/JSAdapterProxyTests.cs @@ -139,6 +139,55 @@ public class JSAdapterProxyTests Assert.AreEqual("Item B", items[2].Title); } + [TestMethod] + public void ContextItems_ParseNestedMoreCommandsRecursively() + { + using var fake = new JSFakeExtension(); + fake.OnResult("provider/getCommand", """{ "id": "nested-list", "pageType": "listPage", "name": "Nested" }"""); + var itemsJson = + """ + { + "items": [ + { + "title": "Root Item", + "moreCommands": [ + { + "command": { "id": "level1", "name": "Level 1" }, + "title": "Level 1", + "moreCommands": [ + { "command": { "id": "level2", "name": "Level 2" }, "title": "Level 2" } + ] + } + ] + }, + { "title": "Leaf Item" } + ] + } + """; + fake.OnResult("listPage/getItems", itemsJson); + + var provider = CreateProvider(fake); + var page = (IListPage)provider.GetCommand("nested-list")!; + var items = page.GetItems(); + + Assert.AreEqual(2, items.Length); + + // The root item carries a first-level nested command. + var firstLevel = items[0].MoreCommands; + Assert.AreEqual(1, firstLevel.Length); + var firstLevelCommand = (ICommandContextItem)firstLevel[0]; + Assert.AreEqual("Level 1", firstLevelCommand.Title); + + // That first-level command carries its own second-level nested command. + Assert.AreEqual(1, firstLevelCommand.MoreCommands.Length); + var secondLevelCommand = (ICommandContextItem)firstLevelCommand.MoreCommands[0]; + Assert.AreEqual("Level 2", secondLevelCommand.Title); + Assert.AreEqual(0, secondLevelCommand.MoreCommands.Length); + + // The leaf item with no moreCommands yields no children. + Assert.AreEqual(0, items[1].MoreCommands.Length); + } + [TestMethod] public async Task DynamicListPage_ForwardsSearchTextAndRaisesItemsChanged() {