diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index da05b90bbdc3..b3224bb044c0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10575,6 +10575,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + private func handleContextIndependentShortcut(event: NSEvent) -> Bool { + if matchConfiguredShortcut(event: event, action: .quit) { + return handleQuitShortcutWarning() + } + + if matchConfiguredShortcut(event: event, action: .openSettings) { + openPreferencesWindow(debugSource: "shortcut.openSettings") + return true + } + + if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") + return true + } + + if matchConfiguredShortcut(event: event, action: .newWindow) { + openNewMainWindow(preferredWindow: mainWindowForShortcutEvent(event)) + return true + } + + return false + } + + private var contextIndependentShortcutActions: [KeyboardShortcutSettings.Action] { + // Keep main-window lifecycle commands, such as Close Window, on the synchronized + // path because their AppKit delegates depend on the active terminal context. + [ + .quit, + .openSettings, + .reloadConfiguration, + .newWindow, + ] + } + private func handleCustomShortcut(event: NSEvent) -> Bool { guard event.type == .keyDown else { clearConfiguredShortcutChordState() @@ -10946,6 +10980,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if handleContextIndependentShortcut(event: event) { + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event, actions: contextIndependentShortcutActions) { + return true + } + let hasEventWindowContext = shortcutEventHasAddressableWindow(event) let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) if hasEventWindowContext && !didSynchronizeShortcutContext { @@ -11036,18 +11079,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredShortcut(event: event, action: .quit) { - return handleQuitShortcutWarning() - } - if matchConfiguredShortcut(event: event, action: .openSettings) { - openPreferencesWindow(debugSource: "shortcut.openSettings") - return true - } - if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { - GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") - return true - } - if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { guard let targetWindow = mainWindowForShortcutEvent(event) else { return false @@ -11078,15 +11109,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // New Window: Cmd+Shift+N - // Handled here instead of relying on SwiftUI's CommandGroup menu item because - // after a browser panel has been shown, SwiftUI's menu dispatch can silently - // consume the key equivalent without firing the action closure. - if matchConfiguredShortcut(event: event, action: .newWindow) { - openNewMainWindow(preferredWindow: mainWindowForShortcutEvent(event)) - return true - } - // Open Folder: Cmd+O // Handled here to prevent AppKit's default NSDocumentController from opening // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 52e2ab47db6b..fcfa41fac6bc 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -2791,6 +2791,61 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(workspace.panels.count, panelCountBefore) } + func testCmdCommaOpensSettingsFromWindowWithoutTerminalShortcutContext() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + let auxiliaryWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + auxiliaryWindow.isReleasedWhenClosed = false + auxiliaryWindow.identifier = NSUserInterfaceItemIdentifier("cmux.shortcut-routing-test") + auxiliaryWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + defer { + auxiliaryWindow.orderOut(nil) + auxiliaryWindow.close() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + + var settingsOpenCount = 0 +#if DEBUG + SettingsWindowPresenter.resetForTests() + defer { SettingsWindowPresenter.resetForTests() } +#endif + SettingsWindowPresenter.configure(openWindow: { + settingsOpenCount += 1 + }) + + guard let event = makeKeyDownEvent( + key: ",", + modifiers: [.command], + keyCode: 43, + windowNumber: auxiliaryWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+, event") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleCustomShortcut(event: event), + "Cmd+, should remain app-scoped when the key event comes from a non-terminal window" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(settingsOpenCount, 1) + } + func testCmdIStillTriggersShowNotificationsShortcut() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared")