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:
parent
6a39bac0e1
commit
867c93e4fa
5 changed files with 696 additions and 22 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue