diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index e17b02090a..2ae8ec56cf 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -1511,12 +1511,38 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown(
) -> Bool {
guard firstResponderIsBrowser else { return false }
guard keyCode == 36 || keyCode == 76 else { return false }
- // Keep browser Return forwarding narrow: only plain/Shift Return should be
- // treated as submit-intent. Command-modified Return is reserved for app shortcuts
- // like Toggle Pane Zoom (Cmd+Shift+Enter).
return browserOmnibarShouldSubmitOnReturn(flags: flags)
}
+func shouldDirectRouteBrowserFirstResponderKeyDown(
+ firstResponder: NSResponder?,
+ firstResponderIsBrowser: Bool,
+ focusedBrowserAddressBarPanelId: UUID?
+) -> Bool {
+ guard firstResponderIsBrowser else { return false }
+ guard focusedBrowserAddressBarPanelId == nil else { return false }
+ return firstResponder != nil
+}
+
+enum BrowserDirectKeyRoutingStrategy: Equatable {
+ case keyDown
+ case menuOrAppShortcutThenKeyDown
+ case deferToOriginalPerformKeyEquivalent
+}
+
+func browserDirectKeyRoutingStrategy(for event: NSEvent) -> BrowserDirectKeyRoutingStrategy {
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ guard flags.contains(.command) else {
+ return .keyDown
+ }
+
+ guard shouldRouteCommandEquivalentDirectlyToMainMenu(event) else {
+ return .deferToOriginalPerformKeyEquivalent
+ }
+
+ return .menuOrAppShortcutThenKeyDown
+}
+
func shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: NSEvent.ModifierFlags,
chars: String,
@@ -2021,6 +2047,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var browserOmnibarRepeatDelta: Int = 0
private var browserAddressBarFocusObserver: NSObjectProtocol?
private var browserAddressBarBlurObserver: NSObjectProtocol?
+ private var browserWebViewClickObserver: NSObjectProtocol?
+ private var browserWebViewFocusObserver: NSObjectProtocol?
private let updateController = UpdateController()
private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel)
private let windowDecorationsController = WindowDecorationsController()
@@ -2074,10 +2102,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var didSetupGotoSplitUITest = false
private var didSetupBonsplitTabDragUITest = false
private var bonsplitTabDragUITestRecorder: DispatchSourceTimer?
+ private var gotoSplitUITestArrowRecorder: DispatchSourceTimer?
+ private var gotoSplitUITestInputSetupGeneration = 0
+ private var gotoSplitUITestContentEditableSetupGeneration = 0
private var gotoSplitUITestObservers: [NSObjectProtocol] = []
private var didSetupMultiWindowNotificationsUITest = false
private var didSetupDisplayResolutionUITestDiagnostics = false
private var displayResolutionUITestObservers: [NSObjectProtocol] = []
+ private var uiTestStartupActivationWorkItems: [DispatchWorkItem] = []
+ private var uiTestStartupActivationGeneration = 0
private struct UITestRenderDiagnosticsSnapshot {
let panelId: UUID
let drawCount: Int
@@ -2365,6 +2398,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM.
// If there are no windows shortly after launch, force-create one so XCUITest can proceed.
if isRunningUnderXCTest {
+ ensureUITestStartupForegroundIfNeeded()
if let rawVariant = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] {
UserDefaults.standard.set(
BrowserImportHintSettings.variant(for: rawVariant).rawValue,
@@ -2389,6 +2423,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
self.openNewMainWindow(nil)
}
self.moveUITestWindowToTargetDisplayIfNeeded()
+ self.ensureUITestStartupForegroundIfNeeded()
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
// On headless CI runners, activate() silently fails (no GUI session).
// Force windows visible so the terminal surface starts rendering.
@@ -2438,9 +2473,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
payload["pid"] = String(ProcessInfo.processInfo.processIdentifier)
payload["bundleId"] = Bundle.main.bundleIdentifier ?? ""
payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0"
+ payload["appIsActive"] = NSApp.isActive ? "1" : "0"
payload["windowsCount"] = String(windows.count)
payload["windowIdentifiers"] = ids
payload["windowVisibleFlags"] = vis
+ payload["keyWindowIdentifier"] = NSApp.keyWindow?.identifier?.rawValue ?? ""
+ payload["mainWindowIdentifier"] = NSApp.mainWindow?.identifier?.rawValue ?? ""
payload["windowScreenDisplayIDs"] = screenIDs
payload["uiTestTargetDisplayID"] = targetDisplayID
if let rawDisplayID = UInt32(targetDisplayID) {
@@ -2569,6 +2607,81 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
+ private func ensureUITestStartupForegroundIfNeeded() {
+ let env = ProcessInfo.processInfo.environment
+ guard isRunningUnderXCTest(env) else { return }
+
+ cancelUITestStartupActivation()
+ uiTestStartupActivationGeneration += 1
+ let generation = uiTestStartupActivationGeneration
+ let delays: [TimeInterval] = [0, 0.05, 0.2, 0.5, 1.0, 2.0, 4.0, 8.0, 12.0]
+
+ for (index, delay) in delays.enumerated() {
+ let isFinalAttempt = index == delays.count - 1
+ let workItem = DispatchWorkItem { [weak self] in
+ self?.applyUITestStartupForegroundAttempt(
+ generation: generation,
+ attempt: index,
+ isFinalAttempt: isFinalAttempt
+ )
+ }
+ uiTestStartupActivationWorkItems.append(workItem)
+ if delay == 0 {
+ DispatchQueue.main.async(execute: workItem)
+ } else {
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
+ }
+ }
+ }
+
+ private func applyUITestStartupForegroundAttempt(
+ generation: Int,
+ attempt: Int,
+ isFinalAttempt: Bool
+ ) {
+ guard uiTestStartupActivationGeneration == generation else { return }
+
+ if let window = preferredUITestStartupWindow() {
+ NSApp.unhide(nil)
+ if !window.isMainWindow {
+ window.makeMain()
+ }
+ if !window.isKeyWindow {
+ window.makeKey()
+ }
+ window.orderFrontRegardless()
+ window.makeKeyAndOrderFront(nil)
+ } else if NSApp.windows.isEmpty {
+ openNewMainWindow(nil)
+ }
+
+ NSApp.activate(ignoringOtherApps: true)
+ _ = NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
+ writeUITestDiagnosticsIfNeeded(stage: "startupActivate.\(attempt)")
+
+ if (NSApp.isActive && NSApp.keyWindow != nil) || isFinalAttempt {
+ cancelUITestStartupActivation()
+ }
+ }
+
+ private func preferredUITestStartupWindow() -> NSWindow? {
+ if let mainWindow = NSApp.windows.first(where: { window in
+ guard let raw = window.identifier?.rawValue else { return false }
+ return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
+ }) {
+ return mainWindow
+ }
+ if let visibleWindow = NSApp.windows.first(where: \.isVisible) {
+ return visibleWindow
+ }
+ return NSApp.windows.first
+ }
+
+ private func cancelUITestStartupActivation() {
+ uiTestStartupActivationWorkItems.forEach { $0.cancel() }
+ uiTestStartupActivationWorkItems.removeAll()
+ }
+
private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) {
let env = ProcessInfo.processInfo.environment
guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"],
@@ -7349,7 +7462,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard gotoSplitUITestObservers.isEmpty else { return }
gotoSplitUITestObservers.append(NotificationCenter.default.addObserver(
- forName: .browserFocusAddressBar,
+ forName: .browserDidFocusAddressBar,
object: nil,
queue: .main
) { [weak self] notification in
@@ -7471,14 +7584,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel) {
let script = """
(() => {
+ const readArrowState = () => {
+ const report = window.__cmuxArrowKeyReport || {
+ down: 0,
+ up: 0,
+ commandShiftDown: 0,
+ commandShiftUp: 0
+ };
+ const active = document.activeElement;
+ return {
+ arrowDown: Number(report.down || 0),
+ arrowUp: Number(report.up || 0),
+ commandShiftDown: Number(report.commandShiftDown || 0),
+ commandShiftUp: Number(report.commandShiftUp || 0),
+ selectionStart: active && typeof active.selectionStart === "number" ? active.selectionStart : null,
+ selectionEnd: active && typeof active.selectionEnd === "number" ? active.selectionEnd : null
+ };
+ };
const snapshot = () => {
const active = document.activeElement;
+ const arrowState = readArrowState();
return {
focused: false,
id: "",
secondaryId: "",
+ textareaId: "",
secondaryCenterX: -1,
secondaryCenterY: -1,
+ textareaCenterX: -1,
+ textareaCenterY: -1,
activeId: active && typeof active.id === "string" ? active.id : "",
activeTag: active && active.tagName ? active.tagName.toLowerCase() : "",
trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true,
@@ -7487,10 +7621,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
typeof window.__cmuxAddressBarFocusState.id === "string"
? window.__cmuxAddressBarFocusState.id
: "",
- readyState: String(document.readyState || "")
+ readyState: String(document.readyState || ""),
+ arrowDown: arrowState.arrowDown,
+ arrowUp: arrowState.arrowUp,
+ commandShiftDown: arrowState.commandShiftDown,
+ commandShiftUp: arrowState.commandShiftUp,
+ selectionStart: arrowState.selectionStart,
+ selectionEnd: arrowState.selectionEnd
};
};
const seed = () => {
+ if (!document.body) {
+ return snapshot();
+ }
const ensureInput = (id, value) => {
const existing = document.getElementById(id);
const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input")
@@ -7518,6 +7661,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
input.style.color = "black";
return input;
};
+ const ensureTextarea = (id, value) => {
+ const existing = document.getElementById(id);
+ const textarea = (existing && existing.tagName && existing.tagName.toLowerCase() === "textarea")
+ ? existing
+ : (() => {
+ const created = document.createElement("textarea");
+ created.id = id;
+ created.value = value;
+ return created;
+ })();
+ textarea.autocapitalize = "off";
+ textarea.autocomplete = "off";
+ textarea.spellcheck = false;
+ textarea.rows = 4;
+ textarea.wrap = "soft";
+ textarea.style.display = "block";
+ textarea.style.width = "100%";
+ textarea.style.minHeight = "96px";
+ textarea.style.margin = "0";
+ textarea.style.padding = "8px 10px";
+ textarea.style.border = "1px solid #5f6368";
+ textarea.style.borderRadius = "6px";
+ textarea.style.boxSizing = "border-box";
+ textarea.style.fontSize = "14px";
+ textarea.style.fontFamily = "system-ui, -apple-system, sans-serif";
+ textarea.style.background = "white";
+ textarea.style.color = "black";
+ textarea.style.lineHeight = "1.4";
+ textarea.style.resize = "none";
+ return textarea;
+ };
let container = document.getElementById("cmux-ui-test-focus-container");
if (!container || !container.tagName || container.tagName.toLowerCase() !== "div") {
@@ -7540,12 +7714,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
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");
+ const textarea = ensureTextarea(
+ "cmux-ui-test-focus-textarea",
+ "cmux-ui-focus-textarea line 1\\ncmux-ui-focus-textarea line 2\\ncmux-ui-focus-textarea line 3"
+ );
if (input.parentElement !== container) {
container.appendChild(input);
}
if (secondaryInput.parentElement !== container) {
container.appendChild(secondaryInput);
}
+ if (textarea.parentElement !== container) {
+ container.appendChild(textarea);
+ }
+
+ if (!window.__cmuxArrowKeyReport || typeof window.__cmuxArrowKeyReport !== "object") {
+ window.__cmuxArrowKeyReport = {
+ down: 0,
+ up: 0,
+ commandShiftDown: 0,
+ commandShiftUp: 0
+ };
+ }
+ if (typeof window.__cmuxArrowKeyReport.commandShiftDown !== "number") {
+ window.__cmuxArrowKeyReport.commandShiftDown = 0;
+ }
+ if (typeof window.__cmuxArrowKeyReport.commandShiftUp !== "number") {
+ window.__cmuxArrowKeyReport.commandShiftUp = 0;
+ }
+ const installArrowTracker = (element) => {
+ if (!element || element.__cmuxArrowKeyReportInstalled) return;
+ element.__cmuxArrowKeyReportInstalled = true;
+ element.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowDown") window.__cmuxArrowKeyReport.down += 1;
+ if (event.key === "ArrowUp") window.__cmuxArrowKeyReport.up += 1;
+ if (event.key === "ArrowDown" && event.metaKey && event.shiftKey) {
+ window.__cmuxArrowKeyReport.commandShiftDown += 1;
+ }
+ if (event.key === "ArrowUp" && event.metaKey && event.shiftKey) {
+ window.__cmuxArrowKeyReport.commandShiftUp += 1;
+ }
+ }, true);
+ };
+ installArrowTracker(input);
+ installArrowTracker(secondaryInput);
+ installArrowTracker(textarea);
input.focus({ preventScroll: true });
if (typeof input.setSelectionRange === "function") {
@@ -7569,8 +7782,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
const secondaryRect = secondaryInput.getBoundingClientRect();
+ const textareaRect = textarea.getBoundingClientRect();
+ const primaryRect = input.getBoundingClientRect();
const viewportWidth = Math.max(Number(window.innerWidth) || 0, 1);
const viewportHeight = Math.max(Number(window.innerHeight) || 0, 1);
+ const primaryCenterX = Math.min(
+ 0.98,
+ Math.max(0.02, (primaryRect.left + (primaryRect.width / 2)) / viewportWidth)
+ );
+ const primaryCenterY = Math.min(
+ 0.98,
+ Math.max(0.02, (primaryRect.top + (primaryRect.height / 2)) / viewportHeight)
+ );
const secondaryCenterX = Math.min(
0.98,
Math.max(0.02, (secondaryRect.left + (secondaryRect.width / 2)) / viewportWidth)
@@ -7579,13 +7802,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
0.98,
Math.max(0.02, (secondaryRect.top + (secondaryRect.height / 2)) / viewportHeight)
);
+ const textareaCenterX = Math.min(
+ 0.98,
+ Math.max(0.02, (textareaRect.left + (textareaRect.width / 2)) / viewportWidth)
+ );
+ const textareaCenterY = Math.min(
+ 0.98,
+ Math.max(0.02, (textareaRect.top + (textareaRect.height / 2)) / viewportHeight)
+ );
const active = document.activeElement;
+ const arrowState = readArrowState();
return {
focused: active === input,
id: input.id || "",
secondaryId: secondaryInput.id || "",
+ textareaId: textarea.id || "",
+ primaryCenterX,
+ primaryCenterY,
secondaryCenterX,
secondaryCenterY,
+ textareaCenterX,
+ textareaCenterY,
activeId: active && typeof active.id === "string" ? active.id : "",
activeTag: active && active.tagName ? active.tagName.toLowerCase() : "",
trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true,
@@ -7594,129 +7831,542 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
typeof window.__cmuxAddressBarFocusState.id === "string"
? window.__cmuxAddressBarFocusState.id
: "",
- readyState: String(document.readyState || "")
+ readyState: String(document.readyState || ""),
+ arrowDown: arrowState.arrowDown,
+ arrowUp: arrowState.arrowUp,
+ commandShiftDown: arrowState.commandShiftDown,
+ commandShiftUp: arrowState.commandShiftUp,
+ selectionStart: arrowState.selectionStart,
+ selectionEnd: arrowState.selectionEnd
};
};
- const ready = () =>
+ const ready =
window.__cmuxAddressBarFocusTrackerInstalled === true &&
- String(document.readyState || "") === "complete";
+ String(document.readyState || "") === "complete" &&
+ !!document.body;
- if (ready()) {
- try {
- return seed();
- } catch (_) {
- return snapshot();
- }
+ if (!ready) {
+ return snapshot();
}
- return new Promise((resolve) => {
- let finished = false;
- let observer = null;
- const cleanups = [];
- const finish = (value) => {
- if (finished) return;
- finished = true;
- if (observer) observer.disconnect();
- for (const cleanup of cleanups) {
- try { cleanup(); } catch (_) {}
- }
- resolve(value);
- };
- const maybeFinish = () => {
- if (!ready()) return;
- try {
- finish(seed());
- } catch (_) {
- finish(snapshot());
- }
- };
- const addListener = (target, eventName, options) => {
- if (!target || typeof target.addEventListener !== "function") return;
- const handler = () => maybeFinish();
- target.addEventListener(eventName, handler, options);
- cleanups.push(() => target.removeEventListener(eventName, handler, options));
- };
- try {
- observer = new MutationObserver(() => maybeFinish());
- observer.observe(document.documentElement || document, {
- childList: true,
- subtree: true,
- attributes: true,
- characterData: true
- });
- } catch (_) {}
- addListener(document, "readystatechange", true);
- addListener(window, "load", true);
- const timeoutId = window.setTimeout(() => finish(snapshot()), 4000);
- cleanups.push(() => window.clearTimeout(timeoutId));
- maybeFinish();
- });
+ try {
+ return seed();
+ } catch (_) {
+ return snapshot();
+ }
})();
"""
- 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,
+ gotoSplitUITestInputSetupGeneration += 1
+ let generation = gotoSplitUITestInputSetupGeneration
+ let deadline = Date().addingTimeInterval(8.0)
+
+ func attempt() {
+ guard gotoSplitUITestInputSetupGeneration == generation else { return }
+
+ panel.webView.evaluateJavaScript(script) { [weak self, weak panel] result, _ in
+ guard let self,
+ self.gotoSplitUITestInputSetupGeneration == generation,
+ let panel 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 textareaId = (payload?["textareaId"] as? String) ?? ""
+ let primaryCenterX = (payload?["primaryCenterX"] as? NSNumber)?.doubleValue ?? -1
+ let primaryCenterY = (payload?["primaryCenterY"] as? NSNumber)?.doubleValue ?? -1
+ let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1
+ let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1
+ let textareaCenterX = (payload?["textareaCenterX"] as? NSNumber)?.doubleValue ?? -1
+ let textareaCenterY = (payload?["textareaCenterY"] 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) ?? ""
+ let arrowDown = (payload?["arrowDown"] as? NSNumber)?.intValue ?? 0
+ let arrowUp = (payload?["arrowUp"] as? NSNumber)?.intValue ?? 0
+ let commandShiftDown = (payload?["commandShiftDown"] as? NSNumber)?.intValue ?? 0
+ let commandShiftUp = (payload?["commandShiftUp"] as? NSNumber)?.intValue ?? 0
+ let selectionStart = (payload?["selectionStart"] as? NSNumber)?.intValue
+ let selectionEnd = (payload?["selectionEnd"] as? NSNumber)?.intValue
+ var primaryClickOffsetX = -1.0
+ var primaryClickOffsetY = -1.0
+ var secondaryClickOffsetX = -1.0
+ var secondaryClickOffsetY = -1.0
+ var textareaClickOffsetX = -1.0
+ var textareaClickOffsetY = -1.0
+ let windowAvailable = panel.webView.window != nil
+
+ 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,
+ primaryCenterX > 0,
+ primaryCenterX < 1,
+ primaryCenterY > 0,
+ primaryCenterY < 1,
+ secondaryCenterX > 0,
+ secondaryCenterX < 1,
+ secondaryCenterY > 0,
+ secondaryCenterY < 1,
+ textareaCenterX > 0,
+ textareaCenterX < 1,
+ textareaCenterY > 0,
+ textareaCenterY < 1 {
+ let primaryXInContent = Double(webFrame.minX) + (primaryCenterX * Double(webFrame.width))
+ let primaryYFromTopInWeb = primaryCenterY * Double(webFrame.height)
+ let primaryYInContent = Double(webFrame.maxY) - primaryYFromTopInWeb
+ let yFromTopInContent = contentHeight - primaryYInContent
+ let titlebarHeight = max(0, Double(window.frame.height) - contentHeight)
+ primaryClickOffsetX = primaryXInContent
+ primaryClickOffsetY = titlebarHeight + yFromTopInContent
+
+ let secondaryXInContent = Double(webFrame.minX) + (secondaryCenterX * Double(webFrame.width))
+ let secondaryYFromTopInWeb = secondaryCenterY * Double(webFrame.height)
+ let secondaryYInContent = Double(webFrame.maxY) - secondaryYFromTopInWeb
+ secondaryClickOffsetX = secondaryXInContent
+ secondaryClickOffsetY = titlebarHeight + (contentHeight - secondaryYInContent)
+
+ let textareaXInContent = Double(webFrame.minX) + (textareaCenterX * Double(webFrame.width))
+ let textareaYFromTopInWeb = textareaCenterY * Double(webFrame.height)
+ let textareaYInContent = Double(webFrame.maxY) - textareaYFromTopInWeb
+ textareaClickOffsetX = textareaXInContent
+ textareaClickOffsetY = titlebarHeight + (contentHeight - textareaYInContent)
+ }
+ }
+
+ if focused,
+ !inputId.isEmpty,
+ !secondaryInputId.isEmpty,
+ !textareaId.isEmpty,
+ inputId == activeId,
+ trackerInstalled,
+ !trackedStateId.isEmpty,
+ primaryCenterX > 0,
+ primaryCenterX < 1,
+ primaryCenterY > 0,
+ primaryCenterY < 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
+ secondaryCenterY < 1,
+ textareaCenterX > 0,
+ textareaCenterX < 1,
+ textareaCenterY > 0,
+ textareaCenterY < 1,
+ primaryClickOffsetX > 0,
+ primaryClickOffsetY > 0,
+ secondaryClickOffsetX > 0,
+ secondaryClickOffsetY > 0,
+ textareaClickOffsetX > 0,
+ textareaClickOffsetY > 0 {
+ self.writeGotoSplitTestData([
+ "webInputFocusSeeded": "true",
+ "webInputFocusElementId": inputId,
+ "webInputFocusSecondaryElementId": secondaryInputId,
+ "webInputFocusTextareaElementId": textareaId,
+ "webInputFocusPrimaryCenterX": "\(primaryCenterX)",
+ "webInputFocusPrimaryCenterY": "\(primaryCenterY)",
+ "webInputFocusPrimaryClickOffsetX": "\(primaryClickOffsetX)",
+ "webInputFocusPrimaryClickOffsetY": "\(primaryClickOffsetY)",
+ "webInputFocusSecondaryCenterX": "\(secondaryCenterX)",
+ "webInputFocusSecondaryCenterY": "\(secondaryCenterY)",
+ "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)",
+ "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)",
+ "webInputFocusTextareaCenterX": "\(textareaCenterX)",
+ "webInputFocusTextareaCenterY": "\(textareaCenterY)",
+ "webInputFocusTextareaClickOffsetX": "\(textareaClickOffsetX)",
+ "webInputFocusTextareaClickOffsetY": "\(textareaClickOffsetY)",
+ "webInputFocusActiveElementId": activeId,
+ "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false",
+ "webInputFocusTrackedStateId": trackedStateId,
+ "webInputFocusReadyState": readyState,
+ "webInputFocusArrowDownCount": "\(arrowDown)",
+ "webInputFocusArrowUpCount": "\(arrowUp)",
+ "webInputFocusCommandShiftDownCount": "\(commandShiftDown)",
+ "webInputFocusCommandShiftUpCount": "\(commandShiftUp)",
+ "webInputFocusSelectionStart": selectionStart.map(String.init) ?? "",
+ "webInputFocusSelectionEnd": selectionEnd.map(String.init) ?? ""
+ ])
+ if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] == "1" {
+ self.startGotoSplitUITestArrowRecorder(panelId: panel.id)
+ }
+ if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_CONTENTEDITABLE_SETUP"] == "1" {
+ self.setupContentEditableForGotoSplitUITest(panel: panel)
+ }
+ return
}
+
+ if Date() < deadline {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
+ guard let self,
+ self.gotoSplitUITestInputSetupGeneration == generation else { return }
+ attempt()
+ }
+ return
+ }
+
+ self.writeGotoSplitTestData([
+ "webInputFocusSeeded": "false",
+ "webInputFocusElementId": inputId,
+ "webInputFocusSecondaryElementId": secondaryInputId,
+ "webInputFocusTextareaElementId": textareaId,
+ "webInputFocusPrimaryCenterX": "\(primaryCenterX)",
+ "webInputFocusPrimaryCenterY": "\(primaryCenterY)",
+ "webInputFocusPrimaryClickOffsetX": "\(primaryClickOffsetX)",
+ "webInputFocusPrimaryClickOffsetY": "\(primaryClickOffsetY)",
+ "webInputFocusSecondaryCenterX": "\(secondaryCenterX)",
+ "webInputFocusSecondaryCenterY": "\(secondaryCenterY)",
+ "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)",
+ "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)",
+ "webInputFocusTextareaCenterX": "\(textareaCenterX)",
+ "webInputFocusTextareaCenterY": "\(textareaCenterY)",
+ "webInputFocusTextareaClickOffsetX": "\(textareaClickOffsetX)",
+ "webInputFocusTextareaClickOffsetY": "\(textareaClickOffsetY)",
+ "webInputFocusActiveElementId": activeId,
+ "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false",
+ "webInputFocusTrackedStateId": trackedStateId,
+ "webInputFocusReadyState": readyState,
+ "webInputFocusArrowDownCount": "\(arrowDown)",
+ "webInputFocusArrowUpCount": "\(arrowUp)",
+ "webInputFocusCommandShiftDownCount": "\(commandShiftDown)",
+ "webInputFocusCommandShiftUpCount": "\(commandShiftUp)",
+ "webInputFocusSelectionStart": selectionStart.map(String.init) ?? "",
+ "webInputFocusSelectionEnd": selectionEnd.map(String.init) ?? "",
+ "setupError":
+ "Timed out focusing page input for omnibar restore test " +
+ "focused=\(focused) inputId=\(inputId) secondaryInputId=\(secondaryInputId) textareaId=\(textareaId) " +
+ "activeId=\(activeId) trackerInstalled=\(trackerInstalled) trackedStateId=\(trackedStateId) " +
+ "readyState=\(readyState) windowAvailable=\(windowAvailable) " +
+ "primaryCenterX=\(primaryCenterX) primaryCenterY=\(primaryCenterY) " +
+ "primaryClickOffsetX=\(primaryClickOffsetX) primaryClickOffsetY=\(primaryClickOffsetY) " +
+ "secondaryCenterX=\(secondaryCenterX) secondaryCenterY=\(secondaryCenterY) " +
+ "secondaryClickOffsetX=\(secondaryClickOffsetX) secondaryClickOffsetY=\(secondaryClickOffsetY) " +
+ "textareaCenterX=\(textareaCenterX) textareaCenterY=\(textareaCenterY) " +
+ "textareaClickOffsetX=\(textareaClickOffsetX) textareaClickOffsetY=\(textareaClickOffsetY)"
+ ])
+ }
+ }
+
+ attempt()
+ }
+
+ private func setupContentEditableForGotoSplitUITest(panel: BrowserPanel) {
+ guard ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_CONTENTEDITABLE_SETUP"] == "1" else {
+ return
+ }
+
+ gotoSplitUITestContentEditableSetupGeneration += 1
+ let generation = gotoSplitUITestContentEditableSetupGeneration
+ let deadline = Date().addingTimeInterval(8.0)
+ let script = """
+ (() => {
+ const snapshot = () => {
+ const active = document.activeElement;
+ return {
+ seeded: false,
+ editorId: "",
+ activeId: active && typeof active.id === "string" ? active.id : "",
+ readyState: String(document.readyState || ""),
+ secondaryCenterX: -1,
+ secondaryCenterY: -1,
+ editorCenterX: -1,
+ editorCenterY: -1
+ };
+ };
+ const seed = () => {
+ const parent = document.getElementById("cmux-ui-test-focus-container");
+ const secondary = document.getElementById("cmux-ui-test-focus-input-secondary");
+ if (!document.body || !parent || !secondary) {
+ return snapshot();
+ }
+
+ let editor = document.getElementById("cmux-ui-test-contenteditable");
+ if (!editor || !editor.tagName || editor.tagName.toLowerCase() !== "div") {
+ editor = document.createElement("div");
+ editor.id = "cmux-ui-test-contenteditable";
+ }
+
+ editor.setAttribute("contenteditable", "true");
+ editor.setAttribute("role", "textbox");
+ editor.setAttribute("aria-label", "cmux-ui-test-contenteditable");
+ editor.spellcheck = false;
+ editor.tabIndex = 0;
+ editor.innerHTML = "alpha
beta
gamma";
+ editor.style.display = "block";
+ editor.style.minHeight = "84px";
+ editor.style.padding = "8px 10px";
+ editor.style.border = "1px solid #5f6368";
+ editor.style.borderRadius = "6px";
+ editor.style.boxSizing = "border-box";
+ editor.style.fontSize = "14px";
+ editor.style.fontFamily = "system-ui, -apple-system, sans-serif";
+ editor.style.background = "white";
+ editor.style.color = "black";
+ editor.style.whiteSpace = "pre-wrap";
+ editor.style.outline = "none";
+
+ if (editor.parentElement !== parent) {
+ parent.appendChild(editor);
+ }
+
+ if (!window.__cmuxContentEditableArrowReport || typeof window.__cmuxContentEditableArrowReport !== "object") {
+ window.__cmuxContentEditableArrowReport = {
+ down: 0,
+ up: 0,
+ commandShiftDown: 0,
+ commandShiftUp: 0
+ };
+ }
+
+ if (!editor.__cmuxContentEditableArrowReportInstalled) {
+ editor.__cmuxContentEditableArrowReportInstalled = true;
+ editor.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowDown") window.__cmuxContentEditableArrowReport.down += 1;
+ if (event.key === "ArrowUp") window.__cmuxContentEditableArrowReport.up += 1;
+ if (event.key === "ArrowDown" && event.metaKey && event.shiftKey) {
+ window.__cmuxContentEditableArrowReport.commandShiftDown += 1;
+ }
+ if (event.key === "ArrowUp" && event.metaKey && event.shiftKey) {
+ window.__cmuxContentEditableArrowReport.commandShiftUp += 1;
+ }
+ }, true);
+ }
+
+ editor.focus({ preventScroll: true });
+ const selection = window.getSelection();
+ if (selection) {
+ const range = document.createRange();
+ range.selectNodeContents(editor);
+ range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
}
- if focused,
- !inputId.isEmpty,
- !secondaryInputId.isEmpty,
- inputId == activeId,
- trackerInstalled,
- !trackedStateId.isEmpty,
- secondaryCenterX > 0,
- secondaryCenterX < 1,
- secondaryCenterY > 0,
- secondaryCenterY < 1,
- secondaryClickOffsetX > 0,
- secondaryClickOffsetY > 0 {
+
+ const secondaryRect = secondary.getBoundingClientRect();
+ const editorRect = editor.getBoundingClientRect();
+ const active = document.activeElement;
+ return {
+ seeded: active === editor,
+ editorId: editor.id || "",
+ activeId: active && typeof active.id === "string" ? active.id : "",
+ readyState: String(document.readyState || ""),
+ secondaryCenterX: secondaryRect.left + (secondaryRect.width / 2),
+ secondaryCenterY: secondaryRect.top + (secondaryRect.height / 2),
+ editorCenterX: editorRect.left + (editorRect.width / 2),
+ editorCenterY: editorRect.top + (editorRect.height / 2)
+ };
+ };
+ const ready =
+ String(document.readyState || "") === "complete" &&
+ !!document.body &&
+ !!document.getElementById("cmux-ui-test-focus-container") &&
+ !!document.getElementById("cmux-ui-test-focus-input-secondary");
+
+ if (!ready) {
+ return snapshot();
+ }
+
+ try {
+ return seed();
+ } catch (_) {
+ return snapshot();
+ }
+ })();
+ """
+
+ func attempt() {
+ guard gotoSplitUITestContentEditableSetupGeneration == generation else { return }
+
+ panel.webView.evaluateJavaScript(script) { [weak self, weak panel] result, _ in
+ guard let self,
+ self.gotoSplitUITestContentEditableSetupGeneration == generation,
+ let panel else { return }
+
+ let payload = result as? [String: Any]
+ let seeded = (payload?["seeded"] as? Bool) ?? false
+ let editorId = (payload?["editorId"] as? String) ?? ""
+ let activeId = (payload?["activeId"] as? String) ?? ""
+ let readyState = (payload?["readyState"] as? String) ?? ""
+ let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1
+ let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1
+ let editorCenterX = (payload?["editorCenterX"] as? NSNumber)?.doubleValue ?? -1
+ let editorCenterY = (payload?["editorCenterY"] as? NSNumber)?.doubleValue ?? -1
+ var editorClickOffsetX = -1.0
+ var editorClickOffsetY = -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,
+ secondaryCenterY >= 0,
+ editorCenterX >= 0,
+ editorCenterY >= 0 {
+ let editorXInContent = Double(webFrame.minX) + editorCenterX
+ let editorYInContent = Double(webFrame.maxY) - editorCenterY
+ let titlebarHeight = max(0, Double(window.frame.height) - contentHeight)
+ editorClickOffsetX = editorXInContent
+ editorClickOffsetY = titlebarHeight + (contentHeight - editorYInContent)
+ }
+ }
+
+ if seeded,
+ !editorId.isEmpty,
+ activeId == editorId,
+ editorClickOffsetX > 0,
+ editorClickOffsetY > 0 {
+ self.writeGotoSplitTestData([
+ "webContentEditableSeeded": "true",
+ "webContentEditableElementId": editorId,
+ "webContentEditableActiveElementId": activeId,
+ "webContentEditableReadyState": readyState,
+ "webContentEditableClickOffsetX": "\(editorClickOffsetX)",
+ "webContentEditableClickOffsetY": "\(editorClickOffsetY)"
+ ])
+ return
+ }
+
+ if Date() < deadline {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
+ guard let self,
+ self.gotoSplitUITestContentEditableSetupGeneration == generation else { return }
+ attempt()
+ }
+ return
+ }
+
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
+ "webContentEditableSeeded": "false",
+ "webContentEditableElementId": editorId,
+ "webContentEditableActiveElementId": activeId,
+ "webContentEditableReadyState": readyState,
+ "webContentEditableClickOffsetX": "\(editorClickOffsetX)",
+ "webContentEditableClickOffsetY": "\(editorClickOffsetY)",
+ "setupError":
+ "Timed out focusing contenteditable for omnibar restore test " +
+ "seeded=\(seeded) editorId=\(editorId) activeId=\(activeId) " +
+ "readyState=\(readyState) secondaryCenterX=\(secondaryCenterX) " +
+ "secondaryCenterY=\(secondaryCenterY) editorCenterX=\(editorCenterX) " +
+ "editorCenterY=\(editorCenterY) editorClickOffsetX=\(editorClickOffsetX) " +
+ "editorClickOffsetY=\(editorClickOffsetY)"
])
+ }
+ }
+
+ attempt()
+ }
+
+ private func startGotoSplitUITestArrowRecorder(panelId: UUID) {
+ guard ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] == "1" else { return }
+
+ gotoSplitUITestArrowRecorder?.cancel()
+ gotoSplitUITestArrowRecorder = nil
+
+ let timer = DispatchSource.makeTimerSource(queue: .main)
+ timer.schedule(deadline: .now(), repeating: .milliseconds(100))
+ timer.setEventHandler { [weak self] in
+ self?.recordGotoSplitUITestArrowState(panelId: panelId)
+ }
+ gotoSplitUITestArrowRecorder = timer
+ timer.resume()
+ }
+
+ private func recordGotoSplitUITestArrowState(panelId: UUID) {
+ guard ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] == "1" else { return }
+ guard let tabManager,
+ let workspace = tabManager.tabs.first(where: { $0.browserPanel(for: panelId) != nil }),
+ let panel = workspace.browserPanel(for: panelId) else {
+ return
+ }
+
+ let script = """
+ (() => {
+ const report = window.__cmuxArrowKeyReport || {
+ down: 0,
+ up: 0,
+ commandShiftDown: 0,
+ commandShiftUp: 0
+ };
+ const contentEditableReport = window.__cmuxContentEditableArrowReport || {
+ down: 0,
+ up: 0,
+ commandShiftDown: 0,
+ commandShiftUp: 0
+ };
+ const active = document.activeElement;
+ return {
+ installed: !!window.__cmuxArrowKeyReport,
+ down: Number(report.down || 0),
+ up: Number(report.up || 0),
+ commandShiftDown: Number(report.commandShiftDown || 0),
+ commandShiftUp: Number(report.commandShiftUp || 0),
+ contentEditableInstalled: !!window.__cmuxContentEditableArrowReport,
+ contentEditableDown: Number(contentEditableReport.down || 0),
+ contentEditableUp: Number(contentEditableReport.up || 0),
+ contentEditableCommandShiftDown: Number(contentEditableReport.commandShiftDown || 0),
+ contentEditableCommandShiftUp: Number(contentEditableReport.commandShiftUp || 0),
+ activeId: active && typeof active.id === "string" ? active.id : "",
+ selectionStart: active && typeof active.selectionStart === "number" ? active.selectionStart : null,
+ selectionEnd: active && typeof active.selectionEnd === "number" ? active.selectionEnd : null,
+ readyState: String(document.readyState || "")
+ };
+ })();
+ """
+
+ panel.webView.evaluateJavaScript(script) { [weak self] result, _ in
+ guard let self,
+ let payload = result as? [String: Any] else {
return
}
+
+ let firstResponder = panel.webView.window?.firstResponder
+ let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? ""
+ let firstResponderIsFieldEditor = ((firstResponder as? NSTextView)?.isFieldEditor == true)
+ let firstResponderIsCmuxWebView = (firstResponder as? CmuxWebView) === panel.webView
+ let focusedAddressBarPanelId = self.focusedBrowserAddressBarPanelId()?.uuidString ?? ""
+ let down = (payload["down"] as? NSNumber)?.intValue ?? 0
+ let up = (payload["up"] as? NSNumber)?.intValue ?? 0
+ let commandShiftDown = (payload["commandShiftDown"] as? NSNumber)?.intValue ?? 0
+ let commandShiftUp = (payload["commandShiftUp"] as? NSNumber)?.intValue ?? 0
+ let contentEditableInstalled = (payload["contentEditableInstalled"] as? Bool) ?? false
+ let contentEditableDown = (payload["contentEditableDown"] as? NSNumber)?.intValue ?? 0
+ let contentEditableUp = (payload["contentEditableUp"] as? NSNumber)?.intValue ?? 0
+ let contentEditableCommandShiftDown = (payload["contentEditableCommandShiftDown"] as? NSNumber)?.intValue ?? 0
+ let contentEditableCommandShiftUp = (payload["contentEditableCommandShiftUp"] as? NSNumber)?.intValue ?? 0
+ let activeId = (payload["activeId"] as? String) ?? ""
+ let selectionStart = (payload["selectionStart"] as? NSNumber)?.intValue
+ let selectionEnd = (payload["selectionEnd"] as? NSNumber)?.intValue
+ let readyState = (payload["readyState"] as? String) ?? ""
+ let installed = (payload["installed"] as? Bool) ?? false
+
self.writeGotoSplitTestData([
- "webInputFocusSeeded": "false",
- "setupError": "Timed out focusing page input for omnibar restore test"
+ "browserArrowPanelId": panelId.uuidString,
+ "browserArrowInstalled": installed ? "true" : "false",
+ "browserArrowDownCount": "\(down)",
+ "browserArrowUpCount": "\(up)",
+ "browserArrowCommandShiftDownCount": "\(commandShiftDown)",
+ "browserArrowCommandShiftUpCount": "\(commandShiftUp)",
+ "browserArrowActiveElementId": activeId,
+ "browserArrowSelectionStart": selectionStart.map(String.init) ?? "",
+ "browserArrowSelectionEnd": selectionEnd.map(String.init) ?? "",
+ "browserArrowReadyState": readyState,
+ "browserContentEditableInstalled": contentEditableInstalled ? "true" : "false",
+ "browserContentEditableDownCount": "\(contentEditableDown)",
+ "browserContentEditableUpCount": "\(contentEditableUp)",
+ "browserContentEditableCommandShiftDownCount": "\(contentEditableCommandShiftDown)",
+ "browserContentEditableCommandShiftUpCount": "\(contentEditableCommandShiftUp)",
+ "browserContentEditableActiveElementId": activeId,
+ "browserContentEditableReadyState": readyState,
+ "browserArrowFirstResponderType": firstResponderType,
+ "browserArrowFirstResponderIsFieldEditor": firstResponderIsFieldEditor ? "true" : "false",
+ "browserArrowFirstResponderIsCmuxWebView": firstResponderIsCmuxWebView ? "true" : "false",
+ "browserArrowFocusedAddressBarPanelId": focusedAddressBarPanelId
])
}
}
@@ -10951,36 +11601,96 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func installBrowserAddressBarFocusObservers() {
- guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return }
+ guard browserAddressBarFocusObserver == nil,
+ browserAddressBarBlurObserver == nil,
+ browserWebViewClickObserver == nil,
+ browserWebViewFocusObserver == nil else { return }
browserAddressBarFocusObserver = NotificationCenter.default.addObserver(
forName: .browserDidFocusAddressBar,
object: nil,
- queue: .main
+ queue: nil
) { [weak self] notification in
- guard let self else { return }
- guard let panelId = notification.object as? UUID else { return }
- self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar()
- self.browserAddressBarFocusedPanelId = panelId
- self.stopBrowserOmnibarSelectionRepeat()
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ guard let panelId = notification.object as? UUID else { return }
+ if let panel = self.browserPanel(for: panelId) {
+ panel.beginSuppressWebViewFocusForAddressBar()
+ let isPanelFocused = self.tabManager?.selectedWorkspace?.focusedPanelId == panelId
+ panel.syncWebViewFirstResponderPolicy(
+ isPanelFocused: isPanelFocused,
+ reason: "addressBarFocusObserver"
+ )
+ }
+ self.browserAddressBarFocusedPanelId = panelId
+ self.stopBrowserOmnibarSelectionRepeat()
#if DEBUG
- dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))")
+ dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))")
#endif
+ }
}
browserAddressBarBlurObserver = NotificationCenter.default.addObserver(
forName: .browserDidBlurAddressBar,
object: nil,
- queue: .main
+ queue: nil
) { [weak self] notification in
- guard let self else { return }
- guard let panelId = notification.object as? UUID else { return }
- self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar()
- if self.browserAddressBarFocusedPanelId == panelId {
- self.browserAddressBarFocusedPanelId = nil
- self.stopBrowserOmnibarSelectionRepeat()
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ guard let panelId = notification.object as? UUID else { return }
+ if let panel = self.browserPanel(for: panelId) {
+ panel.endSuppressWebViewFocusForAddressBar()
+ let isPanelFocused = self.tabManager?.selectedWorkspace?.focusedPanelId == panelId
+ panel.syncWebViewFirstResponderPolicy(
+ isPanelFocused: isPanelFocused,
+ reason: "addressBarBlurObserver"
+ )
+ }
+ if self.browserAddressBarFocusedPanelId == panelId {
+ self.browserAddressBarFocusedPanelId = nil
+ self.stopBrowserOmnibarSelectionRepeat()
+#if DEBUG
+ dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))")
+#endif
+ }
+ }
+ }
+
+ browserWebViewClickObserver = NotificationCenter.default.addObserver(
+ forName: .webViewDidReceiveClick,
+ object: nil,
+ queue: nil
+ ) { [weak self] _ in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ guard let panelId = self.browserAddressBarFocusedPanelId else { return }
+
+ NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId)
+#if DEBUG
+ dlog(
+ "addressBar STALE_CLEAR panelId=\(panelId.uuidString.prefix(8)) " +
+ "reason=webViewClick"
+ )
+#endif
+ }
+ }
+
+ browserWebViewFocusObserver = NotificationCenter.default.addObserver(
+ forName: .browserDidBecomeFirstResponderWebView,
+ object: nil,
+ queue: nil
+ ) { [weak self] notification in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ guard let panelId = self.browserAddressBarFocusedPanelId else { return }
+ guard let webView = notification.object as? WKWebView else { return }
+
+ NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId)
#if DEBUG
- dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))")
+ dlog(
+ "addressBar STALE_CLEAR panelId=\(panelId.uuidString.prefix(8)) " +
+ "reason=webViewFirstResponder web=\(ObjectIdentifier(webView))"
+ )
#endif
}
}
@@ -12075,7 +12785,7 @@ private var cmuxFirstResponderGuardHitViewOverride: NSView?
private var cmuxFirstResponderGuardCurrentEventContext: NSEvent?
private var cmuxFirstResponderGuardHitViewContext: NSView?
private var cmuxFirstResponderGuardContextWindowNumber: Int?
-private var cmuxBrowserReturnForwardingDepth = 0
+private var cmuxBrowserKeyDownForwardingDepth = 0
private var cmuxWindowFirstResponderBypassDepth = 0
private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0
@@ -12100,6 +12810,11 @@ private final class CmuxFieldEditorOwningWebViewBox: NSObject {
}
}
+private struct CmuxBrowserFirstResponderKeyRoute {
+ let webView: CmuxWebView
+ let keyDownTarget: NSResponder
+}
+
private extension NSApplication {
@objc func cmux_applicationSendEvent(_ event: NSEvent) {
#if DEBUG
@@ -12139,6 +12854,103 @@ private extension AppDelegate {
}
private extension NSWindow {
+ static func cmuxBrowserFirstResponderKeyRoute(
+ for firstResponder: NSResponder?,
+ in window: NSWindow,
+ event: NSEvent?
+ ) -> CmuxBrowserFirstResponderKeyRoute? {
+ guard let firstResponder else { return nil }
+ guard let webView = cmuxOwningWebView(for: firstResponder, in: window, event: event) else {
+ return nil
+ }
+
+ let keyDownTarget: NSResponder
+ if let textView = firstResponder as? NSTextView, textView.isFieldEditor {
+ keyDownTarget = textView
+ } else {
+ keyDownTarget = webView
+ }
+
+ return CmuxBrowserFirstResponderKeyRoute(
+ webView: webView,
+ keyDownTarget: keyDownTarget
+ )
+ }
+
+ func cmuxDirectlyHandleBrowserFirstResponderKeyDown(
+ _ event: NSEvent,
+ invokedFromPerformKeyEquivalent: Bool = false
+ ) -> Bool {
+ guard event.type == .keyDown else { return false }
+ guard let browserRoute = Self.cmuxBrowserFirstResponderKeyRoute(
+ for: self.firstResponder,
+ in: self,
+ event: event
+ ) else {
+ return false
+ }
+ guard shouldDirectRouteBrowserFirstResponderKeyDown(
+ firstResponder: self.firstResponder,
+ firstResponderIsBrowser: true,
+ focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId()
+ ) else {
+ return false
+ }
+
+ switch browserDirectKeyRoutingStrategy(for: event) {
+ case .deferToOriginalPerformKeyEquivalent:
+ return false
+
+ case .menuOrAppShortcutThenKeyDown:
+ if let mainMenu = NSApp.mainMenu,
+ mainMenu.performKeyEquivalent(with: event) {
+#if DEBUG
+ dlog("window.browserKeyDirect route=mainMenu")
+#endif
+ return true
+ }
+
+ if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
+#if DEBUG
+ dlog("window.browserKeyDirect route=appShortcut")
+#endif
+ return true
+ }
+
+ case .keyDown:
+ break
+ }
+
+ if invokedFromPerformKeyEquivalent {
+ if cmuxBrowserKeyDownForwardingDepth > 0 {
+#if DEBUG
+ dlog("window.browserKeyDirect route=keyDownReentry")
+#endif
+ return false
+ }
+ do {
+ cmuxBrowserKeyDownForwardingDepth += 1
+ defer {
+ cmuxBrowserKeyDownForwardingDepth = max(0, cmuxBrowserKeyDownForwardingDepth - 1)
+ }
+
+#if DEBUG
+ let targetType = String(describing: type(of: browserRoute.keyDownTarget))
+ dlog("window.browserKeyDirect route=keyDown target=\(targetType)")
+#endif
+ browserRoute.keyDownTarget.keyDown(with: event)
+ return true
+ }
+ }
+
+#if DEBUG
+ let targetType = String(describing: type(of: browserRoute.keyDownTarget))
+ dlog("window.browserKeyDirect route=keyDown target=\(targetType)")
+#endif
+ browserRoute.keyDownTarget.keyDown(with: event)
+ return true
+ }
+
@objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool {
if cmuxIsWindowFirstResponderBypassActive() {
#if DEBUG
@@ -12231,6 +13043,13 @@ private extension NSWindow {
} else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor {
Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView)
}
+ if let responderWebView,
+ !(responder is CmuxWebView) {
+ NotificationCenter.default.post(
+ name: .browserDidBecomeFirstResponderWebView,
+ object: responderWebView
+ )
+ }
}
return result
}
@@ -12306,6 +13125,10 @@ private extension NSWindow {
cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber
}
+ if cmuxDirectlyHandleBrowserFirstResponderKeyDown(event) {
+ return
+ }
+
guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event),
let contentView = self.contentView else {
#if DEBUG
@@ -12385,9 +13208,6 @@ private extension NSWindow {
// (handleCustomShortcut) already handles app-level shortcuts, and anything
// remaining should be menu items.
let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder)
- let firstResponderWebView = self.firstResponder.flatMap {
- Self.cmuxOwningWebView(for: $0, in: self, event: event)
- }
if let ghosttyView = firstResponderGhosttyView {
// If the IME is composing and the key has no Cmd modifier, don't intercept —
// let it flow through normal AppKit event dispatch so the input method can
@@ -12424,36 +13244,10 @@ private extension NSWindow {
}
}
- // Web forms rely on Return/Enter flowing through keyDown. If the original
- // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches
- // WebKit. Route Return/Enter directly to the current first responder and
- // mark handled to avoid the AppKit alert sound path.
- if shouldDispatchBrowserReturnViaFirstResponderKeyDown(
- keyCode: event.keyCode,
- firstResponderIsBrowser: firstResponderWebView != nil,
- flags: event.modifierFlags
+ if cmuxDirectlyHandleBrowserFirstResponderKeyDown(
+ event,
+ invokedFromPerformKeyEquivalent: true
) {
- // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals.
- // On re-entry, fall back to normal dispatch to avoid an infinite loop.
- if cmuxBrowserReturnForwardingDepth > 0 {
-#if DEBUG
- dlog(" → browser Return/Enter reentry; using normal dispatch")
-#endif
- return false
- }
- cmuxBrowserReturnForwardingDepth += 1
- defer { cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) }
-#if DEBUG
- dlog(" → browser Return/Enter routed to firstResponder.keyDown")
-#endif
- self.firstResponder?.keyDown(with: event)
- return true
- }
-
- if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
-#if DEBUG
- dlog(" → consumed by handleBrowserSurfaceKeyEquivalent")
-#endif
return true
}
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index f0bb3778ac..e67a6565f6 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -4908,6 +4908,28 @@ extension BrowserPanel {
#endif
}
+ func syncWebViewFirstResponderPolicy(isPanelFocused: Bool, reason: String) {
+ guard let cmuxWebView = webView as? CmuxWebView else { return }
+ let next = isPanelFocused && !shouldSuppressWebViewFocus()
+ if cmuxWebView.allowsFirstResponderAcquisition != next {
+#if DEBUG
+ dlog(
+ "browser.focus.policy.resync panel=\(id.uuidString.prefix(5)) " +
+ "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
+ "new=\(next ? 1 : 0) reason=\(reason) " +
+ "panelFocusedUsed=\(isPanelFocused ? 1 : 0)"
+ )
+#endif
+ }
+ cmuxWebView.allowsFirstResponderAcquisition = next
+ }
+
+ func prepareForExplicitWebViewFocus(isPanelFocused: Bool, reason: String) {
+ endSuppressWebViewFocusForAddressBar()
+ clearWebViewFocusSuppression()
+ syncWebViewFirstResponderPolicy(isPanelFocused: isPanelFocused, reason: reason)
+ }
+
func shouldSuppressOmnibarAutofocus() -> Bool {
if let until = suppressOmnibarAutofocusUntil {
return Date() < until
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 2e401164b5..ef64193ef7 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -510,17 +510,54 @@ struct BrowserPanelView: View {
guard let webView = note.object as? CmuxWebView else { return false }
return webView === panel?.webView
}) { _ in
+ let shouldDismissOmnibar = browserWebViewClickShouldDismissOmnibar(
+ localAddressBarFocused: addressBarFocused,
+ focusedAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(),
+ pendingAddressBarFocusRequestId: panel.pendingAddressBarFocusRequestId,
+ panelId: panel.id
+ )
+ let shouldPromoteWebViewFocus = browserWebViewClickShouldPromoteWebViewFocus(
+ isPanelFocused: isFocused,
+ shouldDismissOmnibar: shouldDismissOmnibar
+ )
#if DEBUG
dlog(
"browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
"isFocused=\(isFocused ? 1 : 0) " +
- "addressFocused=\(addressBarFocused ? 1 : 0)"
+ "addressFocused=\(addressBarFocused ? 1 : 0) " +
+ "dismissOmnibar=\(shouldDismissOmnibar ? 1 : 0) " +
+ "promoteWeb=\(shouldPromoteWebViewFocus ? 1 : 0)"
)
#endif
- if addressBarFocused {
+ if shouldDismissOmnibar {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.webViewClickBlur")
#endif
+ NotificationCenter.default.post(
+ name: .browserWillBlurAddressBarForWebViewClick,
+ object: panel.id
+ )
+ }
+ if shouldPromoteWebViewFocus {
+ panel.prepareForExplicitWebViewFocus(
+ isPanelFocused: true,
+ reason: "webView.clickIntent"
+ )
+ if let window = panel.webView.window,
+ !panel.webView.isHiddenOrHasHiddenAncestor {
+ let focusedWebView = window.makeFirstResponder(panel.webView)
+ if focusedWebView {
+ panel.noteWebViewFocused()
+ }
+#if DEBUG
+ logBrowserFocusState(
+ event: "addressBarFocus.webViewClickFocus",
+ detail: "focusedWebView=\(focusedWebView ? 1 : 0)"
+ )
+#endif
+ }
+ }
+ if shouldDismissOmnibar {
setAddressBarFocused(false, reason: "webView.clickIntent")
}
if !isFocused {
@@ -1033,6 +1070,7 @@ struct BrowserPanelView: View {
}
OmnibarTextFieldRepresentable(
+ panelId: panel.id,
text: Binding(
get: { omnibarState.buffer },
set: { newValue in
@@ -1239,20 +1277,8 @@ struct BrowserPanelView: View {
reason: String,
isPanelFocusedOverride: Bool? = nil
) {
- guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
let isPanelFocused = isPanelFocusedOverride ?? isFocused
- let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
- if cmuxWebView.allowsFirstResponderAcquisition != next {
-#if DEBUG
- dlog(
- "browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
- "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
- "new=\(next ? 1 : 0) reason=\(reason) " +
- "panelFocusedUsed=\(isPanelFocused ? 1 : 0)"
- )
-#endif
- }
- cmuxWebView.allowsFirstResponderAcquisition = next
+ panel.syncWebViewFirstResponderPolicy(isPanelFocused: isPanelFocused, reason: reason)
}
private func setAddressBarFocused(_ focused: Bool, reason: String) {
@@ -3181,9 +3207,28 @@ struct OmnibarSuggestion: Identifiable, Hashable {
func browserOmnibarShouldReacquireFocusAfterEndEditing(
desiredOmnibarFocus: Bool,
- nextResponderIsOtherTextField: Bool
+ nextResponderIsOtherTextField: Bool,
+ explicitPointerBlurIntent: Bool = false
) -> Bool {
- desiredOmnibarFocus && !nextResponderIsOtherTextField
+ desiredOmnibarFocus && !nextResponderIsOtherTextField && !explicitPointerBlurIntent
+}
+
+func browserWebViewClickShouldDismissOmnibar(
+ localAddressBarFocused: Bool,
+ focusedAddressBarPanelId: UUID?,
+ pendingAddressBarFocusRequestId: UUID?,
+ panelId: UUID
+) -> Bool {
+ localAddressBarFocused ||
+ focusedAddressBarPanelId == panelId ||
+ pendingAddressBarFocusRequestId != nil
+}
+
+func browserWebViewClickShouldPromoteWebViewFocus(
+ isPanelFocused: Bool,
+ shouldDismissOmnibar: Bool
+) -> Bool {
+ isPanelFocused || shouldDismissOmnibar
}
private final class OmnibarNativeTextField: NSTextField {
@@ -3349,6 +3394,7 @@ private final class OmnibarNativeTextField: NSTextField {
}
private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
+ let panelId: UUID
@Binding var text: String
@Binding var isFocused: Bool
let inlineCompletion: OmnibarInlineCompletion?
@@ -3368,15 +3414,33 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
var parent: OmnibarTextFieldRepresentable
var isProgrammaticMutation: Bool = false
var selectionObserver: NSObjectProtocol?
+ var explicitPointerBlurObserver: NSObjectProtocol?
weak var observedEditor: NSTextView?
var appliedInlineCompletion: OmnibarInlineCompletion?
var lastPublishedSelection: NSRange = NSRange(location: NSNotFound, length: 0)
var lastPublishedHasMarkedText: Bool = false
/// Guards against infinite focus loops: `true` = focus requested, `false` = blur requested, `nil` = idle.
var pendingFocusRequest: Bool?
+ var pendingExplicitPointerBlurIntent: Bool = false
init(parent: OmnibarTextFieldRepresentable) {
self.parent = parent
+ super.init()
+ explicitPointerBlurObserver = NotificationCenter.default.addObserver(
+ forName: .browserWillBlurAddressBarForWebViewClick,
+ object: nil,
+ queue: .main
+ ) { [weak self] notification in
+ guard let self,
+ let panelId = notification.object as? UUID,
+ panelId == self.parent.panelId else {
+ return
+ }
+ self.pendingExplicitPointerBlurIntent = true
+#if DEBUG
+ self.logFocusEvent("webViewClickBlurIntent")
+#endif
+ }
}
#if DEBUG
@@ -3412,6 +3476,9 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
if let selectionObserver {
NotificationCenter.default.removeObserver(selectionObserver)
}
+ if let explicitPointerBlurObserver {
+ NotificationCenter.default.removeObserver(explicitPointerBlurObserver)
+ }
}
private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool {
@@ -3483,12 +3550,10 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
- if pointerDownBlurIntent(window: window) {
- return false
- }
return browserOmnibarShouldReacquireFocusAfterEndEditing(
desiredOmnibarFocus: parent.isFocused,
- nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
+ nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window),
+ explicitPointerBlurIntent: pointerDownBlurIntent(window: window) || pendingExplicitPointerBlurIntent
)
}
@@ -3496,6 +3561,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
#if DEBUG
logFocusEvent("controlTextDidBeginEditing")
#endif
+ pendingExplicitPointerBlurIntent = false
if !parent.isFocused {
DispatchQueue.main.async {
#if DEBUG
@@ -3509,16 +3575,23 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
func controlTextDidEndEditing(_ obj: Notification) {
-#if DEBUG
+ let explicitPointerBlurIntent =
+ pointerDownBlurIntent(window: parentField?.window) || pendingExplicitPointerBlurIntent
+ pendingExplicitPointerBlurIntent = false
let nextOther = nextResponderIsOtherTextField(window: parentField?.window)
- let pointerBlur = pointerDownBlurIntent(window: parentField?.window)
+ let shouldReacquire = browserOmnibarShouldReacquireFocusAfterEndEditing(
+ desiredOmnibarFocus: parent.isFocused,
+ nextResponderIsOtherTextField: nextOther,
+ explicitPointerBlurIntent: explicitPointerBlurIntent
+ )
+#if DEBUG
logFocusEvent(
"controlTextDidEndEditing",
- detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)"
+ detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(explicitPointerBlurIntent ? 1 : 0) shouldReacquire=\(shouldReacquire ? 1 : 0)"
)
#endif
if parent.isFocused {
- if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
+ if shouldReacquire {
#if DEBUG
logFocusEvent("controlTextDidEndEditing.reacquire.begin")
#endif
@@ -6336,19 +6409,11 @@ struct WebViewRepresentable: NSViewRepresentable {
webView: WKWebView,
isPanelFocused: Bool
) {
- guard let cmuxWebView = webView as? CmuxWebView else { return }
- let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
- if cmuxWebView.allowsFirstResponderAcquisition != next {
-#if DEBUG
- dlog(
- "browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " +
- "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
- "new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " +
- "suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)"
- )
-#endif
- }
- cmuxWebView.allowsFirstResponderAcquisition = next
+ guard webView is CmuxWebView else { return }
+ panel.syncWebViewFirstResponderPolicy(
+ isPanelFocused: isPanelFocused,
+ reason: "applyWebViewFirstResponderPolicy"
+ )
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift
index 273cc43a20..7b1c90ec72 100644
--- a/Sources/TabManager.swift
+++ b/Sources/TabManager.swift
@@ -5182,6 +5182,7 @@ extension Notification.Name {
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
+ static let browserWillBlurAddressBarForWebViewClick = Notification.Name("browserWillBlurAddressBarForWebViewClick")
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange")
static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange")
diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift
index c6509f464b..fbd397bcf1 100644
--- a/Sources/TerminalController.swift
+++ b/Sources/TerminalController.swift
@@ -8635,6 +8635,12 @@ class TerminalController {
tabManager.selectWorkspace(ws)
}
+ browserPanel.prepareForExplicitWebViewFocus(
+ isPanelFocused: true,
+ reason: "socket.browser.focus_webview"
+ )
+ NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: surfaceId)
+
// Prevent omnibar auto-focus from immediately stealing first responder back.
browserPanel.suppressOmnibarAutofocus(for: 1.0)
@@ -11804,19 +11810,19 @@ class TerminalController {
case "left":
storedKey = "←"
keyCode = 123
- charactersIgnoringModifiers = storedKey
+ charactersIgnoringModifiers = String(UnicodeScalar(NSLeftArrowFunctionKey)!)
case "right":
storedKey = "→"
keyCode = 124
- charactersIgnoringModifiers = storedKey
+ charactersIgnoringModifiers = String(UnicodeScalar(NSRightArrowFunctionKey)!)
case "down":
storedKey = "↓"
keyCode = 125
- charactersIgnoringModifiers = storedKey
+ charactersIgnoringModifiers = String(UnicodeScalar(NSDownArrowFunctionKey)!)
case "up":
storedKey = "↑"
keyCode = 126
- charactersIgnoringModifiers = storedKey
+ charactersIgnoringModifiers = String(UnicodeScalar(NSUpArrowFunctionKey)!)
case "enter", "return":
storedKey = "\r"
keyCode = UInt16(kVK_Return)
@@ -13587,8 +13593,10 @@ class TerminalController {
// Programmatic WebView focus should win over stale omnibar focus state, especially
// after workspace switches where the blank-page omnibar auto-focus can re-trigger.
- browserPanel.endSuppressWebViewFocusForAddressBar()
- browserPanel.clearWebViewFocusSuppression()
+ browserPanel.prepareForExplicitWebViewFocus(
+ isPanelFocused: true,
+ reason: "socket.focus_webview"
+ )
NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId)
// Prevent omnibar auto-focus from immediately stealing first responder back.
diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift
index a2b611bd43..53883d604d 100644
--- a/cmuxTests/AppDelegateShortcutRoutingTests.swift
+++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift
@@ -1,4 +1,5 @@
import XCTest
+import WebKit
#if canImport(cmux_DEV)
@testable import cmux_DEV
@@ -2328,6 +2329,68 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(observedDelta, 1)
}
+ func testDownArrowIsNotConsumedAfterBrowserWebViewRetakesFocusFromStaleAddressBarState() {
+ guard let appDelegate = AppDelegate.shared else {
+ XCTFail("Expected AppDelegate.shared")
+ return
+ }
+
+ let windowId = appDelegate.createMainWindow()
+ defer {
+ closeWindow(withId: windowId)
+ }
+
+ guard let window = window(withId: windowId),
+ let contentView = window.contentView else {
+ XCTFail("Expected test window")
+ return
+ }
+
+ let webView = CmuxWebView(frame: contentView.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ contentView.addSubview(webView)
+
+ window.makeKeyAndOrderFront(nil)
+ RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
+
+ XCTAssertTrue(window.makeFirstResponder(webView), "Expected test web view to become first responder")
+
+ let stalePanelId = UUID()
+ NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: stalePanelId)
+ RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
+ XCTAssertEqual(
+ appDelegate.focusedBrowserAddressBarPanelId(),
+ stalePanelId,
+ "Expected stale address-bar focus state to be installed for the regression setup"
+ )
+
+ NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: webView)
+ RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
+
+ guard let downArrowEvent = makeKeyDownEvent(
+ key: String(UnicodeScalar(NSDownArrowFunctionKey)!),
+ modifiers: [],
+ keyCode: 125,
+ windowNumber: window.windowNumber
+ ) else {
+ XCTFail("Failed to construct Down Arrow event")
+ return
+ }
+
+#if DEBUG
+ XCTAssertFalse(
+ appDelegate.debugHandleCustomShortcut(event: downArrowEvent),
+ "Plain Down Arrow should reach WebKit once the page retakes focus from the omnibar"
+ )
+#else
+ XCTFail("debugHandleCustomShortcut is only available in DEBUG")
+#endif
+ XCTAssertNil(
+ appDelegate.focusedBrowserAddressBarPanelId(),
+ "Browser web view focus should clear stale omnibar focus state"
+ )
+ }
+
func testEscapeDismissesCommandPaletteWhenVisibilityStateStaysStalePastInitialPendingWindow() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift
index ae60de6bf9..e17d1b9be3 100644
--- a/cmuxTests/BrowserConfigTests.swift
+++ b/cmuxTests/BrowserConfigTests.swift
@@ -2547,6 +2547,116 @@ final class BrowserReturnKeyDownRoutingTests: XCTestCase {
}
}
+final class BrowserDirectKeyDownRoutingTests: XCTestCase {
+ func testRoutesWhenBrowserWebViewIsFirstResponder() {
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+
+ XCTAssertTrue(
+ shouldDirectRouteBrowserFirstResponderKeyDown(
+ firstResponder: webView,
+ firstResponderIsBrowser: true,
+ focusedBrowserAddressBarPanelId: nil
+ )
+ )
+ }
+
+ func testRoutesWhenBrowserFieldEditorIsFirstResponder() {
+ let fieldEditor = NSTextView(frame: .zero)
+ fieldEditor.isFieldEditor = true
+
+ XCTAssertTrue(
+ shouldDirectRouteBrowserFirstResponderKeyDown(
+ firstResponder: fieldEditor,
+ firstResponderIsBrowser: true,
+ focusedBrowserAddressBarPanelId: nil
+ )
+ )
+ }
+
+ func testDoesNotRouteWhenBrowserAddressBarRemainsFocused() {
+ let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
+
+ XCTAssertFalse(
+ shouldDirectRouteBrowserFirstResponderKeyDown(
+ firstResponder: webView,
+ firstResponderIsBrowser: true,
+ focusedBrowserAddressBarPanelId: UUID()
+ )
+ )
+ }
+
+ func testDoesNotRouteWhenFirstResponderIsNotBrowser() {
+ let view = NSView(frame: .zero)
+
+ XCTAssertFalse(
+ shouldDirectRouteBrowserFirstResponderKeyDown(
+ firstResponder: view,
+ firstResponderIsBrowser: false,
+ focusedBrowserAddressBarPanelId: nil
+ )
+ )
+ }
+
+ func testNonCommandBrowserKeysGoStraightToKeyDown() {
+ let event = makeKeyDownEvent(
+ characters: String(UnicodeScalar(NSDownArrowFunctionKey)!),
+ modifiers: [],
+ keyCode: 125
+ )
+
+ XCTAssertEqual(browserDirectKeyRoutingStrategy(for: event), .keyDown)
+ }
+
+ func testCommandShiftArrowFallsBackToBrowserKeyDownWhenNoShortcutConsumesIt() {
+ let event = makeKeyDownEvent(
+ characters: String(UnicodeScalar(NSDownArrowFunctionKey)!),
+ modifiers: [.command, .shift],
+ keyCode: 125
+ )
+
+ XCTAssertEqual(
+ browserDirectKeyRoutingStrategy(for: event),
+ .menuOrAppShortcutThenKeyDown
+ )
+ }
+
+ func testCommandBacktickDefersToOriginalPerformKeyEquivalent() {
+ let event = makeKeyDownEvent(
+ characters: "`",
+ modifiers: [.command],
+ keyCode: 50
+ )
+
+ XCTAssertEqual(
+ browserDirectKeyRoutingStrategy(for: event),
+ .deferToOriginalPerformKeyEquivalent
+ )
+ }
+
+ private func makeKeyDownEvent(
+ characters: String,
+ modifiers: NSEvent.ModifierFlags,
+ keyCode: UInt16
+ ) -> NSEvent {
+ guard let event = NSEvent.keyEvent(
+ with: .keyDown,
+ location: .zero,
+ modifierFlags: modifiers,
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: 0,
+ context: nil,
+ characters: characters,
+ charactersIgnoringModifiers: characters,
+ isARepeat: false,
+ keyCode: keyCode
+ ) else {
+ XCTFail("Failed to create keyDown event for routing strategy test")
+ fatalError("Failed to create keyDown event")
+ }
+
+ return event
+ }
+}
final class BrowserZoomShortcutActionTests: XCTestCase {
func testZoomInSupportsEqualsAndPlusVariants() {
@@ -3152,4 +3262,95 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase {
)
)
}
+
+ func testDoesNotReacquireFocusWhenExplicitPointerBlurIntentExists() {
+ XCTAssertFalse(
+ browserOmnibarShouldReacquireFocusAfterEndEditing(
+ desiredOmnibarFocus: true,
+ nextResponderIsOtherTextField: false,
+ explicitPointerBlurIntent: true
+ )
+ )
+ }
+}
+
+final class BrowserWebViewClickFocusPolicyTests: XCTestCase {
+ func testDismissesOmnibarWhenLocalAddressBarStateIsFocused() {
+ let panelId = UUID()
+
+ XCTAssertTrue(
+ browserWebViewClickShouldDismissOmnibar(
+ localAddressBarFocused: true,
+ focusedAddressBarPanelId: nil,
+ pendingAddressBarFocusRequestId: nil,
+ panelId: panelId
+ )
+ )
+ }
+
+ func testDismissesOmnibarWhenAppFocusStateStillPointsAtThisPanel() {
+ let panelId = UUID()
+
+ XCTAssertTrue(
+ browserWebViewClickShouldDismissOmnibar(
+ localAddressBarFocused: false,
+ focusedAddressBarPanelId: panelId,
+ pendingAddressBarFocusRequestId: nil,
+ panelId: panelId
+ )
+ )
+ }
+
+ func testDismissesOmnibarWhenAddressBarFocusRequestIsStillPending() {
+ let panelId = UUID()
+
+ XCTAssertTrue(
+ browserWebViewClickShouldDismissOmnibar(
+ localAddressBarFocused: false,
+ focusedAddressBarPanelId: nil,
+ pendingAddressBarFocusRequestId: UUID(),
+ panelId: panelId
+ )
+ )
+ }
+
+ func testDoesNotDismissOmnibarForAnotherPanelsFocusedAddressBar() {
+ let panelId = UUID()
+
+ XCTAssertFalse(
+ browserWebViewClickShouldDismissOmnibar(
+ localAddressBarFocused: false,
+ focusedAddressBarPanelId: UUID(),
+ pendingAddressBarFocusRequestId: nil,
+ panelId: panelId
+ )
+ )
+ }
+
+ func testPromotesWebViewFocusWhenPanelIsFocused() {
+ XCTAssertTrue(
+ browserWebViewClickShouldPromoteWebViewFocus(
+ isPanelFocused: true,
+ shouldDismissOmnibar: false
+ )
+ )
+ }
+
+ func testPromotesWebViewFocusWhenOmnibarStillOwnsFocus() {
+ XCTAssertTrue(
+ browserWebViewClickShouldPromoteWebViewFocus(
+ isPanelFocused: false,
+ shouldDismissOmnibar: true
+ )
+ )
+ }
+
+ func testDoesNotPromoteWebViewFocusWhenPanelIsNotFocusedAndOmnibarDoesNotOwnFocus() {
+ XCTAssertFalse(
+ browserWebViewClickShouldPromoteWebViewFocus(
+ isPanelFocused: false,
+ shouldDismissOmnibar: false
+ )
+ )
+ }
}
diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
index f10d7a5f0e..c89322276e 100644
--- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
+++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
@@ -4,6 +4,8 @@ import Foundation
final class BrowserPaneNavigationKeybindUITests: XCTestCase {
private var dataPath = ""
private var socketPath = ""
+ private var launchDiagnosticsPath = ""
+ private var launchTag = ""
override func setUp() {
super.setUp()
@@ -12,6 +14,29 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
try? FileManager.default.removeItem(atPath: dataPath)
socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock"
try? FileManager.default.removeItem(atPath: socketPath)
+ launchDiagnosticsPath = "/tmp/cmux-ui-test-launch-\(UUID().uuidString).json"
+ try? FileManager.default.removeItem(atPath: launchDiagnosticsPath)
+ launchTag = "ui-tests-browser-nav-\(UUID().uuidString.prefix(8))"
+
+ let diagnosticsPath = launchDiagnosticsPath
+ addTeardownBlock { [weak self] in
+ guard let self,
+ let contents = try? String(contentsOfFile: diagnosticsPath, encoding: .utf8),
+ !contents.isEmpty else {
+ return
+ }
+ print("UI_TEST_LAUNCH_DIAGNOSTICS_BEGIN")
+ print(contents)
+ print("UI_TEST_LAUNCH_DIAGNOSTICS_END")
+ let attachment = XCTAttachment(string: contents)
+ attachment.name = "ui-test-launch-diagnostics"
+ attachment.lifetime = .deleteOnSuccess
+ self.add(attachment)
+ }
+
+ let cleanup = XCUIApplication()
+ cleanup.terminate()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
func testCmdCtrlHMovesLeftWhenWebViewFocused() {
@@ -319,6 +344,849 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
}
+ func testArrowKeysReachClickedPageInputAfterCmdL() {
+ 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"
+ app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] = "1"
+ launchAndEnsureForeground(app)
+
+ XCTAssertTrue(
+ waitForData(
+ keys: [
+ "browserPanelId",
+ "webViewFocused",
+ "webInputFocusSeeded",
+ "webInputFocusElementId",
+ "webInputFocusSecondaryElementId",
+ "webInputFocusPrimaryClickOffsetX",
+ "webInputFocusPrimaryClickOffsetY",
+ "webInputFocusSecondaryClickOffsetX",
+ "webInputFocusSecondaryClickOffsetY"
+ ],
+ timeout: 20.0
+ ),
+ "Expected focused page input setup data to be written. data=\(String(describing: loadData()))"
+ )
+
+ guard let setup = loadData() else {
+ XCTFail("Missing goto_split setup data")
+ return
+ }
+
+ XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before arrow-key checks")
+ guard let primaryInputId = setup["webInputFocusElementId"], !primaryInputId.isEmpty else {
+ XCTFail("Missing webInputFocusElementId in setup data")
+ return
+ }
+ guard let secondaryInputId = setup["webInputFocusSecondaryElementId"], !secondaryInputId.isEmpty else {
+ XCTFail("Missing webInputFocusSecondaryElementId in setup data")
+ return
+ }
+ guard let primaryClickOffsetXRaw = setup["webInputFocusPrimaryClickOffsetX"],
+ let primaryClickOffsetYRaw = setup["webInputFocusPrimaryClickOffsetY"],
+ let primaryClickOffsetX = Double(primaryClickOffsetXRaw),
+ let primaryClickOffsetY = Double(primaryClickOffsetYRaw) else {
+ XCTFail(
+ "Missing or invalid primary input click offsets in setup data. " +
+ "webInputFocusPrimaryClickOffsetX=\(setup["webInputFocusPrimaryClickOffsetX"] ?? "nil") " +
+ "webInputFocusPrimaryClickOffsetY=\(setup["webInputFocusPrimaryClickOffsetY"] ?? "nil")"
+ )
+ 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
+ }
+
+ let window = app.windows.firstMatch
+ XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window before arrow-key regression check")
+
+ window
+ .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
+ .withOffset(CGVector(dx: primaryClickOffsetX, dy: primaryClickOffsetY))
+ .click()
+
+ guard let initialArrowSnapshot = waitForDataSnapshot(
+ timeout: 8.0,
+ predicate: { data in
+ data["browserArrowInstalled"] == "true" &&
+ data["browserArrowActiveElementId"] == primaryInputId &&
+ data["browserArrowDownCount"] == "0" &&
+ data["browserArrowUpCount"] == "0"
+ }
+ ) else {
+ XCTFail(
+ "Expected arrow recorder to initialize with the primary page input focused. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+ let initialDownCount = Int(initialArrowSnapshot["browserArrowDownCount"] ?? "") ?? -1
+ let initialUpCount = Int(initialArrowSnapshot["browserArrowUpCount"] ?? "") ?? -1
+
+ simulateShortcut("down", app: app)
+ guard let baselineDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == primaryInputId &&
+ data["browserArrowDownCount"] == "\(initialDownCount + 1)" &&
+ data["browserArrowUpCount"] == "\(initialUpCount)"
+ }
+ ) else {
+ XCTFail(
+ "Expected baseline Down Arrow to reach the primary page input. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+ let baselineDownCount = Int(baselineDownSnapshot["browserArrowDownCount"] ?? "") ?? -1
+ let baselineUpCount = Int(baselineDownSnapshot["browserArrowUpCount"] ?? "") ?? -1
+
+ simulateShortcut("up", app: app)
+ guard let baselineUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == primaryInputId &&
+ data["browserArrowDownCount"] == "\(baselineDownCount)" &&
+ data["browserArrowUpCount"] == "\(baselineUpCount + 1)"
+ }
+ ) else {
+ XCTFail(
+ "Expected baseline Up Arrow to reach the primary page input. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+ let baselineUpCountAfterUp = Int(baselineUpSnapshot["browserArrowUpCount"] ?? "") ?? -1
+
+ app.typeKey("l", modifierFlags: [.command])
+ XCTAssertTrue(
+ waitForDataMatch(timeout: 5.0) { data in
+ data["webViewFocusedAfterAddressBarFocus"] == "false"
+ },
+ "Expected Cmd+L to focus the omnibar before the page-click arrow-key check"
+ )
+
+ window
+ .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
+ .withOffset(CGVector(dx: secondaryClickOffsetX, dy: secondaryClickOffsetY))
+ .click()
+
+ guard waitForDataMatch(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == secondaryInputId
+ }
+ ) else {
+ XCTFail(
+ "Expected clicking the page to focus the secondary page input before sending arrows. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+
+ simulateShortcut("down", app: app)
+ guard let postCmdLDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == secondaryInputId &&
+ data["browserArrowDownCount"] == "\(baselineDownCount + 1)" &&
+ data["browserArrowUpCount"] == "\(baselineUpCountAfterUp)"
+ }
+ ) else {
+ XCTFail(
+ "Expected Down Arrow after Cmd+L and page click to reach the secondary page input. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+ let postCmdLDownCount = Int(postCmdLDownSnapshot["browserArrowDownCount"] ?? "") ?? -1
+
+ simulateShortcut("up", app: app)
+ guard let postCmdLUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == secondaryInputId &&
+ data["browserArrowDownCount"] == "\(postCmdLDownCount)" &&
+ data["browserArrowUpCount"] == "\(baselineUpCountAfterUp + 1)"
+ }
+ ) else {
+ XCTFail(
+ "Expected Up Arrow after Cmd+L and page click to reach the secondary page input. " +
+ "postCmdLDownSnapshot=\(postCmdLDownSnapshot) " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+
+ let baselineCommandShiftDownCount = Int(postCmdLUpSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1
+ let baselineCommandShiftUpCount = Int(postCmdLUpSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1
+ guard baselineCommandShiftDownCount >= 0, baselineCommandShiftUpCount >= 0 else {
+ XCTFail(
+ "Expected browser arrow recorder to report Cmd+Shift+arrow counters. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+
+ simulateShortcut("cmdShiftDown", app: app)
+ guard let postCmdLCommandShiftDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == secondaryInputId &&
+ data["browserArrowCommandShiftDownCount"] == "\(baselineCommandShiftDownCount + 1)" &&
+ data["browserArrowCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)"
+ }
+ ) else {
+ XCTFail(
+ "Expected Cmd+Shift+Down after Cmd+L and page click to reach the secondary page input. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+ let postCmdLCommandShiftDownCount = Int(postCmdLCommandShiftDownSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1
+ let postCmdLCommandShiftUpCount = Int(postCmdLCommandShiftDownSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1
+ guard postCmdLCommandShiftDownCount >= 0, postCmdLCommandShiftUpCount >= 0 else {
+ XCTFail(
+ "Expected browser arrow recorder to report Cmd+Shift+Down counters. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+
+ simulateShortcut("cmdShiftUp", app: app)
+ guard let postCmdLCommandShiftUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == secondaryInputId &&
+ data["browserArrowCommandShiftDownCount"] == "\(postCmdLCommandShiftDownCount)" &&
+ data["browserArrowCommandShiftUpCount"] == "\(postCmdLCommandShiftUpCount + 1)"
+ }
+ ) else {
+ XCTFail(
+ "Expected Cmd+Shift+Up after Cmd+L and page click to reach the secondary page input. " +
+ "data=\(String(describing: loadData()))"
+ )
+ return
+ }
+
+ XCTAssertEqual(postCmdLUpSnapshot["browserArrowActiveElementId"], secondaryInputId, "Expected the clicked secondary page input to remain focused")
+ XCTAssertEqual(postCmdLCommandShiftUpSnapshot["browserArrowActiveElementId"], secondaryInputId, "Expected the clicked secondary page input to remain focused after Cmd+Shift+arrows")
+ }
+
+ func testArrowKeysDoNotLeakToPageWhileOmnibarFocused() {
+ 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"
+ app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] = "1"
+ launchAndEnsureForeground(app)
+
+ XCTAssertTrue(
+ waitForData(
+ keys: [
+ "browserPanelId",
+ "webInputFocusSeeded",
+ "webInputFocusElementId",
+ "webInputFocusPrimaryClickOffsetX",
+ "webInputFocusPrimaryClickOffsetY"
+ ],
+ timeout: 20.0
+ ),
+ "Expected focused page input setup data before omnibar leak check. data=\(String(describing: loadData()))"
+ )
+
+ guard let setup = loadData() else {
+ XCTFail("Missing goto_split setup data")
+ return
+ }
+
+ guard let browserPanelId = setup["browserPanelId"], !browserPanelId.isEmpty else {
+ XCTFail("Missing browserPanelId in setup data")
+ return
+ }
+ guard let primaryInputId = setup["webInputFocusElementId"], !primaryInputId.isEmpty else {
+ XCTFail("Missing webInputFocusElementId in setup data")
+ return
+ }
+ guard let primaryClickOffsetXRaw = setup["webInputFocusPrimaryClickOffsetX"],
+ let primaryClickOffsetYRaw = setup["webInputFocusPrimaryClickOffsetY"],
+ let primaryClickOffsetX = Double(primaryClickOffsetXRaw),
+ let primaryClickOffsetY = Double(primaryClickOffsetYRaw) else {
+ XCTFail(
+ "Missing or invalid primary input click offsets in setup data. " +
+ "webInputFocusPrimaryClickOffsetX=\(setup["webInputFocusPrimaryClickOffsetX"] ?? "nil") " +
+ "webInputFocusPrimaryClickOffsetY=\(setup["webInputFocusPrimaryClickOffsetY"] ?? "nil")"
+ )
+ return
+ }
+
+ let window = app.windows.firstMatch
+ XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window before omnibar leak check")
+
+ window
+ .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
+ .withOffset(CGVector(dx: primaryClickOffsetX, dy: primaryClickOffsetY))
+ .click()
+
+ guard let initialSnapshot = waitForDataSnapshot(
+ timeout: 8.0,
+ predicate: { data in
+ data["browserArrowInstalled"] == "true" &&
+ data["browserArrowActiveElementId"] == primaryInputId &&
+ data["browserArrowDownCount"] == "0" &&
+ data["browserArrowUpCount"] == "0" &&
+ data["browserArrowCommandShiftDownCount"] == "0" &&
+ data["browserArrowCommandShiftUpCount"] == "0"
+ }
+ ) else {
+ XCTFail("Expected page input to be focused before omnibar leak check. data=\(String(describing: loadData()))")
+ return
+ }
+
+ let baselineDownCount = Int(initialSnapshot["browserArrowDownCount"] ?? "") ?? -1
+ let baselineUpCount = Int(initialSnapshot["browserArrowUpCount"] ?? "") ?? -1
+ let baselineCommandShiftDownCount = Int(initialSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1
+ let baselineCommandShiftUpCount = Int(initialSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1
+ guard baselineDownCount == 0,
+ baselineUpCount == 0,
+ baselineCommandShiftDownCount == 0,
+ baselineCommandShiftUpCount == 0 else {
+ XCTFail("Expected zeroed arrow counters before omnibar leak check. data=\(initialSnapshot)")
+ return
+ }
+
+ app.typeKey("l", modifierFlags: [.command])
+ XCTAssertTrue(
+ waitForDataMatch(timeout: 5.0) { data in
+ data["webViewFocusedAfterAddressBarFocus"] == "false" &&
+ data["webViewFocusedAfterAddressBarFocusPanelId"] == browserPanelId &&
+ data["browserArrowFocusedAddressBarPanelId"] == browserPanelId
+ },
+ "Expected Cmd+L to keep omnibar focused before leak check. data=\(String(describing: loadData()))"
+ )
+
+ simulateShortcut("down", app: app)
+ XCTAssertTrue(
+ browserArrowCountersRemainUnchanged(
+ down: baselineDownCount,
+ up: baselineUpCount,
+ commandShiftDown: baselineCommandShiftDownCount,
+ commandShiftUp: baselineCommandShiftUpCount,
+ timeout: 1.0
+ ),
+ "Expected Down Arrow to stay out of the page while omnibar remained focused. data=\(String(describing: loadData()))"
+ )
+
+ simulateShortcut("up", app: app)
+ XCTAssertTrue(
+ browserArrowCountersRemainUnchanged(
+ down: baselineDownCount,
+ up: baselineUpCount,
+ commandShiftDown: baselineCommandShiftDownCount,
+ commandShiftUp: baselineCommandShiftUpCount,
+ timeout: 1.0
+ ),
+ "Expected Up Arrow to stay out of the page while omnibar remained focused. data=\(String(describing: loadData()))"
+ )
+
+ simulateShortcut("cmdShiftDown", app: app)
+ XCTAssertTrue(
+ browserArrowCountersRemainUnchanged(
+ down: baselineDownCount,
+ up: baselineUpCount,
+ commandShiftDown: baselineCommandShiftDownCount,
+ commandShiftUp: baselineCommandShiftUpCount,
+ timeout: 1.0
+ ),
+ "Expected Cmd+Shift+Down to stay out of the page while omnibar remained focused. data=\(String(describing: loadData()))"
+ )
+
+ simulateShortcut("cmdShiftUp", app: app)
+ XCTAssertTrue(
+ browserArrowCountersRemainUnchanged(
+ down: baselineDownCount,
+ up: baselineUpCount,
+ commandShiftDown: baselineCommandShiftDownCount,
+ commandShiftUp: baselineCommandShiftUpCount,
+ timeout: 1.0
+ ),
+ "Expected Cmd+Shift+Up to stay out of the page while omnibar remained focused. data=\(String(describing: loadData()))"
+ )
+ }
+
+ func testArrowKeysReachClickedTextareaAfterCmdL() {
+ 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"
+ app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] = "1"
+ launchAndEnsureForeground(app)
+
+ XCTAssertTrue(
+ waitForData(
+ keys: [
+ "browserPanelId",
+ "webViewFocused",
+ "webInputFocusSeeded",
+ "webInputFocusTextareaElementId",
+ "webInputFocusTextareaClickOffsetX",
+ "webInputFocusTextareaClickOffsetY"
+ ],
+ timeout: 20.0
+ ),
+ "Expected textarea setup data to be written. data=\(String(describing: loadData()))"
+ )
+
+ guard let setup = loadData() else {
+ XCTFail("Missing goto_split setup data")
+ return
+ }
+
+ guard let browserPanelId = setup["browserPanelId"], !browserPanelId.isEmpty else {
+ XCTFail("Missing browserPanelId in setup data")
+ return
+ }
+ XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected page input harness to be seeded before textarea check")
+ guard let textareaId = setup["webInputFocusTextareaElementId"], !textareaId.isEmpty else {
+ XCTFail("Missing webInputFocusTextareaElementId in setup data")
+ return
+ }
+ guard let textareaClickOffsetXRaw = setup["webInputFocusTextareaClickOffsetX"],
+ let textareaClickOffsetYRaw = setup["webInputFocusTextareaClickOffsetY"],
+ let textareaClickOffsetX = Double(textareaClickOffsetXRaw),
+ let textareaClickOffsetY = Double(textareaClickOffsetYRaw) else {
+ XCTFail(
+ "Missing or invalid textarea click offsets in setup data. " +
+ "webInputFocusTextareaClickOffsetX=\(setup["webInputFocusTextareaClickOffsetX"] ?? "nil") " +
+ "webInputFocusTextareaClickOffsetY=\(setup["webInputFocusTextareaClickOffsetY"] ?? "nil")"
+ )
+ return
+ }
+
+ let window = app.windows.firstMatch
+ XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window before textarea regression check")
+
+ window
+ .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
+ .withOffset(CGVector(dx: textareaClickOffsetX, dy: textareaClickOffsetY))
+ .click()
+
+ guard let initialSnapshot = waitForDataSnapshot(
+ timeout: 8.0,
+ predicate: { data in
+ data["browserArrowInstalled"] == "true" &&
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowDownCount"] == "0" &&
+ data["browserArrowUpCount"] == "0" &&
+ data["browserArrowCommandShiftDownCount"] == "0" &&
+ data["browserArrowCommandShiftUpCount"] == "0"
+ }
+ ) else {
+ XCTFail("Expected textarea to be focused before baseline arrows. data=\(String(describing: loadData()))")
+ return
+ }
+
+ let initialDownCount = Int(initialSnapshot["browserArrowDownCount"] ?? "") ?? -1
+ let initialUpCount = Int(initialSnapshot["browserArrowUpCount"] ?? "") ?? -1
+ let initialCommandShiftDownCount = Int(initialSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1
+ let initialCommandShiftUpCount = Int(initialSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1
+
+ simulateShortcut("down", app: app)
+ guard let baselineDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowDownCount"] == "\(initialDownCount + 1)" &&
+ data["browserArrowUpCount"] == "\(initialUpCount)"
+ }
+ ) else {
+ XCTFail("Expected baseline Down Arrow to reach the textarea. data=\(String(describing: loadData()))")
+ return
+ }
+ let baselineDownCount = Int(baselineDownSnapshot["browserArrowDownCount"] ?? "") ?? -1
+
+ simulateShortcut("up", app: app)
+ guard waitForDataMatch(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowDownCount"] == "\(baselineDownCount)" &&
+ data["browserArrowUpCount"] == "\(initialUpCount + 1)"
+ }
+ ) else {
+ XCTFail("Expected baseline Up Arrow to reach the textarea. data=\(String(describing: loadData()))")
+ return
+ }
+ simulateShortcut("cmdShiftDown", app: app)
+ guard let baselineCommandShiftDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowCommandShiftDownCount"] == "\(initialCommandShiftDownCount + 1)" &&
+ data["browserArrowCommandShiftUpCount"] == "\(initialCommandShiftUpCount)"
+ }
+ ) else {
+ XCTFail("Expected baseline Cmd+Shift+Down to reach the textarea. data=\(String(describing: loadData()))")
+ return
+ }
+ let baselineCommandShiftDownCount = Int(baselineCommandShiftDownSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1
+
+ simulateShortcut("cmdShiftUp", app: app)
+ guard let baselineCommandShiftUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowCommandShiftDownCount"] == "\(baselineCommandShiftDownCount)" &&
+ data["browserArrowCommandShiftUpCount"] == "\(initialCommandShiftUpCount + 1)"
+ }
+ ) else {
+ XCTFail("Expected baseline Cmd+Shift+Up to reach the textarea. data=\(String(describing: loadData()))")
+ return
+ }
+ let baselineDownCountAfterModifiers = Int(baselineCommandShiftUpSnapshot["browserArrowDownCount"] ?? "") ?? -1
+ let baselineUpCountAfterModifiers = Int(baselineCommandShiftUpSnapshot["browserArrowUpCount"] ?? "") ?? -1
+ let baselineCommandShiftUpCount = Int(baselineCommandShiftUpSnapshot["browserArrowCommandShiftUpCount"] ?? "") ?? -1
+
+ app.typeKey("l", modifierFlags: [.command])
+ XCTAssertTrue(
+ waitForDataMatch(timeout: 5.0) { data in
+ data["webViewFocusedAfterAddressBarFocus"] == "false" &&
+ data["webViewFocusedAfterAddressBarFocusPanelId"] == browserPanelId &&
+ data["browserArrowFocusedAddressBarPanelId"] == browserPanelId
+ },
+ "Expected Cmd+L to focus omnibar before the textarea click path"
+ )
+
+ window
+ .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
+ .withOffset(CGVector(dx: textareaClickOffsetX, dy: textareaClickOffsetY))
+ .click()
+
+ guard waitForDataMatch(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId
+ }
+ ) else {
+ XCTFail("Expected clicking the page to re-focus the textarea after Cmd+L. data=\(String(describing: loadData()))")
+ return
+ }
+
+ simulateShortcut("down", app: app)
+ guard let postCmdLDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowDownCount"] == "\(baselineDownCountAfterModifiers + 1)" &&
+ data["browserArrowUpCount"] == "\(baselineUpCountAfterModifiers)" &&
+ data["browserArrowCommandShiftDownCount"] == "\(baselineCommandShiftDownCount)" &&
+ data["browserArrowCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)"
+ }
+ ) else {
+ XCTFail("Expected Down Arrow after Cmd+L to reach the textarea. data=\(String(describing: loadData()))")
+ return
+ }
+ let postCmdLDownCount = Int(postCmdLDownSnapshot["browserArrowDownCount"] ?? "") ?? -1
+
+ simulateShortcut("up", app: app)
+ guard let postCmdLUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowDownCount"] == "\(postCmdLDownCount)" &&
+ data["browserArrowUpCount"] == "\(baselineUpCountAfterModifiers + 1)"
+ }
+ ) else {
+ XCTFail("Expected Up Arrow after Cmd+L to reach the textarea. data=\(String(describing: loadData()))")
+ return
+ }
+ let postCmdLUpCount = Int(postCmdLUpSnapshot["browserArrowUpCount"] ?? "") ?? -1
+
+ simulateShortcut("cmdShiftDown", app: app)
+ guard let postCmdLCommandShiftDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowDownCount"] == "\(postCmdLDownCount + 1)" &&
+ data["browserArrowUpCount"] == "\(postCmdLUpCount)" &&
+ data["browserArrowCommandShiftDownCount"] == "\(baselineCommandShiftDownCount + 1)" &&
+ data["browserArrowCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)"
+ }
+ ) else {
+ XCTFail("Expected Cmd+Shift+Down after Cmd+L to reach the textarea. data=\(String(describing: loadData()))")
+ return
+ }
+ let postCmdLDownCountAfterCommandShiftDown = Int(postCmdLCommandShiftDownSnapshot["browserArrowDownCount"] ?? "") ?? -1
+ let postCmdLCommandShiftDownCount = Int(postCmdLCommandShiftDownSnapshot["browserArrowCommandShiftDownCount"] ?? "") ?? -1
+
+ simulateShortcut("cmdShiftUp", app: app)
+ guard let postCmdLCommandShiftUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserArrowActiveElementId"] == textareaId &&
+ data["browserArrowDownCount"] == "\(postCmdLDownCountAfterCommandShiftDown)" &&
+ data["browserArrowUpCount"] == "\(postCmdLUpCount + 1)" &&
+ data["browserArrowCommandShiftDownCount"] == "\(postCmdLCommandShiftDownCount)" &&
+ data["browserArrowCommandShiftUpCount"] == "\(baselineCommandShiftUpCount + 1)"
+ }
+ ) else {
+ XCTFail("Expected Cmd+Shift+Up after Cmd+L to reach the textarea. data=\(String(describing: loadData()))")
+ return
+ }
+
+ XCTAssertEqual(
+ postCmdLCommandShiftUpSnapshot["browserArrowActiveElementId"],
+ textareaId,
+ "Expected the clicked textarea to remain focused after Cmd+Shift+arrows"
+ )
+ }
+
+ func testArrowKeysReachClickedContentEditableAfterCmdL() {
+ 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"
+ app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_ARROW_SETUP"] = "1"
+ app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_CONTENTEDITABLE_SETUP"] = "1"
+ launchAndEnsureForeground(app)
+
+ XCTAssertTrue(
+ waitForData(
+ keys: [
+ "browserPanelId",
+ "webInputFocusSeeded",
+ "webContentEditableSeeded",
+ "webContentEditableElementId",
+ "webContentEditableClickOffsetX",
+ "webContentEditableClickOffsetY"
+ ],
+ timeout: 20.0
+ ),
+ "Expected focused page input setup data before contenteditable regression check. data=\(String(describing: loadData()))"
+ )
+
+ guard let setup = loadData() else {
+ XCTFail("Missing goto_split setup data")
+ return
+ }
+
+ guard let browserPanelId = setup["browserPanelId"], !browserPanelId.isEmpty else {
+ XCTFail("Missing browserPanelId in setup data")
+ return
+ }
+ XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page inputs to be seeded before contenteditable regression check")
+ XCTAssertEqual(setup["webContentEditableSeeded"], "true", "Expected contenteditable fixture to be seeded before contenteditable regression check")
+ guard let editorId = setup["webContentEditableElementId"], !editorId.isEmpty else {
+ XCTFail("Missing webContentEditableElementId in setup data")
+ return
+ }
+ guard let editorClickOffsetXRaw = setup["webContentEditableClickOffsetX"],
+ let editorClickOffsetYRaw = setup["webContentEditableClickOffsetY"],
+ let editorClickOffsetX = Double(editorClickOffsetXRaw),
+ let editorClickOffsetY = Double(editorClickOffsetYRaw) else {
+ XCTFail(
+ "Missing or invalid contenteditable click offsets in setup data. " +
+ "webContentEditableClickOffsetX=\(setup["webContentEditableClickOffsetX"] ?? "nil") " +
+ "webContentEditableClickOffsetY=\(setup["webContentEditableClickOffsetY"] ?? "nil")"
+ )
+ return
+ }
+
+ let window = app.windows.firstMatch
+ XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window before contenteditable regression check")
+
+ window
+ .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
+ .withOffset(CGVector(dx: editorClickOffsetX, dy: editorClickOffsetY))
+ .click()
+
+ guard let initialSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableInstalled"] == "true" &&
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableDownCount"] == "0" &&
+ data["browserContentEditableUpCount"] == "0" &&
+ data["browserContentEditableCommandShiftDownCount"] == "0" &&
+ data["browserContentEditableCommandShiftUpCount"] == "0"
+ }
+ ) else {
+ XCTFail("Expected contenteditable fixture to be focused before baseline arrows. data=\(String(describing: loadData()))")
+ return
+ }
+ let initialDownCount = Int(initialSnapshot["browserContentEditableDownCount"] ?? "") ?? -1
+ let initialUpCount = Int(initialSnapshot["browserContentEditableUpCount"] ?? "") ?? -1
+ let initialCommandShiftDownCount = Int(initialSnapshot["browserContentEditableCommandShiftDownCount"] ?? "") ?? -1
+ let initialCommandShiftUpCount = Int(initialSnapshot["browserContentEditableCommandShiftUpCount"] ?? "") ?? -1
+
+ simulateShortcut("down", app: app)
+ guard let baselineDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableDownCount"] == "\(initialDownCount + 1)" &&
+ data["browserContentEditableUpCount"] == "\(initialUpCount)"
+ }
+ ) else {
+ XCTFail("Expected baseline Down Arrow to reach the contenteditable fixture. data=\(String(describing: loadData()))")
+ return
+ }
+ let baselineDownCount = Int(baselineDownSnapshot["browserContentEditableDownCount"] ?? "") ?? -1
+
+ simulateShortcut("up", app: app)
+ guard waitForDataMatch(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableDownCount"] == "\(baselineDownCount)" &&
+ data["browserContentEditableUpCount"] == "\(initialUpCount + 1)"
+ }
+ ) else {
+ XCTFail("Expected baseline Up Arrow to reach the contenteditable fixture. data=\(String(describing: loadData()))")
+ return
+ }
+ simulateShortcut("cmdShiftDown", app: app)
+ guard let baselineCommandShiftDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableCommandShiftDownCount"] == "\(initialCommandShiftDownCount + 1)" &&
+ data["browserContentEditableCommandShiftUpCount"] == "\(initialCommandShiftUpCount)"
+ }
+ ) else {
+ XCTFail("Expected baseline Cmd+Shift+Down to reach the contenteditable fixture. data=\(String(describing: loadData()))")
+ return
+ }
+ let baselineCommandShiftDownCount = Int(baselineCommandShiftDownSnapshot["browserContentEditableCommandShiftDownCount"] ?? "") ?? -1
+
+ simulateShortcut("cmdShiftUp", app: app)
+ guard let baselineCommandShiftUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableCommandShiftDownCount"] == "\(baselineCommandShiftDownCount)" &&
+ data["browserContentEditableCommandShiftUpCount"] == "\(initialCommandShiftUpCount + 1)"
+ }
+ ) else {
+ XCTFail("Expected baseline Cmd+Shift+Up to reach the contenteditable fixture. data=\(String(describing: loadData()))")
+ return
+ }
+ let baselineDownCountAfterModifiers = Int(baselineCommandShiftUpSnapshot["browserContentEditableDownCount"] ?? "") ?? -1
+ let baselineUpCountAfterModifiers = Int(baselineCommandShiftUpSnapshot["browserContentEditableUpCount"] ?? "") ?? -1
+ let baselineCommandShiftUpCount = Int(baselineCommandShiftUpSnapshot["browserContentEditableCommandShiftUpCount"] ?? "") ?? -1
+
+ app.typeKey("l", modifierFlags: [.command])
+ XCTAssertTrue(
+ waitForDataMatch(timeout: 5.0) { data in
+ data["webViewFocusedAfterAddressBarFocus"] == "false" &&
+ data["webViewFocusedAfterAddressBarFocusPanelId"] == browserPanelId &&
+ data["browserArrowFocusedAddressBarPanelId"] == browserPanelId
+ },
+ "Expected Cmd+L to focus omnibar before the contenteditable click path"
+ )
+
+ window
+ .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
+ .withOffset(CGVector(dx: editorClickOffsetX, dy: editorClickOffsetY))
+ .click()
+
+ guard waitForDataMatch(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId
+ }
+ ) else {
+ XCTFail("Expected clicking the page to re-focus the contenteditable fixture after Cmd+L. data=\(String(describing: loadData()))")
+ return
+ }
+
+ simulateShortcut("down", app: app)
+ guard let postCmdLDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableDownCount"] == "\(baselineDownCountAfterModifiers + 1)" &&
+ data["browserContentEditableUpCount"] == "\(baselineUpCountAfterModifiers)" &&
+ data["browserContentEditableCommandShiftDownCount"] == "\(baselineCommandShiftDownCount)" &&
+ data["browserContentEditableCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)"
+ }
+ ) else {
+ XCTFail("Expected Down Arrow after Cmd+L to reach the contenteditable fixture. data=\(String(describing: loadData()))")
+ return
+ }
+ let postCmdLDownCount = Int(postCmdLDownSnapshot["browserContentEditableDownCount"] ?? "") ?? -1
+
+ simulateShortcut("up", app: app)
+ guard let postCmdLUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableDownCount"] == "\(postCmdLDownCount)" &&
+ data["browserContentEditableUpCount"] == "\(baselineUpCountAfterModifiers + 1)"
+ }
+ ) else {
+ XCTFail("Expected Up Arrow after Cmd+L to reach the contenteditable fixture. data=\(String(describing: loadData()))")
+ return
+ }
+ let postCmdLUpCount = Int(postCmdLUpSnapshot["browserContentEditableUpCount"] ?? "") ?? -1
+
+ simulateShortcut("cmdShiftDown", app: app)
+ guard let postCmdLCommandShiftDownSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableDownCount"] == "\(postCmdLDownCount + 1)" &&
+ data["browserContentEditableUpCount"] == "\(postCmdLUpCount)" &&
+ data["browserContentEditableCommandShiftDownCount"] == "\(baselineCommandShiftDownCount + 1)" &&
+ data["browserContentEditableCommandShiftUpCount"] == "\(baselineCommandShiftUpCount)"
+ }
+ ) else {
+ XCTFail("Expected Cmd+Shift+Down after Cmd+L to reach the contenteditable fixture. data=\(String(describing: loadData()))")
+ return
+ }
+ let postCmdLDownCountAfterCommandShiftDown = Int(postCmdLCommandShiftDownSnapshot["browserContentEditableDownCount"] ?? "") ?? -1
+ let postCmdLCommandShiftDownCount = Int(postCmdLCommandShiftDownSnapshot["browserContentEditableCommandShiftDownCount"] ?? "") ?? -1
+
+ simulateShortcut("cmdShiftUp", app: app)
+ guard let postCmdLCommandShiftUpSnapshot = waitForDataSnapshot(
+ timeout: 5.0,
+ predicate: { data in
+ data["browserContentEditableActiveElementId"] == editorId &&
+ data["browserContentEditableDownCount"] == "\(postCmdLDownCountAfterCommandShiftDown)" &&
+ data["browserContentEditableUpCount"] == "\(postCmdLUpCount + 1)" &&
+ data["browserContentEditableCommandShiftDownCount"] == "\(postCmdLCommandShiftDownCount)" &&
+ data["browserContentEditableCommandShiftUpCount"] == "\(baselineCommandShiftUpCount + 1)"
+ }
+ ) else {
+ XCTFail("Expected Cmd+Shift+Up after Cmd+L to reach the contenteditable fixture. data=\(String(describing: loadData()))")
+ return
+ }
+
+ XCTAssertEqual(
+ postCmdLCommandShiftUpSnapshot["browserContentEditableActiveElementId"],
+ editorId,
+ "Expected the clicked contenteditable fixture to remain focused after Cmd+Shift+arrows"
+ )
+ }
+
func testCmdLOpensBrowserWhenTerminalFocused() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
@@ -1014,29 +1882,76 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
}
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
+ prepareLaunchEnvironment(app)
+
// On headless CI runners (no GUI session), XCUIApplication.launch()
- // blocks ~60s then fails with "Failed to activate application
- // (current state: Running Background)". Mark this as an expected
- // failure so the test can continue — keyboard and element APIs work
- // via accessibility even when the app is in .runningBackground.
+ // can fail activation even though the app is usable through
+ // accessibility. Keep the launch diagnostics/socket setup above, then
+ // tolerate a background-only launch before failing hard.
let options = XCTExpectedFailure.Options()
options.isStrict = false
XCTExpectFailure("App activation may fail on headless CI runners", options: options) {
app.launch()
}
- if app.state == .runningForeground { return }
+ if ensureForegroundAfterLaunch(app, timeout: timeout) {
+ return
+ }
if app.state == .runningBackground {
- // App launched but couldn't activate — continue in background.
- // XCUIElement queries and keyboard input work through the
- // accessibility framework regardless of activation state.
return
}
XCTFail("App failed to start. state=\(app.state.rawValue)")
}
+ private func prepareLaunchEnvironment(_ app: XCUIApplication) {
+ if app.launchEnvironment["CMUX_UI_TEST_MODE"] == nil {
+ app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
+ }
+ if app.launchEnvironment["CMUX_TAG"] == nil {
+ app.launchEnvironment["CMUX_TAG"] = launchTag
+ }
+ if app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] == nil {
+ app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = launchDiagnosticsPath
+ }
+ if app.launchEnvironment["CMUX_SOCKET_PATH"] != nil,
+ app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] == nil {
+ app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
+ }
+ if app.launchEnvironment["CMUX_SOCKET_PATH"] != nil {
+ if app.launchEnvironment["CMUX_ALLOW_SOCKET_OVERRIDE"] == nil {
+ app.launchEnvironment["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1"
+ }
+ if !app.launchArguments.contains("-socketControlMode") {
+ app.launchArguments += ["-socketControlMode", "allowAll"]
+ }
+ if app.launchEnvironment["CMUX_SOCKET_ENABLE"] == nil {
+ app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1"
+ }
+ if app.launchEnvironment["CMUX_SOCKET_MODE"] == nil {
+ app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll"
+ }
+ }
+ }
+
+ private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
+ if app.wait(for: .runningForeground, timeout: timeout) {
+ return true
+ }
+
+ let activationDeadline = Date().addingTimeInterval(12.0)
+ while app.state == .runningBackground && Date() < activationDeadline {
+ app.activate()
+ if app.wait(for: .runningForeground, timeout: 2.0) {
+ return true
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.2))
+ }
+
+ return app.state == .runningForeground
+ }
+
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
waitForCondition(timeout: timeout) {
guard let data = self.loadData() else { return false }
@@ -1051,12 +1966,62 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
}
}
+ private func waitForDataSnapshot(
+ timeout: TimeInterval,
+ predicate: @escaping ([String: String]) -> Bool
+ ) -> [String: String]? {
+ var matched: [String: String]?
+ let didMatch = waitForCondition(timeout: timeout) {
+ guard let data = self.loadData(), predicate(data) else { return false }
+ matched = data
+ return true
+ }
+ return didMatch ? matched : nil
+ }
+
+ private func browserArrowCountersRemainUnchanged(
+ down: Int,
+ up: Int,
+ commandShiftDown: Int,
+ commandShiftUp: Int,
+ timeout: TimeInterval
+ ) -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if let data = loadData() {
+ if data["browserArrowDownCount"] != "\(down)" ||
+ data["browserArrowUpCount"] != "\(up)" ||
+ data["browserArrowCommandShiftDownCount"] != "\(commandShiftDown)" ||
+ data["browserArrowCommandShiftUpCount"] != "\(commandShiftUp)" {
+ return false
+ }
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ }
+ return true
+ }
+
private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
+ private func simulateShortcut(_ combo: String, app: XCUIApplication) {
+ switch combo {
+ case "down":
+ app.typeKey(XCUIKeyboardKey.downArrow.rawValue, modifierFlags: [])
+ case "up":
+ app.typeKey(XCUIKeyboardKey.upArrow.rawValue, modifierFlags: [])
+ case "cmdShiftDown":
+ app.typeKey(XCUIKeyboardKey.downArrow.rawValue, modifierFlags: [.command, .shift])
+ case "cmdShiftUp":
+ app.typeKey(XCUIKeyboardKey.upArrow.rawValue, modifierFlags: [.command, .shift])
+ default:
+ XCTFail("Unsupported test shortcut combo \(combo)")
+ }
+ }
+
private func loadData() -> [String: String]? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else {
return nil
@@ -1071,4 +2036,5 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
+
}