Add DEBUG logging for browser omnibar/content focus handoff (#910)

* Add browser focus debug logging around omnibar/content handoff

* Avoid release-only unused-value warnings in focus debug logs

* Add omnibar focus writer trace logs

* Fix omnibar tap focus race

* Stabilize omnibar focus state transitions

* Propagate event context into responder guard

* Fix webview pointer hit testing in focus guard

* Stop omnibar reacquire on pointer blur intent

* Blur omnibar on webview click intent

* Preserve pointer intent through webview focus handoff

* Restore page input focus after omnibar escape

* Fix omnibar escape focus handoff and restore retry

* Track editable focus for omnibar restore and improve test diagnostics

* Add omnibar focus tracker telemetry to failing UI test

* Wait for page readiness before seeding focused input in UI test setup

* Strengthen omnibar escape focus regression with post-click assertion

* Use deterministic window offsets for post-escape web input click test

* Always enforce webview responder on omnibar escape

* Harden omnibar focus restore and address PR review feedback

---------

Co-authored-by: tiffanysun1 <tiffanysun8@gmail.com>
This commit is contained in:
Lawrence Chen 2026-03-07 03:05:13 -08:00 committed by GitHub
parent 58bcc929b2
commit e680f1de55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1659 additions and 71 deletions

View file

@ -5510,7 +5510,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
guard let self else { return }
guard self != nil else { return }
runSetupWhenWindowReady()
}
}
@ -5607,6 +5607,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
"ghosttyGotoSplitDownShortcut": ghosttyGotoSplitDownShortcut?.displayString ?? "",
"webViewFocused": "true"
])
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" {
setupFocusedInputForGotoSplitUITest(panel: browserPanel)
}
return
}
@ -5652,6 +5655,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard let self else { return }
guard let panelId = notification.object as? UUID else { return }
self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarFocus")
self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarFocus")
})
gotoSplitUITestObservers.append(NotificationCenter.default.addObserver(
@ -5662,6 +5666,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard let self else { return }
guard let panelId = notification.object as? UUID else { return }
self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarExit")
self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarExit")
})
}
@ -5689,6 +5694,329 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel, attempt: Int = 0) {
let maxAttempts = 80
guard attempt < maxAttempts else {
writeGotoSplitTestData([
"webInputFocusSeeded": "false",
"setupError": "Timed out focusing page input for omnibar restore test"
])
return
}
let script = """
(() => {
try {
const trackerInstalled = window.__cmuxAddressBarFocusTrackerInstalled === true;
const readyState = String(document.readyState || "");
if (!trackerInstalled || readyState !== "complete") {
const active = document.activeElement;
return {
focused: false,
id: "",
activeId: active && typeof active.id === "string" ? active.id : "",
activeTag: active && active.tagName ? active.tagName.toLowerCase() : "",
trackerInstalled,
trackedStateId:
window.__cmuxAddressBarFocusState &&
typeof window.__cmuxAddressBarFocusState.id === "string"
? window.__cmuxAddressBarFocusState.id
: "",
readyState
};
}
const ensureInput = (id, value) => {
const existing = document.getElementById(id);
const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input")
? existing
: (() => {
const created = document.createElement("input");
created.id = id;
created.type = "text";
created.value = value;
return created;
})();
input.autocapitalize = "off";
input.autocomplete = "off";
input.spellcheck = false;
input.style.display = "block";
input.style.width = "100%";
input.style.margin = "0";
input.style.padding = "8px 10px";
input.style.border = "1px solid #5f6368";
input.style.borderRadius = "6px";
input.style.boxSizing = "border-box";
input.style.fontSize = "14px";
input.style.fontFamily = "system-ui, -apple-system, sans-serif";
input.style.background = "white";
input.style.color = "black";
return input;
};
let container = document.getElementById("cmux-ui-test-focus-container");
if (!container || !container.tagName || container.tagName.toLowerCase() !== "div") {
container = document.createElement("div");
container.id = "cmux-ui-test-focus-container";
document.body.appendChild(container);
}
container.style.position = "fixed";
container.style.left = "24px";
container.style.top = "24px";
container.style.width = "min(520px, calc(100vw - 48px))";
container.style.display = "grid";
container.style.rowGap = "12px";
container.style.padding = "12px";
container.style.background = "rgba(255,255,255,0.92)";
container.style.border = "1px solid rgba(95,99,104,0.55)";
container.style.borderRadius = "8px";
container.style.boxShadow = "0 2px 10px rgba(0,0,0,0.2)";
container.style.zIndex = "2147483647";
const input = ensureInput("cmux-ui-test-focus-input", "cmux-ui-focus-primary");
const secondaryInput = ensureInput("cmux-ui-test-focus-input-secondary", "cmux-ui-focus-secondary");
if (input.parentElement !== container) {
container.appendChild(input);
}
if (secondaryInput.parentElement !== container) {
container.appendChild(secondaryInput);
}
input.focus({ preventScroll: true });
if (typeof input.setSelectionRange === "function") {
const end = input.value.length;
input.setSelectionRange(end, end);
}
let trackedFocusId = input.getAttribute("data-cmux-addressbar-focus-id");
if (!trackedFocusId) {
trackedFocusId = "cmux-ui-test-focus-input-tracked";
input.setAttribute("data-cmux-addressbar-focus-id", trackedFocusId);
}
const selectionStart = typeof input.selectionStart === "number" ? input.selectionStart : null;
const selectionEnd = typeof input.selectionEnd === "number" ? input.selectionEnd : null;
if (
!window.__cmuxAddressBarFocusState ||
typeof window.__cmuxAddressBarFocusState.id !== "string" ||
window.__cmuxAddressBarFocusState.id !== trackedFocusId
) {
window.__cmuxAddressBarFocusState = { id: trackedFocusId, selectionStart, selectionEnd };
}
const secondaryRect = secondaryInput.getBoundingClientRect();
const viewportWidth = Math.max(Number(window.innerWidth) || 0, 1);
const viewportHeight = Math.max(Number(window.innerHeight) || 0, 1);
const secondaryCenterX = Math.min(
0.98,
Math.max(0.02, (secondaryRect.left + (secondaryRect.width / 2)) / viewportWidth)
);
const secondaryCenterY = Math.min(
0.98,
Math.max(0.02, (secondaryRect.top + (secondaryRect.height / 2)) / viewportHeight)
);
const active = document.activeElement;
return {
focused: active === input,
id: input.id || "",
secondaryId: secondaryInput.id || "",
secondaryCenterX,
secondaryCenterY,
activeId: active && typeof active.id === "string" ? active.id : "",
activeTag: active && active.tagName ? active.tagName.toLowerCase() : "",
trackerInstalled,
trackedStateId:
window.__cmuxAddressBarFocusState &&
typeof window.__cmuxAddressBarFocusState.id === "string"
? window.__cmuxAddressBarFocusState.id
: "",
readyState
};
} catch (_) {
return {
focused: false,
id: "",
secondaryId: "",
secondaryCenterX: -1,
secondaryCenterY: -1,
activeId: "",
activeTag: "",
trackerInstalled: false,
trackedStateId: "",
readyState: ""
};
}
})();
"""
panel.webView.evaluateJavaScript(script) { [weak self] result, _ in
guard let self else { return }
let payload = result as? [String: Any]
let focused = (payload?["focused"] as? Bool) ?? false
let inputId = (payload?["id"] as? String) ?? ""
let secondaryInputId = (payload?["secondaryId"] as? String) ?? ""
let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1
let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1
let activeId = (payload?["activeId"] as? String) ?? ""
let trackerInstalled = (payload?["trackerInstalled"] as? Bool) ?? false
let trackedStateId = (payload?["trackedStateId"] as? String) ?? ""
let readyState = (payload?["readyState"] as? String) ?? ""
var secondaryClickOffsetX = -1.0
var secondaryClickOffsetY = -1.0
if let window = panel.webView.window {
let webFrame = panel.webView.convert(panel.webView.bounds, to: nil)
let contentHeight = Double(window.contentView?.bounds.height ?? 0)
if webFrame.width > 1,
webFrame.height > 1,
contentHeight > 1,
secondaryCenterX > 0,
secondaryCenterX < 1,
secondaryCenterY > 0,
secondaryCenterY < 1 {
let xInContent = Double(webFrame.minX) + (secondaryCenterX * Double(webFrame.width))
let yFromTopInWeb = secondaryCenterY * Double(webFrame.height)
let yInContent = Double(webFrame.maxY) - yFromTopInWeb
let yFromTopInContent = contentHeight - yInContent
let titlebarHeight = max(0, Double(window.frame.height) - contentHeight)
secondaryClickOffsetX = xInContent
secondaryClickOffsetY = titlebarHeight + yFromTopInContent
}
}
if focused,
!inputId.isEmpty,
!secondaryInputId.isEmpty,
inputId == activeId,
trackerInstalled,
!trackedStateId.isEmpty,
secondaryCenterX > 0,
secondaryCenterX < 1,
secondaryCenterY > 0,
secondaryCenterY < 1,
secondaryClickOffsetX > 0,
secondaryClickOffsetY > 0 {
self.writeGotoSplitTestData([
"webInputFocusSeeded": "true",
"webInputFocusElementId": inputId,
"webInputFocusSecondaryElementId": secondaryInputId,
"webInputFocusSecondaryCenterX": "\(secondaryCenterX)",
"webInputFocusSecondaryCenterY": "\(secondaryCenterY)",
"webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)",
"webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)",
"webInputFocusActiveElementId": activeId,
"webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false",
"webInputFocusTrackedStateId": trackedStateId,
"webInputFocusReadyState": readyState
])
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.setupFocusedInputForGotoSplitUITest(panel: panel, attempt: attempt + 1)
}
}
}
private func recordGotoSplitUITestActiveElement(panelId: UUID, keyPrefix: String) {
recordGotoSplitUITestActiveElementRetry(panelId: panelId, keyPrefix: keyPrefix, attempt: 0)
}
private func recordGotoSplitUITestActiveElementRetry(panelId: UUID, keyPrefix: String, attempt: Int) {
let delays: [Double] = [0.05, 0.1, 0.25, 0.5]
let delay = attempt < delays.count ? delays[attempt] : delays.last!
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self,
let tabManager,
let tab = tabManager.selectedWorkspace,
let panel = tab.browserPanel(for: panelId) else { return }
self.evaluateGotoSplitUITestActiveElement(panel: panel) { snapshot in
let activeId = snapshot["id"] ?? ""
let expectedInputId = self.gotoSplitUITestExpectedInputId() ?? ""
if keyPrefix == "addressBarExit",
!expectedInputId.isEmpty,
activeId != expectedInputId,
attempt < delays.count - 1 {
self.recordGotoSplitUITestActiveElementRetry(
panelId: panelId,
keyPrefix: keyPrefix,
attempt: attempt + 1
)
return
}
self.writeGotoSplitTestData([
"\(keyPrefix)PanelId": panelId.uuidString,
"\(keyPrefix)ActiveElementId": activeId,
"\(keyPrefix)ActiveElementTag": snapshot["tag"] ?? "",
"\(keyPrefix)ActiveElementType": snapshot["type"] ?? "",
"\(keyPrefix)ActiveElementEditable": snapshot["editable"] ?? "false",
"\(keyPrefix)TrackedFocusStateId": snapshot["trackedFocusStateId"] ?? "",
"\(keyPrefix)FocusTrackerInstalled": snapshot["focusTrackerInstalled"] ?? "false"
])
}
}
}
private func evaluateGotoSplitUITestActiveElement(
panel: BrowserPanel,
completion: @escaping ([String: String]) -> Void
) {
let script = """
(() => {
try {
const active = document.activeElement;
if (!active) {
return { id: "", tag: "", type: "", editable: "false" };
}
const tag = (active.tagName || "").toLowerCase();
const type = (active.type || "").toLowerCase();
const editable =
!!active.isContentEditable ||
tag === "textarea" ||
(tag === "input" && type !== "hidden");
return {
id: typeof active.id === "string" ? active.id : "",
tag,
type,
editable: editable ? "true" : "false",
trackedFocusStateId:
window.__cmuxAddressBarFocusState &&
typeof window.__cmuxAddressBarFocusState.id === "string"
? window.__cmuxAddressBarFocusState.id
: "",
focusTrackerInstalled:
window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false"
};
} catch (_) {
return {
id: "",
tag: "",
type: "",
editable: "false",
trackedFocusStateId: "",
focusTrackerInstalled: "false"
};
}
})();
"""
panel.webView.evaluateJavaScript(script) { result, _ in
let payload = result as? [String: Any]
completion([
"id": (payload?["id"] as? String) ?? "",
"tag": (payload?["tag"] as? String) ?? "",
"type": (payload?["type"] as? String) ?? "",
"editable": (payload?["editable"] as? String) ?? "false",
"trackedFocusStateId": (payload?["trackedFocusStateId"] as? String) ?? "",
"focusTrackerInstalled": (payload?["focusTrackerInstalled"] as? String) ?? "false"
])
}
}
private func gotoSplitUITestExpectedInputId() -> String? {
let env = ProcessInfo.processInfo.environment
guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil }
return loadGotoSplitTestData(at: path)["webInputFocusElementId"]
}
private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) {
guard isGotoSplitUITestRecordingEnabled() else { return }
guard let tabManager, let workspace = tabManager.selectedWorkspace else { return }
@ -6591,7 +6919,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if browserAddressBarFocusedPanelId != nil,
cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil {
#if DEBUG
dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.focus.addressBar.staleClear panel=\(stalePanelToken) " +
"reason=terminal_first_responder fr=\(firstResponderType)"
)
#endif
browserAddressBarFocusedPanelId = nil
stopBrowserOmnibarSelectionRepeat()
@ -7274,6 +7607,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
dlog(line)
}
private func browserFocusStateSnapshot() -> String {
let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
let keyWindow = NSApp.keyWindow?.windowNumber ?? -1
let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)"
}
private func redactedDebugURL(_ url: URL?) -> String {
guard let url else { return "nil" }
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return "<invalid>"
}
components.user = nil
components.password = nil
components.query = nil
components.fragment = nil
return components.string ?? "<redacted>"
}
#endif
@discardableResult
@ -7281,9 +7635,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard let tabManager,
let workspace = tabManager.selectedWorkspace,
let panel = workspace.browserPanel(for: panelId) else {
#if DEBUG
dlog(
"browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " +
"result=miss \(browserFocusStateSnapshot())"
)
#endif
return false
}
#if DEBUG
dlog(
"browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " +
"workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())"
)
#endif
workspace.focusPanel(panel.id)
#if DEBUG
let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " +
"workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)"
)
#endif
focusBrowserAddressBar(in: panel)
return true
}
@ -7291,16 +7664,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@discardableResult
func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else {
#if DEBUG
dlog(
"browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " +
"url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())"
)
#endif
return nil
}
#if DEBUG
dlog(
"browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " +
"insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))"
)
#endif
#if DEBUG
let didFocus = focusBrowserAddressBar(panelId: panelId)
dlog(
"browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " +
"focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())"
)
#else
_ = focusBrowserAddressBar(panelId: panelId)
#endif
return panelId
}
private func focusBrowserAddressBar(in panel: BrowserPanel) {
#if DEBUG
let requestId = panel.requestAddressBarFocus()
dlog(
"browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " +
"request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())"
)
#else
_ = panel.requestAddressBarFocus()
#endif
browserAddressBarFocusedPanelId = panel.id
#if DEBUG
dlog(
"browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " +
"request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())"
)
#endif
NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id)
#if DEBUG
dlog(
"browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " +
"request=\(requestId.uuidString.prefix(8))"
)
#endif
}
func focusedBrowserAddressBarPanelId() -> UUID? {
@ -7309,11 +7722,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? {
guard let panelId = browserAddressBarFocusedPanelId else { return nil }
guard let context = preferredMainWindowContextForShortcutRouting(event: event),
let workspace = context.tabManager.selectedWorkspace,
workspace.browserPanel(for: panelId) != nil else {
guard let context = preferredMainWindowContextForShortcutRouting(event: event) else {
#if DEBUG
dlog(
"browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " +
"accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))"
)
#endif
return nil
}
guard let workspace = context.tabManager.selectedWorkspace else {
#if DEBUG
dlog(
"browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " +
"accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))"
)
#endif
return nil
}
guard workspace.browserPanel(for: panelId) != nil else {
#if DEBUG
dlog(
"browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " +
"accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " +
"event=\(NSWindow.keyDescription(event))"
)
#endif
return nil
}
#if DEBUG
dlog(
"browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " +
"accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))"
)
#endif
return panelId
}
@ -7330,7 +7776,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control]
guard isCommandOrControlOnly else { return false }
return chars == "n" || chars == "p"
let shouldBypass = chars == "n" || chars == "p"
#if DEBUG
if shouldBypass {
let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"browser.focus.addressBar.shortcutBypass panel=\(panelToken) " +
"chars=\(chars) flags=\(normalizedFlags.rawValue)"
)
}
#endif
return shouldBypass
}
private func commandOmnibarSelectionDelta(
@ -7347,6 +7803,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func dispatchBrowserOmnibarSelectionMove(delta: Int) {
guard delta != 0 else { return }
guard let panelId = browserAddressBarFocusedPanelId else { return }
#if DEBUG
dlog(
"browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " +
"delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")"
)
#endif
NotificationCenter.default.post(
name: .browserMoveOmnibarSelection,
object: panelId,
@ -7356,15 +7818,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) {
guard delta != 0 else { return }
guard browserAddressBarFocusedPanelId != nil else { return }
guard browserAddressBarFocusedPanelId != nil else {
#if DEBUG
dlog(
"browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " +
"result=skip_no_focused_address_bar"
)
#endif
return
}
if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta {
#if DEBUG
let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"browser.focus.omnibar.repeat.start panel=\(panelToken) " +
"key=\(keyCode) delta=\(delta) result=reuse"
)
#endif
return
}
stopBrowserOmnibarSelectionRepeat()
browserOmnibarRepeatKeyCode = keyCode
browserOmnibarRepeatDelta = delta
#if DEBUG
let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"browser.focus.omnibar.repeat.start panel=\(panelToken) " +
"key=\(keyCode) delta=\(delta) result=armed"
)
#endif
let start = DispatchWorkItem { [weak self] in
self?.scheduleBrowserOmnibarSelectionRepeatTick()
@ -7376,11 +7860,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func scheduleBrowserOmnibarSelectionRepeatTick() {
browserOmnibarRepeatStartWorkItem = nil
guard browserAddressBarFocusedPanelId != nil else {
#if DEBUG
dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar")
#endif
stopBrowserOmnibarSelectionRepeat()
return
}
guard browserOmnibarRepeatKeyCode != nil else { return }
#if DEBUG
let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"browser.focus.omnibar.repeat.tick panel=\(panelToken) " +
"delta=\(browserOmnibarRepeatDelta)"
)
#endif
dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta)
let tick = DispatchWorkItem { [weak self] in
@ -7391,12 +7885,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func stopBrowserOmnibarSelectionRepeat() {
#if DEBUG
let previousKeyCode = browserOmnibarRepeatKeyCode
let previousDelta = browserOmnibarRepeatDelta
#endif
browserOmnibarRepeatStartWorkItem?.cancel()
browserOmnibarRepeatTickWorkItem?.cancel()
browserOmnibarRepeatStartWorkItem = nil
browserOmnibarRepeatTickWorkItem = nil
browserOmnibarRepeatKeyCode = nil
browserOmnibarRepeatDelta = 0
#if DEBUG
if previousKeyCode != nil || previousDelta != 0 {
dlog(
"browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " +
"delta=\(previousDelta)"
)
}
#endif
}
private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) {
@ -7405,11 +7911,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
switch event.type {
case .keyUp:
if event.keyCode == browserOmnibarRepeatKeyCode {
#if DEBUG
dlog(
"browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " +
"action=stop"
)
#endif
stopBrowserOmnibarSelectionRepeat()
}
case .flagsChanged:
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if !flags.contains(.command) {
#if DEBUG
dlog(
"browser.focus.omnibar.repeat.lifecycle event=flagsChanged " +
"flags=\(flags.rawValue) action=stop"
)
#endif
stopBrowserOmnibarSelectionRepeat()
}
default:
@ -9180,6 +9698,9 @@ enum MenuBarIconRenderer {
private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent?
private var cmuxFirstResponderGuardHitViewOverride: NSView?
#endif
private var cmuxFirstResponderGuardCurrentEventContext: NSEvent?
private var cmuxFirstResponderGuardHitViewContext: NSView?
private var cmuxFirstResponderGuardContextWindowNumber: Int?
private var cmuxBrowserReturnForwardingDepth = 0
private var cmuxWindowFirstResponderBypassDepth = 0
private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0
@ -9221,6 +9742,7 @@ private extension NSWindow {
let responderWebView = responder.flatMap {
Self.cmuxOwningWebView(for: $0, in: self, event: currentEvent)
}
var pointerInitiatedWebFocus = false
if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible(
window: self,
@ -9244,6 +9766,7 @@ private extension NSWindow {
event: currentEvent
)
if pointerInitiatedFocus {
pointerInitiatedWebFocus = true
#if DEBUG
dlog(
"focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " +
@ -9280,7 +9803,16 @@ private extension NSWindow {
)
}
#endif
let result = cmux_makeFirstResponder(responder)
let result: Bool
if pointerInitiatedWebFocus, let webView = responderWebView {
// `NSWindow.makeFirstResponder` may run before `CmuxWebView.mouseDown(with:)`.
// Preserve pointer intent during this synchronous responder change.
result = webView.withPointerFocusAllowance {
cmux_makeFirstResponder(responder)
}
} else {
result = cmux_makeFirstResponder(responder)
}
if result {
if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor {
Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView)
@ -9292,6 +9824,18 @@ private extension NSWindow {
}
@objc func cmux_sendEvent(_ event: NSEvent) {
let previousContextEvent = cmuxFirstResponderGuardCurrentEventContext
let previousContextHitView = cmuxFirstResponderGuardHitViewContext
let previousContextWindowNumber = cmuxFirstResponderGuardContextWindowNumber
cmuxFirstResponderGuardCurrentEventContext = event
cmuxFirstResponderGuardHitViewContext = Self.cmuxHitViewForEventDispatch(in: self, event: event)
cmuxFirstResponderGuardContextWindowNumber = self.windowNumber
defer {
cmuxFirstResponderGuardCurrentEventContext = previousContextEvent
cmuxFirstResponderGuardHitViewContext = previousContextHitView
cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber
}
guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event),
let contentView = self.contentView else {
cmux_sendEvent(event)
@ -9549,37 +10093,63 @@ private extension NSWindow {
return found
}
private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? {
private static func cmuxCurrentEvent(for window: NSWindow) -> NSEvent? {
#if DEBUG
if let override = cmuxFirstResponderGuardCurrentEventOverride {
return override
}
#endif
if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber {
return cmuxFirstResponderGuardCurrentEventContext
}
return NSApp.currentEvent
}
private static func cmuxHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? {
guard let contentView = window.contentView,
let themeFrame = contentView.superview else {
return nil
}
let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil)
return themeFrame.hitTest(pointInTheme)
}
private static func cmuxHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? {
guard let contentView = window.contentView else {
return nil
}
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
}
private static func cmuxTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? {
if let hitInThemeFrame = cmuxHitViewInThemeFrame(in: window, event: event) {
return hitInThemeFrame
}
return cmuxHitViewInContentView(in: window, event: event)
}
private static func cmuxHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? {
if event.windowNumber != 0, event.windowNumber != window.windowNumber {
return nil
}
if let eventWindow = event.window, eventWindow !== window {
return nil
}
return cmuxTopHitViewForEvent(in: window, event: event)
}
private static func cmuxHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? {
#if DEBUG
if let override = cmuxFirstResponderGuardHitViewOverride {
return override
}
#endif
guard let contentView = window.contentView else { return nil }
if contentView.className == "NSGlassEffectView" {
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber,
let contextHitView = cmuxFirstResponderGuardHitViewContext {
return contextHitView
}
if let themeFrame = contentView.superview {
let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil)
if let hit = themeFrame.hitTest(pointInTheme) {
return hit
}
}
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
return cmuxTopHitViewForEvent(in: window, event: event)
}
private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) {

View file

@ -1412,7 +1412,230 @@ final class BrowserPanel: Panel, ObservableObject {
/// Used to keep omnibar text-field focus from being immediately stolen by panel focus.
private var suppressWebViewFocusUntil: Date?
private var suppressWebViewFocusForAddressBar: Bool = false
private var addressBarFocusRestoreGeneration: UInt64 = 0
private let blankURLString = "about:blank"
private static let addressBarFocusCaptureScript = """
(() => {
try {
const syncState = (state) => {
window.__cmuxAddressBarFocusState = state;
try {
if (window.top && window.top !== window) {
window.top.postMessage({ cmuxAddressBarFocusState: state }, "*");
} else if (window.top) {
window.top.__cmuxAddressBarFocusState = state;
}
} catch (_) {}
};
const active = document.activeElement;
if (!active) {
syncState(null);
return "cleared:none";
}
const tag = (active.tagName || "").toLowerCase();
const type = (active.type || "").toLowerCase();
const isEditable =
!!active.isContentEditable ||
tag === "textarea" ||
(tag === "input" && type !== "hidden");
if (!isEditable) {
syncState(null);
return "cleared:noneditable";
}
let id = active.getAttribute("data-cmux-addressbar-focus-id");
if (!id) {
id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
active.setAttribute("data-cmux-addressbar-focus-id", id);
}
const state = { id, selectionStart: null, selectionEnd: null };
if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") {
state.selectionStart = active.selectionStart;
state.selectionEnd = active.selectionEnd;
}
syncState(state);
return "captured:" + id;
} catch (_) {
return "error";
}
})();
"""
private static let addressBarFocusTrackingBootstrapScript = """
(() => {
try {
if (window.__cmuxAddressBarFocusTrackerInstalled) return true;
window.__cmuxAddressBarFocusTrackerInstalled = true;
const syncState = (state) => {
window.__cmuxAddressBarFocusState = state;
try {
if (window.top && window.top !== window) {
window.top.postMessage({ cmuxAddressBarFocusState: state }, "*");
} else if (window.top) {
window.top.__cmuxAddressBarFocusState = state;
}
} catch (_) {}
};
if (window.top === window && !window.__cmuxAddressBarFocusMessageBridgeInstalled) {
window.__cmuxAddressBarFocusMessageBridgeInstalled = true;
window.addEventListener("message", (ev) => {
try {
const data = ev ? ev.data : null;
if (!data || !Object.prototype.hasOwnProperty.call(data, "cmuxAddressBarFocusState")) return;
window.__cmuxAddressBarFocusState = data.cmuxAddressBarFocusState || null;
} catch (_) {}
}, true);
}
const isEditable = (el) => {
if (!el) return false;
const tag = (el.tagName || "").toLowerCase();
const type = (el.type || "").toLowerCase();
return !!el.isContentEditable || tag === "textarea" || (tag === "input" && type !== "hidden");
};
const ensureFocusId = (el) => {
let id = el.getAttribute("data-cmux-addressbar-focus-id");
if (!id) {
id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
el.setAttribute("data-cmux-addressbar-focus-id", id);
}
return id;
};
const snapshot = (el) => {
if (!isEditable(el)) {
syncState(null);
return;
}
const state = {
id: ensureFocusId(el),
selectionStart: null,
selectionEnd: null
};
if (typeof el.selectionStart === "number" && typeof el.selectionEnd === "number") {
state.selectionStart = el.selectionStart;
state.selectionEnd = el.selectionEnd;
}
syncState(state);
};
document.addEventListener("focusin", (ev) => {
snapshot(ev && ev.target ? ev.target : document.activeElement);
}, true);
document.addEventListener("selectionchange", () => {
snapshot(document.activeElement);
}, true);
document.addEventListener("input", () => {
snapshot(document.activeElement);
}, true);
document.addEventListener("mousedown", (ev) => {
const target = ev && ev.target ? ev.target : null;
if (!isEditable(target)) {
syncState(null);
}
}, true);
window.addEventListener("beforeunload", () => {
syncState(null);
}, true);
snapshot(document.activeElement);
return true;
} catch (_) {
return false;
}
})();
"""
private static let addressBarFocusRestoreScript = """
(() => {
try {
const readState = () => {
let state = window.__cmuxAddressBarFocusState;
try {
if ((!state || typeof state.id !== "string" || !state.id) &&
window.top && window.top.__cmuxAddressBarFocusState) {
state = window.top.__cmuxAddressBarFocusState;
}
} catch (_) {}
return state;
};
const clearState = () => {
window.__cmuxAddressBarFocusState = null;
try {
if (window.top && window.top !== window) {
window.top.postMessage({ cmuxAddressBarFocusState: null }, "*");
} else if (window.top) {
window.top.__cmuxAddressBarFocusState = null;
}
} catch (_) {}
};
const state = readState();
if (!state || typeof state.id !== "string" || !state.id) {
return "no_state";
}
const selector = '[data-cmux-addressbar-focus-id="' + state.id + '"]';
const findTarget = (doc) => {
if (!doc) return null;
const direct = doc.querySelector(selector);
if (direct && direct.isConnected) return direct;
const frames = doc.querySelectorAll("iframe,frame");
for (let i = 0; i < frames.length; i += 1) {
const frame = frames[i];
try {
const childDoc = frame.contentDocument;
if (!childDoc) continue;
const nested = findTarget(childDoc);
if (nested) return nested;
} catch (_) {}
}
return null;
};
const target = findTarget(document);
if (!target) {
clearState();
return "missing_target";
}
try {
target.focus({ preventScroll: true });
} catch (_) {
try { target.focus(); } catch (_) {}
}
let focused = false;
try {
focused =
target === target.ownerDocument.activeElement ||
(typeof target.matches === "function" && target.matches(":focus"));
} catch (_) {}
if (!focused) {
return "not_focused";
}
if (
typeof state.selectionStart === "number" &&
typeof state.selectionEnd === "number" &&
typeof target.setSelectionRange === "function"
) {
try {
target.setSelectionRange(state.selectionStart, state.selectionEnd);
} catch (_) {}
}
clearState();
return "restored";
} catch (_) {
return "error";
}
})();
"""
/// Published URL being displayed
@Published private(set) var currentURL: URL?
@ -1561,6 +1784,15 @@ final class BrowserPanel: Panel, ObservableObject {
forMainFrameOnly: false
)
)
// Track the last editable focused element continuously so omnibar exit can
// restore page input focus even if capture runs after first-responder handoff.
config.userContentController.addUserScript(
WKUserScript(
source: Self.addressBarFocusTrackingBootstrapScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
)
let webView = CmuxWebView(frame: .zero, configuration: config)
webView.allowsBackForwardNavigationGestures = true
@ -2750,14 +2982,29 @@ extension BrowserPanel {
func suppressOmnibarAutofocus(for seconds: TimeInterval) {
suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds)
#if DEBUG
dlog(
"browser.focus.omnibarAutofocus.suppress panel=\(id.uuidString.prefix(5)) " +
"seconds=\(String(format: "%.2f", seconds))"
)
#endif
}
func suppressWebViewFocus(for seconds: TimeInterval) {
suppressWebViewFocusUntil = Date().addingTimeInterval(seconds)
#if DEBUG
dlog(
"browser.focus.webView.suppress panel=\(id.uuidString.prefix(5)) " +
"seconds=\(String(format: "%.2f", seconds))"
)
#endif
}
func clearWebViewFocusSuppression() {
suppressWebViewFocusUntil = nil
#if DEBUG
dlog("browser.focus.webView.suppress.clear panel=\(id.uuidString.prefix(5))")
#endif
}
func shouldSuppressOmnibarAutofocus() -> Bool {
@ -2781,12 +3028,17 @@ extension BrowserPanel {
}
func beginSuppressWebViewFocusForAddressBar() {
if !suppressWebViewFocusForAddressBar {
let enteringAddressBar = !suppressWebViewFocusForAddressBar
if enteringAddressBar {
#if DEBUG
dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))")
#endif
invalidateAddressBarPageFocusRestoreAttempts()
}
suppressWebViewFocusForAddressBar = true
if enteringAddressBar {
captureAddressBarPageFocusIfNeeded()
}
}
func endSuppressWebViewFocusForAddressBar() {
@ -2802,16 +3054,175 @@ extension BrowserPanel {
func requestAddressBarFocus() -> UUID {
beginSuppressWebViewFocusForAddressBar()
if let pendingAddressBarFocusRequestId {
#if DEBUG
dlog(
"browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " +
"request=\(pendingAddressBarFocusRequestId.uuidString.prefix(8)) result=reuse_pending"
)
#endif
return pendingAddressBarFocusRequestId
}
let requestId = UUID()
pendingAddressBarFocusRequestId = requestId
#if DEBUG
dlog(
"browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " +
"request=\(requestId.uuidString.prefix(8)) result=new"
)
#endif
return requestId
}
func acknowledgeAddressBarFocusRequest(_ requestId: UUID) {
guard pendingAddressBarFocusRequestId == requestId else { return }
guard pendingAddressBarFocusRequestId == requestId else {
#if DEBUG
dlog(
"browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " +
"request=\(requestId.uuidString.prefix(8)) result=ignored " +
"pending=\(pendingAddressBarFocusRequestId?.uuidString.prefix(8) ?? "nil")"
)
#endif
return
}
pendingAddressBarFocusRequestId = nil
#if DEBUG
dlog(
"browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " +
"request=\(requestId.uuidString.prefix(8)) result=cleared"
)
#endif
}
private func captureAddressBarPageFocusIfNeeded() {
webView.evaluateJavaScript(Self.addressBarFocusCaptureScript) { [weak self] result, error in
#if DEBUG
guard let self else { return }
if let error {
dlog(
"browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " +
"result=error message=\(error.localizedDescription)"
)
return
}
let resultValue = (result as? String) ?? "unknown"
dlog(
"browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " +
"result=\(resultValue)"
)
#else
_ = self
_ = result
_ = error
#endif
}
}
private enum AddressBarPageFocusRestoreStatus: String {
case restored
case noState = "no_state"
case missingTarget = "missing_target"
case notFocused = "not_focused"
case error
}
private static func addressBarPageFocusRestoreStatus(
from result: Any?,
error: Error?
) -> AddressBarPageFocusRestoreStatus {
if error != nil { return .error }
guard let raw = result as? String else { return .error }
return AddressBarPageFocusRestoreStatus(rawValue: raw) ?? .error
}
func invalidateAddressBarPageFocusRestoreAttempts() {
addressBarFocusRestoreGeneration &+= 1
#if DEBUG
dlog(
"browser.focus.addressBar.restore.invalidate panel=\(id.uuidString.prefix(5)) " +
"generation=\(addressBarFocusRestoreGeneration)"
)
#endif
}
func restoreAddressBarPageFocusIfNeeded(completion: @escaping (Bool) -> Void) {
addressBarFocusRestoreGeneration &+= 1
let generation = addressBarFocusRestoreGeneration
let delays: [TimeInterval] = [0.0, 0.03, 0.09, 0.2]
restoreAddressBarPageFocusAttemptIfNeeded(
attempt: 0,
delays: delays,
generation: generation,
completion: completion
)
}
private func restoreAddressBarPageFocusAttemptIfNeeded(
attempt: Int,
delays: [TimeInterval],
generation: UInt64,
completion: @escaping (Bool) -> Void
) {
guard generation == addressBarFocusRestoreGeneration else {
completion(false)
return
}
webView.evaluateJavaScript(Self.addressBarFocusRestoreScript) { [weak self] result, error in
guard let self else {
completion(false)
return
}
guard generation == self.addressBarFocusRestoreGeneration else {
completion(false)
return
}
let status = Self.addressBarPageFocusRestoreStatus(from: result, error: error)
let canRetry = (status == .notFocused || status == .error)
let hasNextAttempt = attempt + 1 < delays.count
#if DEBUG
if let error {
dlog(
"browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " +
"attempt=\(attempt) status=\(status.rawValue) " +
"message=\(error.localizedDescription)"
)
} else {
dlog(
"browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " +
"attempt=\(attempt) status=\(status.rawValue)"
)
}
#endif
if status == .restored {
completion(true)
return
}
if canRetry && hasNextAttempt {
let delay = delays[attempt + 1]
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else {
completion(false)
return
}
guard generation == self.addressBarFocusRestoreGeneration else {
completion(false)
return
}
self.restoreAddressBarPageFocusAttemptIfNeeded(
attempt: attempt + 1,
delays: delays,
generation: generation,
completion: completion
)
}
return
}
completion(false)
}
}
/// Returns the most reliable URL string for omnibar-related matching and UI decisions.

View file

@ -232,6 +232,8 @@ struct BrowserPanelView: View {
@State private var omnibarPillFrame: CGRect = .zero
@State private var addressBarHeight: CGFloat = 0
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var pendingAddressBarFocusRetryRequestId: UUID?
@State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0
@State private var isBrowserThemeMenuPresented = false
@State private var ghosttyBackgroundGeneration: Int = 0
// Keep this below half of the compact omnibar height so it reads as a squircle,
@ -379,7 +381,15 @@ struct BrowserPanelView: View {
"addressFocused=\(addressBarFocused ? 1 : 0)"
)
#endif
onRequestPanelFocus()
if addressBarFocused {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.webViewClickBlur")
#endif
setAddressBarFocused(false, reason: "webView.clickIntent")
}
if !isFocused {
onRequestPanelFocus()
}
}
.onAppear {
UserDefaults.standard.register(defaults: [
@ -399,6 +409,9 @@ struct BrowserPanelView: View {
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
BrowserHistoryStore.shared.loadIfNeeded()
#if DEBUG
logBrowserFocusState(event: "view.onAppear")
#endif
}
.onChange(of: panel.focusFlashToken) { _ in
triggerFocusFlashAnimation()
@ -412,7 +425,7 @@ struct BrowserPanelView: View {
!panel.shouldSuppressWebViewFocus(),
addressWasEmpty,
!isWebViewBlank() {
addressBarFocused = false
setAddressBarFocused(false, reason: "panel.currentURL.loaded")
}
}
.onChange(of: browserThemeModeRaw) { _ in
@ -429,17 +442,30 @@ struct BrowserPanelView: View {
applyPendingAddressBarFocusRequestIfNeeded()
}
.onChange(of: isFocused) { focused in
#if DEBUG
logBrowserFocusState(
event: "panelFocus.onChange",
detail: "next=\(focused ? 1 : 0)"
)
#endif
// Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive).
if focused {
applyPendingAddressBarFocusRequestIfNeeded()
autoFocusOmnibarIfBlank()
} else {
panel.invalidateAddressBarPageFocusRestoreAttempts()
hideSuggestions()
addressBarFocused = false
setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused")
}
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
}
.onChange(of: addressBarFocused) { focused in
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.onChange",
detail: "next=\(focused ? 1 : 0)"
)
#endif
let urlString = panel.preferredURLStringForOmnibar() ?? ""
if focused {
panel.beginSuppressWebViewFocusForAddressBar()
@ -447,6 +473,9 @@ struct BrowserPanelView: View {
// Only request panel focus if this pane isn't currently focused. When already
// focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit.
if !isFocused {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.requestPanelFocus")
#endif
onRequestPanelFocus()
}
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
@ -466,11 +495,17 @@ struct BrowserPanelView: View {
inlineCompletion = nil
}
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.onChange.applied")
#endif
}
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return }
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.moveSelection", detail: "delta=\(delta)")
#endif
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta))
applyOmnibarEffects(effects)
refreshInlineCompletion()
@ -484,7 +519,10 @@ struct BrowserPanelView: View {
return panelId == panel.id
}) { _ in
if addressBarFocused {
addressBarFocused = false
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.externalBlur")
#endif
setAddressBarFocused(false, reason: "notification.externalBlur")
}
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
@ -696,14 +734,14 @@ struct BrowserPanelView: View {
panel.navigateSmart(omnibarState.buffer)
hideSuggestions()
suppressNextFocusLostRevert = true
addressBarFocused = false
setAddressBarFocused(false, reason: "omnibar.submit.navigate")
}
},
onEscape: {
handleOmnibarEscape()
},
onFieldLostFocus: {
addressBarFocused = false
setAddressBarFocused(false, reason: "omnibar.fieldLostFocus")
},
onMoveSelection: { delta in
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
@ -773,6 +811,7 @@ struct BrowserPanelView: View {
},
paneTopChromeHeight: addressBarHeight
)
.accessibilityIdentifier("BrowserWebViewSurface")
// Keep the host stable for normal pane churn, but force a remount when
// BrowserPanel replaces its underlying WKWebView after process termination.
.id(panel.webViewInstanceID)
@ -782,7 +821,10 @@ struct BrowserPanelView: View {
// Chrome-like behavior: clicking web content while editing the
// omnibar should commit blur and revert transient edits.
if addressBarFocused {
addressBarFocused = false
#if DEBUG
logBrowserFocusState(event: "webContent.tapBlur")
#endif
setAddressBarFocused(false, reason: "webContent.tapBlur")
}
})
} else {
@ -792,7 +834,7 @@ struct BrowserPanelView: View {
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
addressBarFocused = false
setAddressBarFocused(false, reason: "placeholderContent.tapBlur")
}
}
}
@ -839,6 +881,82 @@ struct BrowserPanelView: View {
cmuxWebView.allowsFirstResponderAcquisition = next
}
private func setAddressBarFocused(_ focused: Bool, reason: String) {
#if DEBUG
if addressBarFocused == focused {
logBrowserFocusState(
event: "addressBarFocus.write.noop",
detail: "reason=\(reason) value=\(focused ? 1 : 0)"
)
} else {
logBrowserFocusState(
event: "addressBarFocus.write",
detail: "reason=\(reason) old=\(addressBarFocused ? 1 : 0) new=\(focused ? 1 : 0)"
)
}
#endif
addressBarFocused = focused
}
private func browserFocusResponderChainContains(
_ start: NSResponder?,
target: NSResponder
) -> Bool {
var current = start
var hops = 0
while let responder = current, hops < 64 {
if responder === target { return true }
current = responder.nextResponder
hops += 1
}
return false
}
private func isPanelFocusedInModel() -> Bool {
guard let app = AppDelegate.shared,
let manager = app.tabManagerFor(tabId: panel.workspaceId),
manager.selectedTabId == panel.workspaceId,
let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }) else {
return false
}
return workspace.focusedPanelId == panel.id
}
private func shouldApplyAddressBarExitFallback(in window: NSWindow) -> Bool {
panel.webView.window === window && isPanelFocusedInModel()
}
#if DEBUG
private func browserFocusWindow() -> NSWindow? {
panel.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
}
private func browserFocusResponderDescription(_ responder: NSResponder?) -> String {
guard let responder else { return "nil" }
return String(describing: type(of: responder))
}
private func logBrowserFocusState(event: String, detail: String = "") {
let window = browserFocusWindow()
let firstResponder = window?.firstResponder
let firstResponderType = browserFocusResponderDescription(firstResponder)
let webResponder = browserFocusResponderChainContains(firstResponder, target: panel.webView) ? 1 : 0
var line =
"browser.focus.trace event=\(event) panel=\(panel.id.uuidString.prefix(5)) " +
"panelFocused=\(isFocused ? 1 : 0) addrFocused=\(addressBarFocused ? 1 : 0) " +
"suppressWeb=\(panel.shouldSuppressWebViewFocus() ? 1 : 0) " +
"suppressAuto=\(panel.shouldSuppressOmnibarAutofocus() ? 1 : 0) " +
"webResponder=\(webResponder) win=\(window?.windowNumber ?? -1) fr=\(firstResponderType)"
if let pending = panel.pendingAddressBarFocusRequestId {
line += " pending=\(pending.uuidString.prefix(8))"
}
if !detail.isEmpty {
line += " \(detail)"
}
dlog(line)
}
#endif
private func syncURLFromPanel() {
let urlString = panel.preferredURLStringForOmnibar() ?? ""
let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString))
@ -868,12 +986,57 @@ struct BrowserPanelView: View {
return false
}
private func clearPendingAddressBarFocusRetry() {
pendingAddressBarFocusRetryRequestId = nil
pendingAddressBarFocusRetryGeneration &+= 1
}
private func schedulePendingAddressBarFocusRetryIfNeeded(requestId: UUID) {
guard pendingAddressBarFocusRetryRequestId != requestId else { return }
pendingAddressBarFocusRetryRequestId = requestId
pendingAddressBarFocusRetryGeneration &+= 1
let generation = pendingAddressBarFocusRetryGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) {
guard pendingAddressBarFocusRetryGeneration == generation else { return }
pendingAddressBarFocusRetryRequestId = nil
guard panel.pendingAddressBarFocusRequestId == requestId else { return }
applyPendingAddressBarFocusRequestIfNeeded()
}
}
private func applyPendingAddressBarFocusRequestIfNeeded() {
guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
guard lastHandledAddressBarFocusRequestId != requestId else { return }
guard let requestId = panel.pendingAddressBarFocusRequestId else {
clearPendingAddressBarFocusRetry()
return
}
guard !isCommandPaletteVisibleForPanelWindow() else {
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply.skip",
detail: "reason=command_palette_visible request=\(requestId.uuidString.prefix(8))"
)
#endif
schedulePendingAddressBarFocusRetryIfNeeded(requestId: requestId)
return
}
clearPendingAddressBarFocusRetry()
guard lastHandledAddressBarFocusRequestId != requestId else {
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply.skip",
detail: "reason=already_handled request=\(requestId.uuidString.prefix(8))"
)
#endif
return
}
lastHandledAddressBarFocusRequestId = requestId
panel.beginSuppressWebViewFocusForAddressBar()
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply",
detail: "request=\(requestId.uuidString.prefix(8))"
)
#endif
if addressBarFocused {
// Re-run focus behavior (select-all/refresh suggestions) when focus is
@ -882,11 +1045,29 @@ struct BrowserPanelView: View {
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
applyOmnibarEffects(effects)
refreshInlineCompletion()
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply",
detail: "request=\(requestId.uuidString.prefix(8)) mode=refresh"
)
#endif
} else {
addressBarFocused = true
setAddressBarFocused(true, reason: "request.apply")
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply",
detail: "request=\(requestId.uuidString.prefix(8)) mode=set_focused"
)
#endif
}
panel.acknowledgeAddressBarFocusRequest(requestId)
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.ack",
detail: "request=\(requestId.uuidString.prefix(8))"
)
#endif
}
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
@ -896,15 +1077,48 @@ struct BrowserPanelView: View {
}
private func autoFocusOmnibarIfBlank() {
guard isFocused else { return }
guard !addressBarFocused else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
guard isFocused else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=panel_not_focused")
#endif
return
}
guard !addressBarFocused else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=already_focused")
#endif
return
}
guard !isCommandPaletteVisibleForPanelWindow() else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=command_palette_visible")
#endif
return
}
// If a test/automation explicitly focused WebKit, don't steal focus back.
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
guard !panel.shouldSuppressOmnibarAutofocus() else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=autofocus_suppressed")
#endif
return
}
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
guard !panel.webView.isLoading else { return }
guard isWebViewBlank() else { return }
addressBarFocused = true
guard !panel.webView.isLoading else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_loading")
#endif
return
}
guard isWebViewBlank() else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_not_blank")
#endif
return
}
setAddressBarFocused(true, reason: "autoFocus.blank")
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.apply")
#endif
}
private func openDevTools() {
@ -924,13 +1138,15 @@ struct BrowserPanelView: View {
}
private func handleOmnibarTap() {
onRequestPanelFocus()
guard !addressBarFocused else { return }
// `focusPane` converges selection and can transiently move first responder to WebKit.
// Reassert omnibar focus on the next runloop for click-to-type behavior.
DispatchQueue.main.async {
addressBarFocused = true
#if DEBUG
logBrowserFocusState(event: "addressBar.tap")
#endif
if !addressBarFocused {
// Mark focused before pane selection converges so WebKit focus is not
// briefly re-acquired during `focusPane`.
setAddressBarFocused(true, reason: "omnibar.tap")
}
onRequestPanelFocus()
}
private func hideSuggestions() {
@ -961,7 +1177,7 @@ struct BrowserPanelView: View {
hideSuggestions()
inlineCompletion = nil
suppressNextFocusLostRevert = true
addressBarFocused = false
setAddressBarFocused(false, reason: "suggestion.commit")
}
private func handleOmnibarEscape() {
@ -1262,14 +1478,58 @@ struct BrowserPanelView: View {
}
if effects.shouldBlurToWebView {
hideSuggestions()
addressBarFocused = false
// This transition is stateful: drop omnibar focus suppression before
// attempting responder handoff so WKWebView can actually become first responder.
panel.endSuppressWebViewFocusForAddressBar()
syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.preHandoff")
setAddressBarFocused(false, reason: "effects.blurToWebView")
DispatchQueue.main.async {
guard isFocused else { return }
guard let window = panel.webView.window,
!panel.webView.isHiddenOrHasHiddenAncestor else { return }
guard shouldApplyAddressBarExitFallback(in: window) else {
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
"result=skip_not_focused"
)
#endif
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
return
}
syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.handoff")
panel.clearWebViewFocusSuppression()
window.makeFirstResponder(panel.webView)
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
let focusedWebView = window.makeFirstResponder(panel.webView)
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
"focusedWebView=\(focusedWebView ? 1 : 0)"
)
#endif
panel.restoreAddressBarPageFocusIfNeeded { restored in
guard shouldApplyAddressBarExitFallback(in: window) else {
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
"result=skip_stale_restore restored=\(restored ? 1 : 0)"
)
#endif
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
return
}
let hasWebViewResponder =
browserFocusResponderChainContains(window.firstResponder, target: panel.webView)
if !hasWebViewResponder {
let fallbackFocusedWebView = window.makeFirstResponder(panel.webView)
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
"fallbackFocusedWebView=\(fallbackFocusedWebView ? 1 : 0) " +
"restored=\(restored ? 1 : 0)"
)
#endif
}
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
}
}
}
}
@ -2282,10 +2542,10 @@ struct OmnibarSuggestion: Identifiable, Hashable {
}
func browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: Bool,
desiredOmnibarFocus: Bool,
nextResponderIsOtherTextField: Bool
) -> Bool {
suppressWebViewFocus && !nextResponderIsOtherTextField
desiredOmnibarFocus && !nextResponderIsOtherTextField
}
private final class OmnibarNativeTextField: NSTextField {
@ -2310,7 +2570,11 @@ private final class OmnibarNativeTextField: NSTextField {
override func mouseDown(with event: NSEvent) {
#if DEBUG
dlog("browser.omnibarClick")
let frType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.omnibarClick win=\(window?.windowNumber ?? -1) " +
"fr=\(frType) hasEditor=\(currentEditor() == nil ? 0 : 1)"
)
#endif
onPointerDown?()
@ -2318,7 +2582,14 @@ private final class OmnibarNativeTextField: NSTextField {
// First click activate editing and select all (standard URL bar behavior).
// Avoids NSTextView's tracking loop which can spin forever if text layout
// enters an infinite invalidation cycle (e.g. under memory pressure).
window?.makeFirstResponder(self)
let result = window?.makeFirstResponder(self) ?? false
#if DEBUG
let frAfter = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.omnibarClick.makeFirstResponder result=\(result ? 1 : 0) " +
"win=\(window?.windowNumber ?? -1) fr=\(frAfter)"
)
#endif
currentEditor()?.selectAll(nil)
shiftClickAnchor = nil
} else {
@ -2432,6 +2703,35 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
self.parent = parent
}
#if DEBUG
func logFocusEvent(_ event: String, detail: String = "") {
let window = parentField?.window
let responder = window?.firstResponder
let responderType = responder.map { String(describing: type(of: $0)) } ?? "nil"
let responderIsField: Int = {
guard let field = parentField else { return 0 }
if responder === field { return 1 }
if let editor = responder as? NSTextView,
(editor.delegate as? NSTextField) === field {
return 1
}
return 0
}()
let pendingValue: String = {
guard let pendingFocusRequest else { return "nil" }
return pendingFocusRequest ? "focus" : "blur"
}()
var line =
"browser.focus.field event=\(event) focused=\(parent.isFocused ? 1 : 0) " +
"pending=\(pendingValue) suppressWeb=\(parent.shouldSuppressWebViewFocus() ? 1 : 0) " +
"win=\(window?.windowNumber ?? -1) fr=\(responderType) frIsField=\(responderIsField)"
if !detail.isEmpty {
line += " \(detail)"
}
dlog(line)
}
#endif
deinit {
if let selectionObserver {
NotificationCenter.default.removeObserver(selectionObserver)
@ -2454,16 +2754,77 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
return false
}
private func isPointerDownEvent(_ event: NSEvent) -> Bool {
switch event.type {
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
return true
default:
return false
}
}
private func topHitViewForCurrentPointerEvent(window: NSWindow) -> NSView? {
guard let event = NSApp.currentEvent, isPointerDownEvent(event) else {
return nil
}
if event.windowNumber != 0, event.windowNumber != window.windowNumber {
return nil
}
if let eventWindow = event.window, eventWindow !== window {
return nil
}
if let contentView = window.contentView,
let themeFrame = contentView.superview {
let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil)
if let hitInTheme = themeFrame.hitTest(pointInTheme) {
return hitInTheme
}
}
guard let contentView = window.contentView else {
return nil
}
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
}
private func pointerDownBlurIntent(window: NSWindow?) -> Bool {
guard let window, let field = parentField else { return false }
guard let hitView = topHitViewForCurrentPointerEvent(window: window) else {
return false
}
if hitView === field || hitView.isDescendant(of: field) {
return false
}
if let textView = hitView as? NSTextView,
let delegateField = textView.delegate as? NSTextField,
delegateField === field {
return false
}
return true
}
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
if pointerDownBlurIntent(window: window) {
return false
}
return browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: parent.shouldSuppressWebViewFocus(),
desiredOmnibarFocus: parent.isFocused,
nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
)
}
func controlTextDidBeginEditing(_ obj: Notification) {
#if DEBUG
logFocusEvent("controlTextDidBeginEditing")
#endif
if !parent.isFocused {
DispatchQueue.main.async {
#if DEBUG
self.logFocusEvent("controlTextDidBeginEditing.asyncSetFocused", detail: "old=0 new=1")
#endif
self.parent.isFocused = true
}
}
@ -2472,16 +2833,33 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
func controlTextDidEndEditing(_ obj: Notification) {
#if DEBUG
let nextOther = nextResponderIsOtherTextField(window: parentField?.window)
let pointerBlur = pointerDownBlurIntent(window: parentField?.window)
logFocusEvent(
"controlTextDidEndEditing",
detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)"
)
#endif
if parent.isFocused {
if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
#if DEBUG
logFocusEvent("controlTextDidEndEditing.reacquire.begin")
#endif
guard pendingFocusRequest != true else { return }
pendingFocusRequest = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pendingFocusRequest = nil
#if DEBUG
self.logFocusEvent("controlTextDidEndEditing.reacquire.tick")
#endif
guard self.parent.isFocused else { return }
guard let field = self.parentField, let window = field.window else { return }
guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
#if DEBUG
self.logFocusEvent("controlTextDidEndEditing.reacquire.cancel")
#endif
self.parent.onFieldLostFocus()
return
}
@ -2492,11 +2870,21 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
field.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
if !isAlreadyFocused {
#if DEBUG
self.logFocusEvent("controlTextDidEndEditing.reacquire.apply")
#endif
window.makeFirstResponder(field)
} else {
#if DEBUG
self.logFocusEvent("controlTextDidEndEditing.reacquire.skip", detail: "reason=already_focused")
#endif
}
}
return
}
#if DEBUG
logFocusEvent("controlTextDidEndEditing.blur")
#endif
parent.onFieldLostFocus()
}
detachSelectionObserver()
@ -2725,28 +3113,66 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
nsView.currentEditor() != nil ||
((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
#if DEBUG
context.coordinator.logFocusEvent(
"updateNSView.requestFocus.begin",
detail: "isFocused=1 isFirstResponder=0"
)
#endif
// Defer to avoid triggering input method XPC during layout pass,
// which can crash via re-entrant view hierarchy modification.
context.coordinator.pendingFocusRequest = true
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let nsView, let window = nsView.window else { return }
#if DEBUG
if coordinator?.parent.isFocused != true {
coordinator?.logFocusEvent("updateNSView.requestFocus.cancel", detail: "reason=stale_state")
return
}
#endif
guard coordinator?.parent.isFocused == true else { return }
#if DEBUG
coordinator?.logFocusEvent("updateNSView.requestFocus.tick")
#endif
let fr = window.firstResponder
let alreadyFocused = fr === nsView ||
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
guard !alreadyFocused else { return }
#if DEBUG
coordinator?.logFocusEvent("updateNSView.requestFocus.apply")
#endif
window.makeFirstResponder(nsView)
}
} else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false {
#if DEBUG
context.coordinator.logFocusEvent(
"updateNSView.requestBlur.begin",
detail: "isFocused=0 isFirstResponder=1"
)
#endif
context.coordinator.pendingFocusRequest = false
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let nsView, let window = nsView.window else { return }
#if DEBUG
if coordinator?.parent.isFocused == true {
coordinator?.logFocusEvent("updateNSView.requestBlur.cancel", detail: "reason=stale_state")
return
}
#endif
guard coordinator?.parent.isFocused == false else { return }
#if DEBUG
coordinator?.logFocusEvent("updateNSView.requestBlur.tick")
#endif
let fr = window.firstResponder
let stillFirst = fr === nsView ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
guard stillFirst else { return }
#if DEBUG
coordinator?.logFocusEvent("updateNSView.requestBlur.apply")
#endif
window.makeFirstResponder(nil)
}
}
@ -3995,20 +4421,53 @@ struct WebViewRepresentable: NSViewRepresentable {
isPanelFocused: Bool
) {
// Focus handling. Avoid fighting the address bar when it is focused.
guard let window = nsView.window else { return }
guard let window = nsView.window else {
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=skip reason=no_window shouldFocus=\(shouldFocusWebView ? 1 : 0) " +
"panelFocused=\(isPanelFocused ? 1 : 0)"
)
#endif
return
}
if shouldFocusWebView {
if panel.shouldSuppressWebViewFocus() {
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=skip reason=suppressed panelFocused=\(isPanelFocused ? 1 : 0)"
)
#endif
return
}
if responderChainContains(window.firstResponder, target: webView) {
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=skip reason=already_first_responder_chain"
)
#endif
return
}
window.makeFirstResponder(webView)
let result = window.makeFirstResponder(webView)
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))"
)
#endif
} else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
// Only force-resign WebView focus when this panel itself is not focused.
// If the panel is focused but the omnibar-focus state is briefly stale, aggressively
// clearing first responder here can undo programmatic webview focus (socket tests).
window.makeFirstResponder(nil)
let result = window.makeFirstResponder(nil)
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=resign result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))"
)
#endif
}
}

