diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 10e1c47b2475..3dfe78a1bb4a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1051,6 +1051,13 @@ func shouldConsumeShortcutWhileCommandPaletteVisible( keyCode: UInt16 ) -> Bool { guard isCommandPaletteVisible else { return false } + + // Escape dismisses the palette, and must not leak through to the + // underlying terminal or browser content. + if normalizedFlags.isEmpty, keyCode == 53 { + return true + } + guard normalizedFlags.contains(.command) else { return false } let normalizedChars = chars.lowercased() @@ -1526,8 +1533,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didInstallLifecycleSnapshotObservers = false private var didDisableSuddenTermination = false private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] + private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] + private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] + private var commandPaletteEscapeSuppressionByWindowId: Set = [] + private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] + private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 + private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 var updateViewModel: UpdateViewModel { updateController.viewModel @@ -3244,9 +3257,212 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowForMainWindowId(windowId) } + private func markCommandPaletteOpenRequested(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPalettePendingOpenByWindowId[windowId] = true + commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + } + + private func postCommandPaletteRequest( + name: Notification.Name, + preferredWindow: NSWindow?, + source: String, + markPending: Bool + ) { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + if markPending { + markCommandPaletteOpenRequested(for: targetWindow) + } + NotificationCenter.default.post(name: name, object: targetWindow) +#if DEBUG + dlog( + "shortcut.palette.request source=\(source) " + + "target={\(debugWindowToken(targetWindow))} " + + "pendingMarked=\(markPending ? 1 : 0)" + ) +#endif + } + + func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { + postCommandPaletteRequest( + name: .commandPaletteRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + + func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { + postCommandPaletteRequest( + name: .commandPaletteSwitcherRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + + func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { + postCommandPaletteRequest( + name: .commandPaletteRenameTabRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + + func requestCommandPaletteRenameWorkspace( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteRenameWorkspace" + ) { + postCommandPaletteRequest( + name: .commandPaletteRenameWorkspaceRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + + private func clearCommandPalettePendingOpen(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + } + + private func pruneExpiredCommandPalettePendingOpenStates( + now: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + for windowId in Array(commandPalettePendingOpenByWindowId.keys) { + guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } + guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) +#if DEBUG + dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") +#endif + continue + } + let age = now - requestedAt + guard age > Self.commandPalettePendingOpenMaxAge else { continue } + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) +#if DEBUG + dlog( + "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + + "reason=stale ageMs=\(Int(age * 1000))" + ) +#endif + } + } + + private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + pruneExpiredCommandPalettePendingOpenStates() + return commandPalettePendingOpenByWindowId[windowId] == true + } + + private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPaletteEscapeSuppressionByWindowId.insert(windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + } + + private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPaletteEscapeSuppressionByWindowId.remove(windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) + } + + private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { + guard let window, + let windowId = mainWindowId(for: window), + commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { + return false + } + if event.isARepeat { + return true + } + let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 + if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { + return true + } + // Fallback cleanup when keyUp is lost for any reason. + endCommandPaletteEscapeSuppression(for: window) + return false + } + + private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { + guard let window, + let windowId = mainWindowId(for: window) else { + return nil + } + let now = ProcessInfo.processInfo.systemUptime + pruneExpiredCommandPalettePendingOpenStates(now: now) + guard commandPalettePendingOpenByWindowId[windowId] == true else { + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + return nil + } + guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + return nil + } + let age = now - startedAt + if age <= Self.commandPaletteRequestGraceInterval { + return age + } + return nil + } + + private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { + commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + + @discardableResult + private func clearEscapeSuppressionForKeyUp(event: NSEvent, consumeIfSuppressed: Bool = false) -> Bool { + guard event.type == .keyUp, event.keyCode == 53 else { return false } + let suppressionWindow = escapeSuppressionWindow(for: event) + let didConsume = consumeIfSuppressed && shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) + if let window = suppressionWindow { + endCommandPaletteEscapeSuppression(for: window) +#if DEBUG + dlog( + "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + + "keyUpConsumed=\(didConsume ? 1 : 0)" + ) +#endif + return didConsume + } + commandPaletteEscapeSuppressionByWindowId.removeAll() + commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() +#if DEBUG + dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") +#endif + return didConsume + } + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { guard let windowId = mainWindowId(for: window) else { return } + let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false commandPaletteVisibilityByWindowId[windowId] = visible + // Opening (false -> true) always resolves pending-open. + // Closing (true -> false) also clears stale pending state. + // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. + if visible || wasVisible { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + } +#if DEBUG + if !visible, + !wasVisible, + commandPalettePendingOpenByWindowId[windowId] == true { + dlog( + "palette.visibility.retainPending " + + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" + ) + } +#endif } func isCommandPaletteVisible(windowId: UUID) -> Bool { @@ -3619,20 +3835,82 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return UUID(uuidString: idPart) } + private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { + guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } + var stack: [NSView] = [searchRoot] + while let candidate = stack.popLast() { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return candidate + } + stack.append(contentsOf: candidate.subviews) + } + return nil + } + + private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { + guard let container = commandPaletteOverlayContainer(in: window) else { return false } + return !container.isHidden && container.alphaValue > 0.001 + } + + private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { + guard let responder = window.firstResponder else { return false } + if let textView = responder as? NSTextView, + textView.isFieldEditor, + !(textView.delegate is NSView) { + // Field-editor delegates can be non-view responders. Confirm the overlay is + // mounted and visible to avoid treating unrelated editors as palette input. + return isCommandPaletteOverlayPresented(in: window) + } + return isCommandPaletteResponder(responder) + } + + private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { + if let textView = window.firstResponder as? NSTextView, + isCommandPaletteResponder(textView), + textView.hasMarkedText() { + return textView + } + + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView, + isCommandPaletteResponder(editor), + editor.hasMarkedText() { + return editor + } + + return nil + } + + private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { + isCommandPaletteVisible(for: window) + || isCommandPalettePendingOpen(for: window) + || isCommandPaletteOverlayPresented(in: window) + || isCommandPaletteResponderActive(in: window) + } + private func activeCommandPaletteWindow() -> NSWindow? { + pruneExpiredCommandPalettePendingOpenStates() if let keyWindow = NSApp.keyWindow, - let windowId = mainWindowId(for: keyWindow), - commandPaletteVisibilityByWindowId[windowId] == true { + isMainTerminalWindow(keyWindow), + isCommandPaletteEffectivelyVisible(in: keyWindow) { return keyWindow } if let mainWindow = NSApp.mainWindow, - let windowId = mainWindowId(for: mainWindow), - commandPaletteVisibilityByWindowId[windowId] == true { + isMainTerminalWindow(mainWindow), + isCommandPaletteEffectivelyVisible(in: mainWindow) { return mainWindow } + if let orderedWindow = NSApp.orderedWindows.first(where: { window in + isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) + }) { + return orderedWindow + } if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { return windowForMainWindowId(visibleWindowId) } + if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(pendingWindowId) + } return nil } @@ -5516,6 +5794,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return event // Pass through } self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { + return nil + } return event } } @@ -5790,6 +6071,102 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let commandPaletteVisibleInTargetWindow = commandPaletteTargetWindow.map { isCommandPaletteVisible(for: $0) } ?? false + let commandPalettePendingOpenInTargetWindow = commandPaletteTargetWindow.map { + isCommandPalettePendingOpen(for: $0) + } ?? false + let commandPaletteOverlayVisibleInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteOverlayPresented(in: $0) + } ?? false + let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteResponderActive(in: $0) + } ?? false + let commandPaletteEffectiveInTargetWindow = + commandPaletteVisibleInTargetWindow + || commandPalettePendingOpenInTargetWindow + || commandPaletteOverlayVisibleInTargetWindow + || commandPaletteResponderActiveInTargetWindow + + if normalizedFlags.isEmpty, event.keyCode == 53 { + let activePaletteWindow = activeCommandPaletteWindow() + let escapePaletteWindow: NSWindow? = { + if let targetWindow = commandPaletteTargetWindow { + guard commandPaletteEffectiveInTargetWindow else { + return nil + } + return targetWindow + } + return activePaletteWindow + }() +#if DEBUG + dlog( + "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))} " + + "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "effectiveTarget=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + if commandPaletteTargetWindow != nil, + !commandPaletteVisibleInTargetWindow, + !commandPalettePendingOpenInTargetWindow, + (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { + dlog( + "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" + ) + } +#endif + if let paletteWindow = escapePaletteWindow, + isCommandPaletteEffectivelyVisible(in: paletteWindow) { + if commandPaletteMarkedTextInput(in: paletteWindow) != nil { +#if DEBUG + dlog( + "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" + ) +#endif + return false + } + clearCommandPalettePendingOpen(for: paletteWindow) + beginCommandPaletteEscapeSuppression(for: paletteWindow) + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) +#if DEBUG + dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") +#endif + return true + } + let suppressionWindow = commandPaletteTargetWindow + ?? event.window + ?? NSApp.keyWindow + ?? NSApp.mainWindow + if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { +#if DEBUG + dlog( + "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "repeat=\(event.isARepeat ? 1 : 0)" + ) +#endif + return true + } + if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { + beginCommandPaletteEscapeSuppression(for: suppressionWindow) +#if DEBUG + dlog( + "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" + ) +#endif + return true + } +#if DEBUG + dlog( + "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))}" + ) +#endif + } if let delta = commandPaletteSelectionDeltaForKeyboardNavigation( flags: event.modifierFlags, @@ -5828,19 +6205,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent && (chars == "p" || event.keyCode == 35) if isCommandP { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.cmdP") return true } let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35) if isCommandShiftP { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP") return true } if shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: commandPaletteVisibleInTargetWindow, + isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, normalizedFlags: normalizedFlags, chars: chars, keyCode: event.keyCode @@ -6142,7 +6519,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") return true } @@ -6821,7 +7198,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - NotificationCenter.default.post(name: .commandPaletteRenameWorkspaceRequested, object: targetWindow) + requestCommandPaletteRenameWorkspace( + preferredWindow: targetWindow, + source: "shortcut.renameWorkspace" + ) return true } @@ -6833,6 +7213,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent handleCustomShortcut(event: event) } + // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). + func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { + if event.type == .keyDown { + return handleCustomShortcut(event: event) + } + handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) + } + + func debugMarkCommandPaletteOpenPending(window: NSWindow) { + markCommandPaletteOpenRequested(for: window) + } + + @discardableResult + func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + commandPalettePendingOpenByWindowId[windowId] = true + commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) + return true + } + // Test hook: remap a window context under a detached window key so direct // ObjectIdentifier(window) lookups fail and fallback logic is exercised. @discardableResult @@ -7274,6 +7675,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent persistWindowGeometry(from: window) guard let removed = unregisterMainWindowContext(for: window) else { return } commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) + commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) + commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 416896628c84..49dfa9ab5a1f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -329,11 +329,14 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let workspaceExpectation = expectation(description: "Expected command palette rename workspace notification") var observedWorkspaceWindow: NSWindow? + var didObserveWorkspaceNotification = false let workspaceToken = NotificationCenter.default.addObserver( forName: .commandPaletteRenameWorkspaceRequested, object: nil, queue: nil ) { notification in + guard !didObserveWorkspaceNotification else { return } + didObserveWorkspaceNotification = true observedWorkspaceWindow = notification.object as? NSWindow workspaceExpectation.fulfill() } @@ -370,6 +373,626 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedWorkspaceWindow?.windowNumber, window.windowNumber) } + func testEscapeDismissesVisibleCommandPaletteAndIsConsumed() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { + appDelegate.setCommandPaletteVisible(false, for: window) + } + + let dismissExpectation = expectation(description: "Expected command palette toggle notification for Escape dismiss") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let event = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, // kVK_Escape + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDoesNotDismissCommandPaletteWhenInputHasMarkedText() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let fieldEditor = CommandPaletteMarkedTextFieldEditor(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) + fieldEditor.isFieldEditor = true + fieldEditor.hasMarkedTextForTesting = true + window.contentView?.addSubview(fieldEditor) + XCTAssertTrue(window.makeFirstResponder(fieldEditor)) + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { + appDelegate.setCommandPaletteVisible(false, for: window) + fieldEditor.removeFromSuperview() + } + + let dismissExpectation = expectation( + description: "Escape should not dismiss command palette while IME marked text is active" + ) + dismissExpectation.isInverted = true + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + guard let dismissWindow = notification.object as? NSWindow, + dismissWindow.windowNumber == window.windowNumber else { return } + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Escape should pass through to IME composition instead of dismissing command palette" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 0.2) + } + + func testEscapeDismissesCommandPaletteWhenVisibilitySyncLagsAfterOpenRequest() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let dismissExpectation = expectation(description: "Expected command palette dismiss notification for Escape") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + +#if DEBUG + appDelegate.debugMarkCommandPaletteOpenPending(window: window) +#else + XCTFail("debugMarkCommandPaletteOpenPending is only available in DEBUG") +#endif + + // Simulate a visibility sync lag/race where AppDelegate does not yet know the palette is open. + appDelegate.setCommandPaletteVisible(false, for: window) + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: escapeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDismissesCommandPaletteWhenVisibilityStateStaysStalePastInitialPendingWindow() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 1.3), + "Expected to backdate pending-open age for stale visibility test" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + + // Simulate stale app-level visibility bookkeeping. + appDelegate.setCommandPaletteVisible(false, for: window) + + let dismissExpectation = expectation(description: "Escape should dismiss stale-state command palette after delay") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: escapeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDismissesCommandPaletteWhenVisibilityStateRemainsStaleForExtendedDelay() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 6.25), + "Expected to backdate pending-open age for extended stale visibility test" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + + // Simulate stale app-level visibility bookkeeping for a longer user delay. + appDelegate.setCommandPaletteVisible(false, for: window) + + let dismissExpectation = expectation(description: "Escape should dismiss stale-state command palette after extended delay") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: escapeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeDoesNotConsumeWhenMenuTriggeredPendingOpenStateExpires() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 20.0), + "Expected to seed an expired pending-open request state" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + + appDelegate.setCommandPaletteVisible(false, for: window) + + let dismissExpectation = expectation(description: "No dismiss notification for expired pending-open state") + dismissExpectation.isInverted = true + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { _ in + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Escape should pass through once pending-open grace has expired" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 0.2) + } + + func testEscapeDismissesMenuTriggeredCommandPaletteWhenVisibilitySyncIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + // Reproduce the menu-command path (Cmd+Shift+P/Cmd+P) routed via AppDelegate. + appDelegate.requestCommandPaletteCommands( + preferredWindow: window, + source: "test.menuCommandPalette" + ) + // Simulate delayed/stale visibility sync from SwiftUI overlay state. + appDelegate.setCommandPaletteVisible(false, for: window) +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 0.1), + "Expected deterministic pending-open state for menu-triggered stale-visibility path" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + + let dismissExpectation = expectation(description: "Expected command palette dismiss notification for menu-triggered stale visibility") + var observedDismissWindow: NSWindow? + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + observedDismissWindow = notification.object as? NSWindow + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Escape should still be consumed for menu-triggered command palette opens" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 1.0) + XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) + } + + func testEscapeRepeatIsConsumedImmediatelyAfterPaletteDismiss() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { + appDelegate.setCommandPaletteVisible(false, for: window) + } + + guard let firstEscape = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct first Escape event") + return + } + + guard let repeatedEscape = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber, + isARepeat: true + ) else { + XCTFail("Failed to construct repeated Escape event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: firstEscape)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + // Simulate the palette overlay synchronizing to closed state while the Escape key is still held. + appDelegate.setCommandPaletteVisible(false, for: window) + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleCustomShortcut(event: repeatedEscape), + "Repeated Escape immediately after dismiss should be consumed to prevent terminal passthrough" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + func testEscapeKeyUpIsConsumedAfterPaletteDismissToPreventTerminalLeak() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { + appDelegate.setCommandPaletteVisible(false, for: window) + } + + guard let escapeKeyDown = makeKeyEvent( + type: .keyDown, + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape keyDown event") + return + } + + guard let escapeKeyUp = makeKeyEvent( + type: .keyUp, + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape keyUp event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleShortcutMonitorEvent(event: escapeKeyDown)) +#else + XCTFail("debugHandleShortcutMonitorEvent is only available in DEBUG") +#endif + + // Simulate the palette overlay synchronizing to closed state before Escape key-up arrives. + appDelegate.setCommandPaletteVisible(false, for: window) + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleShortcutMonitorEvent(event: escapeKeyUp), + "Escape keyUp after palette dismiss should be consumed to prevent terminal passthrough" + ) +#else + XCTFail("debugHandleShortcutMonitorEvent is only available in DEBUG") +#endif + } + + func testEscapeKeyUpIsConsumedAfterCmdPSwitcherDismiss() { + assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest { appDelegate, window in + appDelegate.requestCommandPaletteSwitcher( + preferredWindow: window, + source: "test.cmdP" + ) + } + } + + func testEscapeKeyUpIsConsumedAfterCmdShiftPCommandsDismiss() { + assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest { appDelegate, window in + appDelegate.requestCommandPaletteCommands( + preferredWindow: window, + source: "test.cmdShiftP" + ) + } + } + + func testEscapeDoesNotDismissPaletteInDifferentWindow() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let paletteWindowId = appDelegate.createMainWindow() + let eventWindowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: paletteWindowId) + closeWindow(withId: eventWindowId) + } + + guard let paletteWindow = window(withId: paletteWindowId), + let eventWindow = window(withId: eventWindowId) else { + XCTFail("Expected both test windows") + return + } + + appDelegate.setCommandPaletteVisible(true, for: paletteWindow) + defer { + appDelegate.setCommandPaletteVisible(false, for: paletteWindow) + } + + let dismissExpectation = expectation(description: "Escape in another window should not dismiss palette") + dismissExpectation.isInverted = true + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { _ in + dismissExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(dismissToken) } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: eventWindow.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Escape should remain scoped to the event window" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [dismissExpectation], timeout: 0.2) + } + func testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -552,10 +1175,29 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { key: String, modifiers: NSEvent.ModifierFlags, keyCode: UInt16, - windowNumber: Int + windowNumber: Int, + isARepeat: Bool = false + ) -> NSEvent? { + makeKeyEvent( + type: .keyDown, + key: key, + modifiers: modifiers, + keyCode: keyCode, + windowNumber: windowNumber, + isARepeat: isARepeat + ) + } + + private func makeKeyEvent( + type: NSEvent.EventType, + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int, + isARepeat: Bool = false ) -> NSEvent? { NSEvent.keyEvent( - with: .keyDown, + with: type, location: .zero, modifierFlags: modifiers, timestamp: ProcessInfo.processInfo.systemUptime, @@ -563,11 +1205,71 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { context: nil, characters: key, charactersIgnoringModifiers: key, - isARepeat: false, + isARepeat: isARepeat, keyCode: keyCode ) } + private func assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest( + _ openRequest: (_ appDelegate: AppDelegate, _ window: NSWindow) -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared", file: file, line: line) + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window", file: file, line: line) + return + } + + openRequest(appDelegate, window) + appDelegate.setCommandPaletteVisible(true, for: window) + + guard let escapeKeyDown = makeKeyEvent( + type: .keyDown, + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ), let escapeKeyUp = makeKeyEvent( + type: .keyUp, + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape key events", file: file, line: line) + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleShortcutMonitorEvent(event: escapeKeyDown), file: file, line: line) +#else + XCTFail("debugHandleShortcutMonitorEvent is only available in DEBUG", file: file, line: line) +#endif + + appDelegate.setCommandPaletteVisible(false, for: window) + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleShortcutMonitorEvent(event: escapeKeyUp), + "Escape keyUp should be consumed after dismiss for command palette open requests", + file: file, + line: line + ) +#else + XCTFail("debugHandleShortcutMonitorEvent is only available in DEBUG", file: file, line: line) +#endif + } + private func window(withId windowId: UUID) -> NSWindow? { let identifier = "cmux.main.\(windowId.uuidString)" return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) @@ -579,3 +1281,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) } } + +private final class CommandPaletteMarkedTextFieldEditor: NSTextView { + var hasMarkedTextForTesting = false + + override func hasMarkedText() -> Bool { + hasMarkedTextForTesting + } +} diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0dd21b27cf3f..7f8017fead12 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3046,6 +3046,17 @@ final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { ) ) } + + func testConsumesEscapeWhenPaletteIsVisible() { + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [], + chars: "", + keyCode: 53 + ) + ) + } } final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase {