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] 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 {