diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0a5cb6e40a4f..e368e575a448 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14497,7 +14497,15 @@ private extension NSWindow { } #endif if !consumedByMenu { - // Fall through to the original performKeyEquivalent path below. + // After a direct-to-menu miss, let Ghostty resolve the command key + // through its normal binding path so user key overrides still win. + let consumedByGhostty = firstResponderGhosttyView?.performKeyEquivalentAfterMenuMiss(with: event) == true +#if DEBUG + dlog(" → mainMenu miss; ghostty command path: \(consumedByGhostty)") +#endif + if consumedByGhostty { + return true + } } else { #if DEBUG dlog(" → consumed by mainMenu (bypassed SwiftUI)") diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4d9855bfde47..255e7c98a816 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5927,6 +5927,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } + @discardableResult + func prepareSurfaceForPaste(reason: String) -> Bool { + guard ensureSurfaceReadyForInput() != nil else { + requestInputRecoveryAfterSurfaceMiss(reason: reason) + return false + } + return true + } + func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } return action.withCString { cString in @@ -6149,11 +6158,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // MARK: - Clipboard paste @IBAction func paste(_ sender: Any?) { + guard prepareSurfaceForPaste(reason: "paste.missingSurface") else { return } _ = performBindingAction("paste_from_clipboard") } /// Pastes clipboard text as plain text, stripping any rich formatting. @IBAction func pasteAsPlainText(_ sender: Any?) { + guard prepareSurfaceForPaste(reason: "pasteAsPlainText.missingSurface") else { return } _ = performBindingAction("paste_from_clipboard") } @@ -6433,6 +6444,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func performKeyEquivalent(with event: NSEvent) -> Bool { + performKeyEquivalent(with: event, shouldRetryMainMenu: true) + } + + func performKeyEquivalentAfterMenuMiss(with event: NSEvent) -> Bool { + performKeyEquivalent(with: event, shouldRetryMainMenu: false) + } + + private func performKeyEquivalent(with event: NSEvent, shouldRetryMainMenu: Bool) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() defer { @@ -6504,14 +6523,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let bindingFlags { let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 let isAll = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0 - let isPerformable = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 // If the binding is consumed and not meant for the menu, allow menu first. // Performable bindings (e.g. paste_from_clipboard) also need the menu // path so that Edit > Paste handles Cmd+V instead of keyDown double- // firing the clipboard request through both interpretKeyEvents and // ghostty_surface_key. - if isConsumed && !isAll && keySequence.isEmpty && keyTables.isEmpty { + if shouldRetryMainMenu && isConsumed && !isAll && keySequence.isEmpty && keyTables.isEmpty { if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index b6e796fb586c..cf617bc3083f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -11,6 +11,25 @@ private final class FakeWKInspectorContainerView: NSView {} private final class FocusableTestView: NSView { override var acceptsFirstResponder: Bool { true } } +private final class GhosttyCommandEquivalentProbeView: GhosttyNSView { + var afterMenuMissCallCount = 0 + var pasteCallCount = 0 + var pasteAsPlainTextCallCount = 0 + var performAfterMenuMissResult = true + + override func performKeyEquivalentAfterMenuMiss(with event: NSEvent) -> Bool { + afterMenuMissCallCount += 1 + return performAfterMenuMissResult + } + + override func paste(_ sender: Any?) { + pasteCallCount += 1 + } + + override func pasteAsPlainText(_ sender: Any?) { + pasteAsPlainTextCallCount += 1 + } +} @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { @@ -4254,6 +4273,55 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } + func testWindowPerformKeyEquivalentDefersTerminalPasteMenuMissToGhosttyBindingResolution() { + let previousMainMenu = NSApp.mainMenu + let probeWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: probeWindow.contentRect(forFrameRect: probeWindow.frame)) + let probeView = GhosttyCommandEquivalentProbeView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + + defer { + NSApp.mainMenu = previousMainMenu + probeWindow.orderOut(nil) + } + + let emptyMenu = NSMenu(title: "Test") + emptyMenu.addItem(withTitle: "Placeholder", action: nil, keyEquivalent: "") + NSApp.mainMenu = emptyMenu + + probeWindow.contentView = contentView + contentView.addSubview(probeView) + probeWindow.makeKeyAndOrderFront(nil) + probeWindow.displayIfNeeded() + XCTAssertTrue(probeWindow.makeFirstResponder(probeView), "Expected probe Ghostty view to own first responder") + + guard let event = makeKeyDownEvent( + key: "v", + modifiers: [.command], + keyCode: 9, + windowNumber: probeWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+V event") + return + } + + XCTAssertTrue( + probeWindow.performKeyEquivalent(with: event), + "Cmd+V menu miss should still route through Ghostty binding resolution" + ) + XCTAssertEqual(probeView.afterMenuMissCallCount, 1, "Ghostty binding resolution should run after the menu miss") + XCTAssertEqual(probeView.pasteCallCount, 0, "Window routing must not force paste before Ghostty inspects bindings") + XCTAssertEqual( + probeView.pasteAsPlainTextCallCount, + 0, + "Window routing must not force plain-text paste before Ghostty inspects bindings" + ) + } + func testWindowSendEventRepairsVisibleSameWindowResponderDriftForFocusedTerminalTyping() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index b5adf57da377..ee970dc8d429 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -10,6 +10,10 @@ import ObjectiveC.runtime private var cjkIMEInterpretKeyEventsSwizzled = false private var cjkIMEInterpretKeyEventsHook: ((GhosttyNSView, [NSEvent]) -> Bool)? +private var ghosttyPasteActionSwizzled = false +private var ghosttyPasteActionHook: ((GhosttyNSView, Any?) -> Void)? +private var ghosttyPasteAsPlainTextActionSwizzled = false +private var ghosttyPasteAsPlainTextActionHook: ((GhosttyNSView, Any?) -> Void)? private extension GhosttyNSView { @objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) { @@ -18,6 +22,16 @@ private extension GhosttyNSView { } cmuxUnitTest_interpretKeyEvents(eventArray) } + + @objc func cmuxUnitTest_paste(_ sender: Any?) { + ghosttyPasteActionHook?(self, sender) + cmuxUnitTest_paste(sender) + } + + @objc func cmuxUnitTest_pasteAsPlainText(_ sender: Any?) { + ghosttyPasteAsPlainTextActionHook?(self, sender) + cmuxUnitTest_pasteAsPlainText(sender) + } } private func installCJKIMEInterpretKeyEventsSwizzle() { @@ -52,6 +66,68 @@ private func installCJKIMEInterpretKeyEventsSwizzle() { cjkIMEInterpretKeyEventsSwizzled = true } +private func installGhosttyPasteActionSwizzle() { + guard !ghosttyPasteActionSwizzled else { return } + + let originalSelector = #selector(GhosttyNSView.paste(_:)) + let swizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_paste(_:)) + + guard let originalMethod = class_getInstanceMethod(GhosttyNSView.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(GhosttyNSView.self, swizzledSelector) else { + fatalError("Unable to locate GhosttyNSView paste methods for swizzling") + } + + let didAddMethod = class_addMethod( + GhosttyNSView.self, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + class_replaceMethod( + GhosttyNSView.self, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod) + ) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + ghosttyPasteActionSwizzled = true + + guard !ghosttyPasteAsPlainTextActionSwizzled else { return } + + let plainTextOriginalSelector = #selector(GhosttyNSView.pasteAsPlainText(_:)) + let plainTextSwizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_pasteAsPlainText(_:)) + + guard let plainTextOriginalMethod = class_getInstanceMethod(GhosttyNSView.self, plainTextOriginalSelector), + let plainTextSwizzledMethod = class_getInstanceMethod(GhosttyNSView.self, plainTextSwizzledSelector) else { + fatalError("Unable to locate GhosttyNSView pasteAsPlainText methods for swizzling") + } + + let didAddPlainTextMethod = class_addMethod( + GhosttyNSView.self, + plainTextOriginalSelector, + method_getImplementation(plainTextSwizzledMethod), + method_getTypeEncoding(plainTextSwizzledMethod) + ) + + if didAddPlainTextMethod { + class_replaceMethod( + GhosttyNSView.self, + plainTextSwizzledSelector, + method_getImplementation(plainTextOriginalMethod), + method_getTypeEncoding(plainTextOriginalMethod) + ) + } else { + method_exchangeImplementations(plainTextOriginalMethod, plainTextSwizzledMethod) + } + + ghosttyPasteAsPlainTextActionSwizzled = true +} + private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? { if let view = view as? GhosttyNSView { return view @@ -1469,7 +1545,11 @@ final class GhosttyBackquoteRegressionTests: XCTestCase { } @MainActor -final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { +final class GhosttyKeyEquivalentRegressionTests: XCTestCase { + private struct PasteboardItemSnapshot { + let representations: [(type: NSPasteboard.PasteboardType, data: Data)] + } + private struct HostedTerminalWindow { let surface: TerminalSurface let window: NSWindow @@ -1516,6 +1596,48 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { ) } + private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { + guard let items = pasteboard.pasteboardItems else { return [] } + return items.map { item in + let representations = item.types.compactMap { type -> (NSPasteboard.PasteboardType, Data)? in + guard let data = item.data(forType: type) else { return nil } + return (type, data) + } + return PasteboardItemSnapshot(representations: representations) + } + } + + private func restorePasteboardItems( + _ snapshots: [PasteboardItemSnapshot], + to pasteboard: NSPasteboard + ) { + pasteboard.clearContents() + guard !snapshots.isEmpty else { return } + let items = snapshots.compactMap { snapshot -> NSPasteboardItem? in + let item = NSPasteboardItem() + guard !snapshot.representations.isEmpty else { return nil } + for representation in snapshot.representations { + item.setData(representation.data, forType: representation.type) + } + return item + } + if !items.isEmpty { + _ = pasteboard.writeObjects(items) + } + } + + private func installUnrelatedMainMenu() -> NSMenu { + let mainMenu = NSMenu() + let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") + let fileMenu = NSMenu(title: "File") + let item = NSMenuItem(title: "New", action: nil, keyEquivalent: "n") + item.keyEquivalentModifierMask = [.command] + fileMenu.addItem(item) + mainMenu.addItem(fileItem) + mainMenu.setSubmenu(fileMenu, for: fileItem) + return mainMenu + } + func testShiftSlashPrintableKeyEquivalentBypassesShortcutPath() throws { let hostedTerminal = try makeHostedTerminalWindow() let window = hostedTerminal.window @@ -1581,6 +1703,252 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { ) } } + + // MARK: - Terminal Paste Fallback + + func testCommandVPasteStillInvokesTerminalPasteWhenMainMenuMisses() throws { + installGhosttyPasteActionSwizzle() + + let hostedTerminal = try makeHostedTerminalWindow() + let terminalSurface = hostedTerminal.surface + let window = hostedTerminal.window + let surfaceView = hostedTerminal.surfaceView + defer { window.orderOut(nil) } + + window.makeFirstResponder(surfaceView) + XCTAssertNotNil(surfaceView.terminalSurface) + + let previousMainMenu = NSApp.mainMenu + NSApp.mainMenu = installUnrelatedMainMenu() + defer { NSApp.mainMenu = previousMainMenu } + + let pasteboard = NSPasteboard.general + let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) + defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } + pasteboard.clearContents() + pasteboard.setString("opencode paste", forType: .string) + + var pasteInvocationCount = 0 + let previousPasteHook = ghosttyPasteActionHook + ghosttyPasteActionHook = { candidateView, sender in + previousPasteHook?(candidateView, sender) + guard candidateView === surfaceView else { return } + pasteInvocationCount += 1 + } + defer { ghosttyPasteActionHook = previousPasteHook } + + var forwardedCommandVCount = 0 + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } + forwardedCommandVCount += 1 + } + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "v", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + ) else { + XCTFail("Failed to construct Cmd+V event") + return + } + + withExtendedLifetime(terminalSurface) { + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + pasteInvocationCount, + 1, + "Cmd+V should still invoke the terminal paste action even if the window main-menu fast path misses" + ) + XCTAssertEqual( + forwardedCommandVCount, + 0, + "Cmd+V should not fall back to Ghostty keyDown when the terminal paste action is available" + ) + } + } + + func testCommandShiftVPasteAsPlainTextStillInvokesTerminalFallbackWhenMainMenuMisses() throws { + installGhosttyPasteActionSwizzle() + + let hostedTerminal = try makeHostedTerminalWindow() + let terminalSurface = hostedTerminal.surface + let window = hostedTerminal.window + let surfaceView = hostedTerminal.surfaceView + defer { window.orderOut(nil) } + + window.makeFirstResponder(surfaceView) + XCTAssertNotNil(surfaceView.terminalSurface) + + let previousMainMenu = NSApp.mainMenu + NSApp.mainMenu = installUnrelatedMainMenu() + defer { NSApp.mainMenu = previousMainMenu } + + let pasteboard = NSPasteboard.general + let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) + defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } + pasteboard.clearContents() + pasteboard.setString("opencode paste plain text", forType: .string) + + var pasteInvocationCount = 0 + let previousPasteHook = ghosttyPasteActionHook + ghosttyPasteActionHook = { candidateView, sender in + previousPasteHook?(candidateView, sender) + guard candidateView === surfaceView else { return } + pasteInvocationCount += 1 + } + defer { ghosttyPasteActionHook = previousPasteHook } + + var pasteAsPlainTextInvocationCount = 0 + let previousPasteAsPlainTextHook = ghosttyPasteAsPlainTextActionHook + ghosttyPasteAsPlainTextActionHook = { candidateView, sender in + previousPasteAsPlainTextHook?(candidateView, sender) + guard candidateView === surfaceView else { return } + pasteAsPlainTextInvocationCount += 1 + } + defer { ghosttyPasteAsPlainTextActionHook = previousPasteAsPlainTextHook } + + var forwardedCommandVCount = 0 + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } + forwardedCommandVCount += 1 + } + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "V", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + ) else { + XCTFail("Failed to construct Cmd+Shift+V event") + return + } + + withExtendedLifetime(terminalSurface) { + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + pasteInvocationCount, + 0, + "Cmd+Shift+V should route through pasteAsPlainText instead of the regular terminal paste action" + ) + XCTAssertEqual( + pasteAsPlainTextInvocationCount, + 1, + "Cmd+Shift+V should still invoke the terminal pasteAsPlainText action even if the window main-menu fast path misses" + ) + XCTAssertEqual( + forwardedCommandVCount, + 0, + "Cmd+Shift+V should not fall back to Ghostty keyDown when the terminal plain-text paste action is available" + ) + } + } + + func testCommandVPasteRecreatesReleasedSurfaceBeforeConsumption() throws { + installGhosttyPasteActionSwizzle() + + let hostedTerminal = try makeHostedTerminalWindow() + let terminalSurface = hostedTerminal.surface + let window = hostedTerminal.window + let surfaceView = hostedTerminal.surfaceView + defer { window.orderOut(nil) } + + window.makeFirstResponder(surfaceView) + XCTAssertNotNil(surfaceView.terminalSurface) + XCTAssertNotNil(terminalSurface.surface) + + let previousMainMenu = NSApp.mainMenu + NSApp.mainMenu = installUnrelatedMainMenu() + defer { NSApp.mainMenu = previousMainMenu } + + let pasteboard = NSPasteboard.general + let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) + defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } + pasteboard.clearContents() + pasteboard.setString("surface recovery paste", forType: .string) + + var pasteInvocationCount = 0 + let previousPasteHook = ghosttyPasteActionHook + ghosttyPasteActionHook = { candidateView, sender in + previousPasteHook?(candidateView, sender) + guard candidateView === surfaceView else { return } + pasteInvocationCount += 1 + } + defer { ghosttyPasteActionHook = previousPasteHook } + + var forwardedCommandVCount = 0 + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } + forwardedCommandVCount += 1 + } + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + } + + terminalSurface.releaseSurfaceForTesting() + XCTAssertNil( + terminalSurface.surface, + "Expected the runtime Ghostty surface to be released before simulating Cmd+V" + ) + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "v", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + ) else { + XCTFail("Failed to construct Cmd+V event") + return + } + + withExtendedLifetime(terminalSurface) { + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + pasteInvocationCount, + 1, + "Cmd+V should still invoke the terminal paste action after a transient surface release" + ) + XCTAssertEqual( + forwardedCommandVCount, + 0, + "Cmd+V should recover the Ghostty surface without falling back to keyDown" + ) + XCTAssertNotNil( + terminalSurface.surface, + "Cmd+V should recreate the Ghostty surface before the direct terminal paste fallback consumes the shortcut" + ) + } + } } @MainActor