Repository navigation
Add configurable menu bar from cmux.json #4052
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
d5cb1fe
9b33375
21798fb
20a4c77
c308c60
c0fd6f7
0581956
96398ed
c5decd8
5ce9172
6cdcbf5
830109c
fc63ea9
cf84540
6410ec0
d270de0
f45866d
ffd1230
9ace32b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -583,6 +583,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| private final class ConfiguredMenuBarActionBox: NSObject { | ||
| let action: CmuxResolvedConfigAction | ||
|
|
||
| init(action: CmuxResolvedConfigAction) { | ||
| self.action = action | ||
| } | ||
| } | ||
|
|
||
| private final class MainWindowController: NSWindowController, NSWindowDelegate { | ||
| var onClose: (() -> Void)? | ||
| var shouldClose: (() -> Bool)? | ||
|
|
@@ -688,6 +697,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| private var shortcutMonitor: Any? | ||
| private var shortcutDefaultsObserver: NSObjectProtocol? | ||
| private var menuBarVisibilityObserver: NSObjectProtocol? | ||
| private var configuredMenuBarObserverTokens: [NSObjectProtocol] = [] | ||
| private var configuredMenuBarTopLevelItems: [NSMenuItem] = [] | ||
| private var configuredMenuBarActionBoxes: [ConfiguredMenuBarActionBox] = [] | ||
| private var configuredMenuBarRefreshScheduled = false | ||
| private var splitButtonTooltipRefreshScheduled = false | ||
| private struct PendingConfiguredShortcutChord { | ||
| let firstStroke: ShortcutStroke | ||
|
|
@@ -1595,6 +1608,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| installLifecycleSnapshotObserversIfNeeded() | ||
| prepareStartupSessionSnapshotIfNeeded() | ||
| startSessionAutosaveTimerIfNeeded() | ||
| installConfiguredMenuBarObserversIfNeeded() | ||
| #if DEBUG | ||
| setupJumpUnreadUITestIfNeeded() | ||
| setupTerminalCmdClickUITestIfNeeded() | ||
|
|
@@ -6236,6 +6250,153 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| } | ||
| } | ||
|
|
||
| func configuredMenuBarMenusForCommands(preferredWindow: NSWindow? = nil) -> [CmuxResolvedMenuBarMenu] { | ||
| guard let store = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow)?.cmuxConfigStore else { | ||
| return [] | ||
| } | ||
| return store.menuBarMenus | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| @discardableResult | ||
| func performConfiguredMenuBarAction( | ||
| _ action: CmuxResolvedConfigAction, | ||
| preferredWindow: NSWindow? = nil | ||
| ) -> Bool { | ||
| let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) | ||
| ?? createMainWindowContextForConfiguredMenuBarAction() | ||
| guard let context else { | ||
| NSSound.beep() | ||
| return false | ||
| } | ||
| let preferredWindow = resolvedWindow(for: context) ?? preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow | ||
| guard executeConfiguredCmuxAction(action, context: context, preferredWindow: preferredWindow) else { | ||
| NSSound.beep() | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| private func createMainWindowContextForConfiguredMenuBarAction() -> MainWindowContext? { | ||
| guard mainWindowContexts.isEmpty else { | ||
| return preferredMainWindowContextForWorkspaceCreation(debugSource: "menuBar.configured") | ||
| } | ||
| let windowId = createMainWindow() | ||
| return mainWindowContexts.values.first { $0.windowId == windowId } | ||
| } | ||
|
|
||
| private func installConfiguredMenuBarObserversIfNeeded() { | ||
| guard configuredMenuBarObserverTokens.isEmpty else { return } | ||
| let names: [Notification.Name] = [ | ||
| .cmuxConfigStoreDidChange, | ||
| .mainWindowContextsDidChange, | ||
| NSWindow.didBecomeKeyNotification, | ||
| NSWindow.didBecomeMainNotification, | ||
| ] | ||
| configuredMenuBarObserverTokens = names.map { name in | ||
| NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in | ||
| Task { @MainActor [weak self] in | ||
| self?.scheduleConfiguredMenuBarRefresh() | ||
| } | ||
| } | ||
| } | ||
| scheduleConfiguredMenuBarRefresh() | ||
| } | ||
|
|
||
| private func scheduleConfiguredMenuBarRefresh() { | ||
| guard !configuredMenuBarRefreshScheduled else { return } | ||
| configuredMenuBarRefreshScheduled = true | ||
| DispatchQueue.main.async { [weak self] in | ||
| guard let self else { return } | ||
| self.configuredMenuBarRefreshScheduled = false | ||
| self.refreshConfiguredMenuBar() | ||
| } | ||
| } | ||
|
|
||
| private func refreshConfiguredMenuBar() { | ||
| guard let mainMenu = NSApp.mainMenu else { return } | ||
|
|
||
| for item in configuredMenuBarTopLevelItems { | ||
| let index = mainMenu.index(of: item) | ||
| if index >= 0 { | ||
| mainMenu.removeItem(at: index) | ||
| } | ||
| } | ||
| configuredMenuBarTopLevelItems.removeAll() | ||
| configuredMenuBarActionBoxes.removeAll() | ||
|
|
||
| let menus = configuredMenuBarMenusForCommands(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) | ||
| guard !menus.isEmpty else { return } | ||
|
|
||
| var insertionIndex = configuredMenuBarInsertionIndex(in: mainMenu) | ||
| for menu in menus { | ||
| let item = NSMenuItem(title: menu.title, action: nil, keyEquivalent: "") | ||
| item.submenu = configuredMenuBarMenu(from: menu) | ||
| mainMenu.insertItem(item, at: insertionIndex) | ||
| configuredMenuBarTopLevelItems.append(item) | ||
| insertionIndex += 1 | ||
| } | ||
| } | ||
|
|
||
| private func configuredMenuBarInsertionIndex(in mainMenu: NSMenu) -> Int { | ||
| let notificationsTitle = String(localized: "menu.notifications.title", defaultValue: "Notifications") | ||
| if let notificationsIndex = mainMenu.items.lastIndex(where: { $0.title == notificationsTitle }) { | ||
| return notificationsIndex + 1 | ||
| } | ||
| #if DEBUG | ||
| if let debugIndex = mainMenu.items.lastIndex(where: { $0.title == "Debug" }) { | ||
| return debugIndex | ||
| } | ||
| #endif | ||
| return min(mainMenu.items.count, max(1, mainMenu.items.count - 1)) | ||
| } | ||
|
|
||
| private func configuredMenuBarMenu(from menu: CmuxResolvedMenuBarMenu) -> NSMenu { | ||
| let nsMenu = NSMenu(title: menu.title) | ||
| for item in menu.items { | ||
| switch item { | ||
| case .separator: | ||
| if !nsMenu.items.isEmpty, nsMenu.items.last?.isSeparatorItem == false { | ||
| nsMenu.addItem(.separator()) | ||
| } | ||
| case .submenu(let submenu): | ||
| let item = NSMenuItem(title: submenu.title, action: nil, keyEquivalent: "") | ||
| item.submenu = configuredMenuBarMenu(from: submenu) | ||
| nsMenu.addItem(item) | ||
| case .action(let menuAction): | ||
| let item = NSMenuItem( | ||
| title: menuAction.title, | ||
| action: #selector(performConfiguredMenuBarMenuItem(_:)), | ||
| keyEquivalent: "" | ||
| ) | ||
| item.target = self | ||
| let box = ConfiguredMenuBarActionBox(action: menuAction.action) | ||
| configuredMenuBarActionBoxes.append(box) | ||
| item.representedObject = box | ||
| item.toolTip = menuAction.tooltip | ||
| item.image = configuredMenuBarImage(for: menuAction.icon ?? menuAction.action.icon) | ||
| nsMenu.addItem(item) | ||
| } | ||
| } | ||
|
|
||
| while nsMenu.items.last?.isSeparatorItem == true { | ||
| nsMenu.removeItem(at: nsMenu.items.count - 1) | ||
| } | ||
| return nsMenu | ||
| } | ||
|
|
||
| private func configuredMenuBarImage(for icon: CmuxButtonIcon?) -> NSImage? { | ||
| guard case .some(.symbol(let symbolName)) = icon else { return nil } | ||
| return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) | ||
| } | ||
|
|
||
| @objc private func performConfiguredMenuBarMenuItem(_ sender: NSMenuItem) { | ||
| guard let box = sender.representedObject as? ConfiguredMenuBarActionBox else { | ||
| NSSound.beep() | ||
| return | ||
| } | ||
| performConfiguredMenuBarAction(box.action, preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift Extract configurable menu-bar construction/dispatch out of This block adds more UI menu rendering + action-routing responsibility to an already cross-cutting file. Please move this feature into a focused coordinator (e.g., As per coding guidelines: "Flag Swift files that mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one place." 🤖 Prompt for AI Agents |
||
|
|
||
| /// Shows the "Open Folder" panel and creates a workspace for the selected directory. | ||
| /// Called from both the SwiftUI menu and `handleCustomShortcut`. | ||
| func showOpenFolderPanel() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When all main windows are closed (a normal state in this app, especially with menu-bar-only usage),
configuredMenuBarMenusForCommandsreturns[]because it requires an existing window context. The next refresh path removes previously injected top-level custom menus and then exits early, so globalui.menuBarcommands become inaccessible until a new main window is created through some other entrypoint. This regresses menu-bar command availability in no-window sessions.Useful? React with 👍 / 👎.