Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion Sources/App/ShortcutRoutingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ func cmuxIsWebInspectorObject(_ object: NSObject) -> Bool {
private enum BrowserDocumentEditingCommandEquivalent: CaseIterable {
case copy
case cut
case paste
case selectAll
case italic

Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions Sources/TextBoxInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4093,13 +4093,30 @@ 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)
}
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
}
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1460,6 +1461,7 @@
604500100000000000000006 /* AppDelegate+WindowIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+WindowIdentity.swift"; sourceTree = "<group>"; };
A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
17FCD4CC61D54A2F8F2F463D /* AppDelegateBareSpaceShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateBareSpaceShortcutRoutingTests.swift; sourceTree = "<group>"; };
724C78536F73958E79B8DABE /* AppDelegateBrowserPasteShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateBrowserPasteShortcutRoutingTests.swift; sourceTree = "<group>"; };
E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateEqualizeSplitsShortcutTests.swift; sourceTree = "<group>"; };
F5996001A1B2C3D4E5F60002 /* AppDelegateFileExplorerShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateFileExplorerShortcutRoutingTests.swift; sourceTree = "<group>"; };
2907A0042907A0042907A004 /* AppDelegateIssue2907RoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateIssue2907RoutingTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
142 changes: 142 additions & 0 deletions cmuxTests/AppDelegateBrowserPasteShortcutRoutingTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Loading