Skip to content
Merged
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
237 changes: 220 additions & 17 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -7190,7 +7300,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,
Expand Down Expand Up @@ -7424,12 +7543,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
}
Expand Down Expand Up @@ -7477,6 +7602,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent

resolved = true
cleanup()
self.startGotoSplitUITestRecorder(browserPanelId: browserPanelId)
writeGotoSplitTestData([
"browserPanelId": browserPanelId.uuidString,
"browserPaneId": browserPaneId.description,
Expand Down Expand Up @@ -7527,6 +7653,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 }
Expand Down Expand Up @@ -10378,22 +10532,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
Expand Down Expand Up @@ -11223,6 +11362,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
Expand Down Expand Up @@ -12690,6 +12872,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")
Expand Down
31 changes: 27 additions & 4 deletions Sources/Panels/CmuxWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading