diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fa65d6dcf094..5aabde52153f 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -19,7 +19,7 @@ 6255 cmuxTests/GhosttyConfigTests.swift 6166 Sources/TabManager.swift 5915 cmuxTests/TerminalAndGhosttyTests.swift -5809 Sources/TextBoxInput.swift +5826 Sources/TextBoxInput.swift 5573 cmuxTests/BrowserConfigTests.swift 4483 Sources/cmuxApp.swift 4482 Sources/Panels/FilePreviewPanel.swift @@ -96,8 +96,8 @@ 1000 cmuxTests/DockSocketLifecycleTests.swift 982 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift 974 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +964 Sources/App/ShortcutRoutingSupport.swift 951 Sources/App/TerminalDirectoryOpenSupport.swift -948 Sources/App/ShortcutRoutingSupport.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift 944 Sources/CommandPalette/CommandPaletteSettingsToggle.swift diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 9c43c05e70ef..03c49814821e 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -614,6 +614,7 @@ func cmuxIsWebInspectorObject(_ object: NSObject) -> Bool { private enum BrowserDocumentEditingCommandEquivalent: CaseIterable { case copy case cut + case paste case selectAll case italic @@ -637,6 +638,18 @@ private enum BrowserDocumentEditingCommandEquivalent: CaseIterable { control: false, keyCode: 7 ) + case .paste: + // Cmd+V only. Cmd+Shift+V (paste-and-match-style) keeps its own + // dedicated path in CmuxWebView; `matches` requires an exact + // modifier match so the Shift variant never resolves here. + return StoredShortcut( + key: "v", + command: true, + shift: false, + option: false, + control: false, + keyCode: 9 + ) case .selectAll: return StoredShortcut( key: "a", @@ -697,8 +710,11 @@ private func browserDocumentEditingCommandEquivalent(for event: NSEvent) -> Brow } /// For browser content, let the focused document/editor try native editing commands -/// before cmux's menu fallback. Rich web apps often implement copy/cut/select-all +/// before cmux's menu fallback. Rich web apps often implement copy/cut/paste/select-all /// in contentEditable handlers that AppKit's Edit menu path cannot reproduce. +/// Paste also has to preflight here so the focused web view claims Cmd+V before the +/// NSWindow.performKeyEquivalent broadcast can hand it to an unfocused text box +/// (text box beta) in a sibling terminal pane (manaflow-ai/cmux#6380). func shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst( _ event: NSEvent, responder: NSResponder? = nil diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 4037c5986b95..161b26b62630 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -4093,6 +4093,14 @@ final class TextBoxInputTextView: NSTextView { return onInsertFileURLs(urls, self) } + /// True when this text view currently owns first responder. Gates the + /// standard editing shortcuts handled in `performKeyEquivalent`, which is + /// broadcast to the whole view tree and must not act for a background + /// text box (manaflow-ai/cmux#6380). + private var isFirstResponderForEditingShortcuts: Bool { + window?.firstResponder === self + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { guard event.type == .keyDown else { return super.performKeyEquivalent(with: event) @@ -4100,6 +4108,15 @@ final class TextBoxInputTextView: NSTextView { if handleConfiguredTextBoxShortcut(event) { return true } + // `performKeyEquivalent` is broadcast to every view in the window, not + // just the first responder. Standard editing shortcuts (Cmd+C/X/V and + // undo/redo) must only be claimed while this text view is the focused + // editor; otherwise an unfocused text box (text box beta) steals + // copy/cut/paste/undo from the focused browser or terminal pane + // (manaflow-ai/cmux#6380). + guard isFirstResponderForEditingShortcuts else { + return super.performKeyEquivalent(with: event) + } if handleStandardEditShortcut(event) { return true } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1a0f2bf013e5..ad7cd3a287fe 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ 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 */; }; + 547E4196D38C1FDCD09BD845 /* AppDelegateBrowserPasteShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 724C78536F73958E79B8DABE /* AppDelegateBrowserPasteShortcutRoutingTests.swift */; }; E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */; }; F5996001A1B2C3D4E5F60001 /* AppDelegateFileExplorerShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5996001A1B2C3D4E5F60002 /* AppDelegateFileExplorerShortcutRoutingTests.swift */; }; 2907A0032907A0032907A003 /* AppDelegateIssue2907RoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2907A0042907A0042907A004 /* AppDelegateIssue2907RoutingTests.swift */; }; @@ -1460,6 +1461,7 @@ 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 = ""; }; + 724C78536F73958E79B8DABE /* AppDelegateBrowserPasteShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateBrowserPasteShortcutRoutingTests.swift; sourceTree = ""; }; E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateEqualizeSplitsShortcutTests.swift; sourceTree = ""; }; F5996001A1B2C3D4E5F60002 /* AppDelegateFileExplorerShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateFileExplorerShortcutRoutingTests.swift; sourceTree = ""; }; 2907A0042907A0042907A004 /* AppDelegateIssue2907RoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateIssue2907RoutingTests.swift; sourceTree = ""; }; @@ -3761,6 +3763,7 @@ FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F5588001A1B2C3D4E5F60718 /* AppDelegateOptionDigitShortcutRoutingTests.swift */, + 724C78536F73958E79B8DABE /* AppDelegateBrowserPasteShortcutRoutingTests.swift */, 6419B0026419B0026419B002 /* AppDelegateShortcutRoutingRepairProbe.swift */, 4E6A6F5C1D2B4980A1234567 /* AppDelegateSurfaceShortcutRoutingTests.swift */, 6512F0C06512F0C06512F001 /* MainWindowFocusRestoreTests.swift */, @@ -5377,6 +5380,7 @@ A9E030000000000000000002 /* AgentSessionSocketSurfaceTests.swift in Sources */, A9E050000000000000000002 /* AgentSessionWebRendererTests.swift in Sources */, 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */, + 547E4196D38C1FDCD09BD845 /* AppDelegateBrowserPasteShortcutRoutingTests.swift in Sources */, E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */, F5996001A1B2C3D4E5F60001 /* AppDelegateFileExplorerShortcutRoutingTests.swift in Sources */, 2907A0032907A0032907A003 /* AppDelegateIssue2907RoutingTests.swift in Sources */, diff --git a/cmuxTests/AppDelegateBrowserPasteShortcutRoutingTests.swift b/cmuxTests/AppDelegateBrowserPasteShortcutRoutingTests.swift new file mode 100644 index 000000000000..e2975a8657b5 --- /dev/null +++ b/cmuxTests/AppDelegateBrowserPasteShortcutRoutingTests.swift @@ -0,0 +1,142 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +private final class BrowserPasteShortcutFocusableTestView: NSView { + override var acceptsFirstResponder: Bool { true } +} + +@MainActor +@Suite(.serialized) +struct AppDelegateBrowserPasteShortcutRoutingTests { + @Test func browserPasteCommandRoutesThroughWebContentFirst() throws { + let pasteEvent = try #require(makeKeyEvent( + modifierFlags: [.command], + characters: "v", + charactersIgnoringModifiers: "v", + keyCode: 9 + )) + + #expect( + shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst(pasteEvent), + "Cmd+V should preflight into focused browser web content like copy/cut/select-all" + ) + } + + @Test func textBoxDeclinesPasteShortcutWhenNotFirstResponder() throws { + let hostWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 80), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + hostWindow.animationBehavior = .none + hostWindow.isReleasedWhenClosed = false + defer { + hostWindow.orderOut(nil) + hostWindow.close() + } + + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 80)) + let otherView = BrowserPasteShortcutFocusableTestView(frame: NSRect(x: 0, y: 40, width: 320, height: 40)) + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + let textBoxScrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textBoxScrollView.documentView = textView + contentView.addSubview(otherView) + contentView.addSubview(textBoxScrollView) + hostWindow.contentView = contentView + hostWindow.makeKeyAndOrderFront(nil) + + #expect(hostWindow.makeFirstResponder(otherView)) + #expect(hostWindow.firstResponder === otherView) + + let pasteEvent = try #require(makeKeyEvent( + modifierFlags: [.command], + characters: "v", + charactersIgnoringModifiers: "v", + keyCode: 9 + )) + + #expect( + !textView.performKeyEquivalent(with: pasteEvent), + "Text box must not claim Cmd+V while another view owns first responder" + ) + } + + @Test func textBoxHandlesPasteShortcutWhenFirstResponder() throws { + let hostWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 80), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + hostWindow.animationBehavior = .none + hostWindow.isReleasedWhenClosed = false + defer { + hostWindow.orderOut(nil) + hostWindow.close() + } + + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 80)) + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + let textBoxScrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textBoxScrollView.documentView = textView + contentView.addSubview(textBoxScrollView) + hostWindow.contentView = contentView + hostWindow.makeKeyAndOrderFront(nil) + + #expect(hostWindow.makeFirstResponder(textView)) + #expect(hostWindow.firstResponder === textView) + + let pasteEvent = try #require(makeKeyEvent( + modifierFlags: [.command], + characters: "v", + charactersIgnoringModifiers: "v", + keyCode: 9 + )) + + #expect( + textView.performKeyEquivalent(with: pasteEvent), + "Text box must still handle Cmd+V while it owns first responder" + ) + } + + @Test func browserPlainTextPasteCommandIsNotADocumentEditingPaste() throws { + let pasteAsPlainTextEvent = try #require(makeKeyEvent( + modifierFlags: [.command, .shift], + characters: "v", + charactersIgnoringModifiers: "v", + keyCode: 9 + )) + + #expect( + !shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst(pasteAsPlainTextEvent), + "Cmd+Shift+V keeps its dedicated paste-as-plain-text path" + ) + } + + private func makeKeyEvent( + modifierFlags: NSEvent.ModifierFlags, + characters: String, + charactersIgnoringModifiers: String, + keyCode: UInt16 + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifierFlags, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: charactersIgnoringModifiers, + isARepeat: false, + keyCode: keyCode + ) + } +}