Keep cmux browser Find shortcuts authoritative (#2356)

* Route browser Find shortcuts through web content first

* Keep cmux browser Find shortcuts authoritative

* Add browser Find inspector regression test

* Fix browser Find routing follow-ups
This commit is contained in:
Austin Wang 2026-03-30 03:16:10 -07:00 committed by GitHub
parent 6a39bac0e1
commit 867c93e4fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 696 additions and 22 deletions

View file

@ -1851,6 +1851,115 @@ func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool {
return true
}
private enum BrowserFindCommandEquivalent {
case find
case findNext
case findPrevious
case hideFind
case useSelection
var keepsCmuxBrowserFindBarOwnershipWhenVisible: Bool {
switch self {
case .find, .findNext, .findPrevious, .hideFind:
return true
case .useSelection:
return false
}
}
}
private func cmuxIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
guard let responder else { return false }
let responderType = String(describing: type(of: responder))
if responderType.contains("WKInspector") {
return true
}
guard let view = responder as? NSView else { return false }
var node: NSView? = view
var hops = 0
while let current = node, hops < 64 {
if String(describing: type(of: current)).contains("WKInspector") {
return true
}
node = current.superview
hops += 1
}
return false
}
private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? {
let flags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased()
let hasSingleASCIIShortcutChar =
normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII)
let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII)
func matches(_ chars: String, keyCode: UInt16) -> Bool {
if hasSingleASCIIShortcutChar {
return normalizedChars == chars
}
if !producedAnyASCIIShortcutChar {
return event.keyCode == keyCode
}
return false
}
switch flags {
case [.command]:
if matches("e", keyCode: 14) { // kVK_ANSI_E
return .useSelection
}
if matches("f", keyCode: 3) { // kVK_ANSI_F
return .find
}
if matches("g", keyCode: 5) { // kVK_ANSI_G
return .findNext
}
return nil
case [.command, .shift]:
if matches("f", keyCode: 3) { // kVK_ANSI_F
return .hideFind
}
if matches("g", keyCode: 5) { // kVK_ANSI_G
return .findPrevious
}
return nil
default:
return nil
}
}
/// For browser content, let the page try the Find command family before cmux's menu fallback.
/// This preserves native web-app shortcuts like VS Code's Cmd+F while still allowing cmux's
/// browser find overlay to keep owning its visible Find UI shortcuts.
func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
_ event: NSEvent,
responder: NSResponder? = nil,
owningWebView: CmuxWebView? = nil
) -> Bool {
guard let shortcut = browserFindCommandEquivalent(for: event) else {
return false
}
if cmuxIsLikelyWebInspectorResponder(responder) {
return false
}
if shortcut.keepsCmuxBrowserFindBarOwnershipWhenVisible,
let owningWebView {
let browserFindBarIsVisible = MainActor.assumeIsolated {
AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true
}
if browserFindBarIsVisible {
return false
}
}
return true
}
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
guard let responder else { return nil }
if let ghosttyView = responder as? GhosttyNSView {
@ -2132,6 +2241,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var didSetupGotoSplitUITest = false
private var didSetupBonsplitTabDragUITest = false
private var bonsplitTabDragUITestRecorder: DispatchSourceTimer?
private var gotoSplitUITestRecorder: DispatchSourceTimer?
private var gotoSplitUITestObservers: [NSObjectProtocol] = []
private var didSetupMultiWindowNotificationsUITest = false
private var didSetupDisplayResolutionUITestDiagnostics = false
@ -7226,7 +7336,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return
}
let url = URL(string: "https://example.com")
let requestedBrowserURL = env["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
let url = requestedBrowserURL.flatMap { rawURL in
guard !rawURL.isEmpty else { return nil }
return URL(string: rawURL)
} ?? URL(string: "https://example.com")
guard let url else {
self.writeGotoSplitTestData(["setupError": "Invalid browser URL"])
return
}
guard let browserPanelId = tabManager.newBrowserSplit(
tabId: tab.id,
fromPanelId: initialPanelId,
@ -7460,12 +7579,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
.first(where: { $0.searchState != nil })
updates["terminalFindPanelId"] = terminalWithFind?.id.uuidString ?? ""
updates["terminalFindNeedle"] = terminalWithFind?.searchState?.needle ?? ""
updates["terminalFindVisible"] = terminalWithFind == nil ? "false" : "true"
let browserWithFind = workspace.panels.values
.compactMap { $0 as? BrowserPanel }
.first(where: { $0.searchState != nil })
updates["browserFindPanelId"] = browserWithFind?.id.uuidString ?? ""
updates["browserFindNeedle"] = browserWithFind?.searchState?.needle ?? ""
updates["browserFindSelected"] = browserWithFind?.searchState?.selected.map {
String($0 + 1)
} ?? ""
updates["browserFindTotal"] = browserWithFind?.searchState?.total.map(String.init) ?? ""
updates["browserFindVisible"] = browserWithFind == nil ? "false" : "true"
return updates
}
@ -7513,6 +7638,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
resolved = true
cleanup()
self.startGotoSplitUITestRecorder(browserPanelId: browserPanelId)
writeGotoSplitTestData([
"browserPanelId": browserPanelId.uuidString,
"browserPaneId": browserPaneId.description,
@ -7563,6 +7689,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
recordFocusedState()
}
private func startGotoSplitUITestRecorder(browserPanelId: UUID) {
guard isGotoSplitUITestRecordingEnabled() else { return }
gotoSplitUITestRecorder?.cancel()
gotoSplitUITestRecorder = nil
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now(), repeating: .milliseconds(100))
timer.setEventHandler { [weak self] in
self?.recordGotoSplitUITestState(browserPanelId: browserPanelId)
}
gotoSplitUITestRecorder = timer
timer.resume()
}
private func recordGotoSplitUITestState(browserPanelId: UUID) {
guard let tabManager,
let workspace = tabManager.selectedWorkspace,
let browserPanel = workspace.browserPanel(for: browserPanelId) else {
return
}
var updates = gotoSplitFindStateSnapshot(for: workspace)
updates["browserPageTitle"] = browserPanel.webView.title?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
updates["browserPageURL"] = browserPanel.preferredURLStringForOmnibar() ?? ""
writeGotoSplitTestData(updates)
}
private func isWebViewFocused(_ panel: BrowserPanel) -> Bool {
guard let window = panel.webView.window else { return false }
guard let fr = window.firstResponder as? NSView else { return false }
@ -10414,22 +10568,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
guard let responder else { return false }
let responderType = String(describing: type(of: responder))
if responderType.contains("WKInspector") {
return true
}
guard let view = responder as? NSView else { return false }
var node: NSView? = view
var hops = 0
while let current = node, hops < 64 {
if String(describing: type(of: current)).contains("WKInspector") {
return true
}
node = current.superview
hops += 1
}
return false
cmuxIsLikelyWebInspectorResponder(responder)
}
#if DEBUG
@ -11259,6 +11398,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return tabManager?.selectedWorkspace?.browserPanel(for: panelId)
}
fileprivate func browserFindBarIsVisible(for webView: CmuxWebView) -> Bool {
browserPanelOwning(webView)?.searchState != nil
}
private func browserPanelOwning(_ webView: CmuxWebView) -> BrowserPanel? {
var candidateManagers: [TabManager] = []
var seenManagers = Set<ObjectIdentifier>()
func appendCandidate(_ manager: TabManager?) {
guard let manager else { return }
let identifier = ObjectIdentifier(manager)
guard seenManagers.insert(identifier).inserted else { return }
candidateManagers.append(manager)
}
if let window = webView.window,
let context = contextForMainWindow(window) {
appendCandidate(context.tabManager)
}
appendCandidate(tabManager)
for context in mainWindowContexts.values {
appendCandidate(context.tabManager)
}
for manager in candidateManagers {
if let panel = browserPanelOwning(webView, in: manager) {
return panel
}
}
return nil
}
private func browserPanelOwning(_ webView: CmuxWebView, in manager: TabManager) -> BrowserPanel? {
for workspace in manager.tabs {
if let panel = workspace.panels.values
.compactMap({ $0 as? BrowserPanel })
.first(where: { $0.webView === webView }) {
return panel
}
}
return nil
}
private func setActiveMainWindow(_ window: NSWindow) {
guard let context = contextForMainTerminalWindow(window) else { return }
#if DEBUG
@ -12726,6 +12908,27 @@ private extension NSWindow {
return true
}
if let firstResponderWebView,
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
event,
responder: self.firstResponder,
owningWebView: firstResponderWebView
) {
let result = firstResponderWebView.performKeyEquivalent(with: event)
#if DEBUG
if result {
dlog(" → browser find command resolved before window menu path")
} else {
dlog(" → browser find command preflight left unclaimed; suppressing replay")
}
#endif
// The focused web view has already received this Find-family shortcut once.
// Do not fall through into the original NSWindow.performKeyEquivalent path,
// or WebKit can observe the same key equivalent a second time before AppKit
// reaches keyDown/menu fallback.
return true
}
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
#if DEBUG
dlog(" → consumed by handleBrowserSurfaceKeyEquivalent")

View file

@ -103,9 +103,9 @@ enum BrowserImageCopyPasteboardBuilder {
}
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
/// preventing the app menu/SwiftUI Commands from receiving them. Route menu
/// key equivalents first so app-level shortcuts continue to work when WebKit is
/// the first responder.
/// preventing the app menu/SwiftUI Commands from receiving them. Route app/menu
/// shortcuts first by default, but allow browser content to try the Find command
/// family before cmux falls back to its own browser find overlay.
final class CmuxWebView: WKWebView {
// Some sites/WebKit paths report middle-click link activations as
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
@ -248,6 +248,22 @@ final class CmuxWebView: WKWebView {
return result
}
var replayedBrowserFindShortcutIntoWebContent = false
if shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
event,
responder: window?.firstResponder,
owningWebView: self
) {
replayedBrowserFindShortcutIntoWebContent = true
let result = super.performKeyEquivalent(with: event)
#if DEBUG
handled = result
#endif
if result {
return true
}
}
if !shouldRouteCommandEquivalentDirectlyToMainMenu(event) {
let result = super.performKeyEquivalent(with: event)
#if DEBUG
@ -273,7 +289,14 @@ final class CmuxWebView: WKWebView {
return true
}
let result = super.performKeyEquivalent(with: event)
let result: Bool
if replayedBrowserFindShortcutIntoWebContent {
// A browser-first Find preflight has already exposed this shortcut to WebKit once.
// Avoid a second `super.performKeyEquivalent` replay when menu/app fallback does not claim it.
result = false
} else {
result = super.performKeyEquivalent(with: event)
}
#if DEBUG
handled = result
#endif

View file

@ -7,12 +7,36 @@ import XCTest
#endif
private let appDelegateLastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut"
private final class FakeWKInspectorContainerView: NSView {}
@MainActor
final class AppDelegateShortcutRoutingTests: XCTestCase {
private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:]
private var actionsWithPersistedShortcut: Set<KeyboardShortcutSettings.Action> = []
private func makeKeyEvent(
modifierFlags: NSEvent.ModifierFlags,
characters: String,
charactersIgnoringModifiers: String,
keyCode: UInt16
) -> NSEvent {
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: modifierFlags,
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: 0,
context: nil,
characters: characters,
charactersIgnoringModifiers: charactersIgnoringModifiers,
isARepeat: false,
keyCode: keyCode
) else {
fatalError("Failed to construct key event")
}
return event
}
override func setUp() {
super.setUp()
// Prevent a single hanging test from consuming the entire CI timeout budget.
@ -3092,6 +3116,110 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
// MARK: - Non-Latin keyboard layout shortcut tests
func testBrowserFirstFindShortcutRoutingRecognizesFindCommandFamily() {
let cases: [(name: String, modifiers: NSEvent.ModifierFlags, chars: String, keyCode: UInt16)] = [
("cmd-f", [.command], "f", 3),
("cmd-g", [.command], "g", 5),
("cmd-shift-g", [.command, .shift], "g", 5),
("cmd-shift-f", [.command, .shift], "f", 3),
("cmd-e", [.command], "e", 14),
]
for testCase in cases {
let event = makeKeyEvent(
modifierFlags: testCase.modifiers,
characters: testCase.chars,
charactersIgnoringModifiers: testCase.chars,
keyCode: testCase.keyCode
)
XCTAssertTrue(
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event),
"Expected browser-first routing for \(testCase.name)"
)
}
}
func testBrowserFirstFindShortcutRoutingFallsBackToKeyCodeForNonLatinInput() {
let event = makeKeyEvent(
modifierFlags: [.command],
characters: "",
charactersIgnoringModifiers: "а", // Cyrillic a from a non-Latin input source
keyCode: 3 // kVK_ANSI_F
)
XCTAssertTrue(
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event),
"Expected browser-first routing to keep Cmd+F eligible under non-Latin input"
)
}
func testBrowserFirstFindShortcutRoutingDoesNotUseANSIPositionsForMismatchedASCIICharacters() {
let cases: [(name: String, modifiers: NSEvent.ModifierFlags, chars: String, keyCode: UInt16)] = [
("cmd-u-on-ansi-f", [.command], "u", 3),
("cmd-o-on-ansi-g", [.command], "o", 5),
("cmd-period-on-ansi-e", [.command], ".", 14),
("cmd-shift-u-on-ansi-f", [.command, .shift], "u", 3),
("cmd-shift-o-on-ansi-g", [.command, .shift], "o", 5),
]
for testCase in cases {
let event = makeKeyEvent(
modifierFlags: testCase.modifiers,
characters: testCase.chars,
charactersIgnoringModifiers: testCase.chars,
keyCode: testCase.keyCode
)
XCTAssertFalse(
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event),
"Did not expect browser-first routing for mismatched ASCII shortcut \(testCase.name)"
)
}
}
func testBrowserFirstFindShortcutRoutingExcludesWebInspectorResponders() {
let inspectorContainer = FakeWKInspectorContainerView(frame: .zero)
let inspectorChild = NSView(frame: .zero)
inspectorContainer.addSubview(inspectorChild)
let event = makeKeyEvent(
modifierFlags: [.command],
characters: "f",
charactersIgnoringModifiers: "f",
keyCode: 3
)
XCTAssertFalse(
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
event,
responder: inspectorChild
),
"Did not expect browser-first routing while a Web Inspector responder is focused"
)
}
func testBrowserFirstFindShortcutRoutingExcludesNonFindCommands() {
let cases: [(name: String, modifiers: NSEvent.ModifierFlags, chars: String, keyCode: UInt16)] = [
("cmd-n", [.command], "n", 45),
("cmd-w", [.command], "w", 13),
("cmd-l", [.command], "l", 37),
("cmd-option-f", [.command, .option], "f", 3),
]
for testCase in cases {
let event = makeKeyEvent(
modifierFlags: testCase.modifiers,
characters: testCase.chars,
charactersIgnoringModifiers: testCase.chars,
keyCode: testCase.keyCode
)
XCTAssertFalse(
shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(event),
"Did not expect browser-first routing for \(testCase.name)"
)
}
}
func testCmdTWorksWithRussianKeyboardLayout() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")

