diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index acafcd9da0a4..1affe44eeb5f 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,11 +2,11 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34179 CLI/cmux.swift -17636 Sources/AppDelegate.swift +17606 Sources/AppDelegate.swift 16025 Sources/ContentView.swift 14100 Sources/TerminalController.swift 12654 Sources/Workspace.swift -12219 cmuxTests/AppDelegateShortcutRoutingTests.swift +12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11827 Sources/GhosttyTerminalView.swift 11411 Sources/Panels/BrowserPanel.swift 9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62146b114114..30d627bc1ef9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,14 +253,9 @@ jobs: run: bun run test:db:behavior tests: - # TEMPORARY (2026-06-18): pinned to hosted runners. The self-hosted austin - # mac-minis in the MACOS_RUNNER_15 pool cannot broker the XCTest control - # session with testmanagerd ("Timed out 120s initiating control session - # with daemon" -> Executed 0 tests -> idle-timeout), so this required check - # can never go green there. Revert to - # `${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }}` once the austin - # runners are repaired (GUI login session + automation mode + unwedged - # testmanagerd). Tracked by the CI-flakiness handoff. + # App-host XCTest needs a runner that can broker testmanagerd control + # sessions. Keep this job on the hosted GUI-capable pool until the + # MACOS_RUNNER_15/Austin runner-infra issue is fixed separately. runs-on: warp-macos-15-arm64-6x timeout-minutes: 75 env: diff --git a/Sources/AppDelegate+ShortcutRoutingTesting.swift b/Sources/AppDelegate+ShortcutRoutingTesting.swift new file mode 100644 index 000000000000..9b231459523e --- /dev/null +++ b/Sources/AppDelegate+ShortcutRoutingTesting.swift @@ -0,0 +1,73 @@ +#if DEBUG +import AppKit +import ObjectiveC.runtime + +final class DebugShortcutRoutingFocusedWindowOverrideForTesting { + weak var window: NSWindow? + weak var keyRepairFirstResponder: NSResponder? + var focusedWindowCaptureDepth = 0 + + var shouldCaptureFocusedWindow: Bool { + focusedWindowCaptureDepth > 0 + } +} + +let debugShortcutRoutingFocusedWindowOverrideForTesting = DebugShortcutRoutingFocusedWindowOverrideForTesting() + +private let didInstallShortcutRoutingWindowMakeKeyAndOrderFrontSwizzleForTesting: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.makeKeyAndOrderFront(_:)) + let swizzledSelector = #selector(NSWindow.cmux_makeKeyAndOrderFront(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) +}() + +extension AppDelegate { + func debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: Bool = true) { + clearConfiguredShortcutChordState() + shortcutEventFocusContextCache = nil + debugShortcutRoutingFocusedWindowOverrideForTesting.keyRepairFirstResponder = nil + debugFocusedTerminalKeyRepairObserverForTesting = nil + if clearFocusedWindowOverride { + debugShortcutRoutingFocusedWindowOverrideForTesting.window = nil + } + } + + func debugSetShortcutRoutingFocusedWindowForTesting(_ window: NSWindow?) { + debugShortcutRoutingFocusedWindowOverrideForTesting.window = window + shortcutEventFocusContextCache = nil + } + + func debugSetShortcutRoutingKeyRepairFirstResponderForTesting(_ responder: NSResponder?) { + debugShortcutRoutingFocusedWindowOverrideForTesting.keyRepairFirstResponder = responder + } + + func debugBeginShortcutRoutingFocusedWindowCaptureForTesting() { + debugShortcutRoutingFocusedWindowOverrideForTesting.focusedWindowCaptureDepth += 1 + debugResetShortcutRoutingStateForTesting() + } + + func debugEndShortcutRoutingFocusedWindowCaptureForTesting() { + let override = debugShortcutRoutingFocusedWindowOverrideForTesting + override.focusedWindowCaptureDepth = max(override.focusedWindowCaptureDepth - 1, 0) + debugResetShortcutRoutingStateForTesting() + } + + static func installShortcutRoutingFocusedWindowSwizzleForTesting() { + _ = didInstallShortcutRoutingWindowMakeKeyAndOrderFrontSwizzleForTesting + } +} + +extension NSWindow { + @objc func cmux_makeKeyAndOrderFront(_ sender: Any?) { + cmux_makeKeyAndOrderFront(sender) + guard debugShortcutRoutingFocusedWindowOverrideForTesting.shouldCaptureFocusedWindow else { + return + } + AppDelegate.shared?.debugSetShortcutRoutingFocusedWindowForTesting(self) + } +} +#endif diff --git a/Sources/AppDelegate+ShortcutRoutingWindow.swift b/Sources/AppDelegate+ShortcutRoutingWindow.swift new file mode 100644 index 000000000000..96a1e0ff02b7 --- /dev/null +++ b/Sources/AppDelegate+ShortcutRoutingWindow.swift @@ -0,0 +1,80 @@ +import AppKit + +extension AppDelegate { + var shortcutRoutingKeyWindow: NSWindow? { +#if DEBUG + if let window = debugShortcutRoutingFocusedWindowOverrideForTesting.window { + if debugShortcutRoutingFocusedWindowOverrideForTesting.shouldCaptureFocusedWindow { + return window + } + if contextForMainWindow(window) != nil + || isMainTerminalWindow(window) + || cmuxWindowShouldOwnCloseShortcut(window) { + return window + } + debugShortcutRoutingFocusedWindowOverrideForTesting.window = nil + } +#endif + return NSApp.keyWindow + } + + var shortcutRoutingActiveWindow: NSWindow? { + shortcutRoutingKeyWindow ?? NSApp.mainWindow + } + + func shortcutRoutingFirstResponder(preferredWindow: NSWindow? = nil) -> NSResponder? { + preferredWindow?.firstResponder + ?? shortcutRoutingKeyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + } + + func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window else { return nil } + return contextForMainTerminalWindow(window) + } + + func activeTabManagerForCommands(preferredWindow: NSWindow? = nil) -> TabManager? { + if let context = contextForMainWindow(preferredWindow) { + return context.tabManager + } + if let context = contextForMainWindow(shortcutRoutingKeyWindow) { + return context.tabManager + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return context.tabManager + } + if let activeManager = tabManager, + let activeContext = liveMainWindowContext(for: activeManager) { + return activeContext.tabManager + } + return mainWindowContexts.values.first { context in + resolvedWindow(for: context) != nil + }?.tabManager + } + + func repairFocusedTerminalKeyboardRoutingIfNeeded( + window: NSWindow, + event: NSEvent + ) { + let firstResponderOverride: NSResponder? +#if DEBUG + firstResponderOverride = debugShortcutRoutingFocusedWindowOverrideForTesting.keyRepairFirstResponder +#else + firstResponderOverride = nil +#endif + repairFocusedTerminalKeyboardRoutingIfNeeded( + window: window, + event: event, + firstResponderOverride: firstResponderOverride + ) + } + + private func liveMainWindowContext(for tabManager: TabManager) -> MainWindowContext? { + for context in Array(mainWindowContexts.values) where context.tabManager === tabManager { + if resolvedWindow(for: context) != nil { + return context + } + } + return nil + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 14027649bf35..b0fd1de3749b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -872,6 +872,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// `SidebarDragState`, read by the `debug.sidebar.simulate_drag` handler. // TODO(de-singletonize): move SidebarDragStateRegistry off AppDelegate.shared when AppDelegate is decomposed. let sidebarDragStateRegistry = SidebarDragStateRegistry() + var debugFocusedTerminalKeyRepairObserverForTesting: ((NSWindow, NSEvent, NSResponder?) -> Void)? #endif private lazy var updateController = UpdateController(log: updateLog) private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(updateLog: updateLog, settingsRuntime: settingsRuntime) @@ -5115,7 +5116,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent preferredWindow: NSWindow?, source: String ) { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow if let targetWindow, let context = contextForMainWindow(targetWindow) { _ = context.tabManager.setFocusedBrowserFocusModeActive(false, reason: "commandPaletteRequest.\(source)") @@ -5248,7 +5249,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { - commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + commandPaletteWindowForShortcutEvent(event) ?? event.window ?? shortcutRoutingActiveWindow } @discardableResult @@ -5425,17 +5426,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func repairFocusedTerminalKeyboardRoutingIfNeeded( window: NSWindow, - event: NSEvent + event: NSEvent, + firstResponderOverride: NSResponder? ) { guard event.type == .keyDown else { return } let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) guard isMainTerminalWindow(window) else { return } guard window.attachedSheet == nil else { return } guard !isCommandPaletteEffectivelyVisible(in: window) else { return } + let firstResponder = firstResponderOverride ?? window.firstResponder // If the active first responder is owned by a non-terminal interaction surface, // never re-route the keystroke to the terminal. Symmetric with // applyFirstResponderIfNeeded's foreign focus guard. - if let firstResponder = window.firstResponder, + if let firstResponder, shouldRespectForeignFirstResponder(firstResponder, in: window, isRightSidebarOwner: { isRightSidebarFocusResponder($0, in: window) }) { @@ -5447,7 +5450,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let terminalPanel = workspace.terminalPanel(for: panelId) else { return } - let firstResponder = window.firstResponder if normalizedFlags.contains(.command) { let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( @@ -5486,6 +5488,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "target=\(targetLabel) " + "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" ) + debugFocusedTerminalKeyRepairObserverForTesting?(window, event, firstResponder) #endif terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) @@ -5807,7 +5810,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) } - private func resolvedWindow(for context: MainWindowContext) -> NSWindow? { + func resolvedWindow(for context: MainWindowContext) -> NSWindow? { if let window = context.window { return window } @@ -6008,7 +6011,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func activeCommandPaletteWindow() -> NSWindow? { pruneExpiredCommandPalettePendingOpenStates() - if let keyWindow = NSApp.keyWindow, + if let keyWindow = shortcutRoutingKeyWindow, isMainTerminalWindow(keyWindow), isCommandPaletteEffectivelyVisible(in: keyWindow) { return keyWindow @@ -6039,39 +6042,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return activeCommandPaletteWindow() } - func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { - guard let window else { return nil } - return contextForMainTerminalWindow(window) - } - - private func liveMainWindowContext(for tabManager: TabManager) -> MainWindowContext? { - for context in Array(mainWindowContexts.values) where context.tabManager === tabManager { - if resolvedWindow(for: context) != nil { - return context - } - } - return nil - } - - func activeTabManagerForCommands(preferredWindow: NSWindow? = nil) -> TabManager? { - if let context = contextForMainWindow(preferredWindow) { - return context.tabManager - } - if let context = contextForMainWindow(NSApp.keyWindow) { - return context.tabManager - } - if let context = contextForMainWindow(NSApp.mainWindow) { - return context.tabManager - } - if let activeManager = tabManager, - let activeContext = liveMainWindowContext(for: activeManager) { - return activeContext.tabManager - } - return mainWindowContexts.values.first { context in - resolvedWindow(for: context) != nil - }?.tabManager - } - /// Opens the diff viewer for the focused workspace of `tabManager` by spawning the /// bundled `cmux diff` CLI. This is the single shared diff-open path: both the /// command-palette entry and the Open Diff Viewer keyboard shortcut funnel through @@ -6228,7 +6198,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" let eventWindow = event?.window - return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" + return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(shortcutRoutingKeyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" } #endif @@ -6241,7 +6211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isMainTerminalWindow(window) { return window } - if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + if let keyWindow = shortcutRoutingKeyWindow, isMainTerminalWindow(keyWindow) { return keyWindow } if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { @@ -6256,6 +6226,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let eventWindowNumber = event.windowNumber guard eventWindowNumber > 0 else { return nil } +#if DEBUG + if let window = debugShortcutRoutingFocusedWindowOverrideForTesting.window, + window.windowNumber == eventWindowNumber { + return window + } +#endif return NSApp.window(withWindowNumber: eventWindowNumber) } @@ -6263,7 +6239,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Close shortcuts are focused-window commands. Some AppKit key-equivalent // paths can preserve stale event window metadata after a new window becomes // key, so prefer the actual focused window before falling back to event data. - if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + if let keyWindow = shortcutRoutingKeyWindow, isMainTerminalWindow(keyWindow) { return keyWindow } if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { @@ -6281,7 +6257,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func auxiliaryWindowForFocusedCloseShortcut(event: NSEvent) -> NSWindow? { [ - NSApp.keyWindow, + shortcutRoutingKeyWindow, NSApp.mainWindow, resolvedShortcutEventWindow(event), ] @@ -6298,7 +6274,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let context = contextForMainWindow(preferredWindow) { return (context, "preferredWindow") } - if let context = contextForMainWindow(NSApp.keyWindow) { + if let context = contextForMainWindow(shortcutRoutingKeyWindow) { return (context, "keyWindow") } if let context = contextForMainWindow(NSApp.mainWindow) { @@ -6381,7 +6357,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return (terminalContext.workspaceId, terminalContext.panelId) } - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow if let context = contextForMainWindow(targetWindow), let selectedTabId = context.tabManager.selectedTabId ?? context.tabManager.tabs.first?.id { return (selectedTabId, context.tabManager.focusedSurfaceId(for: selectedTabId)) @@ -6396,10 +6372,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - let responder = targetWindow?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow + let responder = shortcutRoutingFirstResponder(preferredWindow: targetWindow) guard let ghosttyView = cmuxOwningGhosttyView(for: responder), let workspaceId = ghosttyView.tabId, let panelId = ghosttyView.terminalSurface?.id, @@ -6417,7 +6391,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let context = contextForMainWindow(event.window) { return context } - if let context = contextForMainWindow(NSApp.keyWindow) { + if let context = contextForMainWindow(shortcutRoutingKeyWindow) { return context } if let context = contextForMainWindow(NSApp.mainWindow) { @@ -6435,7 +6409,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let context = contextForMainWindow(preferredWindow) { return context } - if let context = contextForMainWindow(NSApp.keyWindow) { + if let context = contextForMainWindow(shortcutRoutingKeyWindow) { return context } if let context = contextForMainWindow(NSApp.mainWindow) { @@ -6480,7 +6454,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent toggle(preferredContext) { return true } - if let keyWindow = NSApp.keyWindow, + if let keyWindow = shortcutRoutingKeyWindow, let keyContext = contextForMainTerminalWindow(keyWindow), toggle(keyContext) { return true @@ -6677,7 +6651,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult private func restoreFocusedMainPanelFocusForShortcut(event: NSEvent) -> Bool { - let preferredWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let preferredWindow = mainWindowForShortcutEvent(event) ?? event.window ?? shortcutRoutingActiveWindow return restoreFocusedMainPanelFocusFromRightSidebar(preferredWindow: preferredWindow) } @@ -6939,13 +6913,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "fr=\(beforeResponder)" ) #endif + let target = context.keyboardFocusCoordinator.findShortcutTarget( + currentResponder: window?.firstResponder + ) + guard target != .none else { +#if DEBUG + dlog( + "find.shortcut.app.end target=\(target) result=0 " + + "targetWin={\(debugWindowToken(window))} fr=\(beforeResponder)" + ) +#endif + return false + } + if let window { mainWindowVisibilityController.focusForInWindowCommand(window, reason: .findShortcut) } - let target = context.keyboardFocusCoordinator.findShortcutTarget( - currentResponder: window?.firstResponder - ) let result: Bool switch target { case .rightSidebarFileSearch: @@ -6953,7 +6937,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .mainPanelFind: result = context.tabManager.startSearch() case .none: - result = false + return false } #if DEBUG let afterResponder = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" @@ -7033,7 +7017,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let window = mainWindowForShortcutEvent(event) { return window } - if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + if let keyWindow = shortcutRoutingKeyWindow, isMainTerminalWindow(keyWindow) { return keyWindow } if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { @@ -7969,7 +7953,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - if let keyWindow = NSApp.keyWindow, + if let keyWindow = shortcutRoutingKeyWindow, let context = contextForMainTerminalWindow(keyWindow) { #if DEBUG logWorkspaceCreationRouting( @@ -8053,6 +8037,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return context } +#if DEBUG + if event.windowNumber > 0, + let window = debugShortcutRoutingFocusedWindowOverrideForTesting.window, + window.windowNumber == event.windowNumber, + let context = contextForMainTerminalWindow(window) { + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "debug_focused_window_number", + event: event, + chosenContext: context + ) + return context + } +#endif + if event.windowNumber > 0, let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), let context = contextForMainTerminalWindow(numberedWindow) { @@ -8121,7 +8121,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - if let keyWindow = NSApp.keyWindow, + if let keyWindow = shortcutRoutingKeyWindow, let context = contextForMainTerminalWindow(keyWindow) { return context } @@ -12062,6 +12062,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ = didInstallWindowKeyEquivalentSwizzle _ = didInstallWindowFirstResponderSwizzle _ = didInstallWindowSendEventSwizzle +#if DEBUG + installShortcutRoutingFocusedWindowSwizzleForTesting() +#endif } #if DEBUG @@ -12093,7 +12096,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return event } if ShortcutRecorderEventRouter.dispatchActiveRecordingEvent( event, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: event.window ?? shortcutRoutingActiveWindow ) { return nil } @@ -12111,7 +12114,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ProcessInfo.processInfo.environment["CMUX_SHORTCUT_MONITOR_TRACE"] == "1" || UserDefaults.standard.bool(forKey: "cmuxShortcutMonitorTrace") if shortcutMonitorTraceEnabled { - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let frType = shortcutRoutingKeyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" cmuxDebugLog( "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" ) @@ -12206,7 +12209,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func clearConfiguredShortcutChordState() { + func clearConfiguredShortcutChordState() { pendingConfiguredShortcutChord = nil activeConfiguredShortcutChordPrefixForCurrentEvent = nil } @@ -12535,7 +12538,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + if NSApp.modalWindow != nil || shortcutRoutingKeyWindow?.attachedSheet != nil { return false } @@ -12646,8 +12649,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let suppressionWindow = commandPaletteTargetWindow ?? event.window - ?? NSApp.keyWindow - ?? NSApp.mainWindow + ?? shortcutRoutingActiveWindow if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { #if DEBUG cmuxDebugLog( @@ -12747,10 +12749,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // (e.g., split that doesn't properly blur the address bar). If the first responder // is a terminal surface, the address bar can't be focused. if browserAddressBarFocusedPanelId != nil, - cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { + cmuxOwningGhosttyView(for: shortcutRoutingKeyWindow?.firstResponder) != nil { #if DEBUG let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderType = shortcutRoutingKeyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" cmuxDebugLog( "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + "reason=terminal_first_responder fr=\(firstResponderType)" @@ -12769,14 +12771,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if commandPaletteEffectiveInTargetWindow { if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") return true } if !hasFocusedAddressBarInShortcutContext, matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") return true } @@ -12803,7 +12805,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if isPlainEscape { - let escapeWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let escapeWindow = resolvedShortcutEventWindow(event) ?? shortcutRoutingActiveWindow let textBoxShortcutTabManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager if let escapeWindow, isMainTerminalWindow(escapeWindow) { @@ -12823,12 +12825,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still // work during composition since Cmd is never part of IME input sequences. if !normalizedFlags.contains(.command), - let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), + let ghosttyView = cmuxOwningGhosttyView(for: shortcutRoutingKeyWindow?.firstResponder), ghosttyView.hasMarkedText() { return false } - let shortcutWindowForMarkedText = resolvedShortcutEventWindow(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutWindowForMarkedText = resolvedShortcutEventWindow(event) ?? event.window ?? shortcutRoutingActiveWindow if browserOmnibarShouldBypassShortcutRoutingForMarkedText( hasFocusedAddressBar: hasFocusedAddressBarInShortcutContext, firstResponderHasMarkedText: browserResponderHasMarkedText(shortcutWindowForMarkedText?.firstResponder), @@ -12855,7 +12857,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if let mode = rightSidebarModeShortcut(for: event), - let rightSidebarWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + let rightSidebarWindow = mainWindowForShortcutEvent(event) ?? event.window ?? shortcutRoutingActiveWindow, shouldRouteRightSidebarModeShortcut(in: rightSidebarWindow) { _ = focusRightSidebarInActiveMainWindow( mode: mode, @@ -12876,7 +12878,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if cmuxCloseFocusedTerminalFindForEscape(event: event, appDelegate: self) { return true } if matchConfiguredShortcut(event: event, action: .find) { let shortcutWindow = resolvedShortcutEventWindow(event) - cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutWindow ?? NSApp.keyWindow); return performFindShortcutInActiveMainWindow(preferredWindow: shortcutWindow) + cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutWindow ?? shortcutRoutingKeyWindow); return performFindShortcutInActiveMainWindow(preferredWindow: shortcutWindow) } // Keep keyboard routing deterministic after split close/reparent transitions: @@ -12885,12 +12887,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let frType = shortcutRoutingKeyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" cmuxDebugLog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") #endif tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() #if DEBUG - let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let frAfterType = shortcutRoutingKeyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" cmuxDebugLog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) #endif @@ -12965,14 +12967,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") return true } if !hasFocusedAddressBarInShortcutContext, matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") return true } @@ -13064,7 +13066,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Escape AppKit's performKeyEquivalent animation context. Without // deferring the toggle, NSAnimationContext implicitly animates the // layout change. - let preferredWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let preferredWindow = mainWindowForShortcutEvent(event) ?? event.window ?? shortcutRoutingActiveWindow DispatchQueue.main.async { [weak self, weak preferredWindow] in _ = self?.toggleRightSidebarInActiveMainWindow(preferredWindow: preferredWindow) } @@ -13074,9 +13076,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .focusRightSidebar) { let preferredWindow = mainWindowForShortcutEvent(event) #if DEBUG - let beforeResponder = preferredWindow?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder + let beforeResponder = shortcutRoutingFirstResponder(preferredWindow: preferredWindow) dlog( "rs.focus.toggle.shortcut.begin event=\(NSWindow.keyDescription(event)) " + "preferred={\(debugWindowToken(preferredWindow))} fr=\(beforeResponder.map { String(describing: type(of: $0)) } ?? "nil") " + @@ -13085,9 +13085,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif let result = toggleRightSidebarKeyboardFocusInActiveMainWindow(preferredWindow: preferredWindow) #if DEBUG - let afterResponder = preferredWindow?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder + let afterResponder = shortcutRoutingFirstResponder(preferredWindow: preferredWindow) dlog( "rs.focus.toggle.shortcut.end result=\(result ? 1 : 0) " + "preferred={\(debugWindowToken(preferredWindow))} fr=\(afterResponder.map { String(describing: type(of: $0)) } ?? "nil") " + @@ -13226,7 +13224,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .renameWorkspace) { return requestRenameWorkspaceViaCommandPalette( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow ) } @@ -13236,7 +13234,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // `.toggleReactGrab` check (default ⌘⇧G collides with React Grab // and grouping returns false when no multi-selection exists). if handleGroupSelectedWorkspacesShortcut( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow ) { return true } @@ -13247,7 +13245,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // workspace was in a group). Otherwise fall through so a rebinding // that shares this chord with another action still works. if handleToggleFocusedWorkspaceGroupCollapsedShortcut( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow ) { return true } @@ -13256,21 +13254,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { #if DEBUG cmuxDebugLog( - "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + + "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow))} " + "\(debugShortcutRouteSnapshot(event: event))" ) #endif return requestEditWorkspaceDescriptionViaCommandPalette( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow ) } if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { - if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + if let targetWindow = event.window ?? shortcutRoutingActiveWindow, targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) } else { - let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = event.window ?? shortcutRoutingActiveWindow if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() } else { @@ -13331,7 +13329,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .renameTab) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") return true } @@ -13371,7 +13369,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowGlyph: "←", arrowKeyCode: 123 ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { - cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.movePaneFocus(direction: .left) + cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutRoutingKeyWindow); tabManager?.movePaneFocus(direction: .left) #if DEBUG recordGotoSplitMoveIfNeeded(direction: .left) #endif @@ -13383,7 +13381,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowGlyph: "→", arrowKeyCode: 124 ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { - cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.movePaneFocus(direction: .right) + cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutRoutingKeyWindow); tabManager?.movePaneFocus(direction: .right) #if DEBUG recordGotoSplitMoveIfNeeded(direction: .right) #endif @@ -13395,7 +13393,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowGlyph: "↑", arrowKeyCode: 126 ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { - cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.movePaneFocus(direction: .up) + cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutRoutingKeyWindow); tabManager?.movePaneFocus(direction: .up) #if DEBUG recordGotoSplitMoveIfNeeded(direction: .up) #endif @@ -13407,7 +13405,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowGlyph: "↓", arrowKeyCode: 125 ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { - cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.movePaneFocus(direction: .down) + cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutRoutingKeyWindow); tabManager?.movePaneFocus(direction: .down) #if DEBUG recordGotoSplitMoveIfNeeded(direction: .down) #endif @@ -13445,7 +13443,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } _ = performSplitShortcut( direction: .right, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: event.window ?? shortcutRoutingActiveWindow ) return true } @@ -13459,7 +13457,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } _ = performSplitShortcut( direction: .down, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: event.window ?? shortcutRoutingActiveWindow ) return true } @@ -13710,7 +13708,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let hostedSize = hostedView.bounds.size let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor let hostedAttachedToWindow = terminalPanel.surface.isViewInWindow - let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow + let firstResponderIsWindow = shortcutRoutingKeyWindow?.firstResponder is NSWindow let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( firstResponderIsWindow: firstResponderIsWindow, @@ -13724,7 +13722,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG let directionLabel = direction.map { String(describing: $0) } ?? "splitGeometry" - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderType = shortcutRoutingKeyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" cmuxDebugLog( "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + @@ -13875,7 +13873,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Extensions feature. Its entry points are hidden while disabled, but // guard here too so no other path can open it. guard CmuxExtensionSidebarSelection.isEnabled else { return nil } - let preferredWindow = anchorView?.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let preferredWindow = anchorView?.window ?? shortcutRoutingActiveWindow let targetTabManager = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) guard let workspace = targetTabManager?.selectedWorkspace, let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { @@ -13934,7 +13932,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { - let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutWindow = resolvedShortcutEventWindow(event) ?? shortcutRoutingActiveWindow let shortcutResponder = shortcutWindow?.firstResponder let responderPanelId = isBrowserOmnibarResponder(shortcutResponder) ? browserOmnibarPanelId(for: shortcutResponder) @@ -14265,7 +14263,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { guard let browser = tabManager?.focusedBrowserPanel else { return } guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } - guard let keyWindow = NSApp.keyWindow else { return } + guard let keyWindow = shortcutRoutingKeyWindow else { return } guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } let beforeResponder = keyWindow.firstResponder @@ -14288,7 +14286,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) @@ -14301,7 +14299,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG - let keyWindow = NSApp.keyWindow + let keyWindow = shortcutRoutingKeyWindow let firstResponder = keyWindow?.firstResponder let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" @@ -14337,7 +14335,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent }() #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in - let keyWindow = NSApp.keyWindow + let keyWindow = self?.shortcutRoutingKeyWindow let firstResponder = keyWindow?.firstResponder let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" @@ -14373,7 +14371,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + _ = synchronizeActiveMainWindowContext(preferredWindow: shortcutRoutingActiveWindow) #if DEBUG let directionLabel: String @@ -14432,7 +14430,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func handleBrowserSurfaceKeyEquivalentBeforeMainMenu(_ event: NSEvent) -> Bool { if matchConfiguredShortcut(event: event, action: .find) { let shortcutWindow = resolvedShortcutEventWindow(event) - cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutWindow ?? NSApp.keyWindow); return performFindShortcutInActiveMainWindow(preferredWindow: shortcutWindow) + cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutWindow ?? shortcutRoutingKeyWindow); return performFindShortcutInActiveMainWindow(preferredWindow: shortcutWindow) } if matchConfiguredShortcut(event: event, action: .findInDirectory) { return focusFileSearchInActiveMainWindow(preferredWindow: resolvedShortcutEventWindow(event)) @@ -14442,7 +14440,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow requestCommandPaletteRenameWorkspace( preferredWindow: targetWindow, source: "shortcut.renameWorkspace" @@ -14452,7 +14450,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func handleToggleFocusedWorkspaceGroupCollapsedShortcut(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow let resolvedTabManager: TabManager? = contextForMainWindow(targetWindow)?.tabManager ?? self.tabManager guard let tabManager = resolvedTabManager else { return false } guard let focusedId = tabManager.selectedTabId, @@ -14473,7 +14471,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // multi-window users get the group created in the window they were // looking at. Fall back to the app-level tabManager only if no window // context resolves. - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow let resolvedTabManager: TabManager? = contextForMainWindow(targetWindow)?.tabManager ?? self.tabManager guard let tabManager = resolvedTabManager else { return false } let selectedSet = tabManager.sidebarSelectedWorkspaceIds @@ -14518,7 +14516,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow #if DEBUG cmuxDebugLog( "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + @@ -14559,11 +14557,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent matchConfiguredShortcut(event: event, action: action) } - func debugResetShortcutRoutingStateForTesting() { - clearConfiguredShortcutChordState() - shortcutEventFocusContextCache = nil - } - func debugMarkCommandPaletteOpenPending(window: NSWindow) { markCommandPaletteOpenRequested(for: window) } @@ -14832,7 +14825,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return executeConfiguredCmuxAction( action, context: context, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: event.window ?? shortcutRoutingActiveWindow ) } @@ -15007,7 +15000,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let didSplit = performSplitShortcut( direction: .right, - preferredWindow: preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: preferredWindow ?? shortcutRoutingActiveWindow ) if didSplit { onExecuted?() } return didSplit @@ -15020,7 +15013,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let didSplit = performSplitShortcut( direction: .down, - preferredWindow: preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + preferredWindow: preferredWindow ?? shortcutRoutingActiveWindow ) if didSplit { onExecuted?() } return didSplit @@ -15079,7 +15072,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if KeyboardShortcutRecorderActivity.isAnyRecorderActive || RecorderHostButton.isActivelyRecording { return false } - if event.window is NSPanel || NSApp.keyWindow is NSPanel || NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + let keyWindow = shortcutRoutingKeyWindow + if event.window is NSPanel || keyWindow is NSPanel || NSApp.modalWindow != nil || keyWindow?.attachedSheet != nil { return false } let flags = event.modifierFlags @@ -15634,7 +15628,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { - let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutWindow = resolvedShortcutEventWindow(event) ?? shortcutRoutingActiveWindow let shortcutResponder = shortcutWindow?.firstResponder let owningWebView = tabManager?.focusedBrowserPanel?.webView as? CmuxWebView guard let owningWebView else { return false } @@ -15753,7 +15747,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if tabManager === removed.tabManager { // Repoint "active" pointers to any remaining main terminal window. let nextContext: MainWindowContext? = { - if let keyWindow = NSApp.keyWindow, + if let keyWindow = shortcutRoutingKeyWindow, let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { return ctx } @@ -15813,7 +15807,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif - private func isMainTerminalWindow(_ window: NSWindow) -> Bool { + func isMainTerminalWindow(_ window: NSWindow) -> Bool { if mainWindowContexts[ObjectIdentifier(window)] != nil { return true } @@ -16217,7 +16211,7 @@ private extension NSApplication { } if ShortcutRecorderEventRouter.dispatchActiveRecordingEvent( event, - preferredWindow: event.window ?? keyWindow ?? mainWindow + preferredWindow: event.window ?? AppDelegate.shared?.shortcutRoutingActiveWindow ?? keyWindow ?? mainWindow ) { return } @@ -16229,7 +16223,7 @@ private extension NSApplication { return } let responder = event.window?.firstResponder - ?? keyWindow?.firstResponder + ?? AppDelegate.shared?.shortcutRoutingKeyWindow?.firstResponder ?? mainWindow?.firstResponder if let ghosttyView = cmuxOwningGhosttyView(for: responder) { ghosttyView.keyDown(with: event) @@ -16342,9 +16336,9 @@ private extension AppDelegate { return cell.controlView?.window } if target == nil, sender is NSMenuItem { - return NSApp.keyWindow ?? NSApp.mainWindow + return AppDelegate.shared?.shortcutRoutingActiveWindow ?? NSApp.keyWindow ?? NSApp.mainWindow } - return allowFallback ? (NSApp.keyWindow ?? NSApp.mainWindow) : nil + return allowFallback ? (AppDelegate.shared?.shortcutRoutingActiveWindow ?? NSApp.keyWindow ?? NSApp.mainWindow) : nil } private func allBrowserPanelsForInspectorWindowClose() -> [BrowserPanel] { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 85fc0594e3e5..0e4bd070ec70 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -62,6 +62,8 @@ 2907B0012907B0012907B001 /* AppDelegate+NotificationNavSeams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2907B0022907B0022907B002 /* AppDelegate+NotificationNavSeams.swift */; }; 6313C7A00000000000000001 /* AppDelegate+PaneMemoryGuardrail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6313C7A00000000000000002 /* AppDelegate+PaneMemoryGuardrail.swift */; }; 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */; }; + 6419A0016419A0016419A001 /* AppDelegate+ShortcutRoutingTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419A0026419A0026419A002 /* AppDelegate+ShortcutRoutingTesting.swift */; }; + 6419A0036419A0036419A003 /* AppDelegate+ShortcutRoutingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419A0046419A0046419A004 /* AppDelegate+ShortcutRoutingWindow.swift */; }; 604500100000000000000005 /* AppDelegate+WindowIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604500100000000000000006 /* AppDelegate+WindowIdentity.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */; }; @@ -69,6 +71,7 @@ 2907A0032907A0032907A003 /* AppDelegateIssue2907RoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2907A0042907A0042907A004 /* AppDelegateIssue2907RoutingTests.swift */; }; D7AB0000000000000000000F /* AppDelegateMoveTabToNewWorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000010 /* AppDelegateMoveTabToNewWorkspaceTests.swift */; }; C34670010000000000000001 /* AppDelegateRenameShortcutContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34670010000000000000002 /* AppDelegateRenameShortcutContextTests.swift */; }; + 6419B0016419B0016419B001 /* AppDelegateShortcutRoutingRepairProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419B0026419B0026419B002 /* AppDelegateShortcutRoutingRepairProbe.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; A11EAB000000000000000000 /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAB000000000000000001 /* AppearanceSettings.swift */; }; A11EAA000000000000000000 /* AppearanceSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAA000000000000000001 /* AppearanceSettingsTests.swift */; }; @@ -1087,6 +1090,8 @@ 2907B0022907B0022907B002 /* AppDelegate+NotificationNavSeams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+NotificationNavSeams.swift"; sourceTree = ""; }; 6313C7A00000000000000002 /* AppDelegate+PaneMemoryGuardrail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+PaneMemoryGuardrail.swift"; sourceTree = ""; }; 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RecoverableMainWindowRoutes.swift"; sourceTree = ""; }; + 6419A0026419A0026419A002 /* AppDelegate+ShortcutRoutingTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+ShortcutRoutingTesting.swift"; sourceTree = ""; }; + 6419A0046419A0046419A004 /* AppDelegate+ShortcutRoutingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+ShortcutRoutingWindow.swift"; sourceTree = ""; }; 604500100000000000000006 /* AppDelegate+WindowIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+WindowIdentity.swift"; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateBareSpaceShortcutRoutingTests.swift; sourceTree = ""; }; @@ -1094,6 +1099,7 @@ 2907A0042907A0042907A004 /* AppDelegateIssue2907RoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateIssue2907RoutingTests.swift; sourceTree = ""; }; D7AB00000000000000000010 /* AppDelegateMoveTabToNewWorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateMoveTabToNewWorkspaceTests.swift; sourceTree = ""; }; C34670010000000000000002 /* AppDelegateRenameShortcutContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateRenameShortcutContextTests.swift; sourceTree = ""; }; + 6419B0026419B0026419B002 /* AppDelegateShortcutRoutingRepairProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingRepairProbe.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; A11EAB000000000000000001 /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = ""; }; A11EAA000000000000000001 /* AppearanceSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsTests.swift; sourceTree = ""; }; @@ -2463,6 +2469,8 @@ 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */, 4EDE10010000000000000002 /* AppDelegate+NotificationDeliverySeams.swift */, 2907B0022907B0022907B002 /* AppDelegate+NotificationNavSeams.swift */, + 6419A0026419A0026419A002 /* AppDelegate+ShortcutRoutingTesting.swift */, + 6419A0046419A0046419A004 /* AppDelegate+ShortcutRoutingWindow.swift */, 6313C7A00000000000000002 /* AppDelegate+PaneMemoryGuardrail.swift */, C4160A030000000000000002 /* AppDelegate+ClosedItemHistory.swift */, C4160A040000000000000002 /* AppDelegate+FocusHistoryContextMenu.swift */, @@ -2811,6 +2819,7 @@ F5300003A1B2C3D4E5F60718 /* RovoDevTranscriptPreviewTests.swift */, F5310001A1B2C3D4E5F60718 /* RovoDevHookConfigTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, + 6419B0026419B0026419B002 /* AppDelegateShortcutRoutingRepairProbe.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, C3467AB10000000000000002 /* ShortcutWhenClauseTests.swift */, C0DE7B300000000000000002 /* TextBoxMentionCompletionTests.swift */, @@ -3487,6 +3496,8 @@ 2907B0012907B0012907B001 /* AppDelegate+NotificationNavSeams.swift in Sources */, 6313C7A00000000000000001 /* AppDelegate+PaneMemoryGuardrail.swift in Sources */, 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */, + 6419A0016419A0016419A001 /* AppDelegate+ShortcutRoutingTesting.swift in Sources */, + 6419A0036419A0036419A003 /* AppDelegate+ShortcutRoutingWindow.swift in Sources */, 604500100000000000000005 /* AppDelegate+WindowIdentity.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A11EAB000000000000000000 /* AppearanceSettings.swift in Sources */, @@ -4114,6 +4125,7 @@ 2907A0032907A0032907A003 /* AppDelegateIssue2907RoutingTests.swift in Sources */, D7AB0000000000000000000F /* AppDelegateMoveTabToNewWorkspaceTests.swift in Sources */, C34670010000000000000001 /* AppDelegateRenameShortcutContextTests.swift in Sources */, + 6419B0016419B0016419B001 /* AppDelegateShortcutRoutingRepairProbe.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, A11EAA000000000000000000 /* AppearanceSettingsTests.swift in Sources */, A47E00010000000000000001 /* AuthEnvironmentTests.swift in Sources */, diff --git a/cmuxTests/AppDelegateShortcutRoutingRepairProbe.swift b/cmuxTests/AppDelegateShortcutRoutingRepairProbe.swift new file mode 100644 index 000000000000..f3eb829adf3e --- /dev/null +++ b/cmuxTests/AppDelegateShortcutRoutingRepairProbe.swift @@ -0,0 +1,133 @@ +import AppKit +import CmuxTerminal +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension AppDelegateShortcutRoutingTests { + func focusHostedTerminalForRepairTesting( + window: NSWindow, + hostedView: GhosttySurfaceScrollView + ) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + hostedView.moveFocus() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertTrue( + hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to own first responder before repair test" + ) + } + + func installStrandedResponderDriftForTesting( + _ responder: NSView, + in window: NSWindow, + hostedView: GhosttySurfaceScrollView + ) { + (window.contentView?.superview ?? window.contentView)?.addSubview(responder) + XCTAssertTrue(window.makeFirstResponder(responder), "Expected test to install a stranded responder") + XCTAssertTrue(window.firstResponder === responder, "Expected real first responder drift before removal") + responder.removeFromSuperview() + XCTAssertNil(responder.window, "Expected a simulated stranded responder") + XCTAssertFalse( + hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to lose first responder before repaired typing" + ) + } + + func installVisibleResponderDriftForTesting( + _ responder: NSView, + in window: NSWindow, + hostedView: GhosttySurfaceScrollView, + mismatchMessage: String + ) { + (window.contentView?.superview ?? window.contentView)?.addSubview(responder) + XCTAssertTrue(responder.window === window, "Expected a simulated same-window responder") + XCTAssertTrue(window.makeFirstResponder(responder), "Expected test to install a visible wrong first responder") + XCTAssertTrue(window.firstResponder === responder, "Expected real same-window responder drift before repair") + XCTAssertFalse(hostedView.responderMatchesPreferredKeyboardFocus(responder), mismatchMessage) + XCTAssertFalse( + hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to lose first responder before repaired typing" + ) + } + + func installSearchResponderDriftForTesting( + _ responder: NSView, + in window: NSWindow, + hostedView: GhosttySurfaceScrollView, + searchField: NSTextField + ) { + (window.contentView?.superview ?? window.contentView)?.addSubview(responder) + XCTAssertTrue(responder.window === window, "Expected a simulated same-window responder") + XCTAssertTrue(window.makeFirstResponder(responder), "Expected test to install a visible wrong first responder") + XCTAssertTrue(window.firstResponder === responder, "Expected real same-window responder drift before repair") + XCTAssertFalse( + hostedView.responderMatchesPreferredKeyboardFocus(responder), + "Expected the simulated responder to disagree with terminal search focus" + ) + XCTAssertFalse( + repairProbeFirstResponderOwnsTextField(window.firstResponder, textField: searchField), + "Expected terminal search field to lose first responder before repaired typing" + ) + } + + func installFocusedTerminalRepairProbeForTesting( + appDelegate: AppDelegate, + keyCode: UInt32 + ) -> ( + repairCount: () -> Int, + repairResponder: () -> NSResponder?, + forwardedKeyDownCount: () -> Int, + restore: () -> Void + ) { + var repairCount = 0 + var repairResponder: NSResponder? + let previousRepairObserver = appDelegate.debugFocusedTerminalKeyRepairObserverForTesting + appDelegate.debugFocusedTerminalKeyRepairObserverForTesting = { window, event, responder in + previousRepairObserver?(window, event, responder) + guard UInt32(event.keyCode) == keyCode else { return } + repairCount += 1 + repairResponder = responder + } + + var forwardedKeyDownCount = 0 + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == keyCode else { return } + forwardedKeyDownCount += 1 + } + + return ( + repairCount: { repairCount }, + repairResponder: { repairResponder }, + forwardedKeyDownCount: { forwardedKeyDownCount }, + restore: { + appDelegate.debugFocusedTerminalKeyRepairObserverForTesting = previousRepairObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + } + ) + } + + private func repairProbeFirstResponderOwnsTextField( + _ firstResponder: NSResponder?, + textField: NSTextField + ) -> Bool { + if firstResponder === textField { + return true + } + if let editor = firstResponder as? NSTextView, + editor.isFieldEditor, + editor.delegate as? NSTextField === textField { + return true + } + return false + } +} diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index a1b33a025d0a..2e65bbd5896b 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -108,106 +108,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { // and tearDown() still runs after a skip, so it must tolerate a nil here. private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore? - // TEMPORARY (2026-06-18): These tests drive real NSWindow key/focus state and - // assert that a shortcut routes to the *focused* window. On headless CI - // runners the window server does not deterministically honor - // makeKeyAndOrderFront within the test's drain window, so AppDelegate's - // NSApp.keyWindow-based routing resolves a different window and the tests flake - // (a varying subset fails each run). Skip exactly these in CI until the durable - // fix lands: a DEBUG key-window override seam in AppDelegate routing so tests can - // pin the focused window deterministically instead of relying on the OS window - // server. They still run on real dev machines. Tracked via the CI-flakiness handoff. - private static let focusRoutingTestsFlakyOnHeadlessCI: Set = [ - "testApplicationSendEventRoutesCmdDMenuEquivalentToActiveShortcutRecorder", - "testApplicationSendEventRoutesReassignedCmdWBeforeStaleCloseTabMenuEquivalent", - "testApplicationSendEventSuppressesRemappedCmdDStaleMenuShortcut", - "testArrowNavigationRoutesWhileCommandPaletteOverlayIsInteractiveBeforeVisibilitySync", - "testBrowserFocusModeCommandEquivalentSkipsAppMenuFallback", - "testChordedNewWorkspaceShortcutConsumesPrefixAndTriggersOnSecondKey", - "testChordedShortcutMismatchDoesNotConsumeSecondKey", - "testCmdCtrlWClosesWindowAfterConfirmation", - "testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing", - "testCmdNDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing", - "testCmdNResolvesEventWindowWhenObjectKeyLookupIsMismatched", - "testCmdShiftWTargetsFocusedWindowWhenEventWindowMetadataIsStale", - "testCmdUnshiftedSymbolDoesNotMatchDigitShortcut", - "testCmdWClosesAuxiliaryWindowInsteadOfMainTerminalPanel", - "testCmdWClosesMobilePairingWindowInsteadOfTerminalTab", - "testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace", - "testCmdWTargetsFocusedWindowWhenEventWindowMetadataIsStale", - "testConfiguredChordDoesNotCrossWindowBoundary", - "testConfiguredChordPrefixBeatsConflictingSingleStrokeShortcut", - "testConfiguredChordPrefixBlocksUnrelatedSingleStrokeShortcutOnSecondKey", - "testConfiguredChordPrefixIsClearedWhenAppResignsActive", - "testFindShortcutFromOtherRightSidebarModeDoesNotStealFocus", - "testFindShortcutFromTerminalOpensTerminalFind", - "testFocusTextBoxShortcutMovesFocusBackToTerminalWhenTextBoxIsFirstResponder", - "testFocusedTextBoxFirstEscapeBypassesTerminalFindShortcutHandling", - "testNewBrowserWorkspaceShortcutIsBlockedWhileBrowserDisabled", - "testOmnibarArrowSelectionSurvivesTransientWindowFirstResponder", - "testOptionCommandNDefaultShortcutCreatesBrowserWorkspace", - "testPerformSplitShortcutSplitsFocusedTerminalSurfaceWhenSelectedWorkspaceIsStale", - "testRemappedCloseTabDoesNotLetCmdWReachGhosttyCloseSurfaceFallback", - "testSettingsFileChordDispatchesNewWorkspaceShortcut", - "testShortcutChangeClearsPendingConfiguredChord", - "testTerminalFirstResponderGuardBlocksMoveFocusWhenRightSidebarOwnsKeyboardFocus", - "testTextBoxFilePanelFocusRestorerRefocusesAfterSheetEnds", - "testTextBoxFocusInNonFocusedSplitUpdatesFocusedPanel", - "testTextBoxFocusIntentRestoresAfterYieldToAnotherPanel", - "testTextBoxPendingFocusIsCanceledOnUnfocusBeforeViewRegisters", - "testTextBoxPendingFocusRunsWhenTextViewMovesToWindow", - "testTextBoxSecondEscapeAfterFocusMovesToAnotherSplitClearsArmWithoutHiding", - "testTextBoxSecondEscapeDoesNotHideWhenAnotherResponderOwnsFocus", - "testTextBoxSecondEscapeHidesWhenTerminalSurfaceOwnsFocus", - "testTextBoxShortcutReturnsToTextBoxAfterTerminalRegainsFocus", - "testToggleSidebarInActiveMainWindowIgnoresStaleTabManagerPointer", - "testWelcomeWindowSidebarShortcutsUseSharedToggleCommands", - "testWindowPerformKeyEquivalentDefersTerminalPasteMenuMissToGhosttyBindingResolution", - "testWindowPerformKeyEquivalentForwardsClearedCmdDPastStaleMenuShortcut", - "testWindowPerformKeyEquivalentSuppressesRemappedCmdDStaleMenuShortcut", - "testWindowSendEventRepairsFocusedTerminalSearchTypingAfterResponderDrift", - "testWindowSendEventRepairsLostFirstResponderForFocusedTerminalTyping", - "testWindowSendEventRepairsVisibleSameWindowResponderDriftForFocusedTerminalTyping", - ] - - /// Whether this environment's window server actually honors - /// `makeKeyAndOrderFront`. Probed once. Detected at runtime (not via a CI - /// env var) because the xcodebuild test-host process does NOT inherit the - /// job's environment, so `GITHUB_ACTIONS`/`CI` are invisible inside tests. - /// On headless CI runners no test window ever becomes key, so the - /// focus-dependent routing tests cannot be deterministic there. - private static let keyWindowFocusIsReliable: Bool = { - let probe = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 60, height: 60), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - probe.isReleasedWhenClosed = false - probe.makeKeyAndOrderFront(nil) - let deadline = Date(timeIntervalSinceNow: 0.75) - while Date() < deadline, NSApp.keyWindow !== probe { - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) - } - let reliable = NSApp.keyWindow === probe - probe.orderOut(nil) - probe.close() - return reliable - }() - - private static func skipFocusRoutingTestInCI(testName: String) throws { - guard !keyWindowFocusIsReliable else { return } - // testName is like "-[AppDelegateShortcutRoutingTests testFoo]". - guard let method = testName.split(separator: " ").last.map({ String($0.dropLast()) }) else { return } - if focusRoutingTestsFlakyOnHeadlessCI.contains(method) { - throw XCTSkip( - "Window server does not honor makeKeyAndOrderFront in this environment " - + "(headless CI runner); focus/key-window routing is nondeterministic here. " - + "Skipped here, runs on real machines. Tracked: DEBUG key-window routing seam." - ) - } - } - private func makeKeyEvent( modifierFlags: NSEvent.ModifierFlags, characters: String, @@ -262,12 +162,12 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - try Self.skipFocusRoutingTestInCI(testName: name) // Prevent a single hanging test from consuming the entire CI timeout budget. executionTimeAllowance = 30 + AppDelegate.installWindowResponderSwizzlesForTesting() #if DEBUG KeyboardShortcutRecorderActivity.resetForTesting() - AppDelegate.shared?.debugResetShortcutRoutingStateForTesting() + AppDelegate.shared?.debugBeginShortcutRoutingFocusedWindowCaptureForTesting() #endif actionsWithPersistedShortcut = Set( KeyboardShortcutSettings.Action.allCases.filter { @@ -289,7 +189,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { override func tearDown() { #if DEBUG KeyboardShortcutRecorderActivity.resetForTesting() - AppDelegate.shared?.debugResetShortcutRoutingStateForTesting() + AppDelegate.shared?.debugEndShortcutRoutingFocusedWindowCaptureForTesting() KeyboardShortcutSettings.shortcutLookupObserver = nil TextBoxSubmit.debugResetForTesting() #endif @@ -574,8 +474,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } XCTAssertNil(workspace.focusedTerminalPanel) - XCTAssertNotNil( - browserPanel.pendingAddressBarFocusRequestId, + XCTAssertEqual( + browserPanel.preferredFocusIntentForActivation(), + .browser(.addressBar), "Browser workspace should land first focus in the address bar" ) #else @@ -854,7 +755,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { defer { closeWindow(withId: windowId) } guard let window = window(withId: windowId), - let contentView = window.contentView, let manager = appDelegate.tabManagerFor(windowId: windowId), let workspace = manager.selectedWorkspace else { XCTFail("Expected test window and workspace") @@ -2128,7 +2028,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { defer { closeWindow(withId: windowId) } guard let window = window(withId: windowId), - let contentView = window.contentView, let manager = appDelegate.tabManagerFor(windowId: windowId), let workspace = manager.selectedWorkspace, let leftPanelId = workspace.focusedPanelId, @@ -4093,7 +3992,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { defer { closeWindow(withId: windowId) } guard let window = window(withId: windowId), - let contentView = window.contentView, let manager = appDelegate.tabManagerFor(windowId: windowId), let workspace = manager.selectedWorkspace, let browserPanelId = manager.openBrowser(inWorkspace: workspace.id) else { @@ -4105,7 +4003,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.identifier = browserOmnibarTextFieldIdentifier field.panelId = browserPanelId field.stringValue = "example" - contentView.addSubview(field) + attachTestResponder(field, to: window) BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: browserPanelId) @@ -4164,7 +4062,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { defer { closeWindow(withId: windowId) } guard let window = window(withId: windowId), - let contentView = window.contentView, let manager = appDelegate.tabManagerFor(windowId: windowId), let workspace = manager.selectedWorkspace, let browserPanelId = manager.openBrowser(inWorkspace: workspace.id) else { @@ -4176,7 +4073,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.identifier = browserOmnibarTextFieldIdentifier field.panelId = browserPanelId field.stringValue = "ㄉㄚˋ" - contentView.addSubview(field) + attachTestResponder(field, to: window) BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { @@ -4240,7 +4137,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { defer { closeWindow(withId: windowId) } guard let window = window(withId: windowId), - let contentView = window.contentView, let manager = appDelegate.tabManagerFor(windowId: windowId), let workspace = manager.selectedWorkspace, let browserPanelId = manager.openBrowser(inWorkspace: workspace.id), @@ -4255,7 +4151,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.identifier = browserOmnibarTextFieldIdentifier field.panelId = browserPanelId field.stringValue = "example" - contentView.addSubview(field) + attachTestResponder(field, to: window) BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: browserPanelId) @@ -4408,29 +4304,33 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { action: .showNotifications, shortcut: StoredShortcut(key: "8", command: true, shift: false, option: false, control: false) ) { - // Some non-US layouts can produce "*" without Shift. - // This must not be coerced into "8" for a Cmd+8 shortcut match. - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - characters: "*", - charactersIgnoringModifiers: "*", - isARepeat: false, - keyCode: 30 // kVK_ANSI_RightBracket - ) else { - XCTFail("Failed to construct Cmd+* event") - return - } + withTemporaryShortcut(action: .focusHistoryForward, shortcut: .unbound) { + withTemporaryShortcut(action: .browserForward, shortcut: .unbound) { + // Some non-US layouts can produce "*" without Shift. + // This must not be coerced into "8" for a Cmd+8 shortcut match. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct Cmd+* event") + return + } #if DEBUG - XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) #else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") + XCTFail("debugHandleCustomShortcut is only available in DEBUG") #endif + } + } } } @@ -6396,7 +6296,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } - func testWindowSendEventRepairsLostFirstResponderForFocusedTerminalTyping() { + func testWindowSendEventRepairsLostFirstResponderForFocusedTerminalTyping() throws { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return @@ -6415,38 +6315,20 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - terminalPanel.hostedView.setVisibleInUI(true) - terminalPanel.hostedView.setActive(true) - terminalPanel.hostedView.moveFocus() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + focusHostedTerminalForRepairTesting(window: window, hostedView: terminalPanel.hostedView) - XCTAssertTrue( - terminalPanel.hostedView.isSurfaceViewFirstResponder(), - "Expected terminal surface to own first responder before repair test" - ) + let orphanResponder = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + installStrandedResponderDriftForTesting(orphanResponder, in: window, hostedView: terminalPanel.hostedView) - XCTAssertTrue(window.makeFirstResponder(nil), "Expected test to clear the window first responder") - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) +#if DEBUG + appDelegate.debugSetShortcutRoutingKeyRepairFirstResponderForTesting(orphanResponder) + defer { appDelegate.debugSetShortcutRoutingKeyRepairFirstResponderForTesting(nil) } - XCTAssertFalse( - terminalPanel.hostedView.isSurfaceViewFirstResponder(), - "Expected terminal surface to lose first responder before repaired typing" - ) - XCTAssertTrue(window.firstResponder == nil || window.firstResponder is NSWindow, "Expected a broken key-routing responder") + let repairProbe = installFocusedTerminalRepairProbeForTesting(appDelegate: appDelegate, keyCode: 0) + defer { repairProbe.restore() } -#if DEBUG - var forwardedKeyDownCount = 0 - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 0 else { return } - forwardedKeyDownCount += 1 - } - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - } +#else + throw XCTSkip("DEBUG-only simulated responder override is required for deterministic key-repair coverage") #endif guard let keyDown = makeKeyDownEvent( @@ -6460,7 +6342,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } window.sendEvent(keyDown) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + waitUntil(timeout: 1.0) { + terminalPanel.hostedView.isSurfaceViewFirstResponder() && window.firstResponder === terminalView + } XCTAssertTrue( terminalPanel.hostedView.isSurfaceViewFirstResponder(), @@ -6468,10 +6352,13 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) XCTAssertTrue(window.firstResponder === terminalView, "Typing repair should restore the Ghostty surface view as first responder") #if DEBUG - // forwardedKeyDownCount is only observable through the DEBUG-only - // GhosttyNSView.debugGhosttySurfaceKeyEventObserver seam; the first- - // responder assertions above act as the Release-build proxy. - XCTAssertGreaterThan(forwardedKeyDownCount, 0, "Typing repair should forward the keyDown into Ghostty") + XCTAssertEqual(repairProbe.repairCount(), 1, "window.sendEvent should run the focused terminal repair path") + XCTAssertTrue(repairProbe.repairResponder() === orphanResponder, "Repair should evaluate the simulated stranded responder") + XCTAssertGreaterThan( + repairProbe.forwardedKeyDownCount(), + 0, + "Typing repair should forward the keyDown into Ghostty" + ) #endif } @@ -6934,7 +6821,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } - func testWindowSendEventRepairsVisibleSameWindowResponderDriftForFocusedTerminalTyping() { + func testWindowSendEventRepairsVisibleSameWindowResponderDriftForFocusedTerminalTyping() throws { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return @@ -6944,7 +6831,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { defer { closeWindow(withId: windowId) } guard let window = window(withId: windowId), - let contentView = window.contentView, let manager = appDelegate.tabManagerFor(windowId: windowId), let workspace = manager.selectedWorkspace, let panelId = workspace.focusedPanelId, @@ -6955,41 +6841,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } let strayView = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - contentView.addSubview(strayView) - defer { strayView.removeFromSuperview() } - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - terminalPanel.hostedView.setVisibleInUI(true) - terminalPanel.hostedView.setActive(true) - terminalPanel.hostedView.moveFocus() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - - XCTAssertTrue( - terminalPanel.hostedView.isSurfaceViewFirstResponder(), - "Expected terminal surface to own first responder before repair test" + focusHostedTerminalForRepairTesting(window: window, hostedView: terminalPanel.hostedView) + installVisibleResponderDriftForTesting( + strayView, + in: window, + hostedView: terminalPanel.hostedView, + mismatchMessage: "Expected the simulated responder to disagree with the focused terminal" ) + defer { strayView.removeFromSuperview() } - XCTAssertTrue(window.makeFirstResponder(strayView), "Expected test to install a visible wrong first responder") - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) +#if DEBUG + appDelegate.debugSetShortcutRoutingKeyRepairFirstResponderForTesting(strayView) + defer { appDelegate.debugSetShortcutRoutingKeyRepairFirstResponderForTesting(nil) } - XCTAssertFalse( - terminalPanel.hostedView.isSurfaceViewFirstResponder(), - "Expected terminal surface to lose first responder before repaired typing" - ) - XCTAssertTrue(window.firstResponder === strayView, "Expected a visible same-window responder drift") + let repairProbe = installFocusedTerminalRepairProbeForTesting(appDelegate: appDelegate, keyCode: 0) + defer { repairProbe.restore() } -#if DEBUG - var forwardedKeyDownCount = 0 - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 0 else { return } - forwardedKeyDownCount += 1 - } - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - } +#else + throw XCTSkip("DEBUG-only simulated responder override is required for deterministic key-repair coverage") #endif guard let keyDown = makeKeyDownEvent( @@ -7003,7 +6872,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } window.sendEvent(keyDown) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + waitUntil(timeout: 1.0) { + terminalPanel.hostedView.isSurfaceViewFirstResponder() && window.firstResponder === terminalView + } XCTAssertTrue( terminalPanel.hostedView.isSurfaceViewFirstResponder(), @@ -7011,7 +6882,13 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) XCTAssertTrue(window.firstResponder === terminalView, "Typing repair should restore the Ghostty surface view as first responder") #if DEBUG - XCTAssertGreaterThan(forwardedKeyDownCount, 0, "Typing repair should forward the keyDown into Ghostty") + XCTAssertEqual(repairProbe.repairCount(), 1, "window.sendEvent should run the focused terminal repair path") + XCTAssertTrue(repairProbe.repairResponder() === strayView, "Repair should evaluate the simulated wrong same-window responder") + XCTAssertGreaterThan( + repairProbe.forwardedKeyDownCount(), + 0, + "Typing repair should forward the keyDown into Ghostty" + ) #endif } @@ -7025,7 +6902,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { defer { closeWindow(withId: windowId) } guard let window = window(withId: windowId), - let contentView = window.contentView, let manager = appDelegate.tabManagerFor(windowId: windowId), let workspace = manager.selectedWorkspace, let panelId = workspace.focusedPanelId, @@ -7040,7 +6916,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { textBoxView.onToggleFocus = { _ = terminalPanel.focusTextBoxInputOrTerminal() } let textBoxScrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 240, height: 30)) textBoxScrollView.documentView = textBoxView - contentView.addSubview(textBoxScrollView) + attachTestResponder(textBoxScrollView, to: window) defer { textBoxScrollView.removeFromSuperview() } window.makeKeyAndOrderFront(nil) @@ -7865,19 +7741,19 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) XCTAssertEqual(textView.debugMentionSuggestionCount(), 1) - textView.string = "@z" - textView.setSelectedRange(NSRange(location: 2, length: 0)) + textView.string = "@al" + textView.setSelectedRange(NSRange(location: 3, length: 0)) textView.refreshMentionCompletions() XCTAssertEqual(textView.debugMentionSuggestionCount(), 1) XCTAssertFalse(textView.debugMentionSuggestionsAreCurrent()) XCTAssertFalse(textView.debugAcceptMentionCompletion()) XCTAssertFalse(textView.debugAcceptMentionCompletion(suggestion: staleSuggestion)) - XCTAssertEqual(textView.string, "@z") + XCTAssertEqual(textView.string, "@al") var submitCount = 0 textView.onSubmit = { submitCount += 1 } textView.doCommand(by: #selector(NSResponder.insertNewline(_:))) XCTAssertEqual(submitCount, 1) - XCTAssertEqual(textView.string, "@z") + XCTAssertEqual(textView.string, "@al") textView.string = "/z" textView.setSelectedRange(NSRange(location: 2, length: 0)) @@ -9823,6 +9699,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(submitCount, 0, "Return should let the input method commit marked text") textView.unmarkText() + textView.string = "かな" + textView.setSelectedRange(NSRange(location: 2, length: 0)) textView.keyDown(with: returnEvent) XCTAssertEqual(submitCount, 1, "Return should submit after marked text is committed") } @@ -10736,7 +10614,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { textView.insertPendingAttachmentUploadPlaceholder(id: uploadID) XCTAssertTrue(textView.replacePendingAttachmentUploadPlaceholder(id: uploadID, with: [attachment])) - GhosttyApp.terminalPasteboard.cleanupTransferredTemporaryImageFiles([temporaryURL]) + TextBoxInputTextView.flushPendingSessionDraftAttachmentCopies() let draft = try XCTUnwrap(textView.sessionDraftSnapshot(isActive: true)) let snapshot = try XCTUnwrap(draft.parts.first?.attachment) @@ -10745,6 +10623,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { addTeardownBlock { try? FileManager.default.removeItem(at: durableURL) } + GhosttyApp.terminalPasteboard.cleanupTransferredTemporaryImageFiles([temporaryURL]) XCTAssertNotEqual(durableURL.path, temporaryURL.path) XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryURL.path)) @@ -10903,7 +10782,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } let sidebarResponder = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - contentView.addSubview(sidebarResponder) + attachTestResponder(sidebarResponder, to: window) defer { sidebarResponder.removeFromSuperview() } XCTAssertTrue(window.makeFirstResponder(sidebarResponder), "Expected right sidebar responder to take focus") @@ -11005,7 +10884,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } let sidebarResponder = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - contentView.addSubview(sidebarResponder) + attachTestResponder(sidebarResponder, to: window) defer { sidebarResponder.removeFromSuperview() } window.makeKeyAndOrderFront(nil) @@ -11015,9 +10894,21 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { terminalPanel.hostedView.moveFocus() RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + let nonFileMode = RightSidebarMode.sessions XCTAssertTrue(window.makeFirstResponder(sidebarResponder), "Expected right sidebar responder to take focus") - appDelegate.fileExplorerState?.mode = .feed - appDelegate.noteRightSidebarKeyboardFocusIntent(mode: .feed, in: window) +#if DEBUG + let revealResult = appDelegate.debugRevealRightSidebarInActiveMainWindow( + mode: nonFileMode, + focusFirstItem: false, + preferredWindow: window + ) + XCTAssertTrue(revealResult.stateFound, "Expected registered right sidebar state") + XCTAssertEqual(revealResult.activeMode, nonFileMode.rawValue) + XCTAssertTrue(window.makeFirstResponder(sidebarResponder), "Expected sidebar responder to retake focus") +#else + appDelegate.fileExplorerState?.mode = nonFileMode +#endif + appDelegate.noteRightSidebarKeyboardFocusIntent(mode: nonFileMode, in: window) XCTAssertFalse( appDelegate.allowsTerminalKeyboardFocus( workspaceId: workspace.id, @@ -11038,7 +10929,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } #if DEBUG - XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) + withTemporaryShortcut(action: .switchRightSidebarToFiles, shortcut: .unbound) { + withTemporaryShortcut(action: .switchRightSidebarToFind, shortcut: .unbound) { + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) + } + } #else XCTFail("debugHandleCustomShortcut is only available in DEBUG") #endif @@ -11052,14 +10947,14 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { "Cmd+F should keep keyboard ownership in the existing right sidebar section" ) XCTAssertNil(terminalPanel.searchState, "Cmd+F should not create terminal search state") - XCTAssertEqual(appDelegate.fileExplorerState?.mode, .feed) + XCTAssertEqual(appDelegate.fileExplorerState?.mode, nonFileMode) XCTAssertFalse( terminalPanel.hostedView.isSurfaceViewFirstResponder(), "Cmd+F from a non-file right sidebar mode should not refocus the terminal responder" ) } - func testWindowSendEventRepairsFocusedTerminalSearchTypingAfterResponderDrift() { + func testWindowSendEventRepairsFocusedTerminalSearchTypingAfterResponderDrift() throws { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return @@ -11077,12 +10972,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - terminalPanel.hostedView.setVisibleInUI(true) - terminalPanel.hostedView.setActive(true) - terminalPanel.hostedView.moveFocus() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + focusHostedTerminalForRepairTesting(window: window, hostedView: terminalPanel.hostedView) let searchState = TerminalSurface.SearchState(needle: "") terminalPanel.surface.searchState = searchState @@ -11094,18 +10984,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + searchField.selectText(nil) + _ = window.makeFirstResponder(searchField) + waitUntil(timeout: 1.0) { + firstResponderOwnsTextField(window.firstResponder, textField: searchField) + } XCTAssertTrue( firstResponderOwnsTextField(window.firstResponder, textField: searchField), "Expected terminal search field to own first responder before drift" ) - XCTAssertTrue(window.makeFirstResponder(nil), "Expected test to clear the window first responder") - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - - XCTAssertFalse( - firstResponderOwnsTextField(window.firstResponder, textField: searchField), - "Expected terminal search field to lose first responder before repaired typing" + let strayView = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + installSearchResponderDriftForTesting( + strayView, + in: window, + hostedView: terminalPanel.hostedView, + searchField: searchField ) + defer { strayView.removeFromSuperview() } guard let keyDown = makeKeyDownEvent( key: "a", @@ -11117,14 +11013,39 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } +#if DEBUG + appDelegate.debugSetShortcutRoutingKeyRepairFirstResponderForTesting(strayView) + defer { appDelegate.debugSetShortcutRoutingKeyRepairFirstResponderForTesting(nil) } + + var repairCount = 0 + var repairResponder: NSResponder? + let previousRepairObserver = appDelegate.debugFocusedTerminalKeyRepairObserverForTesting + appDelegate.debugFocusedTerminalKeyRepairObserverForTesting = { window, event, responder in + previousRepairObserver?(window, event, responder) + guard event.keyCode == 0 else { return } + repairCount += 1 + repairResponder = responder + } + defer { appDelegate.debugFocusedTerminalKeyRepairObserverForTesting = previousRepairObserver } +#else + throw XCTSkip("DEBUG-only simulated responder override is required for deterministic key-repair coverage") +#endif + window.sendEvent(keyDown) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + waitUntil(timeout: 1.0) { + firstResponderOwnsTextField(window.firstResponder, textField: searchField) + && searchField.stringValue == "a" + } XCTAssertTrue( firstResponderOwnsTextField(window.firstResponder, textField: searchField), "Typing should repair focus back to the terminal search field" ) XCTAssertEqual(searchField.stringValue, "a", "Typing repair should preserve the first key in the search field") +#if DEBUG + XCTAssertEqual(repairCount, 1, "window.sendEvent should run the focused terminal search repair path") + XCTAssertTrue(repairResponder === strayView, "Repair should evaluate the simulated wrong same-window responder") +#endif } private func makeRegisteredShortcutRoutingWindow(id: UUID) -> NSWindow { @@ -11776,12 +11697,12 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { KeyboardShortcutSettings.resetShortcut(for: action) } #if DEBUG - AppDelegate.shared?.debugResetShortcutRoutingStateForTesting() + AppDelegate.shared?.debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: false) #endif } KeyboardShortcutSettings.setShortcut(shortcut ?? action.defaultShortcut, for: action) #if DEBUG - AppDelegate.shared?.debugResetShortcutRoutingStateForTesting() + AppDelegate.shared?.debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: false) #endif body() } @@ -12201,6 +12122,10 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } + private func attachTestResponder(_ responder: NSView, to window: NSWindow) { + (window.contentView?.superview ?? window.contentView)?.addSubview(responder) + } + private func restoreDefaultsValue(_ value: Any?, forKey key: String, defaults: UserDefaults) { if let value { defaults.set(value, forKey: key)