From 867c93e4fa9231b13276e76457ef3450f293a1e5 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 30 Mar 2026 03:16:10 -0700 Subject: [PATCH] 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 --- Sources/AppDelegate.swift | 237 ++++++++++++++++-- Sources/Panels/CmuxWebView.swift | 31 ++- .../AppDelegateShortcutRoutingTests.swift | 128 ++++++++++ cmuxTests/BrowserConfigTests.swift | 105 ++++++++ .../MenuKeyEquivalentRoutingUITests.swift | 217 +++++++++++++++- 5 files changed, 696 insertions(+), 22 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cbf34779..bffe3632 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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() + + 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") diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 6f5338ca..9693e372 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -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 diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 7c1a6315..22497040 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -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 = [] + 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") diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 2cb498a2..11785cc1 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -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, diff --git a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift index 8ff0ab47..621879af 100644 --- a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +++ b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift @@ -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 = """ + + + + + cmdf-pending + + +
Browser find shortcut passthrough
+ + + + """ + return makeDataURL(html) + } + + private func makeBrowserObservedCmdEPageURL() -> String { + let html = """ + + + + + cmde-0 + + +
Cmd+E should only reach the page once
+ + + + """ + return makeDataURL(html) + } + + private func makeVisibleBrowserFindOwnershipPageURL() -> String { + let html = """ + + + + + find-owner-idle + + +
needle alpha
+
needle beta
+
needle gamma
+ + + + """ + 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])