From 0d37b4914927d704ee4843916149d39d0d35f9a2 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 02:05:52 -0700 Subject: [PATCH 1/4] Add failing regression tests for paste-into-browser routing (#6380) With a terminal + browser side by side and text box beta enabled, Cmd+V into the focused browser pastes into the terminal's text box instead. Root cause: paste is missing from the browser document-editing commands (only copy/cut/select-all are routed to focused web content first), so Cmd+V falls through to the NSWindow.performKeyEquivalent broadcast where the terminal text box's performKeyEquivalent claims it even though it is not the first responder. These two tests fail on the current tree and pass once the fix lands: - shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst must return true for Cmd+V. - TextBoxInputTextView.performKeyEquivalent must decline Cmd+V while another view owns first responder. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AppDelegateShortcutRoutingTests.swift | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 2e65bbd5896b..82c19626c237 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -7207,6 +7207,67 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(hostWindow.firstResponder === otherView) } + // Regression for https://github.com/manaflow-ai/cmux/issues/6380. + // Paste must preflight into focused browser web content first, exactly like + // copy/cut/select-all. When paste is missing from the browser document + // editing commands, Cmd+V is never routed to the focused web view and falls + // through to the NSWindow.performKeyEquivalent broadcast, where a terminal + // pane's text box (text box beta) claims it instead of the browser. + func testBrowserPasteCommandRoutesThroughWebContentFirst() { + let pasteEvent = makeKeyEvent( + modifierFlags: [.command], + characters: "v", + charactersIgnoringModifiers: "v", + keyCode: 9 + ) + XCTAssertTrue( + shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst(pasteEvent), + "Cmd+V should preflight into focused browser web content like copy/cut/select-all" + ) + } + + // Regression for https://github.com/manaflow-ai/cmux/issues/6380. + // `performKeyEquivalent` is broadcast to every view in the window, not just + // the first responder. A terminal pane's text box (text box beta) must not + // claim Cmd+V while another view (e.g. the focused browser) owns first + // responder, or paste lands in the text box instead of the browser. + func testTextBoxDeclinesPasteShortcutWhenNotFirstResponder() { + let hostWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 80), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 80)) + let otherView = FocusableTestView(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.animationBehavior = .none + hostWindow.isReleasedWhenClosed = false + hostWindow.contentView = contentView + hostWindow.makeKeyAndOrderFront(nil) + Self.retainedTextBoxUndoWindows.append(hostWindow) + Self.retainedTextBoxRestoreViews.append(textView) + defer { hostWindow.orderOut(nil) } + + XCTAssertTrue(hostWindow.makeFirstResponder(otherView)) + XCTAssertTrue(hostWindow.firstResponder === otherView) + + let pasteEvent = makeKeyEvent( + modifierFlags: [.command], + characters: "v", + charactersIgnoringModifiers: "v", + keyCode: 9 + ) + XCTAssertFalse( + textView.performKeyEquivalent(with: pasteEvent), + "Text box must not claim Cmd+V while another view owns first responder" + ) + } + func testFocusTextBoxShortcutRoutesToEventWindowWhenActiveManagerIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") From f54efef7a619f19df1dec1db328bff706efe139b Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 02:46:18 -0700 Subject: [PATCH 2/4] Route browser paste to web content and stop text box stealing Cmd+V (#6380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a terminal + browser side by side and text box beta enabled, pasting into the focused browser pasted into the terminal's text box instead. Two complementary causes, fixed here: 1. Paste was missing from the browser document-editing commands (BrowserDocumentEditingCommandEquivalent had copy/cut/select-all but not paste). Copy/cut/select-all preflight into the focused web view before cmux's menu fallback, but Cmd+V fell through to the NSWindow.performKeyEquivalent broadcast. Add paste (Cmd+V) so the focused web view claims it first, exactly like the other editing commands. Cmd+V only — Cmd+Shift+V keeps its dedicated paste-as-plain-text path. 2. performKeyEquivalent is broadcast to every view in the window, not just the first responder. TextBoxInputTextView's override claimed Cmd+C/X/V and undo/redo unconditionally, so an unfocused text box stole those shortcuts from whatever actually owned focus (browser web content, the browser omnibar, etc.). Gate the standard edit/undo shortcuts on the text view being the first responder; the focus shortcut path is intentionally left ungated so it can still focus the text box. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 6 +-- Sources/App/ShortcutRoutingSupport.swift | 18 ++++++- Sources/TextBoxInput.swift | 17 ++++++ .../AppDelegateShortcutRoutingTests.swift | 52 +++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7b3ae3f3ab43..36faf0fc44a0 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,8 +6,8 @@ 16128 Sources/ContentView.swift 13832 Sources/TerminalController.swift 12878 Sources/Workspace.swift +12257 cmuxTests/AppDelegateShortcutRoutingTests.swift 12237 Sources/GhosttyTerminalView.swift -12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11578 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift @@ -18,7 +18,7 @@ 6317 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift 6183 Sources/TabManager.swift -6084 Sources/TextBoxInput.swift +6094 Sources/TextBoxInput.swift 5915 cmuxTests/TerminalAndGhosttyTests.swift 5573 cmuxTests/BrowserConfigTests.swift 4487 Sources/Panels/FilePreviewPanel.swift @@ -90,10 +90,10 @@ 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1002 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift 951 Sources/App/TerminalDirectoryOpenSupport.swift +950 Sources/App/ShortcutRoutingSupport.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift 937 Sources/TextBoxMentionIndexStore.swift -934 Sources/App/ShortcutRoutingSupport.swift 928 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 926 Sources/DockPanelView.swift 920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 06a2a560f302..c31f822e721f 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 var shortcut: StoredShortcut { @@ -636,6 +637,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", @@ -683,8 +696,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 ecdf5798e1eb..85551db11469 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -4373,6 +4373,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) @@ -4380,6 +4388,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/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 82c19626c237..2cfc39d94b5e 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -7268,6 +7268,58 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + // The first-responder gate must not regress normal text box paste: when the + // text box itself owns first responder, Cmd+V is handled by the text box. + func testTextBoxHandlesPasteShortcutWhenFirstResponder() { + let hostWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 80), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + 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.animationBehavior = .none + hostWindow.isReleasedWhenClosed = false + hostWindow.contentView = contentView + hostWindow.makeKeyAndOrderFront(nil) + Self.retainedTextBoxUndoWindows.append(hostWindow) + Self.retainedTextBoxRestoreViews.append(textView) + defer { hostWindow.orderOut(nil) } + + XCTAssertTrue(hostWindow.makeFirstResponder(textView)) + XCTAssertTrue(hostWindow.firstResponder === textView) + + let pasteEvent = makeKeyEvent( + modifierFlags: [.command], + characters: "v", + charactersIgnoringModifiers: "v", + keyCode: 9 + ) + XCTAssertTrue( + textView.performKeyEquivalent(with: pasteEvent), + "Text box must still handle Cmd+V while it owns first responder" + ) + } + + // Cmd+Shift+V (paste-and-match-style) must keep its dedicated CmuxWebView + // path and must not resolve as a plain document-editing paste. + func testBrowserPlainTextPasteCommandIsNotADocumentEditingPaste() { + let pasteAsPlainTextEvent = makeKeyEvent( + modifierFlags: [.command, .shift], + characters: "v", + charactersIgnoringModifiers: "v", + keyCode: 9 + ) + XCTAssertFalse( + shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst(pasteAsPlainTextEvent), + "Cmd+Shift+V keeps its dedicated paste-as-plain-text path" + ) + } + func testFocusTextBoxShortcutRoutesToEventWindowWhenActiveManagerIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") From 549d88ccae7f67c578e523f44148b5305af28cec Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 21:50:01 -0700 Subject: [PATCH 3/4] Stop tracking stale local scheduled_tasks.lock This gitignored scheduler lock (a stale local artifact) was accidentally tracked on this branch and is unrelated to the paste-routing fix. Remove it from the PR diff so only the intended files remain. Co-Authored-By: Claude Opus 4.8 --- .claude/scheduled_tasks.lock | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 20959ecc454a..000000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"5c2a62d0-99c2-42c0-9452-f67b6fbaacda","pid":75304,"procStart":"Thu Jun 4 23:34:54 2026","acquiredAt":1780616564932} \ No newline at end of file From de635279920ad9773b163b8f603d42dfff2bac1c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 14:39:57 -0700 Subject: [PATCH 4/4] Move paste shortcut regression tests to Swift Testing --- .github/swift-file-length-budget.tsv | 2 +- cmux.xcodeproj/project.pbxproj | 4 + ...gateBrowserPasteShortcutRoutingTests.swift | 142 ++++++++++++++++++ .../AppDelegateShortcutRoutingTests.swift | 113 -------------- 4 files changed, 147 insertions(+), 114 deletions(-) create mode 100644 cmuxTests/AppDelegateBrowserPasteShortcutRoutingTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5a6ad1c17fcc..39d28edcd5d7 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,7 +6,7 @@ 16427 Sources/ContentView.swift 14270 Sources/TerminalController.swift 13172 Sources/Workspace.swift -12461 cmuxTests/AppDelegateShortcutRoutingTests.swift +12348 cmuxTests/AppDelegateShortcutRoutingTests.swift 12212 Sources/GhosttyTerminalView.swift 11669 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 27cb93261751..04205936eb34 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -78,6 +78,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 */; }; @@ -1426,6 +1427,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 = ""; }; @@ -3702,6 +3704,7 @@ FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F5588001A1B2C3D4E5F60718 /* AppDelegateOptionDigitShortcutRoutingTests.swift */, + 724C78536F73958E79B8DABE /* AppDelegateBrowserPasteShortcutRoutingTests.swift */, 6419B0026419B0026419B002 /* AppDelegateShortcutRoutingRepairProbe.swift */, 4E6A6F5C1D2B4980A1234567 /* AppDelegateSurfaceShortcutRoutingTests.swift */, 6512F0C06512F0C06512F001 /* MainWindowFocusRestoreTests.swift */, @@ -5299,6 +5302,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 + ) + } +} diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 02ba86c4d7fd..4910dc20fdc9 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -7242,119 +7242,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(hostWindow.firstResponder === otherView) } - // Regression for https://github.com/manaflow-ai/cmux/issues/6380. - // Paste must preflight into focused browser web content first, exactly like - // copy/cut/select-all. When paste is missing from the browser document - // editing commands, Cmd+V is never routed to the focused web view and falls - // through to the NSWindow.performKeyEquivalent broadcast, where a terminal - // pane's text box (text box beta) claims it instead of the browser. - func testBrowserPasteCommandRoutesThroughWebContentFirst() { - let pasteEvent = makeKeyEvent( - modifierFlags: [.command], - characters: "v", - charactersIgnoringModifiers: "v", - keyCode: 9 - ) - XCTAssertTrue( - shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst(pasteEvent), - "Cmd+V should preflight into focused browser web content like copy/cut/select-all" - ) - } - - // Regression for https://github.com/manaflow-ai/cmux/issues/6380. - // `performKeyEquivalent` is broadcast to every view in the window, not just - // the first responder. A terminal pane's text box (text box beta) must not - // claim Cmd+V while another view (e.g. the focused browser) owns first - // responder, or paste lands in the text box instead of the browser. - func testTextBoxDeclinesPasteShortcutWhenNotFirstResponder() { - let hostWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 80), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 80)) - let otherView = FocusableTestView(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.animationBehavior = .none - hostWindow.isReleasedWhenClosed = false - hostWindow.contentView = contentView - hostWindow.makeKeyAndOrderFront(nil) - Self.retainedTextBoxUndoWindows.append(hostWindow) - Self.retainedTextBoxRestoreViews.append(textView) - defer { hostWindow.orderOut(nil) } - - XCTAssertTrue(hostWindow.makeFirstResponder(otherView)) - XCTAssertTrue(hostWindow.firstResponder === otherView) - - let pasteEvent = makeKeyEvent( - modifierFlags: [.command], - characters: "v", - charactersIgnoringModifiers: "v", - keyCode: 9 - ) - XCTAssertFalse( - textView.performKeyEquivalent(with: pasteEvent), - "Text box must not claim Cmd+V while another view owns first responder" - ) - } - - // The first-responder gate must not regress normal text box paste: when the - // text box itself owns first responder, Cmd+V is handled by the text box. - func testTextBoxHandlesPasteShortcutWhenFirstResponder() { - let hostWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 80), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - 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.animationBehavior = .none - hostWindow.isReleasedWhenClosed = false - hostWindow.contentView = contentView - hostWindow.makeKeyAndOrderFront(nil) - Self.retainedTextBoxUndoWindows.append(hostWindow) - Self.retainedTextBoxRestoreViews.append(textView) - defer { hostWindow.orderOut(nil) } - - XCTAssertTrue(hostWindow.makeFirstResponder(textView)) - XCTAssertTrue(hostWindow.firstResponder === textView) - - let pasteEvent = makeKeyEvent( - modifierFlags: [.command], - characters: "v", - charactersIgnoringModifiers: "v", - keyCode: 9 - ) - XCTAssertTrue( - textView.performKeyEquivalent(with: pasteEvent), - "Text box must still handle Cmd+V while it owns first responder" - ) - } - - // Cmd+Shift+V (paste-and-match-style) must keep its dedicated CmuxWebView - // path and must not resolve as a plain document-editing paste. - func testBrowserPlainTextPasteCommandIsNotADocumentEditingPaste() { - let pasteAsPlainTextEvent = makeKeyEvent( - modifierFlags: [.command, .shift], - characters: "v", - charactersIgnoringModifiers: "v", - keyCode: 9 - ) - XCTAssertFalse( - shouldRouteBrowserDocumentEditingCommandEquivalentThroughWebContentFirst(pasteAsPlainTextEvent), - "Cmd+Shift+V keeps its dedicated paste-as-plain-text path" - ) - } - func testFocusTextBoxShortcutRoutesToEventWindowWhenActiveManagerIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared")