From 743cfcdc6d346834cf4670b83a938919832622e9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:20:42 -0800 Subject: [PATCH 1/9] Fix browser devtools persistence and Safari shortcut wiring --- README.md | 5 +- Sources/AppDelegate.swift | 15 ++ Sources/KeyboardShortcutSettings.swift | 14 ++ Sources/Panels/BrowserPanel.swift | 118 ++++++++++ Sources/Panels/BrowserPanelView.swift | 213 ++++++++++++++---- Sources/TabManager.swift | 10 + Sources/cmuxApp.swift | 106 ++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 84 +++++++ skills/cmux-debug-windows/SKILL.md | 3 + .../scripts/debug_windows_snapshot.sh | 13 +- vendor/bonsplit | 2 +- 11 files changed, 538 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 8157d38a..0b284537 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta ### Browser +Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`. + | Shortcut | Action | |----------|--------| | ⌘ ⇧ L | Open browser in split | @@ -143,7 +145,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta | ⌘ [ | Back | | ⌘ ] | Forward | | ⌘ R | Reload page | -| ⌥ ⌘ I | Open Developer Tools | +| ⌥ ⌘ I | Toggle Developer Tools (Safari default) | +| ⌥ ⌘ C | Show JavaScript Console (Safari default) | ### Notifications diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cede8954..45ee1aa1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1878,6 +1878,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Safari defaults: + // - Option+Command+I => Show/Toggle Web Inspector + // - Option+Command+C => Show JavaScript Console + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false + if !didHandle { NSSound.beep() } + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false + if !didHandle { NSSound.beep() } + return true + } + // Focus browser address bar: Cmd+L if flags == [.command] && chars == "l" { if let focusedPanel = tabManager?.focusedBrowserPanel { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 82a7fe84..e3e27884 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -29,6 +29,8 @@ enum KeyboardShortcutSettings { // Panels case openBrowser + case toggleBrowserDeveloperTools + case showBrowserJavaScriptConsole var id: String { rawValue } @@ -52,6 +54,8 @@ enum KeyboardShortcutSettings { case .splitRight: return "Split Right" case .splitDown: return "Split Down" case .openBrowser: return "Open Browser" + case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools" + case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console" } } @@ -75,6 +79,8 @@ enum KeyboardShortcutSettings { case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" case .openBrowser: return "shortcut.openBrowser" + case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" + case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" } } @@ -116,6 +122,12 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + case .toggleBrowserDeveloperTools: + // Safari default: Show Web Inspector. + return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false) + case .showBrowserJavaScriptConsole: + // Safari default: Show JavaScript Console. + return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) } } @@ -182,6 +194,8 @@ enum KeyboardShortcutSettings { static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) } static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) } + static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) } + static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } } /// A keyboard shortcut that can be stored in UserDefaults diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 6c405b3d..bd50abc6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -825,6 +825,8 @@ final class BrowserPanel: Panel, ObservableObject { private let minPageZoom: CGFloat = 0.25 private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 + // Persist user intent across WebKit detach/reattach churn (split/layout updates). + private var preferredDeveloperToolsVisible: Bool = false var displayTitle: String { if !pageTitle.isEmpty { @@ -865,6 +867,11 @@ final class BrowserPanel: Panel, ObservableObject { let webView = CmuxWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true + // Required for Web Inspector support on recent WebKit SDKs. + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + // Match the empty-page background to the window so newly-created browsers // don't flash white before content loads. webView.underPageBackgroundColor = .windowBackgroundColor @@ -1296,6 +1303,90 @@ extension BrowserPanel { webView.stopLoading() } + @discardableResult + func toggleDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + let targetVisible = !visible + let selector = NSSelectorFromString(targetVisible ? "show" : "close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + preferredDeveloperToolsVisible = targetVisible + return true + } + + @discardableResult + func showDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if !visible { + let showSelector = NSSelectorFromString("show") + guard inspector.responds(to: showSelector) else { return false } + inspector.cmuxCallVoid(selector: showSelector) + } + preferredDeveloperToolsVisible = true + return true + } + + @discardableResult + func showDeveloperToolsConsole() -> Bool { + guard showDeveloperTools() else { return false } + guard let inspector = webView.cmuxInspectorObject() else { return true } + // WebKit private inspector API differs by OS; try known console selectors. + let consoleSelectors = [ + "showConsole", + "showConsoleTab", + "showConsoleView", + ] + for raw in consoleSelectors { + let selector = NSSelectorFromString(raw) + if inspector.responds(to: selector) { + inspector.cmuxCallVoid(selector: selector) + break + } + } + return true + } + + /// Called before WKWebView detaches so manual inspector closes are respected. + func syncDeveloperToolsPreferenceFromInspector() { + guard let inspector = webView.cmuxInspectorObject() else { return } + if let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) { + preferredDeveloperToolsVisible = visible + } + } + + /// Called after WKWebView reattaches to keep inspector stable across split/layout churn. + func restoreDeveloperToolsAfterAttachIfNeeded() { + guard preferredDeveloperToolsVisible else { return } + guard let inspector = webView.cmuxInspectorObject() else { return } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + guard !visible else { return } + let selector = NSSelectorFromString("show") + guard inspector.responds(to: selector) else { return } + inspector.cmuxCallVoid(selector: selector) + preferredDeveloperToolsVisible = true + } + + @discardableResult + func isDeveloperToolsVisible() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + return inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + } + + @discardableResult + func hideDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if visible { + let selector = NSSelectorFromString("close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + } + preferredDeveloperToolsVisible = false + return true + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) @@ -1427,6 +1518,33 @@ private extension BrowserPanel { } } +private extension WKWebView { + func cmuxInspectorObject() -> NSObject? { + let selector = NSSelectorFromString("_inspector") + guard responds(to: selector), + let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else { + return nil + } + return inspector + } +} + +private extension NSObject { + func cmuxCallBool(selector: Selector) -> Bool? { + guard responds(to: selector) else { return nil } + typealias Fn = @convention(c) (AnyObject, Selector) -> Bool + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + return fn(self, selector) + } + + func cmuxCallVoid(selector: Selector) { + guard responds(to: selector) else { return } + typealias Fn = @convention(c) (AnyObject, Selector) -> Void + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + fn(self, selector) + } +} + // MARK: - Navigation Delegate private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 76e5e9a1..54ed2aab 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3,6 +3,113 @@ import SwiftUI import WebKit import AppKit +enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable { + case wrenchAndScrewdriver = "wrench.and.screwdriver" + case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill" + case curlyBracesSquare = "curlybraces.square" + case curlyBraces = "curlybraces" + case terminalFill = "terminal.fill" + case terminal = "terminal" + case hammer = "hammer" + case hammerCircle = "hammer.circle" + case ladybug = "ladybug" + case ladybugFill = "ladybug.fill" + case scope = "scope" + case codeChevrons = "chevron.left.slash.chevron.right" + case gearshape = "gearshape" + case gearshapeFill = "gearshape.fill" + case globe = "globe" + case globeAmericas = "globe.americas.fill" + + var id: String { rawValue } + + var title: String { + switch self { + case .wrenchAndScrewdriver: return "Wrench + Screwdriver" + case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)" + case .curlyBracesSquare: return "Curly Braces" + case .curlyBraces: return "Curly Braces (Plain)" + case .terminalFill: return "Terminal (Fill)" + case .terminal: return "Terminal" + case .hammer: return "Hammer" + case .hammerCircle: return "Hammer Circle" + case .ladybug: return "Bug" + case .ladybugFill: return "Bug (Fill)" + case .scope: return "Scope" + case .codeChevrons: return "Code Chevrons" + case .gearshape: return "Gear" + case .gearshapeFill: return "Gear (Fill)" + case .globe: return "Globe" + case .globeAmericas: return "Globe Americas (Fill)" + } + } +} + +enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { + case bonsplitInactive + case bonsplitActive + case accent + case tertiary + + var id: String { rawValue } + + var title: String { + switch self { + case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)" + case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)" + case .accent: return "Accent" + case .tertiary: return "Tertiary" + } + } + + var color: Color { + switch self { + case .bonsplitInactive: + // Matches Bonsplit tab icon tint for inactive tabs. + return Color(nsColor: .secondaryLabelColor) + case .bonsplitActive: + // Matches Bonsplit tab icon tint for active tabs. + return Color(nsColor: .labelColor) + case .accent: + return .accentColor + case .tertiary: + return Color(nsColor: .tertiaryLabelColor) + } + } +} + +enum BrowserDevToolsButtonDebugSettings { + static let iconNameKey = "browserDevToolsIconName" + static let iconColorKey = "browserDevToolsIconColor" + static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver + static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive + + static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption { + guard let raw = defaults.string(forKey: iconNameKey), + let option = BrowserDevToolsIconOption(rawValue: raw) else { + return defaultIcon + } + return option + } + + static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption { + guard let raw = defaults.string(forKey: iconColorKey), + let option = BrowserDevToolsIconColorOption(rawValue: raw) else { + return defaultColor + } + return option + } + + static func copyPayload(defaults: UserDefaults = .standard) -> String { + let icon = iconOption(defaults: defaults) + let color = colorOption(defaults: defaults) + return """ + browserDevToolsIconName=\(icon.rawValue) + browserDevToolsIconColor=\(color.rawValue) + """ + } +} + struct OmnibarInlineCompletion: Equatable { let typedText: String let displayText: String @@ -25,6 +132,8 @@ struct BrowserPanelView: View { @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @State private var suggestionTask: Task? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @@ -38,6 +147,8 @@ struct BrowserPanelView: View { @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? private let omnibarPillCornerRadius: CGFloat = 12 + private let addressBarButtonSize: CGFloat = 22 + private let devToolsButtonIconSize: CGFloat = 11 private var searchEngine: BrowserSearchEngine { BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine @@ -63,6 +174,14 @@ struct BrowserPanelView: View { return searchSuggestionsEnabled } + private var devToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var devToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } + var body: some View { VStack(spacing: 0) { addressBar @@ -202,6 +321,8 @@ struct BrowserPanelView: View { omnibarField .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") + + developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, 6) @@ -211,8 +332,6 @@ struct BrowserPanelView: View { } private var addressBarButtonBar: some View { - let navButtonSize: CGFloat = 22 - return HStack(spacing: 0) { Button(action: { #if DEBUG @@ -222,10 +341,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.left") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) .help("Go Back") @@ -238,10 +357,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) .help("Go Forward") @@ -261,14 +380,29 @@ struct BrowserPanelView: View { }) { Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .help(panel.isLoading ? "Stop" : "Reload") } } + private var developerToolsButton: some View { + Button(action: { + openDevTools() + }) { + Image(systemName: devToolsIconOption.rawValue) + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(devToolsColorOption.color) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(.plain) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .help("Toggle Developer Tools") + .accessibilityIdentifier("BrowserToggleDevToolsButton") + } + private var omnibarField: some View { let showSecureBadge = panel.currentURL?.scheme == "https" @@ -376,12 +510,6 @@ struct BrowserPanelView: View { } }) .zIndex(0) - .contextMenu { - Button("Open Developer Tools") { - openDevTools() - } - .keyboardShortcut("i", modifiers: [.command, .option]) - } } private func triggerFocusFlashAnimation() { @@ -445,10 +573,11 @@ struct BrowserPanelView: View { } private func openDevTools() { - // WKWebView with developerExtrasEnabled allows right-click > Inspect Element - // We can also trigger via JavaScript - Task { - try? await panel.evaluateJavaScript("window.webkit?.messageHandlers?.devTools?.postMessage('open')") + #if DEBUG + dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") + #endif + if !panel.toggleDeveloperTools() { + NSSound.beep() } } @@ -2426,7 +2555,6 @@ struct WebViewRepresentable: NSViewRepresentable { final class Coordinator { weak var webView: WKWebView? - var constraints: [NSLayoutConstraint] = [] var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 var attachGeneration: Int = 0 @@ -2453,7 +2581,7 @@ struct WebViewRepresentable: NSViewRepresentable { return container } - private static func attachWebView(_ webView: WKWebView, to host: NSView, coordinator: Coordinator) { + private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. if let window = webView.window, @@ -2466,15 +2594,11 @@ struct WebViewRepresentable: NSViewRepresentable { host.subviews.forEach { $0.removeFromSuperview() } host.addSubview(webView) - webView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints = [ - webView.leadingAnchor.constraint(equalTo: host.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: host.trailingAnchor), - webView.topAnchor.constraint(equalTo: host.topAnchor), - webView.bottomAnchor.constraint(equalTo: host.bottomAnchor), - ] - NSLayoutConstraint.activate(coordinator.constraints) + // Work around WebKit bug 272474 where Inspect Element can render blank/flicker + // when WKWebView is edge-pinned using Auto Layout constraints. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = host.bounds // Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out. webView.needsLayout = true @@ -2483,7 +2607,13 @@ struct WebViewRepresentable: NSViewRepresentable { webView.displayIfNeeded() } - private static func scheduleAttachRetry(_ webView: WKWebView, to host: NSView, coordinator: Coordinator, generation: Int) { + private static func scheduleAttachRetry( + _ webView: WKWebView, + panel: BrowserPanel, + to host: NSView, + coordinator: Coordinator, + generation: Int + ) { // Don't schedule multiple overlapping retries. guard coordinator.attachRetryWorkItem == nil else { return } @@ -2506,14 +2636,21 @@ struct WebViewRepresentable: NSViewRepresentable { // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - scheduleAttachRetry(webView, to: host, coordinator: coordinator, generation: generation) + scheduleAttachRetry( + webView, + panel: panel, + to: host, + coordinator: coordinator, + generation: generation + ) } } return } coordinator.attachRetryCount = 0 - attachWebView(webView, to: host, coordinator: coordinator) + attachWebView(webView, to: host) + panel.restoreDeveloperToolsAfterAttachIfNeeded() } coordinator.attachRetryWorkItem = work @@ -2528,6 +2665,7 @@ struct WebViewRepresentable: NSViewRepresentable { // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { + panel.syncDeveloperToolsPreferenceFromInspector() context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 @@ -2539,9 +2677,6 @@ struct WebViewRepresentable: NSViewRepresentable { window.makeFirstResponder(nil) } - NSLayoutConstraint.deactivate(context.coordinator.constraints) - context.coordinator.constraints.removeAll() - if webView.superview != nil { webView.removeFromSuperview() } @@ -2560,12 +2695,14 @@ struct WebViewRepresentable: NSViewRepresentable { // can create containers that are never inserted into the window. Self.scheduleAttachRetry( webView, + panel: panel, to: nsView, coordinator: context.coordinator, generation: context.coordinator.attachGeneration ) } else { - Self.attachWebView(webView, to: nsView, coordinator: context.coordinator) + Self.attachWebView(webView, to: nsView) + panel.restoreDeveloperToolsAfterAttachIfNeeded() } } else { // Already attached; no need for any pending retry. @@ -2573,6 +2710,7 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 + panel.restoreDeveloperToolsAfterAttachIfNeeded() } // Focus handling. Avoid fighting the address bar when it is focused. @@ -2601,9 +2739,6 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints.removeAll() - guard let webView = coordinator.webView else { return } // If we're being torn down while the WKWebView (or one of its subviews) is first responder, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f3c43522..90baf6ac 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -823,6 +823,16 @@ class TabManager: ObservableObject { focusedBrowserPanel?.resetZoom() ?? false } + @discardableResult + func toggleDeveloperToolsFocusedBrowser() -> Bool { + focusedBrowserPanel?.toggleDeveloperTools() ?? false + } + + @discardableResult + func showJavaScriptConsoleFocusedBrowser() -> Bool { + focusedBrowserPanel?.showDeveloperToolsConsole() ?? false + } + /// Backwards compatibility: returns the focused surface ID func focusedSurfaceId(for tabId: UUID) -> UUID? { focusedPanelId(for: tabId) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 973409e9..26e4d504 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -15,6 +15,10 @@ struct cmuxApp: App { @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) + private var toggleBrowserDeveloperToolsShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey) + private var showBrowserJavaScriptConsoleShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -422,6 +426,20 @@ struct cmuxApp: App { } .keyboardShortcut("r", modifiers: .command) + splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { + let manager = (AppDelegate.shared?.tabManager ?? tabManager) + if !manager.toggleDeveloperToolsFocusedBrowser() { + NSSound.beep() + } + } + + splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { + let manager = (AppDelegate.shared?.tabManager ?? tabManager) + if !manager.showJavaScriptConsoleFocusedBrowser() { + NSSound.beep() + } + } + Button("Zoom In") { _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() } @@ -536,6 +554,20 @@ struct cmuxApp: App { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } + private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut { + decodeShortcut( + from: toggleBrowserDeveloperToolsShortcutData, + fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + ) + } + + private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut { + decodeShortcut( + from: showBrowserJavaScriptConsoleShortcutData, + fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -1123,6 +1155,7 @@ private enum DebugWindowConfigSnapshot { """ let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults) + let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) return """ # Sidebar Debug @@ -1133,6 +1166,9 @@ private enum DebugWindowConfigSnapshot { # Menu Bar Extra Debug \(menuBarPayload) + + # Browser DevTools Button + \(browserDevToolsPayload) """ } @@ -1199,6 +1235,16 @@ private struct DebugWindowControlsView: View { @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + + private var selectedDevToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } var body: some View { ScrollView { @@ -1282,12 +1328,58 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } + GroupBox("Browser DevTools Button") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Icon") + Picker("Icon", selection: $browserDevToolsIconNameRaw) { + ForEach(BrowserDevToolsIconOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Color") + Picker("Color", selection: $browserDevToolsIconColorRaw) { + ForEach(BrowserDevToolsIconColorOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Preview") + Spacer() + Image(systemName: selectedDevToolsIconOption.rawValue) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(selectedDevToolsColorOption.color) + } + + HStack(spacing: 12) { + Button("Reset Button") { + resetBrowserDevToolsButton() + } + Button("Copy Button Config") { + copyBrowserDevToolsButtonConfig() + } + } + } + .padding(.top, 2) + } + GroupBox("Copy") { VStack(alignment: .leading, spacing: 8) { Button("Copy All Debug Config") { DebugWindowConfigSnapshot.copyCombinedToPasteboard() } - Text("Copies sidebar, background, and menu bar debug settings as one payload.") + Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.") .font(.caption) .foregroundColor(.secondary) } @@ -1348,6 +1440,18 @@ private struct DebugWindowControlsView: View { pasteboard.clearContents() pasteboard.setString(payload, forType: .string) } + + private func resetBrowserDevToolsButton() { + browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + } + + private func copyBrowserDevToolsButtonConfig() { + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } } private final class AboutWindowController: NSWindowController, NSWindowDelegate { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 1630f254..9728e504 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -88,6 +88,90 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create defaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + } + return defaults + } + + func testIconCatalogIncludesExpandedChoices() { + XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare)) + } + + func testIconOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultIcon + ) + } + + func testColorOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultColor + ) + } + + func testCopyPayloadUsesPersistedValues() { + let defaults = makeIsolatedDefaults() + defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) + XCTAssertTrue(payload.contains("browserDevToolsIconName=scope")) + XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive")) + } +} + +final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { + func testSafariDefaultShortcutForToggleDeveloperTools() { + let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + XCTAssertEqual(shortcut.key, "i") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } + + func testSafariDefaultShortcutForShowJavaScriptConsole() { + let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + XCTAssertEqual(shortcut.key, "c") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } +} + +@MainActor +final class BrowserDeveloperToolsConfigurationTests: XCTestCase { + func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { + let panel = BrowserPanel(workspaceId: UUID()) + let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool + XCTAssertEqual(developerExtras, true) + + if #available(macOS 13.3, *) { + XCTAssertTrue(panel.webView.isInspectable) + } + } +} + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0) diff --git a/skills/cmux-debug-windows/SKILL.md b/skills/cmux-debug-windows/SKILL.md index fb5dc859..885c5f49 100644 --- a/skills/cmux-debug-windows/SKILL.md +++ b/skills/cmux-debug-windows/SKILL.md @@ -10,6 +10,9 @@ Keep this workflow focused on existing debug windows and menu entries. Do not ad ## Workflow 1. Verify debug menu wiring in `Sources/cmuxApp.swift` under `CommandMenu("Debug")`. + - Menu path in app: `Debug` → `Debug Windows` → window entry. + - The `Debug` menu only exists in DEBUG builds (`./scripts/reload.sh --tag ...`). + - Release builds (`reloadp.sh`, `reloads.sh`) do not show this menu. 2. Keep these actions available in `Menu("Debug Windows")`: - `Sidebar Debug…` - `Background Debug…` diff --git a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh index c4461cb0..ac08502d 100755 --- a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh +++ b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh @@ -5,8 +5,8 @@ usage() { cat <<'USAGE' Usage: debug_windows_snapshot.sh [--domain ] [--copy] -Collect Sidebar Debug, Background Debug, and Menu Bar Extra debug values from macOS defaults -and print a combined payload. Use --copy to also copy the payload to clipboard. +Collect Sidebar Debug, Background Debug, Menu Bar Extra, and Browser DevTools debug values +from macOS defaults and print a combined payload. Use --copy to also copy the payload. Examples: debug_windows_snapshot.sh @@ -118,13 +118,16 @@ menubarDebugSingleDigitYOffset="$(format_number "$(read_value menubarDebugSingle menubarDebugMultiDigitYOffset="$(format_number "$(read_value menubarDebugMultiDigitYOffset 0.60)" 2)" legacySingleDigitX="$(read_value menubarDebugTextRectXAdjust '')" if [[ -n "$legacySingleDigitX" ]]; then - menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" +menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" else menubarDebugSingleDigitXAdjust="$(format_number "$(read_value menubarDebugSingleDigitXAdjust -1.10)" 2)" fi menubarDebugMultiDigitXAdjust="$(format_number "$(read_value menubarDebugMultiDigitXAdjust 2.42)" 2)" menubarDebugTextRectWidthAdjust="$(format_number "$(read_value menubarDebugTextRectWidthAdjust 1.80)" 2)" +browserDevToolsIconName="$(read_value browserDevToolsIconName 'wrench.and.screwdriver')" +browserDevToolsIconColor="$(read_value browserDevToolsIconColor bonsplitInactive)" + payload="$(cat < Date: Thu, 19 Feb 2026 20:24:32 -0800 Subject: [PATCH 2/9] Add regression tests for browser devtools visibility persistence --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9728e504..8221d677 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1,6 +1,7 @@ import XCTest import AppKit import WebKit +import ObjectiveC.runtime #if canImport(cmux_DEV) @testable import cmux_DEV @@ -8,6 +9,49 @@ import WebKit @testable import cmux #endif +private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0 +private var cmuxUnitTestInspectorOverrideInstalled = false + +private extension CmuxWebView { + @objc func cmuxUnitTestInspector() -> NSObject? { + objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject + } +} + +private extension WKWebView { + func cmuxSetUnitTestInspector(_ inspector: NSObject?) { + objc_setAssociatedObject( + self, + &cmuxUnitTestInspectorAssociationKey, + inspector, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } +} + +private func installCmuxUnitTestInspectorOverride() { + guard !cmuxUnitTestInspectorOverrideInstalled else { return } + + guard let replacementMethod = class_getInstanceMethod( + CmuxWebView.self, + #selector(CmuxWebView.cmuxUnitTestInspector) + ) else { + fatalError("Unable to locate test inspector replacement method") + } + + let added = class_addMethod( + CmuxWebView.self, + NSSelectorFromString("_inspector"), + method_getImplementation(replacementMethod), + method_getTypeEncoding(replacementMethod) + ) + guard added else { + fatalError("Unable to install CmuxWebView _inspector test override") + } + + cmuxUnitTestInspectorOverrideInstalled = true +} + final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class ActionSpy: NSObject { private(set) var invoked: Bool = false @@ -172,6 +216,73 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { } } +@MainActor +final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { + private final class FakeInspector: NSObject { + private(set) var showCount = 0 + private(set) var closeCount = 0 + private var visible = false + + @objc func isVisible() -> Bool { + visible + } + + @objc func show() { + showCount += 1 + visible = true + } + + @objc func close() { + closeCount += 1 + visible = false + } + } + + override class func setUp() { + super.setUp() + installCmuxUnitTestInspectorOverride() + } + + private func makePanelWithInspector() -> (BrowserPanel, FakeInspector) { + let panel = BrowserPanel(workspaceId: UUID()) + let inspector = FakeInspector() + panel.webView.cmuxSetUnitTestInspector(inspector) + return (panel, inspector) + } + + func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate WebKit closing inspector during detach/reattach churn. + inspector.close() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 1) + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 2) + } + + func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate user closing inspector before detach. + inspector.close() + panel.syncDeveloperToolsPreferenceFromInspector() + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + } +} + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0) From 397e46a66768bfc6f0344737e90393e821b6f232 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:31:00 -0800 Subject: [PATCH 3/9] Add devtools split diagnostics and restore retries --- Sources/AppDelegate.swift | 22 +++++ Sources/Panels/BrowserPanel.swift | 93 +++++++++++++++++-- Sources/Panels/BrowserPanelView.swift | 76 ++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 15 +++ 4 files changed, 198 insertions(+), 8 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 45ee1aa1..1eb89d94 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2042,8 +2042,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { + #if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + if let browser = tabManager?.focusedBrowserPanel { + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())") + } else { + dlog("split.shortcut dir=\(directionLabel) pre panel=nil") + } + #endif + tabManager?.createSplit(direction: direction) #if DEBUG + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + if let browser = self?.tabManager?.focusedBrowserPanel { + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())") + } else { + dlog("split.shortcut dir=\(directionLabel) post panel=nil") + } + } recordGotoSplitSplitIfNeeded(direction: direction) #endif return true diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index bd50abc6..545cfafa 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -827,6 +827,10 @@ final class BrowserPanel: Panel, ObservableObject { private let pageZoomStep: CGFloat = 0.1 // Persist user intent across WebKit detach/reattach churn (split/layout updates). private var preferredDeveloperToolsVisible: Bool = false + private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? + private var developerToolsRestoreRetryAttempt: Int = 0 + private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 + private let developerToolsRestoreRetryMaxAttempts: Int = 40 var displayTitle: String { if !pageTitle.isEmpty { @@ -1236,6 +1240,8 @@ final class BrowserPanel: Panel, ObservableObject { } deinit { + developerToolsRestoreRetryWorkItem?.cancel() + developerToolsRestoreRetryWorkItem = nil webViewObservers.removeAll() } } @@ -1312,6 +1318,11 @@ extension BrowserPanel { guard inspector.responds(to: selector) else { return false } inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = targetVisible + if targetVisible { + developerToolsRestoreRetryAttempt = 0 + } else { + cancelDeveloperToolsRestoreRetry() + } return true } @@ -1325,6 +1336,11 @@ extension BrowserPanel { inspector.cmuxCallVoid(selector: showSelector) } preferredDeveloperToolsVisible = true + if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { + cancelDeveloperToolsRestoreRetry() + } else { + scheduleDeveloperToolsRestoreRetry() + } return true } @@ -1349,23 +1365,49 @@ extension BrowserPanel { } /// Called before WKWebView detaches so manual inspector closes are respected. - func syncDeveloperToolsPreferenceFromInspector() { + func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) { guard let inspector = webView.cmuxInspectorObject() else { return } - if let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) { - preferredDeveloperToolsVisible = visible + guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return } + if visible { + preferredDeveloperToolsVisible = true + cancelDeveloperToolsRestoreRetry() + return } + if preserveVisibleIntent && preferredDeveloperToolsVisible { + return + } + preferredDeveloperToolsVisible = false + cancelDeveloperToolsRestoreRetry() } /// Called after WKWebView reattaches to keep inspector stable across split/layout churn. func restoreDeveloperToolsAfterAttachIfNeeded() { - guard preferredDeveloperToolsVisible else { return } - guard let inspector = webView.cmuxInspectorObject() else { return } + guard preferredDeveloperToolsVisible else { + cancelDeveloperToolsRestoreRetry() + return + } + guard let inspector = webView.cmuxInspectorObject() else { + scheduleDeveloperToolsRestoreRetry() + return + } let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - guard !visible else { return } + guard !visible else { + cancelDeveloperToolsRestoreRetry() + return + } let selector = NSSelectorFromString("show") - guard inspector.responds(to: selector) else { return } + guard inspector.responds(to: selector) else { + cancelDeveloperToolsRestoreRetry() + return + } inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = true + let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if visibleAfterShow { + cancelDeveloperToolsRestoreRetry() + } else { + scheduleDeveloperToolsRestoreRetry() + } } @discardableResult @@ -1384,6 +1426,7 @@ extension BrowserPanel { inspector.cmuxCallVoid(selector: selector) } preferredDeveloperToolsVisible = false + cancelDeveloperToolsRestoreRetry() return true } @@ -1495,6 +1538,42 @@ extension BrowserPanel { } +private extension BrowserPanel { + func scheduleDeveloperToolsRestoreRetry() { + guard preferredDeveloperToolsVisible else { return } + guard developerToolsRestoreRetryWorkItem == nil else { return } + guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return } + + developerToolsRestoreRetryAttempt += 1 + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.developerToolsRestoreRetryWorkItem = nil + self.restoreDeveloperToolsAfterAttachIfNeeded() + } + developerToolsRestoreRetryWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work) + } + + func cancelDeveloperToolsRestoreRetry() { + developerToolsRestoreRetryWorkItem?.cancel() + developerToolsRestoreRetryWorkItem = nil + developerToolsRestoreRetryAttempt = 0 + } +} + +#if DEBUG +extension BrowserPanel { + func debugDeveloperToolsStateSummary() -> String { + let preferred = preferredDeveloperToolsVisible ? 1 : 0 + let visible = isDeveloperToolsVisible() ? 1 : 0 + let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1 + let attached = webView.superview == nil ? 0 : 1 + let inWindow = webView.window == nil ? 0 : 1 + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt)" + } +} +#endif + private extension BrowserPanel { @discardableResult func applyPageZoom(_ candidate: CGFloat) -> Bool { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 54ed2aab..7b3c3ea8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2560,6 +2560,19 @@ struct WebViewRepresentable: NSViewRepresentable { var attachGeneration: Int = 0 } + #if DEBUG + private static func logDevToolsState( + _ panel: BrowserPanel, + event: String, + generation: Int, + retryCount: Int + ) { + dlog( + "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" + ) + } + #endif + private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool { var r = start var hops = 0 @@ -2632,6 +2645,16 @@ struct WebViewRepresentable: NSViewRepresentable { // is in a window during bonsplit tree updates; moving the webview too early can be flaky. guard host.window != nil else { coordinator.attachRetryCount += 1 + #if DEBUG + if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 { + logDevToolsState( + panel, + event: "retry.waitingForWindow", + generation: generation, + retryCount: coordinator.attachRetryCount + ) + } + #endif // Be generous here: bonsplit structural updates can keep a representable // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { @@ -2651,6 +2674,9 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryCount = 0 attachWebView(webView, to: host) panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + logDevToolsState(panel, event: "retry.attached", generation: generation, retryCount: 0) + #endif } coordinator.attachRetryWorkItem = work @@ -2665,7 +2691,23 @@ struct WebViewRepresentable: NSViewRepresentable { // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { - panel.syncDeveloperToolsPreferenceFromInspector() + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.beforeSync", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif + panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.afterSync", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 @@ -2681,6 +2723,14 @@ struct WebViewRepresentable: NSViewRepresentable { webView.removeFromSuperview() } nsView.subviews.forEach { $0.removeFromSuperview() } + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.done", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif return } @@ -2693,6 +2743,14 @@ struct WebViewRepresentable: NSViewRepresentable { if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.defer.offWindow", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif Self.scheduleAttachRetry( webView, panel: panel, @@ -2703,6 +2761,14 @@ struct WebViewRepresentable: NSViewRepresentable { } else { Self.attachWebView(webView, to: nsView) panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.immediate", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif } } else { // Already attached; no need for any pending retry. @@ -2711,6 +2777,14 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.alreadyAttached", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif } // Focus handling. Avoid fighting the address bar when it is focused. diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8221d677..5913fd79 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -281,6 +281,21 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertFalse(panel.isDeveloperToolsVisible()) XCTAssertEqual(inspector.showCount, 1) } + + func testSyncCanPreserveVisibleIntentDuringDetachChurn() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate a transient close caused by view detach, not user intent. + inspector.close() + panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 2) + } } final class WorkspaceShortcutMapperTests: XCTestCase { From f546c289c3162699bb21adde9eea2f0f4178c12d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:38:31 -0800 Subject: [PATCH 4/9] Preserve devtools webview during split teardown --- Sources/Panels/BrowserPanel.swift | 7 +++ Sources/Panels/BrowserPanelView.swift | 50 ++++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 50 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 545cfafa..5ed71a7b 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1430,6 +1430,13 @@ extension BrowserPanel { return true } + /// During split/layout transitions SwiftUI can briefly mark the browser surface hidden + /// while its container is off-window. Avoid detaching in that transient phase if + /// DevTools is intended to remain open, because detach/reattach can blank inspector content. + func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool { + preferredDeveloperToolsVisible + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 7b3c3ea8..c0aeee61 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2554,6 +2554,7 @@ struct WebViewRepresentable: NSViewRepresentable { let isPanelFocused: Bool final class Coordinator { + weak var panel: BrowserPanel? weak var webView: WKWebView? var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 @@ -2585,7 +2586,9 @@ struct WebViewRepresentable: NSViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator() + let coordinator = Coordinator() + coordinator.panel = panel + return coordinator } func makeNSView(context: Context) -> NSView { @@ -2685,12 +2688,29 @@ struct WebViewRepresentable: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView + context.coordinator.panel = panel context.coordinator.webView = webView // Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { + // Split/layout churn can briefly create an off-window phase while DevTools is open. + // Detaching here can blank inspector content even when visibility preference stays true. + if nsView.window == nil, + webView.superview != nil, + panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.skipped.offWindowDevTools", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif + return + } + #if DEBUG Self.logDevToolsState( panel, @@ -2814,6 +2834,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachGeneration += 1 guard let webView = coordinator.webView else { return } + let panel = coordinator.panel // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. @@ -2821,8 +2842,35 @@ struct WebViewRepresentable: NSViewRepresentable { if let window, responderChainContains(window.firstResponder, target: webView) { window.makeFirstResponder(nil) } + + // During split/layout churn, SwiftUI may tear down a host view while a new one is still + // coming online. When DevTools is intended open, avoid eagerly detaching here. + if let panel, + panel.shouldPreserveWebViewAttachmentDuringTransientHide(), + webView.superview === nsView { + #if DEBUG + logDevToolsState( + panel, + event: "dismantle.skipDetach.devTools", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount + ) + #endif + return + } + if webView.superview === nsView { webView.removeFromSuperview() + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.detached", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount + ) + } + #endif } } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5913fd79..a51ba07b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -296,6 +296,56 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertTrue(panel.isDeveloperToolsVisible()) XCTAssertEqual(inspector.showCount, 2) } + + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { + let (panel, _) = makePanelWithInspector() + + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.hideDeveloperTools()) + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testWebViewDismantleSkipsDetachWhenDeveloperToolsIntentIsVisible() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let representable = WebViewRepresentable( + panel: panel, + shouldAttachWebView: true, + shouldFocusWebView: false, + isPanelFocused: true + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + host.addSubview(panel.webView) + + WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + + XCTAssertTrue(panel.webView.superview === host) + } + + func testWebViewDismantleDetachesWhenDeveloperToolsIntentIsHidden() { + let (panel, _) = makePanelWithInspector() + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + + let representable = WebViewRepresentable( + panel: panel, + shouldAttachWebView: true, + shouldFocusWebView: false, + isPanelFocused: true + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + host.addSubview(panel.webView) + + WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + + XCTAssertNil(panel.webView.superview) + } } final class WorkspaceShortcutMapperTests: XCTestCase { From 6170143b6d9cc8a6532217ab3dbdfa910f1ad4c3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:41:22 -0800 Subject: [PATCH 5/9] Add deep split/devtools reparent diagnostics --- Sources/AppDelegate.swift | 40 +++++++++++-- Sources/Panels/BrowserPanelView.swift | 84 ++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 19 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1eb89d94..8091005e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2050,20 +2050,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .up: directionLabel = "up" case .down: directionLabel = "down" } + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" if let browser = tabManager?.focusedBrowserPanel { - dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())") + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - dlog("split.shortcut dir=\(directionLabel) pre panel=nil") + dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") } #endif tabManager?.createSplit(direction: direction) #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" if let browser = self?.tabManager?.focusedBrowserPanel { - dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())") + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - dlog("split.shortcut dir=\(directionLabel) post panel=nil") + dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") } } recordGotoSplitSplitIfNeeded(direction: direction) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c0aeee61..954d3768 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2566,11 +2566,31 @@ struct WebViewRepresentable: NSViewRepresentable { _ panel: BrowserPanel, event: String, generation: Int, - retryCount: Int + retryCount: Int, + details: String? = nil ) { - dlog( - "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" - ) + var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" + if let details, !details.isEmpty { + line += " \(details)" + } + dlog(line) + } + + private static func objectID(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func responderDescription(_ responder: NSResponder?) -> String { + guard let responder else { return "nil" } + return "\(type(of: responder))@\(objectID(responder))" + } + + private static func attachContext(webView: WKWebView, host: NSView) -> String { + let hostWindow = host.window?.windowNumber ?? -1 + let webWindow = webView.window?.windowNumber ?? -1 + let firstResponder = (webView.window ?? host.window)?.firstResponder + return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) fr=\(responderDescription(firstResponder))" } #endif @@ -2654,7 +2674,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "retry.waitingForWindow", generation: generation, - retryCount: coordinator.attachRetryCount + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: host) ) } #endif @@ -2675,10 +2696,25 @@ struct WebViewRepresentable: NSViewRepresentable { } coordinator.attachRetryCount = 0 + #if DEBUG + logDevToolsState( + panel, + event: "retry.attach.begin", + generation: generation, + retryCount: 0, + details: attachContext(webView: webView, host: host) + ) + #endif attachWebView(webView, to: host) panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG - logDevToolsState(panel, event: "retry.attached", generation: generation, retryCount: 0) + logDevToolsState( + panel, + event: "retry.attached", + generation: generation, + retryCount: 0, + details: attachContext(webView: webView, host: host) + ) #endif } @@ -2705,7 +2741,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "detach.skipped.offWindowDevTools", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif return @@ -2716,7 +2753,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "detach.beforeSync", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) @@ -2725,7 +2763,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "detach.afterSync", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif context.coordinator.attachRetryWorkItem?.cancel() @@ -2748,7 +2787,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "detach.done", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif return @@ -2768,7 +2808,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "attach.defer.offWindow", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif Self.scheduleAttachRetry( @@ -2779,6 +2820,15 @@ struct WebViewRepresentable: NSViewRepresentable { generation: context.coordinator.attachGeneration ) } else { + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.immediate.begin", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif Self.attachWebView(webView, to: nsView) panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG @@ -2786,7 +2836,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "attach.immediate", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif } @@ -2802,7 +2853,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "attach.alreadyAttached", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif } @@ -2853,7 +2905,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "dismantle.skipDetach.devTools", generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) ) #endif return @@ -2867,7 +2920,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "dismantle.detached", generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) ) } #endif From 54a0f78d75e9e3d579a881aed92854a49e2f38f2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:13:41 -0800 Subject: [PATCH 6/9] Harden browser devtools split reparent flow --- Sources/AppDelegate.swift | 48 +++++++- Sources/Panels/BrowserPanel.swift | 37 +++++- Sources/Panels/BrowserPanelView.swift | 109 ++++++++++++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 21 ++++ 4 files changed, 204 insertions(+), 11 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8091005e..f4f5a63e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2040,9 +2040,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 + } + return false + } + + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { + guard let browser = tabManager?.focusedBrowserPanel else { return } + guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } + guard let keyWindow = NSApp.keyWindow else { return } + guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + + let beforeResponder = keyWindow.firstResponder + let movedToWebView = keyWindow.makeFirstResponder(browser.webView) + let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + browser.requestDeveloperToolsRefreshAfterNextAttach(reason: "split.\(directionLabel).inspectorFirstResponder") + + #if DEBUG + let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" + let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let afterResponder = keyWindow.firstResponder + let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" + let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog( + "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + + "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + + "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" + ) + #endif + } + @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { - #if DEBUG let directionLabel: String switch direction { case .left: directionLabel = "left" @@ -2050,6 +2093,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .up: directionLabel = "up" case .down: directionLabel = "down" } + + #if DEBUG let keyWindow = NSApp.keyWindow let firstResponder = keyWindow?.firstResponder let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" @@ -2073,6 +2118,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) tabManager?.createSplit(direction: direction) #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5ed71a7b..106dc646 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2,6 +2,7 @@ import Foundation import Combine import WebKit import AppKit +import Bonsplit enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google @@ -827,6 +828,7 @@ final class BrowserPanel: Panel, ObservableObject { private let pageZoomStep: CGFloat = 0.1 // Persist user intent across WebKit detach/reattach churn (split/layout updates). private var preferredDeveloperToolsVisible: Bool = false + private var forceDeveloperToolsRefreshOnNextAttach: Bool = false private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 @@ -1322,6 +1324,7 @@ extension BrowserPanel { developerToolsRestoreRetryAttempt = 0 } else { cancelDeveloperToolsRestoreRetry() + forceDeveloperToolsRefreshOnNextAttach = false } return true } @@ -1384,22 +1387,42 @@ extension BrowserPanel { func restoreDeveloperToolsAfterAttachIfNeeded() { guard preferredDeveloperToolsVisible else { cancelDeveloperToolsRestoreRetry() + forceDeveloperToolsRefreshOnNextAttach = false return } guard let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return } + + let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach + forceDeveloperToolsRefreshOnNextAttach = false + + let closeSelector = NSSelectorFromString("close") + if shouldForceRefresh, + inspector.responds(to: closeSelector) { + #if DEBUG + dlog("browser.devtools refresh.forceClose panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + #endif + inspector.cmuxCallVoid(selector: closeSelector) + } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - guard !visible else { + if visible && !shouldForceRefresh { cancelDeveloperToolsRestoreRetry() return } + let selector = NSSelectorFromString("show") guard inspector.responds(to: selector) else { cancelDeveloperToolsRestoreRetry() return } + #if DEBUG + if shouldForceRefresh { + dlog("browser.devtools refresh.forceShow panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + } + #endif inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = true let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false @@ -1426,6 +1449,7 @@ extension BrowserPanel { inspector.cmuxCallVoid(selector: selector) } preferredDeveloperToolsVisible = false + forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() return true } @@ -1437,6 +1461,14 @@ extension BrowserPanel { preferredDeveloperToolsVisible } + func requestDeveloperToolsRefreshAfterNextAttach(reason: String) { + guard preferredDeveloperToolsVisible else { return } + forceDeveloperToolsRefreshOnNextAttach = true + #if DEBUG + dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())") + #endif + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) @@ -1576,7 +1608,8 @@ extension BrowserPanel { let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1 let attached = webView.superview == nil ? 0 : 1 let inWindow = webView.window == nil ? 0 : 1 - return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt)" + let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" } } #endif diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 954d3768..dc094f9e 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2605,6 +2605,38 @@ struct WebViewRepresentable: NSViewRepresentable { return false } + private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 + } + return false + } + + private static func firstResponderResignState( + _ responder: NSResponder?, + webView: WKWebView + ) -> (needsResign: Bool, flags: String) { + let inWebViewChain = responderChainContains(responder, target: webView) + let inspectorResponder = isLikelyInspectorResponder(responder) + let needsResign = inWebViewChain || inspectorResponder + return ( + needsResign: needsResign, + flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)" + ) + } + func makeCoordinator() -> Coordinator { let coordinator = Coordinator() coordinator.panel = panel @@ -2620,9 +2652,20 @@ struct WebViewRepresentable: NSViewRepresentable { private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. - if let window = webView.window, - responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window = webView.window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + window.makeFirstResponder(nil) + } + } + + // The target host can already be in-window while the source host is tearing down. + // Re-check against the target window too (it can differ during split churn). + if let window = host.window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + window.makeFirstResponder(nil) + } } // Detach from any previous host (bonsplit/SwiftUI may rearrange views). @@ -2773,9 +2816,20 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachGeneration += 1 // Resign focus if WebKit currently owns first responder. - if let window = webView.window, - Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window = webView.window ?? nsView.window { + let state = Self.firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.resignFirstResponder", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + ) + #endif + window.makeFirstResponder(nil) + } } if webView.superview != nil { @@ -2800,6 +2854,31 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachGeneration += 1 + if let window = webView.window ?? nsView.window { + let state = Self.firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.reparent.resignFirstResponder.begin", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + ) + #endif + let resigned = window.makeFirstResponder(nil) + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.reparent.resignFirstResponder.end", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)" + ) + #endif + } + } + if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. @@ -2891,8 +2970,22 @@ struct WebViewRepresentable: NSViewRepresentable { // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. let window = webView.window ?? nsView.window - if let window, responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.resignFirstResponder", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + " " + state.flags + ) + } + #endif + window.makeFirstResponder(nil) + } } // During split/layout churn, SwiftUI may tear down a host view while a new one is still diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a51ba07b..45b19aca 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -297,6 +297,27 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } + func testForcedRefreshAfterAttachReopensVisibleInspectorOnce() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 1) + XCTAssertEqual(inspector.showCount, 2) + + // The force-refresh request should be one-shot. + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertEqual(inspector.closeCount, 1) + XCTAssertEqual(inspector.showCount, 2) + } + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { let (panel, _) = makePanelWithInspector() From 943858acff7a358f25106a6d67075fbc0a66efe3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:17:55 -0800 Subject: [PATCH 7/9] Defer and auto-arm devtools refresh across reparent --- Sources/Panels/BrowserPanel.swift | 4 +++ Sources/Panels/BrowserPanelView.swift | 26 ++++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 13 ++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 106dc646..9cd75c87 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1469,6 +1469,10 @@ extension BrowserPanel { #endif } + func hasPendingDeveloperToolsRefreshAfterAttach() -> Bool { + forceDeveloperToolsRefreshOnNextAttach + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index dc094f9e..50fc3ab3 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2882,6 +2882,18 @@ struct WebViewRepresentable: NSViewRepresentable { if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. + if panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "attach.defer.offWindow") + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.defer.requestRefresh", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif + } #if DEBUG Self.logDevToolsState( panel, @@ -2926,7 +2938,19 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 - panel.restoreDeveloperToolsAfterAttachIfNeeded() + if panel.hasPendingDeveloperToolsRefreshAfterAttach() { + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.alreadyAttached.deferPendingRefresh", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif + } else { + panel.restoreDeveloperToolsAfterAttachIfNeeded() + } #if DEBUG Self.logDevToolsState( panel, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 45b19aca..f16b434a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -318,6 +318,19 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } + func testRefreshRequestTracksPendingStateUntilRestoreRuns() { + let (panel, _) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + } + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { let (panel, _) = makePanelWithInspector() From db66fc4bb0d9339df38b00004aedd22f825a8403 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:19:00 -0800 Subject: [PATCH 8/9] Stabilize browser portal bounds and log inspector height metrics --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/AppDelegate.swift | 1 - Sources/BrowserWindowPortal.swift | 867 ++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 22 +- Sources/Panels/BrowserPanelView.swift | 204 ++++- Sources/Panels/PanelContentView.swift | 1 + cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 232 ++++- 7 files changed, 1297 insertions(+), 34 deletions(-) create mode 100644 Sources/BrowserWindowPortal.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d69a5be5..4777e4cc 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; + A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; @@ -135,6 +136,7 @@ A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; + A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; @@ -309,6 +311,7 @@ A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, A5001531 /* TerminalWindowPortal.swift */, + A5001533 /* BrowserWindowPortal.swift */, A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, @@ -535,6 +538,7 @@ A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, + A5001534 /* BrowserWindowPortal.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f4f5a63e..cc5e863b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2068,7 +2068,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let beforeResponder = keyWindow.firstResponder let movedToWebView = keyWindow.makeFirstResponder(browser.webView) let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) - browser.requestDeveloperToolsRefreshAfterNextAttach(reason: "split.\(directionLabel).inspectorFirstResponder") #if DEBUG let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift new file mode 100644 index 00000000..726a90d7 --- /dev/null +++ b/Sources/BrowserWindowPortal.swift @@ -0,0 +1,867 @@ +import AppKit +import ObjectiveC +import WebKit +#if DEBUG +import Bonsplit +#endif + +private var cmuxWindowBrowserPortalKey: UInt8 = 0 +private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0 + +#if DEBUG +private func browserPortalDebugToken(_ view: NSView?) -> String { + guard let view else { return "nil" } + let ptr = Unmanaged.passUnretained(view).toOpaque() + return String(describing: ptr) +} + +private func browserPortalDebugFrame(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) +} +#endif + +final class WindowBrowserHostView: NSView { + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + if shouldPassThroughToSplitDivider(at: point) { + return nil + } + let hitView = super.hitTest(point) + return hitView === self ? nil : hitView + } + + private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { + guard let window else { return false } + let windowPoint = convert(point, to: nil) + guard let rootView = window.contentView else { return false } + return Self.containsSplitDivider(at: windowPoint, in: rootView) + } + + private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool { + guard !view.isHidden else { return false } + + if let splitView = view as? NSSplitView { + let pointInSplit = splitView.convert(windowPoint, from: nil) + if splitView.bounds.contains(pointInSplit) { + let expansion: CGFloat = 5 + let dividerCount = max(0, splitView.arrangedSubviews.count - 1) + for dividerIndex in 0.. 1, second.width > 1 else { continue } + let x = max(0, first.maxX) + dividerRect = NSRect( + x: x, + y: 0, + width: thickness, + height: splitView.bounds.height + ) + } else { + guard first.height > 1, second.height > 1 else { continue } + let y = max(0, first.maxY) + dividerRect = NSRect( + x: 0, + y: y, + width: splitView.bounds.width, + height: thickness + ) + } + let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion) + if expanded.contains(pointInSplit) { + return true + } + } + } + } + + for subview in view.subviews.reversed() { + if containsSplitDivider(at: windowPoint, in: subview) { + return true + } + } + + return false + } +} + +final class WindowBrowserSlotView: NSView { + override var isOpaque: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + translatesAutoresizingMaskIntoConstraints = true + autoresizingMask = [] + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } +} + +@MainActor +final class WindowBrowserPortal: NSObject { + private weak var window: NSWindow? + private let hostView = WindowBrowserHostView(frame: .zero) + private weak var installedContainerView: NSView? + private weak var installedReferenceView: NSView? + private var hasDeferredFullSyncScheduled = false + + private struct Entry { + weak var webView: WKWebView? + weak var containerView: WindowBrowserSlotView? + weak var anchorView: NSView? + var visibleInUI: Bool + var zPriority: Int + } + + private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] + private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:] + + init(window: NSWindow) { + self.window = window + super.init() + hostView.wantsLayer = true + hostView.layer?.masksToBounds = true + hostView.translatesAutoresizingMaskIntoConstraints = true + hostView.autoresizingMask = [] + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window else { return false } + guard let (container, reference) = installationTarget(for: window) else { return false } + + if hostView.superview !== container || + installedContainerView !== container || + installedReferenceView !== reference { + hostView.removeFromSuperview() + container.addSubview(hostView, positioned: .above, relativeTo: reference) + installedContainerView = container + installedReferenceView = reference + } else if !Self.isView(hostView, above: reference, in: container) { + container.addSubview(hostView, positioned: .above, relativeTo: reference) + } + + synchronizeHostFrameToReference() + return true + } + + @discardableResult + private func synchronizeHostFrameToReference() -> Bool { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return false + } + let frameInContainer = container.convert(reference.bounds, from: reference) + let hasFiniteFrame = + frameInContainer.origin.x.isFinite && + frameInContainer.origin.y.isFinite && + frameInContainer.size.width.isFinite && + frameInContainer.size.height.isFinite + guard hasFiniteFrame else { return false } + + if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostView.frame = frameInContainer + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " + + "frame=\(browserPortalDebugFrame(frameInContainer))" + ) +#endif + } + return frameInContainer.width > 1 && frameInContainer.height > 1 + } + + private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { + guard let contentView = window.contentView else { return nil } + + if contentView.className == "NSGlassEffectView", + let foreground = contentView.subviews.first(where: { $0 !== hostView }) { + return (contentView, foreground) + } + + guard let themeFrame = contentView.superview else { return nil } + return (themeFrame, contentView) + } + + private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool { + if view.isHidden { return true } + var current = view.superview + while let v = current { + if v.isHidden { return true } + current = v.superview + } + return false + } + + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { + frame.minX < bounds.minX - epsilon || + frame.minY < bounds.minY - epsilon || + frame.maxX > bounds.maxX + epsilon || + frame.maxY > bounds.maxY + epsilon + } + +#if DEBUG + private static func inspectorSubviewCount(in root: NSView) -> Int { + var stack: [NSView] = [root] + var count = 0 + while let current = stack.popLast() { + for subview in current.subviews { + if String(describing: type(of: subview)).contains("WKInspector") { + count += 1 + } + stack.append(subview) + } + } + return count + } +#endif + + private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool { + guard let viewIndex = container.subviews.firstIndex(of: view), + let referenceIndex = container.subviews.firstIndex(of: reference) else { + return false + } + return viewIndex > referenceIndex + } + + private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView { + if let existing = entry.containerView { + return existing + } + let created = WindowBrowserSlotView(frame: .zero) +#if DEBUG + dlog( + "browser.portal.container.create web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(created))" + ) +#endif + return created + } + + private func moveWebKitRelatedSubviewsIfNeeded( + from sourceSuperview: NSView, + to containerView: WindowBrowserSlotView, + primaryWebView: WKWebView, + reason: String + ) { + guard sourceSuperview !== containerView else { return } + // When Web Inspector is docked, WebKit can inject companion WK* subviews + // next to the primary WKWebView. Move those with the web view so inspector + // UI state does not get orphaned in the old host during split churn. + let relatedSubviews = sourceSuperview.subviews.filter { view in + if view === primaryWebView { return true } + return String(describing: type(of: view)).contains("WK") + } + guard !relatedSubviews.isEmpty else { return } +#if DEBUG + dlog( + "browser.portal.reparent.batch reason=\(reason) source=\(browserPortalDebugToken(sourceSuperview)) " + + "container=\(browserPortalDebugToken(containerView)) count=\(relatedSubviews.count) " + + "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: containerView))) " + + "sourceFlipped=\(sourceSuperview.isFlipped ? 1 : 0) targetFlipped=\(containerView.isFlipped ? 1 : 0) " + + "sourceBounds=\(browserPortalDebugFrame(sourceSuperview.bounds)) targetBounds=\(browserPortalDebugFrame(containerView.bounds))" + ) +#endif + for view in relatedSubviews { + let frameInWindow = sourceSuperview.convert(view.frame, to: nil) + let className = String(describing: type(of: view)) + view.removeFromSuperview() + containerView.addSubview(view, positioned: .above, relativeTo: nil) + let convertedFrame = containerView.convert(frameInWindow, from: nil) + view.frame = convertedFrame +#if DEBUG + dlog( + "browser.portal.reparent.batch.item reason=\(reason) class=\(className) " + + "view=\(browserPortalDebugToken(view)) frameInWindow=\(browserPortalDebugFrame(frameInWindow)) " + + "converted=\(browserPortalDebugFrame(convertedFrame))" + ) +#endif + } + } + + func detachWebView(withId webViewId: ObjectIdentifier) { + guard let entry = entriesByWebViewId.removeValue(forKey: webViewId) else { return } + if let anchor = entry.anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } +#if DEBUG + let hadContainerSuperview = (entry.containerView?.superview === hostView) ? 1 : 0 + let hadWebSuperview = entry.webView?.superview == nil ? 0 : 1 + dlog( + "browser.portal.detach web=\(browserPortalDebugToken(entry.webView)) " + + "container=\(browserPortalDebugToken(entry.containerView)) " + + "anchor=\(browserPortalDebugToken(entry.anchorView)) " + + "hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)" + ) +#endif + entry.webView?.removeFromSuperview() + entry.containerView?.removeFromSuperview() + } + + func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { + guard ensureInstalled() else { return } + + let webViewId = ObjectIdentifier(webView) + let anchorId = ObjectIdentifier(anchorView) + let previousEntry = entriesByWebViewId[webViewId] + let containerView = ensureContainerView( + for: previousEntry ?? Entry(webView: nil, containerView: nil, anchorView: nil, visibleInUI: false, zPriority: 0), + webView: webView + ) + + if let previousWebViewId = webViewByAnchorId[anchorId], previousWebViewId != webViewId { +#if DEBUG + let previousToken = entriesByWebViewId[previousWebViewId] + .map { browserPortalDebugToken($0.webView) } + ?? String(describing: previousWebViewId) + dlog( + "browser.portal.bind.replace anchor=\(browserPortalDebugToken(anchorView)) " + + "oldWeb=\(previousToken) newWeb=\(browserPortalDebugToken(webView))" + ) +#endif + detachWebView(withId: previousWebViewId) + } + + if let oldEntry = entriesByWebViewId[webViewId], + let oldAnchor = oldEntry.anchorView, + oldAnchor !== anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor)) + } + + webViewByAnchorId[anchorId] = webViewId + entriesByWebViewId[webViewId] = Entry( + webView: webView, + containerView: containerView, + anchorView: anchorView, + visibleInUI: visibleInUI, + zPriority: zPriority + ) + + let didChangeAnchor: Bool = { + guard let previousAnchor = previousEntry?.anchorView else { return true } + return previousAnchor !== anchorView + }() + let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI + let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min) +#if DEBUG + if previousEntry == nil || + didChangeAnchor || + becameVisible || + priorityIncreased || + webView.superview !== containerView || + containerView.superview !== hostView { + dlog( + "browser.portal.bind web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) " + + "anchor=\(browserPortalDebugToken(anchorView)) prevAnchor=\(browserPortalDebugToken(previousEntry?.anchorView)) " + + "visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " + + "z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)" + ) + } +#endif + + if webView.superview !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + + "reason=attachContainer super=\(browserPortalDebugToken(webView.superview)) " + + "container=\(browserPortalDebugToken(containerView))" + ) +#endif + if let sourceSuperview = webView.superview { + moveWebKitRelatedSubviewsIfNeeded( + from: sourceSuperview, + to: containerView, + primaryWebView: webView, + reason: "bind.attachContainer" + ) + } else { + containerView.addSubview(webView, positioned: .above, relativeTo: nil) + } + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = containerView.bounds + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + } + + if containerView.superview !== hostView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " + + "reason=attach super=\(browserPortalDebugToken(containerView.superview))" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } else if (becameVisible || priorityIncreased), hostView.subviews.last !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) reason=raise " + + "didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " + + "priorityIncreased=\(priorityIncreased ? 1 : 0)" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } + + synchronizeWebView(withId: webViewId, source: "bind") + pruneDeadEntries() + } + + func synchronizeWebViewForAnchor(_ anchorView: NSView) { + pruneDeadEntries() + let anchorId = ObjectIdentifier(anchorView) + let primaryWebViewId = webViewByAnchorId[anchorId] + if let primaryWebViewId { + synchronizeWebView(withId: primaryWebViewId, source: "anchorPrimary") + } + + synchronizeAllWebViews(excluding: primaryWebViewId, source: "anchorSecondary") + scheduleDeferredFullSynchronizeAll() + } + + private func scheduleDeferredFullSynchronizeAll() { + guard !hasDeferredFullSyncScheduled else { return } + hasDeferredFullSyncScheduled = true +#if DEBUG + dlog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)") +#endif + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasDeferredFullSyncScheduled = false +#if DEBUG + dlog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)") +#endif + self.synchronizeAllWebViews(excluding: nil, source: "deferredTick") + } + } + + private func synchronizeAllWebViews(excluding webViewIdToSkip: ObjectIdentifier?, source: String) { + guard ensureInstalled() else { return } + pruneDeadEntries() + let webViewIds = Array(entriesByWebViewId.keys) + for webViewId in webViewIds { + if webViewId == webViewIdToSkip { continue } + synchronizeWebView(withId: webViewId, source: source) + } + } + + private func synchronizeWebView(withId webViewId: ObjectIdentifier, source: String) { + guard ensureInstalled() else { return } + guard let entry = entriesByWebViewId[webViewId] else { return } + guard let webView = entry.webView else { + entriesByWebViewId.removeValue(forKey: webViewId) + return + } + guard let containerView = entry.containerView else { + entriesByWebViewId.removeValue(forKey: webViewId) + if let anchor = entry.anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } + return + } + guard let anchorView = entry.anchorView, let window else { +#if DEBUG + if !containerView.isHidden { + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=1 reason=missingAnchorOrWindow" + ) + } +#endif + containerView.isHidden = true + return + } + guard anchorView.window === window else { +#if DEBUG + if !containerView.isHidden { + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=1 " + + "reason=anchorWindowMismatch anchorWindow=\(browserPortalDebugToken(anchorView.window?.contentView))" + ) + } +#endif + containerView.isHidden = true + return + } + + if containerView.superview !== hostView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " + + "reason=syncAttach super=\(browserPortalDebugToken(containerView.superview))" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } + if webView.superview !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + + "reason=syncAttachContainer super=\(browserPortalDebugToken(webView.superview)) " + + "container=\(browserPortalDebugToken(containerView))" + ) +#endif + if let sourceSuperview = webView.superview { + moveWebKitRelatedSubviewsIfNeeded( + from: sourceSuperview, + to: containerView, + primaryWebView: webView, + reason: "sync.attachContainer" + ) + } else { + containerView.addSubview(webView, positioned: .above, relativeTo: nil) + } + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = containerView.bounds + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + } + + _ = synchronizeHostFrameToReference() + let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + let frameInHost = hostView.convert(frameInWindow, from: nil) + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 + if !hostBoundsReady { +#if DEBUG + dlog( + "browser.portal.sync.defer container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) " + + "reason=hostBoundsNotReady host=\(browserPortalDebugFrame(hostBounds)) " + + "anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" + ) +#endif + containerView.isHidden = true + scheduleDeferredFullSynchronizeAll() + return + } + let oldFrame = containerView.frame + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite + let clampedFrame = frameInHost.intersection(hostBounds) + let hasVisibleIntersection = + !clampedFrame.isNull && + clampedFrame.width > 1 && + clampedFrame.height > 1 + let targetFrame = hasVisibleIntersection ? clampedFrame : frameInHost + let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) + let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1 + let outsideHostBounds = !hasVisibleIntersection + let shouldHide = + !entry.visibleInUI || + anchorHidden || + tinyFrame || + !hasFiniteFrame || + outsideHostBounds +#if DEBUG + let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) + if frameWasClamped { + dlog( + "browser.portal.frame.clamp container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "raw=\(browserPortalDebugFrame(frameInHost)) clamped=\(browserPortalDebugFrame(targetFrame)) " + + "host=\(browserPortalDebugFrame(hostBounds))" + ) + } + let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame + let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame + if collapsedToTiny { + dlog( + "browser.portal.frame.collapse container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))" + ) + } else if restoredFromTiny { + dlog( + "browser.portal.frame.restore container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))" + ) + } +#endif + if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { + CATransaction.begin() + CATransaction.setDisableActions(true) + containerView.frame = targetFrame + CATransaction.commit() + } + + let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) + if !Self.rectApproximatelyEqual(containerView.bounds, expectedContainerBounds) { + let oldContainerBounds = containerView.bounds + CATransaction.begin() + CATransaction.setDisableActions(true) + containerView.bounds = expectedContainerBounds + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.bounds.normalize container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) old=\(browserPortalDebugFrame(oldContainerBounds)) " + + "target=\(browserPortalDebugFrame(expectedContainerBounds))" + ) +#endif + } + + let containerBounds = containerView.bounds + let preNormalizeWebFrame = webView.frame + let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height) + let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY) + let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow) +#if DEBUG + let inspectorSubviews = Self.inspectorSubviewCount(in: containerView) +#endif + if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) { + let oldWebFrame = preNormalizeWebFrame + CATransaction.begin() + CATransaction.setDisableActions(true) + webView.frame = containerBounds + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.webframe.normalize web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " + + "new=\(browserPortalDebugFrame(webView.frame)) bounds=\(browserPortalDebugFrame(containerBounds)) " + + "inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " + + "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + + "inspectorSubviews=\(inspectorSubviews) " + + "source=\(source)" + ) +#endif + } + + if containerView.isHidden != shouldHide { +#if DEBUG + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " + + "host=\(browserPortalDebugFrame(hostBounds))" + ) +#endif + containerView.isHidden = shouldHide + } +#if DEBUG + dlog( + "browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " + + "container=\(browserPortalDebugToken(containerView)) " + + "anchor=\(browserPortalDebugToken(anchorView)) host=\(browserPortalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) " + + "old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " + + "target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) " + + "containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " + + "containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " + + "preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " + + "webFrame=\(browserPortalDebugFrame(webView.frame)) webBounds=\(browserPortalDebugFrame(webView.bounds)) " + + "inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " + + "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + + "inspectorSubviews=\(inspectorSubviews)" + ) +#endif + } + + private func pruneDeadEntries() { + let currentWindow = window + let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in + guard entry.webView != nil else { return webViewId } + guard let container = entry.containerView else { return webViewId } + guard let anchor = entry.anchorView else { return webViewId } + if container.superview == nil || !container.isDescendant(of: hostView) { + return webViewId + } + if anchor.window !== currentWindow || anchor.superview == nil { + return webViewId + } + if let reference = installedReferenceView, + !anchor.isDescendant(of: reference) { + return webViewId + } + return nil + } + + for webViewId in deadWebViewIds { + detachWebView(withId: webViewId) + } + + let validAnchorIds = Set(entriesByWebViewId.compactMap { _, entry in + entry.anchorView.map { ObjectIdentifier($0) } + }) + webViewByAnchorId = webViewByAnchorId.filter { validAnchorIds.contains($0.key) } + } + + func webViewIds() -> Set { + Set(entriesByWebViewId.keys) + } + + func tearDown() { + for webViewId in Array(entriesByWebViewId.keys) { + detachWebView(withId: webViewId) + } + hostView.removeFromSuperview() + installedContainerView = nil + installedReferenceView = nil + } + +#if DEBUG + func debugEntryCount() -> Int { + entriesByWebViewId.count + } + + func debugHostedSubviewCount() -> Int { + hostView.subviews.count + } +#endif + + func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? { + guard ensureInstalled() else { return nil } + let point = hostView.convert(windowPoint, from: nil) + for subview in hostView.subviews.reversed() { + guard let container = subview as? WindowBrowserSlotView else { continue } + guard !container.isHidden else { continue } + guard container.frame.contains(point) else { continue } + guard let webView = entriesByWebViewId + .first(where: { _, entry in entry.containerView === container })? + .value + .webView else { continue } + return webView + } + return nil + } +} + +@MainActor +enum BrowserWindowPortalRegistry { + private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] + private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { + guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return } + let windowId = ObjectIdentifier(window) + let observer = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak window] _ in + MainActor.assumeIsolated { + if let window { + removePortal(for: window) + } else { + removePortal(windowId: windowId, window: nil) + } + } + } + objc_setAssociatedObject( + window, + &cmuxWindowBrowserPortalCloseObserverKey, + observer, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + private static func removePortal(for window: NSWindow) { + removePortal(windowId: ObjectIdentifier(window), window: window) + } + + private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) { + if let portal = portalsByWindowId.removeValue(forKey: windowId) { + portal.tearDown() + } + webViewToWindowId = webViewToWindowId.filter { $0.value != windowId } + + guard let window else { return } + if let observer = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) { + NotificationCenter.default.removeObserver(observer) + } + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, nil, .OBJC_ASSOCIATION_RETAIN) + } + + private static func pruneWebViewMappings(for windowId: ObjectIdentifier, validWebViewIds: Set) { + webViewToWindowId = webViewToWindowId.filter { webViewId, mappedWindowId in + mappedWindowId != windowId || validWebViewIds.contains(webViewId) + } + } + + private static func portal(for window: NSWindow) -> WindowBrowserPortal { + if let existing = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalKey) as? WindowBrowserPortal { + portalsByWindowId[ObjectIdentifier(window)] = existing + installWindowCloseObserverIfNeeded(for: window) + return existing + } + + let portal = WindowBrowserPortal(window: window) + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, portal, .OBJC_ASSOCIATION_RETAIN) + portalsByWindowId[ObjectIdentifier(window)] = portal + installWindowCloseObserverIfNeeded(for: window) + return portal + } + + static func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { + guard let window = anchorView.window else { return } + + let windowId = ObjectIdentifier(window) + let webViewId = ObjectIdentifier(webView) + let nextPortal = portal(for: window) + + if let oldWindowId = webViewToWindowId[webViewId], + oldWindowId != windowId { + portalsByWindowId[oldWindowId]?.detachWebView(withId: webViewId) + } + + nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) + webViewToWindowId[webViewId] = windowId + pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds()) + } + + static func synchronizeForAnchor(_ anchorView: NSView) { + guard let window = anchorView.window else { return } + let portal = portal(for: window) + portal.synchronizeWebViewForAnchor(anchorView) + } + + static func detach(webView: WKWebView) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } + portalsByWindowId[windowId]?.detachWebView(withId: webViewId) + } + +#if DEBUG + static func debugPortalCount() -> Int { + portalsByWindowId.count + } +#endif +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9cd75c87..628931d6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1244,6 +1244,10 @@ final class BrowserPanel: Panel, ObservableObject { deinit { developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil + let webView = webView + Task { @MainActor in + BrowserWindowPortalRegistry.detach(webView: webView) + } webViewObservers.removeAll() } } @@ -1398,17 +1402,13 @@ extension BrowserPanel { let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach forceDeveloperToolsRefreshOnNextAttach = false - let closeSelector = NSSelectorFromString("close") - if shouldForceRefresh, - inspector.responds(to: closeSelector) { - #if DEBUG - dlog("browser.devtools refresh.forceClose panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") - #endif - inspector.cmuxCallVoid(selector: closeSelector) - } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if visible && !shouldForceRefresh { + if visible { + #if DEBUG + if shouldForceRefresh { + dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + } + #endif cancelDeveloperToolsRestoreRetry() return } @@ -1420,7 +1420,7 @@ extension BrowserPanel { } #if DEBUG if shouldForceRefresh { - dlog("browser.devtools refresh.forceShow panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif inspector.cmuxCallVoid(selector: selector) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 50fc3ab3..1d849576 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -127,6 +127,7 @@ struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel let isFocused: Bool let isVisibleInUI: Bool + let portalPriority: Int let onRequestPanelFocus: () -> Void @State private var omnibarState = OmnibarState() @State private var addressBarFocused: Bool = false @@ -496,7 +497,8 @@ struct BrowserPanelView: View { panel: panel, shouldAttachWebView: isVisibleInUI, shouldFocusWebView: isFocused && !addressBarFocused, - isPanelFocused: isFocused + isPanelFocused: isFocused, + portalZPriority: portalPriority ) // Keep the representable identity stable across bonsplit structural updates. // This reduces WKWebView reparenting churn (and the associated WebKit crashes). @@ -2552,6 +2554,7 @@ struct WebViewRepresentable: NSViewRepresentable { let shouldAttachWebView: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool + let portalZPriority: Int final class Coordinator { weak var panel: BrowserPanel? @@ -2559,6 +2562,41 @@ struct WebViewRepresentable: NSViewRepresentable { var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 var attachGeneration: Int = 0 + var usesWindowPortal: Bool = false + var desiredPortalVisibleInUI: Bool = true + var desiredPortalZPriority: Int = 0 + var lastPortalHostId: ObjectIdentifier? + } + + private final class HostContainerView: NSView { + var onDidMoveToWindow: (() -> Void)? + var onGeometryChanged: (() -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onDidMoveToWindow?() + onGeometryChanged?() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + onGeometryChanged?() + } + + override func layout() { + super.layout() + onGeometryChanged?() + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + onGeometryChanged?() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + onGeometryChanged?() + } } #if DEBUG @@ -2586,11 +2624,15 @@ struct WebViewRepresentable: NSViewRepresentable { return "\(type(of: responder))@\(objectID(responder))" } + private static func rectDescription(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) + } + private static func attachContext(webView: WKWebView, host: NSView) -> String { let hostWindow = host.window?.windowNumber ?? -1 let webWindow = webView.window?.windowNumber ?? -1 let firstResponder = (webView.window ?? host.window)?.firstResponder - return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) fr=\(responderDescription(firstResponder))" + return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))" } #endif @@ -2644,11 +2686,85 @@ struct WebViewRepresentable: NSViewRepresentable { } func makeNSView(context: Context) -> NSView { - let container = NSView() + let container = HostContainerView() container.wantsLayer = true return container } + private static func clearPortalCallbacks(for host: NSView) { + guard let host = host as? HostContainerView else { return } + host.onDidMoveToWindow = nil + host.onGeometryChanged = nil + } + + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { + guard let host = nsView as? HostContainerView else { return } + + let coordinator = context.coordinator + let previousVisible = coordinator.desiredPortalVisibleInUI + let previousZPriority = coordinator.desiredPortalZPriority + coordinator.desiredPortalVisibleInUI = shouldAttachWebView + coordinator.desiredPortalZPriority = portalZPriority + coordinator.attachGeneration += 1 + let generation = coordinator.attachGeneration + + host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in + guard let host, let webView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard host.window != nil else { return } + BrowserWindowPortalRegistry.bind( + webView: webView, + to: host, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastPortalHostId = ObjectIdentifier(host) + } + host.onGeometryChanged = { [weak host, weak coordinator] in + guard let host, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } + BrowserWindowPortalRegistry.synchronizeForAnchor(host) + } + + if !shouldAttachWebView { + // In portal mode we no longer detach/re-attach to preserve DevTools state. + // Sync the inspector preference directly so manual closes are respected. + panel.syncDeveloperToolsPreferenceFromInspector() + } + + if host.window != nil { + let hostId = ObjectIdentifier(host) + let shouldBindNow = + coordinator.lastPortalHostId != hostId || + webView.superview == nil || + previousVisible != shouldAttachWebView || + previousZPriority != portalZPriority + if shouldBindNow { + BrowserWindowPortalRegistry.bind( + webView: webView, + to: host, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastPortalHostId = hostId + } + BrowserWindowPortalRegistry.synchronizeForAnchor(host) + } + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + #if DEBUG + Self.logDevToolsState( + panel, + event: "portal.update", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: host) + ) + #endif + } + private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. @@ -2770,6 +2886,28 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.panel = panel context.coordinator.webView = webView + let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() + if shouldUseWindowPortal { + context.coordinator.usesWindowPortal = true + Self.clearPortalCallbacks(for: nsView) + updateUsingWindowPortal(nsView, context: context, webView: webView) + Self.applyFocus( + panel: panel, + webView: webView, + nsView: nsView, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused + ) + return + } + + if context.coordinator.usesWindowPortal { + BrowserWindowPortalRegistry.detach(webView: webView) + context.coordinator.usesWindowPortal = false + context.coordinator.lastPortalHostId = nil + } + Self.clearPortalCallbacks(for: nsView) + // Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. @@ -2938,20 +3076,18 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 - if panel.hasPendingDeveloperToolsRefreshAfterAttach() { - #if DEBUG + let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach() + panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + if hadPendingRefresh { Self.logDevToolsState( panel, - event: "attach.alreadyAttached.deferPendingRefresh", + event: "attach.alreadyAttached.consumePendingRefresh", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) - #endif - } else { - panel.restoreDeveloperToolsAfterAttachIfNeeded() } - #if DEBUG Self.logDevToolsState( panel, event: "attach.alreadyAttached", @@ -2962,23 +3098,37 @@ struct WebViewRepresentable: NSViewRepresentable { #endif } + Self.applyFocus( + panel: panel, + webView: webView, + nsView: nsView, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused + ) + } + + private static func applyFocus( + panel: BrowserPanel, + webView: WKWebView, + nsView: NSView, + shouldFocusWebView: Bool, + isPanelFocused: Bool + ) { // Focus handling. Avoid fighting the address bar when it is focused. guard let window = nsView.window else { return } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { return } - if Self.responderChainContains(window.firstResponder, target: webView) { + if responderChainContains(window.firstResponder, target: webView) { return } window.makeFirstResponder(webView) - } else { + } else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) { // Only force-resign WebView focus when this panel itself is not focused. // If the panel is focused but the omnibar-focus state is briefly stale, aggressively // clearing first responder here can undo programmatic webview focus (socket tests). - if !isPanelFocused && Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) - } + window.makeFirstResponder(nil) } } @@ -2987,10 +3137,34 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryWorkItem = nil coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 + clearPortalCallbacks(for: nsView) guard let webView = coordinator.webView else { return } let panel = coordinator.panel + if coordinator.usesWindowPortal { + coordinator.usesWindowPortal = false + coordinator.lastPortalHostId = nil + + // During split/layout churn we keep the WKWebView portal-hosted so DevTools + // does not lose state. BrowserPanel deinit explicitly detaches on real teardown. + if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + #if DEBUG + logDevToolsState( + panel, + event: "dismantle.portal.keepAttached", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + ) + #endif + return + } + + BrowserWindowPortalRegistry.detach(webView: webView) + return + } + // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. let window = webView.window ?? nsView.window diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 9049e166..f4c16fbc 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -37,6 +37,7 @@ struct PanelContentView: View { panel: browserPanel, isFocused: isFocused, isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, onRequestPanelFocus: onRequestPanelFocus ) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index f16b434a..af8ae749 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -297,7 +297,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } - func testForcedRefreshAfterAttachReopensVisibleInspectorOnce() { + func testForcedRefreshAfterAttachKeepsVisibleInspectorState() { let (panel, inspector) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) @@ -309,13 +309,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel.restoreDeveloperToolsAfterAttachIfNeeded() XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.closeCount, 1) - XCTAssertEqual(inspector.showCount, 2) + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) // The force-refresh request should be one-shot. panel.restoreDeveloperToolsAfterAttachIfNeeded() - XCTAssertEqual(inspector.closeCount, 1) - XCTAssertEqual(inspector.showCount, 2) + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) } func testRefreshRequestTracksPendingStateUntilRestoreRuns() { @@ -349,7 +349,8 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel: panel, shouldAttachWebView: true, shouldFocusWebView: false, - isPanelFocused: true + isPanelFocused: true, + portalZPriority: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -369,7 +370,8 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel: panel, shouldAttachWebView: true, shouldFocusWebView: false, - isPanelFocused: true + isPanelFocused: true, + portalZPriority: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -2450,3 +2452,219 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { ) } } + +@MainActor +final class BrowserWindowPortalLifecycleTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + func testPortalHostInstallsAboveContentViewForVisibility() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowBrowserPortal(window: window) + _ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), + let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else { + XCTFail("Expected host/content views in same container") + return + } + + XCTAssertGreaterThan( + hostIndex, + contentIndex, + "Browser portal host must remain above content view so portal-hosted web views stay visible" + ) + } + + func testAnchorRebindKeepsWebViewInStablePortalSuperview() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120)) + contentView.addSubview(anchor1) + contentView.addSubview(anchor2) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor1, visibleInUI: true) + let firstSuperview = webView.superview + + XCTAssertNotNil(firstSuperview) + XCTAssertTrue(firstSuperview is WindowBrowserSlotView) + + portal.bind(webView: webView, to: anchor2, visibleInUI: true) + XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view") + + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor2) + guard let slot = webView.superview as? WindowBrowserSlotView, + let host = slot.superview as? WindowBrowserHostView else { + XCTFail("Expected browser slot + host views") + return + } + let expectedFrame = host.convert(anchor2.bounds, from: anchor2) + XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5) + } + + func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + // Simulate a transient oversized anchor rect during split churn. + let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected web view slot") + return + } + + XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible") + XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5) + } + + func testPortalSyncNormalizesOutOfBoundsWebFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + // Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds. + webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height) + XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY) + + portal.synchronizeWebViewForAnchor(anchor) + XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5) + XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5) + XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5) + XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) + } + + func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView, + let host = slot.superview as? WindowBrowserHostView else { + XCTFail("Expected portal slot + host views") + return + } + XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync") + XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync") + } + + func testRegistryDetachRemovesPortalHostedWebView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + contentView.addSubview(anchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + XCTAssertNotNil(webView.superview) + + BrowserWindowPortalRegistry.detach(webView: webView) + XCTAssertNil(webView.superview) + } +} From 153b73c952a4a69129b1bcb7fd0c4ca82c244fa3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:04:16 -0800 Subject: [PATCH 9/9] Add earlier devtools shortcut snapshots and geometry probes --- Sources/AppDelegate.swift | 74 +++++++++++++++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 60 +++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cc5e863b..94fbcfaf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1442,6 +1442,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")") + if let probeKind = self.developerToolsShortcutProbeKind(event: event) { + self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) + } #endif if self.handleCustomShortcut(event: event) { #if DEBUG @@ -1882,13 +1885,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // - Option+Command+I => Show/Toggle Web Inspector // - Option+Command+C => Show JavaScript Console if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) +#endif let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) + } +#endif if !didHandle { NSSound.beep() } return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) +#endif let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) + } +#endif if !didHandle { NSSound.beep() } return true } @@ -2059,6 +2080,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } +#if DEBUG + private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + return "toggle.configured" + } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + return "console.configured" + } + + let chars = (event.charactersIgnoringModifiers ?? "").lowercased() + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == [.command, .option] { + if chars == "i" || event.keyCode == 34 { + return "toggle.literal" + } + if chars == "c" || event.keyCode == 8 { + return "console.literal" + } + } + return nil + } + + private func logDeveloperToolsShortcutSnapshot( + phase: String, + event: NSEvent? = nil, + didHandle: Bool? = nil + ) { + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let eventDescription = event.map(NSWindow.keyDescription) ?? "none" + if let browser = tabManager?.focusedBrowserPanel { + var line = + "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + + "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + + "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) + return + } + var line = + "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) + } +#endif + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { guard let browser = tabManager?.focusedBrowserPanel else { return } guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 628931d6..fd5eb45a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1317,6 +1317,12 @@ extension BrowserPanel { @discardableResult func toggleDeveloperTools() -> Bool { +#if DEBUG + dlog( + "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif guard let inspector = webView.cmuxInspectorObject() else { return false } let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false let targetVisible = !visible @@ -1330,6 +1336,19 @@ extension BrowserPanel { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false } +#if DEBUG + dlog( + "browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + dlog( + "browser.devtools toggle.tick panel=\(self.id.uuidString.prefix(5)) " + + "\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())" + ) + } +#endif return true } @@ -1606,6 +1625,35 @@ private extension BrowserPanel { #if DEBUG extension BrowserPanel { + private static func debugRectDescription(_ rect: NSRect) -> String { + String( + format: "%.1f,%.1f %.1fx%.1f", + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height + ) + } + + private static func debugObjectToken(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func debugInspectorSubviewCount(in root: NSView) -> Int { + var stack: [NSView] = [root] + var count = 0 + while let current = stack.popLast() { + for subview in current.subviews { + if String(describing: type(of: subview)).contains("WKInspector") { + count += 1 + } + stack.append(subview) + } + } + return count + } + func debugDeveloperToolsStateSummary() -> String { let preferred = preferredDeveloperToolsVisible ? 1 : 0 let visible = isDeveloperToolsVisible() ? 1 : 0 @@ -1615,6 +1663,18 @@ extension BrowserPanel { let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" } + + func debugDeveloperToolsGeometrySummary() -> String { + let container = webView.superview + let containerBounds = container?.bounds ?? .zero + let webFrame = webView.frame + let inspectorInsets = max(0, containerBounds.height - webFrame.height) + let inspectorOverflow = max(0, webFrame.maxY - containerBounds.maxY) + let inspectorHeightApprox = max(inspectorInsets, inspectorOverflow) + let inspectorSubviews = container.map { Self.debugInspectorSubviewCount(in: $0) } ?? 0 + let containerType = container.map { String(describing: type(of: $0)) } ?? "nil" + return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)" + } } #endif