diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eba3d12d8..9a04b0674 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1830,6 +1830,27 @@ func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow } +func focusedTerminalKeyRepairNeeded( + responderIsWindow: Bool, + responderHasViableKeyRoutingOwner: Bool, + responderMatchesPreferredKeyboardFocus: Bool +) -> Bool { + responderIsWindow || !responderHasViableKeyRoutingOwner || !responderMatchesPreferredKeyboardFocus +} + +func shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: NSEvent.ModifierFlags, + responderIsWindow: Bool, + responderHasViableKeyRoutingOwner: Bool +) -> Bool { + let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask) + guard normalizedFlags.contains(.command) else { return false } + // Command shortcuts should only repair genuinely broken responder states. + // If another live view already owns first responder, let menu routing use + // that responder rather than retargeting to the selected terminal pane. + return responderIsWindow || !responderHasViableKeyRoutingOwner +} + func shouldRouteTerminalFontZoomShortcutToGhostty( firstResponderIsGhostty: Bool, flags: NSEvent.ModifierFlags, @@ -5689,14 +5710,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hostedView: GhosttySurfaceScrollView ) -> Bool { guard let responder else { return true } - if responder is NSWindow { return true } - guard responderHasViableKeyRoutingOwner(responder, in: window) else { - return true - } - if hostedView.responderMatchesPreferredKeyboardFocus(responder) { - return false - } - return true + return focusedTerminalKeyRepairNeeded( + responderIsWindow: responder is NSWindow, + responderHasViableKeyRoutingOwner: responderHasViableKeyRoutingOwner(responder, in: window), + responderMatchesPreferredKeyboardFocus: hostedView.responderMatchesPreferredKeyboardFocus(responder) + ) } func repairFocusedTerminalKeyboardRoutingIfNeeded( @@ -5705,7 +5723,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { guard event.type == .keyDown else { return } let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard !normalizedFlags.contains(.command) else { return } guard isMainTerminalWindow(window) else { return } guard window.attachedSheet == nil else { return } guard !isCommandPaletteEffectivelyVisible(in: window) else { return } @@ -5715,19 +5732,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let terminalPanel = workspace.terminalPanel(for: panelId) else { return } - guard responderNeedsFocusedTerminalKeyRepair( - window.firstResponder, - in: window, - hostedView: terminalPanel.hostedView - ) else { return } + let firstResponder = window.firstResponder + if normalizedFlags.contains(.command) { + let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false + let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: normalizedFlags, + responderIsWindow: firstResponder is NSWindow, + responderHasViableKeyRoutingOwner: responderHasViableOwner + ) + guard commandEquivalentNeedsRepair else { return } + } else { + guard responderNeedsFocusedTerminalKeyRepair( + firstResponder, + in: window, + hostedView: terminalPanel.hostedView + ) else { return } + } #if DEBUG - let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let before = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() + let mode = normalizedFlags.contains(.command) ? "command" : "plain" dlog( "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + "workspace=\(String(workspace.id.uuidString.prefix(5))) " + "panel=\(String(panelId.uuidString.prefix(5))) " + + "mode=\(mode) " + "target=\(target == .findField ? "searchField" : "surface") " + "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" ) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0c8f1a92c..ee8311278 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -101,6 +101,12 @@ private func cmuxScalarHex(_ value: String?) -> String { #endif enum GhosttyPasteboardHelper { + enum ImageFileMaterializationResult { + case saved(URL) + case noDecodableImagePayload + case rejectedImagePayload + } + private static let selectionPasteboard = NSPasteboard( name: NSPasteboard.Name("com.mitchellh.ghostty.selection") ) @@ -160,6 +166,10 @@ enum GhosttyPasteboardHelper { return hasPasteableContents(in: pasteboard) } + static func fallbackPlainTextContents(from pasteboard: NSPasteboard) -> String? { + plainTextContents(from: pasteboard) + } + static func writeString(_ string: String, to location: ghostty_clipboard_e) { guard let pasteboard = pasteboard(for: location) else { return } pasteboard.clearContents() @@ -364,15 +374,12 @@ enum GhosttyPasteboardHelper { return normalized.isEmpty } - /// When the clipboard contains only image data (or rich text that resolves to - /// an attachment-only image), saves it as a temporary image file and returns the - /// file URL. Returns nil if the clipboard contains text or no image. - static func saveImageFileURLIfNeeded( - from pasteboard: NSPasteboard = .general, - assumeNoText: Bool = false - ) -> URL? { - if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } - + /// Attempts to materialize a decodable pasteboard image into a temporary file. + /// `rejectedImagePayload` means a real image was found but could not be used, + /// so callers should not fall back to auxiliary plain text or URLs. + static func materializeImageFileURLIfNeeded( + from pasteboard: NSPasteboard = .general + ) -> ImageFileMaterializationResult { let imageData: Data let fileExtension: String if let directImage = directImageRepresentation(in: pasteboard) { @@ -386,7 +393,9 @@ enum GhosttyPasteboardHelper { let image = NSImage(pasteboard: pasteboard), let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData), - let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + let pngData = bitmap.representation(using: .png, properties: [:]) else { + return .noDecodableImagePayload + } imageData = pngData fileExtension = "png" } @@ -396,7 +405,7 @@ enum GhosttyPasteboardHelper { #if DEBUG dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)") #endif - return nil + return .rejectedImagePayload } let formatter = DateFormatter() @@ -412,10 +421,25 @@ enum GhosttyPasteboardHelper { #if DEBUG dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") #endif - return nil + return .rejectedImagePayload } registerOwnedTemporaryImageFile(fileURL) + return .saved(fileURL) + } + + /// When the clipboard contains only image data (or rich text that resolves to + /// an attachment-only image), saves it as a temporary image file and returns the + /// file URL. Returns nil if the clipboard contains text or no image. + static func saveImageFileURLIfNeeded( + from pasteboard: NSPasteboard = .general, + assumeNoText: Bool = false + ) -> URL? { + if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } + + guard case .saved(let fileURL) = materializeImageFileURLIfNeeded(from: pasteboard) else { + return nil + } return fileURL } diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index ce8473046..0fb28e9f9 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -261,8 +261,18 @@ enum TerminalImageTransferPlanner { return .insertText(string) } - if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) { + switch GhosttyPasteboardHelper.materializeImageFileURLIfNeeded(from: pasteboard) { + case .saved(let imageURL): return .fileURLs([imageURL]) + case .rejectedImagePayload: + return .reject + case .noDecodableImagePayload: + break + } + + // Clipboard managers can advertise unusable image types alongside valid text. + if let string = GhosttyPasteboardHelper.fallbackPlainTextContents(from: pasteboard), !string.isEmpty { + return .insertText(string) } if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 567d0c87e..3869639f6 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -59,6 +59,58 @@ final class SplitShortcutTransientFocusGuardTests: XCTestCase { } } +final class CommandEquivalentTransientFocusRepairTests: XCTestCase { + func testRepairsCommandEquivalentWhenFirstResponderFallsBackToWindow() { + XCTAssertTrue( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: true, + responderHasViableKeyRoutingOwner: false + ) + ) + } + + func testRepairsCommandEquivalentWhenResponderHasNoViableOwner() { + XCTAssertTrue( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: false + ) + ) + } + + func testDoesNotRepairCommandEquivalentWhenLiveResponderDiffersFromSelectedPane() { + XCTAssertFalse( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: true + ) + ) + } + + func testDoesNotRepairCommandEquivalentWhenResponderHasViableOwner() { + XCTAssertFalse( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: true + ) + ) + } + + func testIgnoresNonCommandEvents() { + XCTAssertFalse( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [], + responderIsWindow: true, + responderHasViableKeyRoutingOwner: false + ) + ) + } +} + final class ReactGrabShortcutRouteTests: XCTestCase { func testFocusedBrowserRoutesDirectlyWithoutPasteback() { let browserId = UUID() diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index bc3836b7f..de8841a63 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -430,6 +430,24 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertEqual(targetResolutionCount, 0) } + func testPastePlanFallsBackToAlternatePlainTextWhenImageTypeIsUnusable() { + let pasteboard = NSPasteboard(name: .init("cmux-test-raycast-fallback-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString( + "hello from Raycast", + forType: NSPasteboard.PasteboardType(UTType.plainText.identifier) + ) + pasteboard.setData(Data("not a real tiff".utf8), forType: .tiff) + + let plan = TerminalImageTransferPlanner.plan( + pasteboard: pasteboard, + mode: .paste, + target: .local + ) + + XCTAssertEqual(plan, .insertText("hello from Raycast")) + } + func testLazyPastePlanResolvesTargetForFileURLPaste() throws { let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-image-\(UUID().uuidString).png") try make1x1PNG(color: .systemTeal).write(to: fileURL)