Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Original file line number Diff line number Diff line change
@@ -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"])
}
}
5 changes: 4 additions & 1 deletion docs/custom-sidebars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading