diff --git a/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Runtime/SceneNodeView.swift b/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Runtime/SceneNodeView.swift index 35443b8ed7c9..f643451a123b 100644 --- a/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Runtime/SceneNodeView.swift +++ b/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Runtime/SceneNodeView.swift @@ -155,6 +155,10 @@ private struct SceneNodeContent: View { } case "contextMenu": children + case "menu": + // Inside a context menu this becomes a submenu; elsewhere, a + // menu button. + Menu(node.string("text") ?? "") { children } case "textfield": SceneTextFieldView(node: node, sink: sink) case "reorderable": diff --git a/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/SceneNodeViewRenderingTests.swift b/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/SceneNodeViewRenderingTests.swift new file mode 100644 index 000000000000..78ce49c15518 --- /dev/null +++ b/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/SceneNodeViewRenderingTests.swift @@ -0,0 +1,62 @@ +import AppKit +@testable import CmuxSwiftRenderUI +import SwiftUI +import Testing + +/// Opens a scene row's context menu through the real host view, the same +/// AppKit path a right-click takes, so menu items the host fails to render +/// show up as missing entries. +@MainActor +struct SceneNodeViewRenderingTests { + private func contextMenu(ofRoot runtime: SidebarJSRuntime) throws -> NSMenu { + let rootId = try #require(runtime.store.rootId) + let host = NSHostingView( + rootView: SceneNodeView(nodeId: rootId) + .environment(\.sceneStore, runtime.store) + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.contentView = host + host.layoutSubtreeIfNeeded() + let center = NSPoint(x: host.bounds.midX, y: host.bounds.midY) + let event = try #require(NSEvent.mouseEvent( + with: .rightMouseDown, + location: host.convert(center, to: nil), + modifierFlags: [], + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + )) + return try #require(host.menu(for: event)) + } + + /// https://github.com/manaflow-ai/cmux/issues/14662: a `Menu` inside + /// `.contextMenu` rendered nothing, so the submenu vanished from the + /// context menu while its sibling buttons still showed. + @Test func contextMenuSubmenuRenders() throws { + let runtime = SidebarJSRuntime() + runtime.start(source: """ + sidebar(() => + Text("row").contextMenu([ + Button("Open chat", () => {}), + Divider(), + Menu("Move to project", [Button("fun", () => {}), Button("Landing", () => {})]), + ]) + ) + """) + let menu = try contextMenu(ofRoot: runtime) + + #expect(menu.items.first?.title == "Open chat") + #expect(menu.items.contains { $0.isSeparatorItem }) + let submenuItem = try #require(menu.items.first { $0.title == "Move to project" }) + let submenu = try #require(submenuItem.submenu) + #expect(submenu.items.map(\.title) == ["fun", "Landing"]) + } +} diff --git a/docs/custom-sidebars.md b/docs/custom-sidebars.md index 3107657bdf88..eba65b568692 100644 --- a/docs/custom-sidebars.md +++ b/docs/custom-sidebars.md @@ -189,7 +189,10 @@ Rules of the runtime: - Right-click menus: `.contextMenu([Button("Pin", fn), Divider(), Menu("Move", [...]), Button("Close", fn).destructive()])` on any view. Menu items are ordinary Button/Menu/Divider nodes, so labels and actions can be - live bindings (`Button(() => w().pinned ? "Unpin" : "Pin", ...)`). Useful + live bindings (`Button(() => w().pinned ? "Unpin" : "Pin", ...)`). The menu + opens only over the view it is attached to, so put it on the row's outer + HStack when right-clicking anywhere on the row, including the empty space a + Spacer fills, should open it. Useful verbs: `workspace.action` (pin/unpin, mark_read/mark_unread, move_up/move_down/move_top, close_others, set/clear color and description), `workspace.close`, `workspace.move_to_window`, `workspace.group.action`