diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 83e08b00c519..af0675b13231 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,42 +1,42 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -20474 CLI/cmux.swift -17257 Sources/TerminalController.swift -16009 Sources/ContentView.swift -14268 Sources/AppDelegate.swift -13972 Sources/Workspace.swift -13403 Sources/GhosttyTerminalView.swift -10603 Sources/Panels/BrowserPanel.swift -8454 Sources/cmuxApp.swift -7495 Sources/TabManager.swift +20461 CLI/cmux.swift +17215 Sources/TerminalController.swift +16007 Sources/ContentView.swift +14338 Sources/AppDelegate.swift +13964 Sources/Workspace.swift +13428 Sources/GhosttyTerminalView.swift +10597 Sources/Panels/BrowserPanel.swift +8389 Sources/cmuxApp.swift +7492 Sources/TabManager.swift 6811 Sources/Panels/BrowserPanelView.swift -5252 cmuxTests/AppDelegateShortcutRoutingTests.swift +5491 cmuxTests/AppDelegateShortcutRoutingTests.swift 4661 cmuxTests/WorkspaceUnitTests.swift 4639 cmuxTests/TerminalAndGhosttyTests.swift 4557 cmuxTests/GhosttyConfigTests.swift -4469 cmuxTests/WorkspaceRemoteConnectionTests.swift -4403 Sources/BrowserWindowPortal.swift +4412 cmuxTests/WorkspaceRemoteConnectionTests.swift +4305 Sources/BrowserWindowPortal.swift 3933 Sources/Panels/FilePreviewPanel.swift 3840 Sources/Feed/FeedPanelView.swift -3660 cmuxTests/BrowserConfigTests.swift -3329 cmuxTests/BrowserPanelTests.swift -2850 Sources/CmuxConfig.swift +3588 cmuxTests/BrowserConfigTests.swift +3145 cmuxTests/BrowserPanelTests.swift 2863 cmuxTests/WindowAndDragTests.swift -2687 Sources/KeyboardShortcutSettings.swift +2830 Sources/CmuxConfig.swift 2609 Sources/SessionIndexView.swift 2491 Sources/Panels/CmuxWebView.swift +2325 Sources/KeyboardShortcutSettings.swift 2317 cmuxTests/TabManagerUnitTests.swift -2189 Sources/TerminalWindowPortal.swift +2172 Sources/TerminalWindowPortal.swift 2153 cmuxTests/SessionPersistenceTests.swift 2112 Sources/Update/UpdateTitlebarAccessory.swift 2026 cmuxTests/CJKIMEInputTests.swift -1949 Sources/FileExplorerView.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1941 Sources/SessionIndexStore.swift +1879 Sources/FileExplorerView.swift 1784 cmuxTests/ShortcutAndCommandPaletteTests.swift 1692 cmuxTests/CmuxConfigTests.swift -1681 Sources/KeyboardShortcutSettingsFileStore.swift +1679 Sources/KeyboardShortcutSettingsFileStore.swift 1517 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1454 Sources/TerminalNotificationStore.swift 1365 Sources/Feed/FeedButtonStyleDebugWindowController.swift @@ -48,22 +48,22 @@ 1165 Sources/Auth/AuthManager.swift 1003 Sources/FileExplorerStore.swift 1001 cmuxTests/SidebarOrderingTests.swift -995 Sources/DockPanelView.swift 987 Sources/CommandPalette/CommandPaletteSearch.swift 976 cmuxTests/OmnibarAndToolsTests.swift +943 Sources/DockPanelView.swift 908 cmuxTests/CommandPaletteSearchEngineTests.swift 846 Sources/WorkspaceContentView.swift 834 Sources/RestorableAgentSession.swift 829 cmuxUITests/TerminalCmdClickUITests.swift -809 cmuxUITests/BonsplitTabDragUITests.swift 807 Sources/TerminalSSHSessionDetector.swift 785 Sources/App/TerminalDirectoryOpenSupport.swift 784 Sources/GhosttyConfig.swift 778 Sources/MainWindowFocusController.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift -720 Sources/App/MenuBarExtraController.swift 719 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +718 Sources/App/MenuBarExtraController.swift 714 Sources/AppleScriptSupport.swift +701 cmuxUITests/BonsplitTabDragUITests.swift 683 Sources/SocketControlSettings.swift 674 Sources/App/ShortcutRoutingSupport.swift 643 Sources/Panels/BrowserPopupWindowController.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3bdbbd04fc51..da8f10adb30e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11145,7 +11145,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Split actions: Cmd+D / Cmd+Shift+D + // Configured split actions. if matchConfiguredShortcut(event: event, action: .splitRight) { #if DEBUG cmuxDebugLog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") @@ -12169,6 +12169,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func matchConfiguredShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.isUnbound else { return false } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, shortcut.firstStroke == prefix else { @@ -12189,6 +12190,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: KeyboardShortcutSettings.Action ) -> Int? { let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard !shortcut.isUnbound else { return nil } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, shortcut.firstStroke == prefix else { @@ -12207,6 +12209,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowKeyCode: UInt16 ) -> Bool { let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard !shortcut.isUnbound else { return false } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, shortcut.firstStroke == prefix else { @@ -12349,6 +12352,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent shortcut.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider) } + private func matchesKeyboardShortcutEvent( + _ event: NSEvent, + action: KeyboardShortcutSettings.Action, + shortcut: StoredShortcut + ) -> Bool { + guard !shortcut.isUnbound else { return false } + if action.usesNumberedDigitMatching { + return numberedShortcutDigit(event: event, shortcut: shortcut) != nil + } + guard !shortcut.hasChord else { return false } + return matchShortcut(event: event, shortcut: shortcut) + } + + func shouldSuppressStaleCmuxMenuShortcut(event: NSEvent) -> Bool { + guard event.type == .keyDown else { return false } + if event.window is NSPanel || NSApp.keyWindow is NSPanel || NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + return false + } + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags.contains(.command) else { return false } + + for action in KeyboardShortcutSettings.Action.allCases where action != .showHideAllWindows { + let currentShortcut = KeyboardShortcutSettings.shortcut(for: action) + if matchesKeyboardShortcutEvent(event, action: action, shortcut: currentShortcut) { + return false + } + } + + for action in KeyboardShortcutSettings.Action.allCases where action != .showHideAllWindows { + if matchesKeyboardShortcutEvent(event, action: action, shortcut: action.defaultShortcut) { + return true + } + } + return false + } + private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) @@ -13468,6 +13509,22 @@ private extension NSApplication { AppDelegate.shared?.handleMinimalModeTitlebarDoubleClickMouseDown(event: event) == true { return } + if AppDelegate.shared?.shouldSuppressStaleCmuxMenuShortcut(event: event) == true { + let responder = event.window?.firstResponder + ?? keyWindow?.firstResponder + ?? mainWindow?.firstResponder + if let ghosttyView = cmuxOwningGhosttyView(for: responder) { + ghosttyView.keyDown(with: event) +#if DEBUG + cmuxDebugLog("app.sendEvent suppressed stale cmux menu shortcut and forwarded to terminal") +#endif + } else { +#if DEBUG + cmuxDebugLog("app.sendEvent suppressed stale cmux menu shortcut") +#endif + } + return + } cmux_applicationSendEvent(event) } } @@ -13814,6 +13871,20 @@ private extension NSWindow { ) return true } + if AppDelegate.shared?.shouldSuppressStaleCmuxMenuShortcut(event: event) == true { + if let firstResponderGhosttyView { + firstResponderGhosttyView.keyDown(with: event) +#if DEBUG + cmuxDebugLog(" → terminal received command equivalent bypassing stale cmux menu shortcut") +#endif + return true + } +#if DEBUG + cmuxDebugLog(" → suppressed stale cmux menu shortcut") +#endif + return false + } + if let ghosttyView = firstResponderGhosttyView { // If the IME is composing and the key has no Cmd modifier, don't intercept — // let it flow through normal AppKit event dispatch so the input method can diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index fec499f0a916..da8294261bbe 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -941,7 +941,7 @@ struct CmuxConfigActionDefinition: Codable, Sendable, Hashable { throw DecodingError.dataCorruptedError( forKey: key, in: container, - debugDescription: "shortcut must use modifier+key syntax like 'cmd+shift+t'" + debugDescription: "shortcut must use modifier+key syntax like 'cmd+shift+t' or be empty to unbind" ) } return shortcut @@ -969,6 +969,10 @@ struct CmuxConfigActionDefinition: Codable, Sendable, Hashable { in container: inout KeyedEncodingContainer ) throws { guard let shortcut else { return } + if shortcut.isUnbound { + try container.encode("", forKey: key) + return + } if let secondStroke = shortcut.secondStroke { try container.encode( [shortcut.firstStroke.configString(), secondStroke.configString()], diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7213e8653254..ca64cb13696c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1950,6 +1950,7 @@ class GhosttyApp { prefix: "cmux-shell-integration-override", logLabel: "shell integration override (fallback)" ) + loadCmuxOwnedGhosttyKeybindOverrides(fallbackConfig) let fallbackRenderingModeChanged = setUsesHostLayerBackground( true, source: "initialize.fallbackConfig" @@ -2119,11 +2120,27 @@ class GhosttyApp { prefix: "cmux-shell-integration-override", logLabel: "shell integration override" ) + loadCmuxOwnedGhosttyKeybindOverrides(config) ghostty_config_finalize(config) return renderingModeChanged } + private func loadCmuxOwnedGhosttyKeybindOverrides(_ config: ghostty_config_t) { + // cmux owns these split shortcuts through KeyboardShortcutSettings. + // Remove Ghostty's default fallbacks so remapped or cleared shortcuts + // can reach the focused terminal instead of creating a split. + loadInlineGhosttyConfig( + """ + keybind = super+d=unbind + keybind = super+shift+d=unbind + """, + into: config, + prefix: "cmux-owned-split-keybind-overrides", + logLabel: "cmux-owned split keybind overrides" + ) + } + /// When the user has not configured `font-codepoint-map` for CJK ranges /// and has not already provided an explicit multi-entry `font-family` /// fallback chain, Ghostty's `CTFontCollection` scoring may pick an @@ -6762,6 +6779,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _ = performBindingAction("paste_from_clipboard") } + private func applyConfiguredMenuShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { + guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + /// Validates whether edit menu items (copy, paste, split) should be enabled. func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { switch item.action { @@ -8762,10 +8790,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let splitHorizontallyItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.splitHorizontally", defaultValue: "Split Horizontally"), action: #selector(splitHorizontally(_:)), - keyEquivalent: "d" + keyEquivalent: "" ) splitHorizontallyItem.target = self - splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift] + applyConfiguredMenuShortcut(KeyboardShortcutSettings.shortcut(for: .splitDown), to: splitHorizontallyItem) splitHorizontallyItem.image = NSImage( systemSymbolName: "rectangle.bottomhalf.inset.filled", accessibilityDescription: nil @@ -8774,10 +8802,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let splitVerticallyItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.splitVertically", defaultValue: "Split Vertically"), action: #selector(splitVertically(_:)), - keyEquivalent: "d" + keyEquivalent: "" ) splitVerticallyItem.target = self - splitVerticallyItem.keyEquivalentModifierMask = [.command] + applyConfiguredMenuShortcut(KeyboardShortcutSettings.shortcut(for: .splitRight), to: splitVerticallyItem) splitVerticallyItem.image = NSImage( systemSymbolName: "rectangle.righthalf.inset.filled", accessibilityDescription: nil diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f9a7e6adf49e..e24d33e32954 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -495,7 +495,6 @@ enum KeyboardShortcutSettings { } private static let hardcodedSystemWideHotkeyConflicts: [StoredShortcut] = [ - StoredShortcut(key: "d", command: true, shift: false, option: false, control: false), StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true), StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true), StoredShortcut(key: "`", command: true, shift: false, option: false, control: false), @@ -726,6 +725,8 @@ enum KeyboardShortcutSettings { postDidChangeNotification(action: action) } + static func clearShortcut(for action: Action) { setShortcut(.unbound, for: action) } + static func resetAll() { for action in Action.allCases { UserDefaults.standard.removeObject(forKey: action.defaultsKey) @@ -2172,9 +2173,7 @@ extension StoredShortcut { } var configIdentifier: String { - if isUnbound { - return "none" - } + if isUnbound { return "none" } if let secondStroke { return "\(firstStroke.configString()) \(secondStroke.configString())" } @@ -2183,7 +2182,7 @@ extension StoredShortcut { private static func isUnboundConfigToken(_ rawValue: String) -> Bool { let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return normalized.isEmpty || normalized == "none" || normalized == "unbound" + return normalized.isEmpty || normalized == "none" || normalized == "clear" || normalized == "unbound" } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 1961761df750..0c1483259865 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -12789,6 +12789,10 @@ class TerminalController { action = .focusUp case "focus_down", "focusdown": action = .focusDown + case "split_right", "splitright": + action = .splitRight + case "split_down", "splitdown": + action = .splitDown case "workspace_digits", "workspace_number", "select_workspace_by_number": action = .selectWorkspaceByNumber case "surface_digits", "surface_number", "select_surface_by_number": @@ -12798,10 +12802,15 @@ class TerminalController { } guard let action else { - return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down, workspace_digits, surface_digits" + return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down, split_right, split_down, workspace_digits, surface_digits" } - if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { + if combo.lowercased() == "clear" || combo.lowercased() == "unbound" || combo.lowercased() == "none" { + KeyboardShortcutSettings.clearShortcut(for: action) + return "OK" + } + + if combo.lowercased() == "default" || combo.lowercased() == "reset" { KeyboardShortcutSettings.resetShortcut(for: action) return "OK" } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index b4b4cf27e150..f081b5eb82a3 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -11,8 +11,17 @@ private final class FakeWKInspectorContainerView: NSView {} private final class FocusableTestView: NSView { override var acceptsFirstResponder: Bool { true } } +private final class MenuActionProbe: NSObject { + var callCount = 0 + + @objc func perform(_ sender: Any?) { + callCount += 1 + } +} private final class GhosttyCommandEquivalentProbeView: GhosttyNSView { var afterMenuMissCallCount = 0 + var keyDownCallCount = 0 + var lastKeyDownCharactersIgnoringModifiers: String? var pasteCallCount = 0 var pasteAsPlainTextCallCount = 0 var performAfterMenuMissResult = true @@ -22,6 +31,11 @@ private final class GhosttyCommandEquivalentProbeView: GhosttyNSView { return performAfterMenuMissResult } + override func keyDown(with event: NSEvent) { + keyDownCallCount += 1 + lastKeyDownCharactersIgnoringModifiers = event.charactersIgnoringModifiers + } + override func paste(_ sender: Any?) { pasteCallCount += 1 } @@ -1346,6 +1360,56 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Split shortcut routing should keep the event window active") } + func testCmdDPropagatesWhenSplitRightShortcutIsCleared() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window, manager, and workspace") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialPanelCount = workspace.panels.count + + withTemporaryShortcut(action: .splitRight, shortcut: .unbound) { + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: event), + "Cleared Cmd+D split shortcut should not be consumed by cmux" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual( + workspace.panels.count, + initialPanelCount, + "Cleared Cmd+D split shortcut should propagate instead of creating a new pane" + ) + } + func testPerformSplitShortcutSplitsFocusedTerminalSurfaceWhenSelectedWorkspaceIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -4705,6 +4769,181 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + func testWindowPerformKeyEquivalentForwardsClearedCmdDPastStaleMenuShortcut() { + let previousMainMenu = NSApp.mainMenu + let probeWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: probeWindow.contentRect(forFrameRect: probeWindow.frame)) + let probeView = GhosttyCommandEquivalentProbeView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let menuProbe = MenuActionProbe() + + defer { + NSApp.mainMenu = previousMainMenu + probeWindow.orderOut(nil) + } + + let staleMenu = NSMenu(title: "Test") + let staleSplitItem = NSMenuItem( + title: "Split Right", + action: #selector(MenuActionProbe.perform(_:)), + keyEquivalent: "d" + ) + staleSplitItem.keyEquivalentModifierMask = [.command] + staleSplitItem.target = menuProbe + staleMenu.addItem(staleSplitItem) + NSApp.mainMenu = staleMenu + + probeWindow.contentView = contentView + contentView.addSubview(probeView) + probeWindow.makeKeyAndOrderFront(nil) + probeWindow.displayIfNeeded() + XCTAssertTrue(probeWindow.makeFirstResponder(probeView), "Expected probe Ghostty view to own first responder") + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: probeWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + + withTemporaryShortcut(action: .splitRight, shortcut: .unbound) { + XCTAssertTrue( + probeWindow.performKeyEquivalent(with: event), + "Cleared Cmd+D should still be handled by forwarding it to the focused terminal" + ) + } + + XCTAssertEqual(menuProbe.callCount, 0, "A stale menu equivalent must not keep consuming cleared Cmd+D") + XCTAssertEqual(probeView.keyDownCallCount, 1, "Cleared Cmd+D should be forwarded into the terminal") + XCTAssertEqual(probeView.lastKeyDownCharactersIgnoringModifiers, "d") + } + + func testWindowPerformKeyEquivalentSuppressesRemappedCmdDStaleMenuShortcut() { + let previousMainMenu = NSApp.mainMenu + let probeWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: probeWindow.contentRect(forFrameRect: probeWindow.frame)) + let focusableView = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let menuProbe = MenuActionProbe() + + defer { + NSApp.mainMenu = previousMainMenu + probeWindow.orderOut(nil) + } + + let staleMenu = NSMenu(title: "Test") + let staleSplitItem = NSMenuItem( + title: "Split Right", + action: #selector(MenuActionProbe.perform(_:)), + keyEquivalent: "d" + ) + staleSplitItem.keyEquivalentModifierMask = [.command] + staleSplitItem.target = menuProbe + staleMenu.addItem(staleSplitItem) + NSApp.mainMenu = staleMenu + + probeWindow.contentView = contentView + contentView.addSubview(focusableView) + probeWindow.makeKeyAndOrderFront(nil) + probeWindow.displayIfNeeded() + XCTAssertTrue(probeWindow.makeFirstResponder(focusableView), "Expected probe view to own first responder") + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: probeWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + + let remappedSplitRight = StoredShortcut( + key: "j", + command: true, + shift: false, + option: false, + control: false + ) + withTemporaryShortcut(action: .splitRight, shortcut: remappedSplitRight) { + XCTAssertFalse( + probeWindow.performKeyEquivalent(with: event), + "Remapped Cmd+D should not be consumed by stale cmux menu equivalents" + ) + } + + XCTAssertEqual(menuProbe.callCount, 0, "Cmd+D must not keep splitting after splitRight is remapped") + } + + func testApplicationSendEventSuppressesRemappedCmdDStaleMenuShortcut() { + let previousMainMenu = NSApp.mainMenu + let probeWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: probeWindow.contentRect(forFrameRect: probeWindow.frame)) + let focusableView = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let menuProbe = MenuActionProbe() + + defer { + NSApp.mainMenu = previousMainMenu + probeWindow.orderOut(nil) + } + + let staleMenu = NSMenu(title: "Test") + let staleSplitItem = NSMenuItem( + title: "Split Right", + action: #selector(MenuActionProbe.perform(_:)), + keyEquivalent: "d" + ) + staleSplitItem.keyEquivalentModifierMask = [.command] + staleSplitItem.target = menuProbe + staleMenu.addItem(staleSplitItem) + NSApp.mainMenu = staleMenu + + probeWindow.contentView = contentView + contentView.addSubview(focusableView) + probeWindow.makeKeyAndOrderFront(nil) + probeWindow.displayIfNeeded() + XCTAssertTrue(probeWindow.makeFirstResponder(focusableView), "Expected probe view to own first responder") + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: probeWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + + let remappedSplitRight = StoredShortcut( + key: "j", + command: true, + shift: false, + option: false, + control: false + ) + withTemporaryShortcut(action: .splitRight, shortcut: remappedSplitRight) { + NSApp.sendEvent(event) + } + + XCTAssertEqual(menuProbe.callCount, 0, "App-level Cmd+D dispatch must not fire a stale split menu item after remap") + } + func testWindowSendEventRepairsVisibleSameWindowResponderDriftForFocusedTerminalTyping() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh index 043a1449a2ab..2f685dab54e6 100755 --- a/scripts/build-ghostty-cli-helper.sh +++ b/scripts/build-ghostty-cli-helper.sh @@ -95,6 +95,43 @@ if ! command -v zig >/dev/null 2>&1; then exit 1 fi +zig_binary_arch() { + local zig_bin="$1" + file "$zig_bin" 2>/dev/null | grep -oE '(arm64|x86_64)' | head -1 || true +} + +zig_binary_for_arch() { + local desired_arch="$1" + local default_zig + default_zig="$(command -v zig 2>/dev/null || true)" + + local candidates=( + /opt/homebrew/bin/zig + /usr/local/bin/zig + "$default_zig" + ) + local candidate="" + local seen=" " + for candidate in "${candidates[@]}"; do + [[ -n "$candidate" && -x "$candidate" ]] || continue + if [[ "$seen" == *" $candidate "* ]]; then + continue + fi + seen="${seen}${candidate} " + if [[ "$(zig_binary_arch "$candidate")" == "$desired_arch" ]]; then + echo "$candidate" + return 0 + fi + done + + if [[ -n "$default_zig" ]]; then + echo "$default_zig" + return 0 + fi + + return 1 +} + if [[ ! -f "$GHOSTTY_DIR/build.zig" ]]; then echo "error: Ghostty submodule is missing at $GHOSTTY_DIR" >&2 exit 1 @@ -103,8 +140,25 @@ fi build_helper() { local prefix="$1" local target="${2:-}" + local zig_bin + local target_arch="" + + case "$target" in + aarch64-macos) target_arch="arm64" ;; + x86_64-macos) target_arch="x86_64" ;; + esac + + if [[ -n "$target_arch" ]]; then + zig_bin="$(zig_binary_for_arch "$target_arch")" + if [[ "$(zig_binary_arch "$zig_bin")" == "$target_arch" ]]; then + target="" + fi + else + zig_bin="$(command -v zig)" + fi + local args=( - zig build + "$zig_bin" build cli-helper -Dapp-runtime=none -Demit-macos-app=false diff --git a/scripts/reload.sh b/scripts/reload.sh index a44604f7b699..0f6b0ef3c7f2 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -612,7 +612,16 @@ if [[ -d "$PWD/ghostty" ]]; then if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then echo "Skipping direct ghostty CLI helper zig build (CMUX_SKIP_ZIG_BUILD=1)" else - (cd "$PWD/ghostty" && zig build cli-helper -Dapp-runtime=none -Demit-macos-app=false -Demit-xcframework=false -Doptimize=ReleaseFast) + GHOSTTY_HELPER_TARGET="" + case "$(/usr/bin/arch)" in + arm64) GHOSTTY_HELPER_TARGET="aarch64-macos" ;; + i386|x86_64) GHOSTTY_HELPER_TARGET="x86_64-macos" ;; + esac + if [[ -n "$GHOSTTY_HELPER_TARGET" ]]; then + "$PWD/scripts/build-ghostty-cli-helper.sh" --target "$GHOSTTY_HELPER_TARGET" --output "$GHOSTTY_HELPER_SRC" + else + "$PWD/scripts/build-ghostty-cli-helper.sh" --output "$GHOSTTY_HELPER_SRC" + fi fi fi if [[ -x "$CMUXD_SRC" ]]; then diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx index 005a4c517430..d0fd33d67f74 100644 --- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx @@ -11,7 +11,8 @@ const shortcutChordExample = `{ "bindings": { "newSurface": ["ctrl+b", "c"], "showNotifications": ["ctrl+b", "i"], - "toggleSidebar": "cmd+b" + "toggleSidebar": "cmd+b", + "splitRight": "" } } }`; diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index 52545fe56c83..e92322149016 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -515,7 +515,7 @@ "bindings": { "type": "object", "default": {}, - "description": "Shortcut overrides keyed by cmux action id. Use a string for a single shortcut or an array for a chord.", + "description": "Shortcut overrides keyed by cmux action id. Use a string for a single shortcut, an array for a chord, or an empty string to unbind.", "propertyNames": { "enum": [ "openSettings", @@ -612,7 +612,7 @@ "oneOf": [ { "type": "string", - "description": "Single-stroke shortcut, for example cmd+n." + "description": "Single-stroke shortcut, for example cmd+n. Use an empty string, none, clear, or unbound to unbind." }, { "type": "array",