View file

@ -93,7 +93,7 @@ final class CmuxWebView: WKWebView {
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
/// (mouse click into this webview) while keeping background autofocus blocked.
func withPointerFocusAllowance(_ body: () -> Void) {
func withPointerFocusAllowance<T>(_ body: () -> T) -> T {
pointerFocusAllowanceDepth += 1
#if DEBUG
dlog(
@ -110,7 +110,7 @@ final class CmuxWebView: WKWebView {
)
#endif
}
body()
return body()
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {

View file

@ -11553,10 +11553,10 @@ final class TerminalControllerSocketTextChunkTests: XCTestCase {
}
final class BrowserOmnibarFocusPolicyTests: XCTestCase {
func testReacquiresFocusWhenWebViewSuppressionIsActiveAndNextResponderIsNotAnotherTextField() {
func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() {
XCTAssertTrue(
browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: true,
desiredOmnibarFocus: true,
nextResponderIsOtherTextField: false
)
)
@ -11565,16 +11565,16 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase {
func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() {
XCTAssertFalse(
browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: true,
desiredOmnibarFocus: true,
nextResponderIsOtherTextField: true
)
)
}
func testDoesNotReacquireFocusWhenWebViewSuppressionIsInactive() {
func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() {
XCTAssertFalse(
browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: false,
desiredOmnibarFocus: false,
nextResponderIsOtherTextField: false
)
)

View file

@ -171,6 +171,154 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
}
func testEscapeRestoresFocusedPageInputAfterCmdL() {
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"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1"
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(
keys: [
"browserPanelId",
"webViewFocused",
"webInputFocusSeeded",
"webInputFocusElementId",
"webInputFocusSecondaryElementId",
"webInputFocusSecondaryClickOffsetX",
"webInputFocusSecondaryClickOffsetY"
],
timeout: 12.0
),
"Expected setup data including focused page input to be written"
)
guard let setup = loadData() else {
XCTFail("Missing goto_split setup data")
return
}
XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test")
XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+L")
guard let expectedInputId = setup["webInputFocusElementId"], !expectedInputId.isEmpty else {
XCTFail("Missing webInputFocusElementId in setup data")
return
}
guard let expectedSecondaryInputId = setup["webInputFocusSecondaryElementId"], !expectedSecondaryInputId.isEmpty else {
XCTFail("Missing webInputFocusSecondaryElementId in setup data")
return
}
guard let secondaryClickOffsetXRaw = setup["webInputFocusSecondaryClickOffsetX"],
let secondaryClickOffsetYRaw = setup["webInputFocusSecondaryClickOffsetY"],
let secondaryClickOffsetX = Double(secondaryClickOffsetXRaw),
let secondaryClickOffsetY = Double(secondaryClickOffsetYRaw) else {
XCTFail(
"Missing or invalid secondary input click offsets in setup data. " +
"webInputFocusSecondaryClickOffsetX=\(setup["webInputFocusSecondaryClickOffsetX"] ?? "nil") " +
"webInputFocusSecondaryClickOffsetY=\(setup["webInputFocusSecondaryClickOffsetY"] ?? "nil")"
)
return
}
app.typeKey("l", modifierFlags: [.command])
XCTAssertTrue(
waitForDataMatch(timeout: 5.0) { data in
data["webViewFocusedAfterAddressBarFocus"] == "false"
},
"Expected Cmd+L to focus omnibar"
)
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
if !waitForDataMatch(timeout: 2.0, predicate: { data in
data["webViewFocusedAfterAddressBarExit"] == "true" &&
data["addressBarExitActiveElementId"] == expectedInputId &&
data["addressBarExitActiveElementEditable"] == "true"
}) {
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
}
let restoredExpectedInput = waitForDataMatch(timeout: 6.0) { data in
data["webViewFocusedAfterAddressBarExit"] == "true" &&
data["addressBarExitActiveElementId"] == expectedInputId &&
data["addressBarExitActiveElementEditable"] == "true"
}
if !restoredExpectedInput {
let snapshot = loadData() ?? [:]
XCTFail(
"Expected Escape to restore focus to the previously focused page input. " +
"expectedInputId=\(expectedInputId) " +
"webViewFocusedAfterAddressBarExit=\(snapshot["webViewFocusedAfterAddressBarExit"] ?? "nil") " +
"addressBarExitActiveElementId=\(snapshot["addressBarExitActiveElementId"] ?? "nil") " +
"addressBarExitActiveElementTag=\(snapshot["addressBarExitActiveElementTag"] ?? "nil") " +
"addressBarExitActiveElementType=\(snapshot["addressBarExitActiveElementType"] ?? "nil") " +
"addressBarExitActiveElementEditable=\(snapshot["addressBarExitActiveElementEditable"] ?? "nil") " +
"addressBarExitTrackedFocusStateId=\(snapshot["addressBarExitTrackedFocusStateId"] ?? "nil") " +
"addressBarExitFocusTrackerInstalled=\(snapshot["addressBarExitFocusTrackerInstalled"] ?? "nil") " +
"addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " +
"addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " +
"addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil") " +
"webInputFocusElementId=\(snapshot["webInputFocusElementId"] ?? "nil") " +
"webInputFocusTrackerInstalled=\(snapshot["webInputFocusTrackerInstalled"] ?? "nil") " +
"webInputFocusTrackedStateId=\(snapshot["webInputFocusTrackedStateId"] ?? "nil")"
)
}
let window = app.windows.firstMatch
XCTAssertTrue(
window.waitForExistence(timeout: 6.0),
"Expected app window for post-escape click regression check"
)
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
window
.coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
.withOffset(CGVector(dx: secondaryClickOffsetX, dy: secondaryClickOffsetY))
.click()
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
app.typeKey("l", modifierFlags: [.command])
let clickMovedFocusToSecondary = waitForDataMatch(timeout: 6.0) { data in
data["webViewFocusedAfterAddressBarFocus"] == "false" &&
data["addressBarFocusActiveElementId"] == expectedSecondaryInputId &&
data["addressBarFocusActiveElementEditable"] == "true"
}
if !clickMovedFocusToSecondary {
let snapshot = loadData() ?? [:]
XCTFail(
"Expected post-escape click to focus secondary page input before Cmd+L. " +
"secondaryInputId=\(expectedSecondaryInputId) " +
"addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " +
"addressBarFocusActiveElementTag=\(snapshot["addressBarFocusActiveElementTag"] ?? "nil") " +
"addressBarFocusActiveElementType=\(snapshot["addressBarFocusActiveElementType"] ?? "nil") " +
"addressBarFocusActiveElementEditable=\(snapshot["addressBarFocusActiveElementEditable"] ?? "nil") " +
"addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " +
"addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil")"
)
}
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
if !waitForDataMatch(timeout: 2.0, predicate: { data in
data["webViewFocusedAfterAddressBarExit"] == "true" &&
data["addressBarExitActiveElementId"] == expectedSecondaryInputId &&
data["addressBarExitActiveElementEditable"] == "true"
}) {
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
}
XCTAssertTrue(
waitForDataMatch(timeout: 6.0) { data in
data["webViewFocusedAfterAddressBarExit"] == "true" &&
data["addressBarExitActiveElementId"] == expectedSecondaryInputId &&
data["addressBarExitActiveElementEditable"] == "true"
},
"Expected Escape to restore focus to the clicked secondary page input"
)
}
func testCmdLOpensBrowserWhenTerminalFocused() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath