Merge remote-tracking branch 'origin/main' into fix-popover-arrow

This commit is contained in:
cmux 2026-03-07 18:52:16 -08:00
commit 7fc71fc7cc
66 changed files with 4235 additions and 440 deletions

View file

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

View file

@ -70,6 +70,13 @@ final class WindowBrowserHostView: NSView {
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
deinit {
if let trackingArea {
removeTrackingArea(trackingArea)
}
clearActiveDividerCursor(restoreArrow: false)
}
#if DEBUG
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
switch event?.type {
@ -1765,6 +1772,20 @@ final class WindowBrowserPortal: NSObject {
)
}
private static func searchOverlayConfigurationsEquivalent(
_ lhs: BrowserPortalSearchOverlayConfiguration?,
_ rhs: BrowserPortalSearchOverlayConfiguration?
) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
return true
case let (lhs?, rhs?):
return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState
default:
return false
}
}
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
/// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the
/// visible split pane during rearrangement; intersecting through ancestor bounds keeps the
@ -1953,6 +1974,7 @@ final class WindowBrowserPortal: NSObject {
/// do not keep an old anchor visible.
func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) {
guard var entry = entriesByWebViewId[webViewId] else { return }
guard entry.visibleInUI != visibleInUI || entry.zPriority != zPriority else { return }
entry.visibleInUI = visibleInUI
entry.zPriority = zPriority
entriesByWebViewId[webViewId] = entry
@ -1968,6 +1990,7 @@ final class WindowBrowserPortal: NSObject {
func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) {
guard var entry = entriesByWebViewId[webViewId] else { return }
guard entry.dropZone != zone else { return }
entry.dropZone = zone
entriesByWebViewId[webViewId] = entry
entry.containerView?.setDropZoneOverlay(zone: zone)
@ -1975,6 +1998,7 @@ final class WindowBrowserPortal: NSObject {
func updatePaneDropContext(forWebViewId webViewId: ObjectIdentifier, context: BrowserPaneDropContext?) {
guard var entry = entriesByWebViewId[webViewId] else { return }
guard entry.paneDropContext != context else { return }
entry.paneDropContext = context
entriesByWebViewId[webViewId] = entry
entry.containerView?.setPaneDropContext(context)
@ -1985,6 +2009,7 @@ final class WindowBrowserPortal: NSObject {
configuration: BrowserPortalSearchOverlayConfiguration?
) {
guard var entry = entriesByWebViewId[webViewId] else { return }
guard !Self.searchOverlayConfigurationsEquivalent(entry.searchOverlay, configuration) else { return }
entry.searchOverlay = configuration
entriesByWebViewId[webViewId] = entry
entry.containerView?.setSearchOverlay(configuration)
@ -2263,6 +2288,28 @@ final class WindowBrowserPortal: NSObject {
return
}
guard anchorView.window === window else {
let isOffWindowReparent =
entry.visibleInUI &&
anchorView.window == nil &&
anchorView.superview != nil
if isOffWindowReparent {
let didScheduleTransientRecovery = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: "anchorWindowMismatch"
)
#if DEBUG
if didScheduleTransientRecovery && !containerView.isHidden {
dlog(
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=anchorWindowMismatch.offWindow frame=\(browserPortalDebugFrame(containerView.frame))"
)
}
#endif
containerView.setDropZoneOverlay(zone: nil)
return
}
if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") {
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)

File diff suppressed because it is too large Load diff

View file

@ -73,7 +73,7 @@ struct BrowserSearchOverlay: View {
Image(systemName: "chevron.up")
}
.buttonStyle(SearchButtonStyle())
.help("Next match (Return)")
.safeHelp("Next match (Return)")
Button(action: {
#if DEBUG
@ -84,7 +84,7 @@ struct BrowserSearchOverlay: View {
Image(systemName: "chevron.down")
}
.buttonStyle(SearchButtonStyle())
.help("Previous match (Shift+Return)")
.safeHelp("Previous match (Shift+Return)")
Button(action: {
#if DEBUG
@ -95,7 +95,7 @@ struct BrowserSearchOverlay: View {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
.help("Close (Esc)")
.safeHelp("Close (Esc)")
}
.padding(8)
.background(.background)

View file

@ -88,7 +88,7 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "chevron.up")
}
.buttonStyle(SearchButtonStyle())
.help(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)"))
.safeHelp(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)"))
Button(action: {
#if DEBUG
@ -99,7 +99,7 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "chevron.down")
}
.buttonStyle(SearchButtonStyle())
.help(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)"))
.safeHelp(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)"))
Button(action: {
#if DEBUG
@ -110,7 +110,7 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
.help(String(localized: "search.close.help", defaultValue: "Close (Esc)"))
.safeHelp(String(localized: "search.close.help", defaultValue: "Close (Esc)"))
}
.padding(8)
.background(.background)

View file

@ -2121,6 +2121,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
surfaceView.tabId = newTabId
}
func isAttached(to view: GhosttyNSView) -> Bool {
attachedView === view && surface != nil
}
func portalBindingGeneration() -> UInt64 {
portalLifecycleGeneration
}
@ -2262,6 +2266,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
// removed/re-added (or briefly have window/screen nil) without recreating the surface.
// Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing
// or stale, the surface can appear visually frozen until a focus/visibility change.
// SwiftUI also re-enters this path for ordinary state propagation (drag hover, active
// markers, visibility flags), so avoid forcing a geometry refresh when the attachment
// itself is unchanged.
if attachedView === view && surface != nil {
#if DEBUG
dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())")
@ -2272,7 +2279,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
let s = surface {
ghostty_surface_set_display_id(s, displayID)
}
view.forceRefreshSurface()
return
}
@ -2570,6 +2576,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
#endif
}
@discardableResult
func updateSize(
width: CGFloat,
height: CGFloat,
@ -2577,15 +2584,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
yScale: CGFloat,
layerScale: CGFloat,
backingSize: CGSize? = nil
) {
guard let surface = surface else { return }
) -> Bool {
guard let surface = surface else { return false }
_ = layerScale
let resolvedBackingWidth = backingSize?.width ?? (width * xScale)
let resolvedBackingHeight = backingSize?.height ?? (height * yScale)
let wpx = pixelDimension(from: resolvedBackingWidth)
let hpx = pixelDimension(from: resolvedBackingHeight)
guard wpx > 0, hpx > 0 else { return }
guard wpx > 0, hpx > 0 else { return false }
let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale)
let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight
@ -2594,7 +2601,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)")
#endif
guard scaleChanged || sizeChanged else { return }
guard scaleChanged || sizeChanged else { return false }
#if DEBUG
if sizeChanged {
@ -2616,10 +2623,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
// Let Ghostty continue rendering on its own wakeups for steady-state frames.
return true
}
/// Force a full size recalculation and surface redraw.
func forceRefresh() {
func forceRefresh(reason: String = "unspecified") {
let hasSurface = surface != nil
let viewState: String
if let view = attachedView {
@ -2632,7 +2640,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
#if DEBUG
let ts = ISO8601DateFormatter().string(from: Date())
let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n"
let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n"
let logPath = "/tmp/cmux-refresh-debug.log"
if let handle = FileHandle(forWritingAtPath: logPath) {
handle.seekToEndOfFile()
@ -2941,6 +2949,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var lastScrollEventTime: CFTimeInterval = 0
private var visibleInUI: Bool = true
private var pendingSurfaceSize: CGSize?
private var lastDrawableSize: CGSize = .zero
private var isFindEscapeSuppressionArmed = false
#if DEBUG
private var lastSizeSkipSignature: String?
@ -3114,14 +3123,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
func attachSurface(_ surface: TerminalSurface) {
appliedColorScheme = nil
let isSameSurface = terminalSurface === surface
let isAlreadyAttached = surface.isAttached(to: self)
if !isSameSurface {
appliedColorScheme = nil
}
terminalSurface = surface
tabId = surface.tabId
surface.attachToView(self)
if !isAlreadyAttached {
surface.attachToView(self)
}
surface.setKeyboardCopyModeActive(keyboardCopyModeActive)
updateSurfaceSize()
if !isAlreadyAttached {
updateSurfaceSize()
}
applySurfaceBackground()
applySurfaceColorScheme(force: true)
applySurfaceColorScheme(force: !isSameSurface || !isAlreadyAttached)
}
override func viewDidMoveToWindow() {
@ -3229,8 +3246,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return currentBounds
}
private func updateSurfaceSize(size: CGSize? = nil) {
guard let terminalSurface = terminalSurface else { return }
@discardableResult
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
guard let terminalSurface = terminalSurface else { return false }
let size = resolvedSurfaceSize(preferred: size)
guard size.width > 0 && size.height > 0 else {
#if DEBUG
@ -3244,7 +3262,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
lastSizeSkipSignature = signature
}
#endif
return
return false
}
pendingSurfaceSize = size
guard let window else {
@ -3258,7 +3276,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
lastSizeSkipSignature = signature
}
#endif
return
return false
}
// First principles: derive pixel size from AppKit's backing conversion for the current
@ -3276,7 +3294,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
lastSizeSkipSignature = signature
}
#endif
return
return false
}
#if DEBUG
if lastSizeSkipSignature != nil {
@ -3295,17 +3313,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
width: floor(max(0, backingSize.width)),
height: floor(max(0, backingSize.height))
)
var didChange = false
CATransaction.begin()
CATransaction.setDisableActions(true)
if let layer, !nearlyEqual(layer.contentsScale, layerScale) {
didChange = true
}
layer?.contentsScale = layerScale
layer?.masksToBounds = true
if let metalLayer = layer as? CAMetalLayer {
metalLayer.drawableSize = drawablePixelSize
if drawablePixelSize != lastDrawableSize || metalLayer.drawableSize != drawablePixelSize {
if metalLayer.drawableSize != drawablePixelSize {
didChange = true
}
if metalLayer.drawableSize != drawablePixelSize {
metalLayer.drawableSize = drawablePixelSize
}
lastDrawableSize = drawablePixelSize
}
}
CATransaction.commit()
terminalSurface.updateSize(
let surfaceSizeChanged = terminalSurface.updateSize(
width: size.width,
height: size.height,
xScale: xScale,
@ -3313,15 +3343,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
layerScale: layerScale,
backingSize: backingSize
)
return didChange || surfaceSizeChanged
}
fileprivate func pushTargetSurfaceSize(_ size: CGSize) {
@discardableResult
fileprivate func pushTargetSurfaceSize(_ size: CGSize) -> Bool {
updateSurfaceSize(size: size)
}
/// Force a full size recalculation and Metal layer refresh.
/// Resets cached metrics so updateSurfaceSize() re-runs unconditionally.
func forceRefreshSurface() {
/// Force a full size reconciliation for the current bounds.
/// Keep the drawable-size cache intact so redundant refresh paths do not
/// reallocate Metal drawables when the pixel size is unchanged.
@discardableResult
func forceRefreshSurface() -> Bool {
updateSurfaceSize()
}
@ -4654,6 +4688,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if let windowObserver {
NotificationCenter.default.removeObserver(windowObserver)
}
if let trackingArea {
removeTrackingArea(trackingArea)
}
terminalSurface = nil
}
@ -4882,6 +4919,7 @@ final class GhosttySurfaceScrollView: NSView {
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
private let keyboardCopyModeBadgeLabel: NSTextField
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
private var lastSearchOverlayStateID: ObjectIdentifier?
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
private var isLiveScrolling = false
@ -4908,6 +4946,9 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
private var lastDropZoneOverlayLogSignature: String?
private var dragLayoutLogSequence: UInt64 = 0
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
private static var flashCounts: [UUID: Int] = [:]
private static var drawCounts: [UUID: Int] = [:]
private static var lastDrawTimes: [UUID: CFTimeInterval] = [:]
@ -5238,36 +5279,50 @@ final class GhosttySurfaceScrollView: NSView {
/// Reconcile AppKit geometry with ghostty surface geometry synchronously.
/// Used after split topology mutations (close/split) to prevent a stale one-frame
/// IOSurface size from being presented after pane expansion.
func reconcileGeometryNow() {
@discardableResult
func reconcileGeometryNow() -> Bool {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.reconcileGeometryNow()
}
return
return false
}
synchronizeGeometryAndContent()
return synchronizeGeometryAndContent()
}
/// Request an immediate terminal redraw after geometry updates so stale IOSurface
/// contents do not remain stretched during live resize churn.
func refreshSurfaceNow() {
surfaceView.terminalSurface?.forceRefresh()
func refreshSurfaceNow(reason: String = "portal.refreshSurfaceNow") {
surfaceView.terminalSurface?.forceRefresh(reason: reason)
}
private func synchronizeGeometryAndContent() {
@discardableResult
private func synchronizeGeometryAndContent() -> Bool {
CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
backgroundView.frame = bounds
scrollView.frame = bounds
let previousSurfaceSize = surfaceView.frame.size
_ = setFrameIfNeeded(backgroundView, to: bounds)
_ = setFrameIfNeeded(scrollView, to: bounds)
let targetSize = scrollView.bounds.size
surfaceView.frame.size = targetSize
documentView.frame.size.width = scrollView.bounds.width
inactiveOverlayView.frame = bounds
#if DEBUG
logLayoutDuringActiveDrag(targetSize: targetSize)
#endif
let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize)
_ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame)
let targetDocumentFrame = CGRect(
origin: documentView.frame.origin,
size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height)
)
_ = setFrameIfNeeded(documentView, to: targetDocumentFrame)
_ = setFrameIfNeeded(inactiveOverlayView, to: bounds)
if let zone = activeDropZone {
dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size)
_ = setFrameIfNeeded(
dropZoneOverlayView,
to: dropZoneOverlayFrame(for: zone, in: bounds.size)
)
}
if let pending = pendingDropZone,
bounds.width > 2,
@ -5281,15 +5336,68 @@ final class GhosttySurfaceScrollView: NSView {
// same initial animation as direct drop-zone activation.
setDropZoneOverlay(zone: pending)
}
notificationRingOverlayView.frame = bounds
flashOverlayView.frame = bounds
_ = setFrameIfNeeded(notificationRingOverlayView, to: bounds)
_ = setFrameIfNeeded(flashOverlayView, to: bounds)
updateNotificationRingPath()
updateFlashPath()
synchronizeScrollView()
synchronizeSurfaceView()
synchronizeCoreSurface()
let didCoreSurfaceChange = synchronizeCoreSurface()
return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange
}
@discardableResult
private func setFrameIfNeeded(_ view: NSView, to frame: CGRect) -> Bool {
guard !Self.rectApproximatelyEqual(view.frame, frame) else { return false }
view.frame = frame
return true
}
private func sizeApproximatelyEqual(_ lhs: CGSize, _ rhs: CGSize, epsilon: CGFloat = 0.0001) -> Bool {
abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon
}
private func pointApproximatelyEqual(_ lhs: CGPoint, _ rhs: CGPoint, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.x - rhs.x) <= epsilon && abs(lhs.y - rhs.y) <= epsilon
}
#if DEBUG
private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool {
switch eventType {
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
return true
default:
return false
}
}
private func logLayoutDuringActiveDrag(targetSize: CGSize) {
let pasteboardTypes = NSPasteboard(name: .drag).types
let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true
let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true
let eventType = NSApp.currentEvent?.type
let hasActiveDrag =
activeDropZone != nil ||
pendingDropZone != nil ||
((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType))
guard hasActiveDrag else { return }
dragLayoutLogSequence &+= 1
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
let activeZone = activeDropZone.map { String(describing: $0) } ?? "none"
let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none"
let event = eventType.map { String(describing: $0) } ?? "nil"
dlog(
"terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " +
"activeZone=\(activeZone) pendingZone=\(pendingZone) " +
"hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " +
"event=\(event) inWindow=\(window != nil ? 1 : 0) " +
"bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"target=\(String(format: "%.1fx%.1f", targetSize.width, targetSize.height))"
)
}
#endif
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
@ -5385,10 +5493,15 @@ final class GhosttySurfaceScrollView: NSView {
return
}
let targetHidden = !visible
let targetOpacity: Float = visible ? 1 : 0
guard notificationRingOverlayView.isHidden != targetHidden ||
notificationRingLayer.opacity != targetOpacity else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
notificationRingOverlayView.isHidden = !visible
notificationRingLayer.opacity = visible ? 1 : 0
notificationRingOverlayView.isHidden = targetHidden
notificationRingLayer.opacity = targetOpacity
CATransaction.commit()
}
@ -5405,6 +5518,8 @@ final class GhosttySurfaceScrollView: NSView {
guard let terminalSurface = surfaceView.terminalSurface,
let searchState else {
let hadOverlay = searchOverlayHostingView != nil
lastSearchOverlayStateID = nil
guard hadOverlay else { return }
#if DEBUG
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
#endif
@ -5414,6 +5529,16 @@ final class GhosttySurfaceScrollView: NSView {
return
}
let searchStateID = ObjectIdentifier(searchState)
if let overlay = searchOverlayHostingView,
lastSearchOverlayStateID == searchStateID,
overlay.superview === self {
if !keyboardCopyModeBadgeView.isHidden {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
}
return
}
let hadOverlay = searchOverlayHostingView != nil
#if DEBUG
dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")")
@ -5457,6 +5582,7 @@ final class GhosttySurfaceScrollView: NSView {
if !keyboardCopyModeBadgeView.isHidden {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
}
lastSearchOverlayStateID = searchStateID
return
}
@ -5474,6 +5600,7 @@ final class GhosttySurfaceScrollView: NSView {
addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay)
}
searchOverlayHostingView = overlay
lastSearchOverlayStateID = searchStateID
}
func setKeyboardCopyModeIndicator(visible: Bool) {
@ -6356,16 +6483,18 @@ final class GhosttySurfaceScrollView: NSView {
private func synchronizeSurfaceView() {
let visibleRect = scrollView.contentView.documentVisibleRect
guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return }
surfaceView.frame.origin = visibleRect.origin
}
/// Match upstream Ghostty behavior: use content area width (excluding non-content
/// regions such as scrollbar space) when telling libghostty the terminal size.
private func synchronizeCoreSurface() {
@discardableResult
private func synchronizeCoreSurface() -> Bool {
let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth())
let height = surfaceView.frame.height
guard width > 0, height > 0 else { return }
surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
guard width > 0, height > 0 else { return false }
return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
}
/// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller.
@ -6425,19 +6554,30 @@ final class GhosttySurfaceScrollView: NSView {
}
private func synchronizeScrollView() {
documentView.frame.size.height = documentHeight()
var didChangeGeometry = false
let targetDocumentHeight = documentHeight()
if abs(documentView.frame.height - targetDocumentHeight) > 0.5 {
documentView.frame.size.height = targetDocumentHeight
didChangeGeometry = true
}
if !isLiveScrolling {
let cellHeight = surfaceView.cellSize.height
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
let offsetY =
CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight
scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY))
let targetOrigin = CGPoint(x: 0, y: offsetY)
if !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) {
scrollView.contentView.scroll(to: targetOrigin)
didChangeGeometry = true
}
lastSentRow = Int(scrollbar.offset)
}
}
scrollView.reflectScrolledClipView(scrollView.contentView)
if didChangeGeometry {
scrollView.reflectScrolledClipView(scrollView.contentView)
}
}
private func handleScrollChange() {
@ -6669,31 +6809,57 @@ struct GhosttyTerminalView: NSViewRepresentable {
private final class HostContainerView: NSView {
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
private(set) var geometryRevision: UInt64 = 0
private var lastReportedGeometryState: GeometryState?
private struct GeometryState: Equatable {
let frame: CGRect
let bounds: CGRect
let windowNumber: Int?
let superviewID: ObjectIdentifier?
}
private func currentGeometryState() -> GeometryState {
GeometryState(
frame: frame,
bounds: bounds,
windowNumber: window?.windowNumber,
superviewID: superview.map(ObjectIdentifier.init)
)
}
private func notifyGeometryChangedIfNeeded() {
let state = currentGeometryState()
guard state != lastReportedGeometryState else { return }
lastReportedGeometryState = state
geometryRevision &+= 1
onGeometryChanged?()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
onDidMoveToWindow?()
onGeometryChanged?()
notifyGeometryChangedIfNeeded()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
onGeometryChanged?()
notifyGeometryChangedIfNeeded()
}
override func layout() {
super.layout()
onGeometryChanged?()
notifyGeometryChangedIfNeeded()
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
onGeometryChanged?()
notifyGeometryChangedIfNeeded()
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
onGeometryChanged?()
notifyGeometryChangedIfNeeded()
}
}
@ -6706,6 +6872,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
var desiredPortalZPriority: Int = 0
var lastBoundHostId: ObjectIdentifier?
var lastPaneDropZone: DropZone?
var lastSynchronizedHostGeometryRevision: UInt64 = 0
weak var hostedView: GhosttySurfaceScrollView?
}
@ -6825,6 +6992,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
expectedGeneration: portalExpectedGeneration
)
coordinator.lastBoundHostId = ObjectIdentifier(host)
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
hostedView.setActive(coordinator.desiredIsActive)
hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing)
@ -6856,17 +7024,30 @@ struct GhosttyTerminalView: NSViewRepresentable {
hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing)
}
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
}
if host.window != nil {
let hostId = ObjectIdentifier(host)
let geometryRevision = host.geometryRevision
let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
let shouldBindNow =
coordinator.lastBoundHostId != hostId ||
hostedView.superview == nil ||
portalEntryMissing ||
previousDesiredIsVisibleInUI != isVisibleInUI ||
previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing ||
previousDesiredPortalZPriority != portalZPriority
if shouldBindNow {
#if DEBUG
if portalEntryMissing {
dlog(
"ws.hostState.rebindOnUpdate surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " +
"active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)"
)
}
#endif
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
to: host,
@ -6876,8 +7057,11 @@ struct GhosttyTerminalView: NSViewRepresentable {
expectedGeneration: portalExpectedGeneration
)
coordinator.lastBoundHostId = hostId
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
} else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
}
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
} else {
// Bind is deferred until host moves into a window. Update the
// existing portal entry's visibleInUI now so that any portal sync

View file

@ -1,3 +1,4 @@
import Bonsplit
import SwiftUI
struct NotificationsPage: View {
@ -113,7 +114,7 @@ struct NotificationsPage: View {
}
.buttonStyle(.bordered)
.keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
.safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
.disabled(!hasUnreadNotifications)
} else {
Button(action: {
@ -125,7 +126,7 @@ struct NotificationsPage: View {
}
}
.buttonStyle(.bordered)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
.safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
.disabled(!hasUnreadNotifications)
}
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -86,15 +86,17 @@ struct MarkdownPanelView: View {
Image(systemName: "doc.questionmark")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("File unavailable")
Text(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable"))
.font(.headline)
.foregroundColor(.primary)
Text(panel.filePath)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(2)
.multilineTextAlignment(.center)
Text("The file may have been moved or deleted.")
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 24)
Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "The file may have been moved or deleted."))
.font(.caption)
.foregroundColor(.secondary)
}

View file

@ -2880,7 +2880,7 @@ class TabManager: ObservableObject {
continue
}
terminal.hostedView.reconcileGeometryNow()
terminal.surface.forceRefresh()
terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry")
}
}

View file

@ -4161,7 +4161,7 @@ class TerminalController {
var refreshedCount = 0
for panel in ws.panels.values {
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.surface.forceRefresh()
terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceRefresh")
refreshedCount += 1
}
}
@ -4243,7 +4243,7 @@ class TerminalController {
// Ensure we present a new frame after injecting input so snapshot-based tests (and
// socket-driven agents) can observe the updated terminal without requiring a focus
// change to trigger a draw.
terminalPanel.surface.forceRefresh()
terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText")
queued = false
} else {
// Avoid blocking the main actor waiting for view/surface attachment.
@ -4301,7 +4301,7 @@ class TerminalController {
result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key])
return
}
terminalPanel.surface.forceRefresh()
terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey")
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
}
return result
@ -4333,7 +4333,7 @@ class TerminalController {
return
}
terminalPanel.surface.forceRefresh()
terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceClearHistory")
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": ws.id.uuidString,
@ -9704,81 +9704,91 @@ class TerminalController {
return "OK"
}
private func simulateShortcut(_ args: String) -> String {
let combo = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !combo.isEmpty else {
return "ERROR: Usage: simulate_shortcut <combo>"
}
guard let parsed = parseShortcutCombo(combo) else {
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
}
private func prepareWindowForSyntheticInput(_ window: NSWindow?) {
guard let window else { return }
// Stamp at socket-handler arrival so event.timestamp includes any wait
// before the main-thread event dispatch.
let requestTimestamp = ProcessInfo.processInfo.systemUptime
var result = "ERROR: Failed to create event"
DispatchQueue.main.sync {
// Prefer the current active-tab-manager window so shortcut simulation stays
// scoped to the intended window even when NSApp.keyWindow is stale.
let targetWindow: NSWindow? = {
if let activeTabManager = self.tabManager,
let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
let window = AppDelegate.shared?.mainWindow(for: windowId) {
return window
}
return NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first
}()
if let targetWindow {
NSApp.activate(ignoringOtherApps: true)
targetWindow.makeKeyAndOrderFront(nil)
}
let windowNumber = targetWindow?.windowNumber ?? 0
guard let keyDownEvent = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: parsed.modifierFlags,
timestamp: requestTimestamp,
windowNumber: windowNumber,
context: nil,
characters: parsed.characters,
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
isARepeat: false,
keyCode: parsed.keyCode
) else {
result = "ERROR: NSEvent.keyEvent returned nil"
return
}
let keyUpEvent = NSEvent.keyEvent(
with: .keyUp,
location: .zero,
modifierFlags: parsed.modifierFlags,
timestamp: requestTimestamp + 0.0001,
windowNumber: windowNumber,
context: nil,
characters: parsed.characters,
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
isARepeat: false,
keyCode: parsed.keyCode
)
// Socket-driven shortcut simulation should reuse the exact same matching logic as the
// app-level shortcut monitor (so tests are hermetic), while still falling back to the
// normal responder chain for plain typing.
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) {
result = "OK"
return
}
NSApp.sendEvent(keyDownEvent)
if let keyUpEvent {
NSApp.sendEvent(keyUpEvent)
}
result = "OK"
}
return result
}
// Keep socket-driven input simulation focused on the intended window without
// paying repeated activation/order-front costs for every synthetic key event.
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
if !window.isKeyWindow || !window.isVisible {
window.makeKeyAndOrderFront(nil)
}
}
private func simulateShortcut(_ args: String) -> String {
let combo = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !combo.isEmpty else {
return "ERROR: Usage: simulate_shortcut <combo>"
}
guard let parsed = parseShortcutCombo(combo) else {
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
}
// Stamp at socket-handler arrival so event.timestamp includes any wait
// before the main-thread event dispatch.
let requestTimestamp = ProcessInfo.processInfo.systemUptime
var result = "ERROR: Failed to create event"
DispatchQueue.main.sync {
// Prefer the current active-tab-manager window so shortcut simulation stays
// scoped to the intended window even when NSApp.keyWindow is stale.
let targetWindow: NSWindow? = {
if let activeTabManager = self.tabManager,
let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
let window = AppDelegate.shared?.mainWindow(for: windowId) {
return window
}
return NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first
}()
prepareWindowForSyntheticInput(targetWindow)
let windowNumber = targetWindow?.windowNumber ?? 0
guard let keyDownEvent = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: parsed.modifierFlags,
timestamp: requestTimestamp,
windowNumber: windowNumber,
context: nil,
characters: parsed.characters,
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
isARepeat: false,
keyCode: parsed.keyCode
) else {
result = "ERROR: NSEvent.keyEvent returned nil"
return
}
let keyUpEvent = NSEvent.keyEvent(
with: .keyUp,
location: .zero,
modifierFlags: parsed.modifierFlags,
timestamp: requestTimestamp + 0.0001,
windowNumber: windowNumber,
context: nil,
characters: parsed.characters,
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
isARepeat: false,
keyCode: parsed.keyCode
)
// Socket-driven shortcut simulation should reuse the exact same matching logic as the
// app-level shortcut monitor (so tests are hermetic), while still falling back to the
// normal responder chain for plain typing.
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) {
result = "OK"
return
}
NSApp.sendEvent(keyDownEvent)
if let keyUpEvent {
NSApp.sendEvent(keyUpEvent)
}
result = "OK"
}
return result
}
private func activateApp() -> String {
DispatchQueue.main.sync {
@ -9823,8 +9833,7 @@ class TerminalController {
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first else { return }
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
prepareWindowForSyntheticInput(window)
guard let fr = window.firstResponder else {
result = "ERROR: No first responder"
return
@ -11146,7 +11155,7 @@ class TerminalController {
var cgImage = view.debugCopyIOSurfaceCGImage()
if cgImage == nil {
// If the surface is mid-attach we may not have contents yet. Nudge a draw and retry once.
terminalPanel.surface.forceRefresh()
terminalPanel.surface.forceRefresh(reason: "terminalController.debugCopyIOSurfaceRetry")
cgImage = view.debugCopyIOSurfaceCGImage()
}
guard let cgImage else {
@ -13712,7 +13721,7 @@ class TerminalController {
// (resets cached metrics so the Metal layer drawable resizes correctly)
for panel in tab.panels.values {
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.surface.forceRefresh()
terminalPanel.surface.forceRefresh(reason: "terminalController.refreshAllTerminalPanels")
refreshedCount += 1
}
}

View file

@ -54,6 +54,13 @@ final class WindowTerminalHostView: NSView {
private var lastDragRouteSignature: String?
#endif
deinit {
if let trackingArea {
removeTrackingArea(trackingArea)
}
clearActiveDividerCursor(restoreArrow: false)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
@ -698,12 +705,13 @@ final class WindowTerminalPortal: NSObject {
synchronizeAllHostedViews(excluding: nil)
// During live resize, AppKit can deliver frame churn where host/container geometry
// settles a tick before the terminal's own scroll/surface hierarchy. Force a final
// in-place geometry + surface refresh for all visible entries in this window.
// settles a tick before the terminal's own scroll/surface hierarchy. Only force an
// in-place surface refresh when reconciliation actually changed terminal geometry.
for entry in entriesByHostedId.values {
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
if hostedView.reconcileGeometryNow() {
hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync")
}
}
}
@ -1392,7 +1400,7 @@ final class WindowTerminalPortal: NSObject {
hostedView.frame = targetFrame
CATransaction.commit()
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
hostedView.refreshSurfaceNow(reason: "portal.frameChange")
}
if hasFiniteFrame {
@ -1431,7 +1439,7 @@ final class WindowTerminalPortal: NSObject {
// normal frame-change refresh path won't run. Nudge geometry + redraw so newly
// revealed terminals don't sit on a stale/blank IOSurface until later focus churn.
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
hostedView.refreshSurfaceNow(reason: "portal.reveal")
}
if transientRecoveryReason == nil {

View file

@ -1,4 +1,5 @@
import AppKit
import Bonsplit
import Foundation
import SwiftUI
@ -54,7 +55,7 @@ struct UpdatePill: View {
.contentShape(Capsule())
}
.buttonStyle(.plain)
.help(model.text)
.safeHelp(model.text)
.accessibilityLabel(model.text)
.accessibilityIdentifier("UpdatePill")
}

View file

@ -273,7 +273,7 @@ struct TitlebarControlsView: View {
}
var body: some View {
// Force the `.help(...)` tooltips to re-evaluate when shortcuts are changed in settings.
// Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings.
// (The titlebar controls don't otherwise re-render on UserDefaults changes.)
let _ = shortcutRefreshTick
let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic
@ -321,7 +321,7 @@ struct TitlebarControlsView: View {
}
.accessibilityIdentifier("titlebarControl.toggleSidebar")
.accessibilityLabel(String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar"))
.help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar")))
.safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar")))
TitlebarControlButton(config: config, action: {
#if DEBUG
@ -348,7 +348,7 @@ struct TitlebarControlsView: View {
.accessibilityIdentifier("titlebarControl.showNotifications")
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
.accessibilityLabel(String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications"))
.help(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications")))
.safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications")))
TitlebarControlButton(config: config, action: {
#if DEBUG
@ -360,7 +360,7 @@ struct TitlebarControlsView: View {
}
.accessibilityIdentifier("titlebarControl.newTab")
.accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"))
.help(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace")))
.safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace")))
}
let paddedContent = content.padding(config.groupPadding)
@ -729,6 +729,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
view = containerView
containerView.translatesAutoresizingMaskIntoConstraints = true
// Prevent the titlebar accessory from clipping button backgrounds
// at the bottom edge (the system constrains accessory height to the
// titlebar, which can be slightly shorter than the button frames).
containerView.wantsLayer = true
containerView.layer?.masksToBounds = false
hostingView.translatesAutoresizingMaskIntoConstraints = true
hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)

View file

@ -3432,11 +3432,11 @@ final class Workspace: Identifiable, ObservableObject {
needsFollowUpPass = true
}
hostedView.reconcileGeometryNow()
let geometryChanged = hostedView.reconcileGeometryNow()
// Re-check surface after reconcileGeometryNow() which can trigger AppKit
// layout and view lifecycle changes that free surfaces (#432).
if terminalPanel.surface.surface != nil {
terminalPanel.surface.forceRefresh()
if geometryChanged, terminalPanel.surface.surface != nil {
terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile")
}
if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
@ -3492,9 +3492,9 @@ final class Workspace: Identifiable, ObservableObject {
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard let self, let panel = self.terminalPanel(for: panelId) else { return }
panel.hostedView.reconcileGeometryNow()
if panel.surface.surface != nil {
panel.surface.forceRefresh()
let geometryChanged = panel.hostedView.reconcileGeometryNow()
if geometryChanged, panel.surface.surface != nil {
panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh")
}
if panel.surface.surface == nil {
panel.surface.requestBackgroundSurfaceStartIfNeeded()