View file

@ -16,6 +16,8 @@ import UserNotifications
var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
var cmuxUnitTestInspectorOverrideInstalled = false
var cmuxUnitTestWKWebViewPerformKeyEquivalentOverrideInstalled = false
var cmuxUnitTestWKWebViewPerformKeyEquivalentHook: ((WKWebView, NSEvent) -> Bool?)?
extension CmuxWebView {
@objc func cmuxUnitTestInspector() -> NSObject? {
@ -24,6 +26,14 @@ extension CmuxWebView {
}
extension WKWebView {
@objc func cmuxUnitTest_performKeyEquivalent(with event: NSEvent) -> Bool {
if let hook = cmuxUnitTestWKWebViewPerformKeyEquivalentHook,
let result = hook(self, event) {
return result
}
return cmuxUnitTest_performKeyEquivalent(with: event)
}
func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
objc_setAssociatedObject(
self,
@ -57,6 +67,38 @@ func installCmuxUnitTestInspectorOverride() {
cmuxUnitTestInspectorOverrideInstalled = true
}
func installCmuxUnitTestWKWebViewPerformKeyEquivalentOverride() {
guard !cmuxUnitTestWKWebViewPerformKeyEquivalentOverrideInstalled else { return }
let originalSelector = #selector(NSResponder.performKeyEquivalent(with:))
let swizzledSelector = #selector(WKWebView.cmuxUnitTest_performKeyEquivalent(with:))
guard let originalMethod = class_getInstanceMethod(WKWebView.self, originalSelector),
let swizzledMethod = class_getInstanceMethod(WKWebView.self, swizzledSelector) else {
fatalError("Unable to locate WKWebView performKeyEquivalent methods for swizzling")
}
let didAddMethod = class_addMethod(
WKWebView.self,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod)
)
if didAddMethod {
class_replaceMethod(
WKWebView.self,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod)
)
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
cmuxUnitTestWKWebViewPerformKeyEquivalentOverrideInstalled = true
}
private final class BrowserMarkedTextProbeTextView: NSTextView {
var hasMarkedTextForTesting = false
private(set) var keyDownEvents: [NSEvent] = []
@ -102,6 +144,10 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
override var acceptsFirstResponder: Bool { true }
}
private final class FakeWKInspectorResponderView: NSView {
override var acceptsFirstResponder: Bool { true }
}
private final class DelegateProbeTextView: NSTextView {
private(set) var delegateReadCount = 0
@ -780,6 +826,65 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
)
}
@MainActor
func testCmdFDoesNotPreflightIntoPageWhenWebInspectorResponderIsFocused() {
_ = NSApplication.shared
installCmuxUnitTestWKWebViewPerformKeyEquivalentOverride()
let spy = ActionSpy()
installMenu(spy: spy, key: "f", modifiers: [.command])
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)
let inspectorView = FakeWKInspectorResponderView(frame: NSRect(x: 0, y: 0, width: 32, height: 20))
webView.addSubview(inspectorView)
var forwardedEvents: [NSEvent] = []
cmuxUnitTestWKWebViewPerformKeyEquivalentHook = { currentWebView, event in
guard currentWebView === webView else { return nil }
forwardedEvents.append(event)
return true
}
window.makeKeyAndOrderFront(nil)
defer {
cmuxUnitTestWKWebViewPerformKeyEquivalentHook = nil
window.orderOut(nil)
}
XCTAssertTrue(window.makeFirstResponder(inspectorView))
guard let event = makeKeyDownEvent(
key: "f",
modifiers: [.command],
keyCode: 3,
windowNumber: window.windowNumber
) else {
XCTFail("Failed to construct Cmd+F event")
return
}
let consumed = webView.performKeyEquivalent(with: event)
XCTAssertTrue(consumed, "Expected the menu/inspector path to keep consuming Cmd+F")
XCTAssertTrue(spy.invoked, "Expected Cmd+F to stay on the menu/inspector path while Web Inspector is focused")
XCTAssertEqual(
forwardedEvents.count,
0,
"Did not expect CmuxWebView to preflight Cmd+F into page content while Web Inspector is focused"
)
}
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
installMenu(
target: spy,

View file

@ -89,12 +89,119 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase {
)
}
private func launchWithBrowserSetup() -> XCUIApplication {
func testCmdFFirstLetsWebContentHandleFindShortcut() {
let app = launchWithBrowserSetup(browserURL: makeBrowserHandledCmdFPageURL())
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 10.0) { data in
data["browserPageTitle"] == "cmdf-pending"
},
"Expected the browser test page to finish loading before Cmd+F"
)
app.typeKey("f", modifierFlags: [.command])
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 5.0) { data in
data["browserPageTitle"] == "cmdf-handled" &&
data["browserFindVisible"] == "false"
},
"Expected Cmd+F to reach browser content before cmux find overlay. data=\(loadGotoSplit() ?? [:])"
)
}
func testBrowserFirstFindShortcutDoesNotReplayUnclaimedCmdEIntoWebContentTwice() {
let app = launchWithBrowserSetup(browserURL: makeBrowserObservedCmdEPageURL())
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 10.0) { data in
data["browserPageTitle"] == "cmde-0"
},
"Expected the Cmd+E test page to finish loading before the shortcut. data=\(loadGotoSplit() ?? [:])"
)
app.typeKey("e", modifierFlags: [.command])
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 5.0) { data in
data["browserPageTitle"] == "cmde-1"
},
"Expected Cmd+E to reach browser content exactly once. data=\(loadGotoSplit() ?? [:])"
)
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
XCTAssertEqual(
loadGotoSplit()?["browserPageTitle"],
"cmde-1",
"Expected Cmd+E to avoid a second WebKit replay. data=\(loadGotoSplit() ?? [:])"
)
}
func testVisibleBrowserFindBarKeepsCmdGAndCmdShiftFOwnedByCmux() {
let app = launchWithBrowserSetup(browserURL: makeVisibleBrowserFindOwnershipPageURL())
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 10.0) { data in
data["browserPageTitle"] == "find-owner-idle"
},
"Expected the browser find ownership page to finish loading before opening find. data=\(loadGotoSplit() ?? [:])"
)
app.typeKey("f", modifierFlags: [.command])
let findField = app.textFields["BrowserFindSearchTextField"].firstMatch
XCTAssertTrue(findField.waitForExistence(timeout: 6.0), "Expected browser find field after Cmd+F")
app.typeText("needle")
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 6.0) { data in
data["browserFindVisible"] == "true" &&
data["browserFindNeedle"] == "needle" &&
data["browserFindSelected"] == "1" &&
data["browserFindTotal"] == "3"
},
"Expected cmux browser find bar to open and capture the query before page-focus checks. data=\(loadGotoSplit() ?? [:])"
)
guard let browserPanelId = loadGotoSplit()?["browserPanelId"], !browserPanelId.isEmpty else {
XCTFail("Missing browserPanelId in goto_split setup data")
return
}
clickBrowserPane(app: app, browserPanelId: browserPanelId)
app.typeKey("g", modifierFlags: [.command])
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 6.0) { data in
data["browserPageTitle"] == "find-owner-idle" &&
data["browserFindVisible"] == "true" &&
data["browserFindSelected"] == "2" &&
data["browserFindTotal"] == "3"
},
"Expected visible cmux browser find bar to keep Cmd+G ownership after page refocus. data=\(loadGotoSplit() ?? [:])"
)
clickBrowserPane(app: app, browserPanelId: browserPanelId)
app.typeKey("f", modifierFlags: [.command, .shift])
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 6.0) { data in
data["browserPageTitle"] == "find-owner-idle" &&
data["browserFindVisible"] == "false"
},
"Expected visible cmux browser find bar to keep Cmd+Shift+F ownership after page refocus. data=\(loadGotoSplit() ?? [:])"
)
}
private func launchWithBrowserSetup(browserURL: String? = nil) -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = gotoSplitPath
app.launchEnvironment["CMUX_UI_TEST_KEYEQUIV_PATH"] = keyequivPath
if let browserURL {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_BROWSER_URL"] = browserURL
}
app.launch()
app.activate()
@ -110,6 +217,114 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase {
return app
}
private func makeBrowserHandledCmdFPageURL() -> String {
let html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>cmdf-pending</title>
</head>
<body tabindex="-1">
<main>Browser find shortcut passthrough</main>
<script>
window.addEventListener('load', () => {
document.body.focus();
});
window.addEventListener('keydown', (event) => {
const key = String(event.key || '').toLowerCase();
if (event.metaKey && !event.shiftKey && !event.altKey && !event.ctrlKey && key === 'f') {
event.preventDefault();
document.title = 'cmdf-handled';
document.body.dataset.cmdf = 'handled';
}
}, true);
</script>
</body>
</html>
"""
return makeDataURL(html)
}
private func makeBrowserObservedCmdEPageURL() -> String {
let html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>cmde-0</title>
</head>
<body tabindex="-1">
<main>Cmd+E should only reach the page once</main>
<script>
window.addEventListener('load', () => {
document.body.focus();
});
let countState = { value: 0 };
window.addEventListener('keydown', (event) => {
const key = String(event.key || '').toLowerCase();
if (event.metaKey && !event.shiftKey && !event.altKey && !event.ctrlKey && key === 'e') {
countState.value += 1;
document.title = `cmde-${countState.value}`;
document.body.dataset.cmdeCount = String(countState.value);
}
}, true);
</script>
</body>
</html>
"""
return makeDataURL(html)
}
private func makeVisibleBrowserFindOwnershipPageURL() -> String {
let html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>find-owner-idle</title>
</head>
<body tabindex="-1">
<main>needle alpha</main>
<main>needle beta</main>
<main>needle gamma</main>
<script>
window.addEventListener('load', () => {
document.body.focus();
});
window.addEventListener('keydown', (event) => {
const key = String(event.key || '').toLowerCase();
if (event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey && key === 'g') {
event.preventDefault();
event.stopImmediatePropagation();
document.title = 'page-handled-cmdg';
return;
}
if (event.metaKey && event.shiftKey && !event.altKey && !event.ctrlKey && key === 'f') {
event.preventDefault();
event.stopImmediatePropagation();
document.title = 'page-handled-cmdshiftf';
}
}, true);
</script>
</body>
</html>
"""
return makeDataURL(html)
}
private func makeDataURL(_ html: String) -> String {
let encoded = Data(html.utf8).base64EncodedString()
return "data:text/html;base64,\(encoded)"
}
private func clickBrowserPane(app: XCUIApplication, browserPanelId: String) {
let browserPane = app.otherElements["BrowserPanelContent.\(browserPanelId)"].firstMatch
XCTAssertTrue(browserPane.waitForExistence(timeout: 6.0), "Expected browser pane content for click target")
browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
}
private func refocusWebView(app: XCUIApplication) {
// Cmd+L focuses the omnibar (so WebKit is no longer first responder).
app.typeKey("l", modifierFlags: [.command])