diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 200a9ef0..bbe13377 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -126,13 +126,11 @@ enum HostedInspectorDockSide { inspectorFrame: NSRect, expansion: CGFloat ) -> NSRect { - let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY)) - let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY)) return NSRect( x: dividerX(pageFrame: pageFrame, inspectorFrame: inspectorFrame) - expansion, - y: minY, + y: bounds.minY, width: expansion * 2, - height: max(0, maxY - minY) + height: max(0, bounds.height) ) } @@ -168,35 +166,54 @@ enum HostedInspectorDockSide { in containerBounds: NSRect, pageFrame: NSRect, inspectorFrame: NSRect, - minimumInspectorWidth _: CGFloat + minimumInspectorWidth: CGFloat ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + let normalizedMinY = containerBounds.minY + let normalizedHeight = max(0, containerBounds.height) + switch self { case .leading: let maximumInspectorWidth = max(0, containerBounds.width) - let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) + let clampedMinimumInspectorWidth = min(maximumInspectorWidth, max(0, minimumInspectorWidth)) + let clampedInspectorWidth = min( + maximumInspectorWidth, + max(clampedMinimumInspectorWidth, preferredWidth) + ) let dividerX = min(containerBounds.maxX, containerBounds.minX + clampedInspectorWidth) var nextPageFrame = pageFrame nextPageFrame.origin.x = dividerX + nextPageFrame.origin.y = normalizedMinY nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX) + nextPageFrame.size.height = normalizedHeight var nextInspectorFrame = inspectorFrame nextInspectorFrame.origin.x = containerBounds.minX + nextInspectorFrame.origin.y = normalizedMinY nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX) + nextInspectorFrame.size.height = normalizedHeight return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame) case .trailing: let maximumInspectorWidth = max(0, containerBounds.width) - let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) + let clampedMinimumInspectorWidth = min(maximumInspectorWidth, max(0, minimumInspectorWidth)) + let clampedInspectorWidth = min( + maximumInspectorWidth, + max(clampedMinimumInspectorWidth, preferredWidth) + ) let dividerX = max(containerBounds.minX, containerBounds.maxX - clampedInspectorWidth) var nextPageFrame = pageFrame nextPageFrame.origin.x = containerBounds.minX + nextPageFrame.origin.y = normalizedMinY nextPageFrame.size.width = max(0, dividerX - containerBounds.minX) + nextPageFrame.size.height = normalizedHeight var nextInspectorFrame = inspectorFrame nextInspectorFrame.origin.x = dividerX + nextInspectorFrame.origin.y = normalizedMinY nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX) + nextInspectorFrame.size.height = normalizedHeight return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame) } } @@ -572,6 +589,7 @@ final class WindowBrowserHostView: NSView { inspectorView: dragState.inspectorView, dockSide: dragState.dockSide ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, reason: "drag" ) updateDividerCursor( @@ -946,7 +964,12 @@ final class WindowBrowserHostView: NSView { guard let hit = hostedInspectorDividerCandidate(in: slot) else { return false } let oldPageFrame = hit.pageView.frame let oldInspectorFrame = hit.inspectorView.frame - _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason) + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: hit, + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) return !Self.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) || !Self.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5) } @@ -955,6 +978,7 @@ final class WindowBrowserHostView: NSView { private func applyHostedInspectorDividerWidth( _ preferredWidth: CGFloat, to hit: HostedInspectorDividerHit, + minimumInspectorWidth: CGFloat, reason: String ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { let containerBounds = hit.containerView.bounds @@ -963,7 +987,7 @@ final class WindowBrowserHostView: NSView { in: containerBounds, pageFrame: hit.pageView.frame, inspectorFrame: hit.inspectorView.frame, - minimumInspectorWidth: 0 + minimumInspectorWidth: minimumInspectorWidth ) let pageFrame = nextFrames.pageFrame let inspectorFrame = nextFrames.inspectorFrame @@ -1742,8 +1766,9 @@ final class WindowBrowserSlotView: NSView { func pinHostedWebView(_ webView: WKWebView) { guard webView.superview === self else { return } + let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) let needsPlainWebViewFrameReset = - !Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) && + !hasCompanionWKSubviews && Self.frameDiffersFromBounds(webView.frame, bounds: bounds) let needsFrameHosting = hostedWebView !== webView || @@ -1765,7 +1790,9 @@ final class WindowBrowserSlotView: NSView { // WebKit-managed split frame when docked DevTools siblings are present. webView.translatesAutoresizingMaskIntoConstraints = true webView.autoresizingMask = [.width, .height] - webView.frame = bounds + if !hasCompanionWKSubviews { + webView.frame = bounds + } needsLayout = true layoutSubtreeIfNeeded() } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5c2d7cd8..fd8853a6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1775,6 +1775,10 @@ final class BrowserPanel: Panel, ObservableObject { private let developerToolsRestoreRetryMaxAttempts: Int = 40 private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private var developerToolsDetachedOpenGraceDeadline: Date? + private var developerToolsTransitionTargetVisible: Bool? + private var pendingDeveloperToolsTransitionTargetVisible: Bool? + private var developerToolsTransitionSettleWorkItem: DispatchWorkItem? + private let developerToolsTransitionSettleDelay: TimeInterval = 0.15 private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol? private var preferredAttachedDeveloperToolsWidth: CGFloat? private var preferredAttachedDeveloperToolsWidthFraction: CGFloat? @@ -2698,6 +2702,8 @@ final class BrowserPanel: Panel, ObservableObject { deinit { developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil + developerToolsTransitionSettleWorkItem?.cancel() + developerToolsTransitionSettleWorkItem = nil if let detachedDeveloperToolsWindowCloseObserver { NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver) } @@ -3002,29 +3008,97 @@ extension BrowserPanel { return false } + private var isDeveloperToolsTransitionInFlight: Bool { + developerToolsTransitionSettleWorkItem != nil + } + + private func effectiveDeveloperToolsVisibilityIntent() -> Bool { + if let pendingDeveloperToolsTransitionTargetVisible { + return pendingDeveloperToolsTransitionTargetVisible + } + if let developerToolsTransitionTargetVisible { + return developerToolsTransitionTargetVisible + } + return isDeveloperToolsVisible() + } + + private func scheduleDeveloperToolsTransitionSettle(source: String) { + developerToolsTransitionSettleWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.developerToolsTransitionSettleWorkItem = nil + self?.finishDeveloperToolsTransition(source: source) + } + developerToolsTransitionSettleWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsTransitionSettleDelay, execute: workItem) + } + + private func finishDeveloperToolsTransition(source: String) { + let pendingTargetVisible = pendingDeveloperToolsTransitionTargetVisible + pendingDeveloperToolsTransitionTargetVisible = nil + developerToolsTransitionTargetVisible = nil + + guard let pendingTargetVisible else { return } + guard pendingTargetVisible != isDeveloperToolsVisible() else { return } + _ = performDeveloperToolsVisibilityTransition(to: pendingTargetVisible, source: "\(source).queued") + } + @discardableResult - func toggleDeveloperTools() -> Bool { + private func enqueueDeveloperToolsVisibilityTransition( + to targetVisible: Bool, + source: String + ) -> Bool { + if isDeveloperToolsTransitionInFlight { + pendingDeveloperToolsTransitionTargetVisible = targetVisible + preferredDeveloperToolsVisible = targetVisible + if !targetVisible { + developerToolsDetachedOpenGraceDeadline = nil + forceDeveloperToolsRefreshOnNextAttach = false + cancelDeveloperToolsRestoreRetry() + } #if DEBUG - dlog( - "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + - "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" - ) + dlog( + "browser.devtools transition.queue panel=\(id.uuidString.prefix(5)) " + + "source=\(source) target=\(targetVisible ? 1 : 0) \(debugDeveloperToolsStateSummary())" + ) #endif + return true + } + + return performDeveloperToolsVisibilityTransition(to: targetVisible, source: source) + } + + @discardableResult + private func performDeveloperToolsVisibilityTransition( + to targetVisible: Bool, + source: String + ) -> Bool { guard let inspector = webView.cmuxInspectorObject() else { return false } + let isVisibleSelector = NSSelectorFromString("isVisible") let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false - let targetVisible = !visible + preferredDeveloperToolsVisible = targetVisible + developerToolsTransitionTargetVisible = targetVisible + if targetVisible { - _ = revealDeveloperTools(inspector) + if !visible { + _ = revealDeveloperTools(inspector) + } else { + developerToolsDetachedOpenGraceDeadline = nil + } } else { - syncDeveloperToolsPresentationPreferenceFromUI() - guard concealDeveloperTools(inspector) else { return false } + if visible { + syncDeveloperToolsPresentationPreferenceFromUI() + guard concealDeveloperTools(inspector) else { + developerToolsTransitionTargetVisible = nil + return false + } + } developerToolsDetachedOpenGraceDeadline = nil } - preferredDeveloperToolsVisible = targetVisible + if targetVisible { - let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false - if visibleAfterToggle { + let visibleAfterTransition = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + if visibleAfterTransition { syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() scheduleDetachedDeveloperToolsWindowDismissal() @@ -3036,6 +3110,26 @@ extension BrowserPanel { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false } + + if visible != targetVisible { + scheduleDeveloperToolsTransitionSettle(source: source) + } else { + developerToolsTransitionTargetVisible = nil + } + + return true + } + + @discardableResult + func toggleDeveloperTools() -> Bool { +#if DEBUG + dlog( + "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif + let targetVisible = !effectiveDeveloperToolsVisibilityIntent() + let handled = enqueueDeveloperToolsVisibilityTransition(to: targetVisible, source: "toggle") #if DEBUG dlog( "browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " + @@ -3049,30 +3143,18 @@ extension BrowserPanel { ) } #endif - return true + return handled } @discardableResult func showDeveloperTools() -> Bool { - guard let inspector = webView.cmuxInspectorObject() else { return false } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if !visible { - guard revealDeveloperTools(inspector) else { return false } - } - preferredDeveloperToolsVisible = true - if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { - syncDeveloperToolsPresentationPreferenceFromUI() - cancelDeveloperToolsRestoreRetry() - scheduleDetachedDeveloperToolsWindowDismissal() - } else { - scheduleDeveloperToolsRestoreRetry() - } - return true + return enqueueDeveloperToolsVisibilityTransition(to: true, source: "show") } @discardableResult func showDeveloperToolsConsole() -> Bool { guard showDeveloperTools() else { return false } + guard !isDeveloperToolsTransitionInFlight else { return true } guard let inspector = webView.cmuxInspectorObject() else { return true } // WebKit private inspector API differs by OS; try known console selectors. let consoleSelectors = [ @@ -3094,6 +3176,20 @@ extension BrowserPanel { func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) { guard let inspector = webView.cmuxInspectorObject() else { return } guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return } + if isDeveloperToolsTransitionInFlight { + let targetVisible = pendingDeveloperToolsTransitionTargetVisible ?? developerToolsTransitionTargetVisible ?? visible + preferredDeveloperToolsVisible = targetVisible + if targetVisible, visible { + developerToolsDetachedOpenGraceDeadline = nil + syncDeveloperToolsPresentationPreferenceFromUI() + cancelDeveloperToolsRestoreRetry() + } else if !targetVisible { + developerToolsDetachedOpenGraceDeadline = nil + forceDeveloperToolsRefreshOnNextAttach = false + cancelDeveloperToolsRestoreRetry() + } + return + } if visible { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() @@ -3115,6 +3211,7 @@ extension BrowserPanel { forceDeveloperToolsRefreshOnNextAttach = false return } + guard !isDeveloperToolsTransitionInFlight else { return } guard let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return @@ -3180,17 +3277,7 @@ extension BrowserPanel { @discardableResult func hideDeveloperTools() -> Bool { - guard let inspector = webView.cmuxInspectorObject() else { return false } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if visible { - syncDeveloperToolsPresentationPreferenceFromUI() - guard concealDeveloperTools(inspector) else { return false } - } - preferredDeveloperToolsVisible = false - developerToolsDetachedOpenGraceDeadline = nil - forceDeveloperToolsRefreshOnNextAttach = false - cancelDeveloperToolsRestoreRetry() - return true + return enqueueDeveloperToolsVisibilityTransition(to: false, source: "hide") } /// During split/layout transitions SwiftUI can briefly mark the browser surface hidden @@ -4056,7 +4143,9 @@ extension BrowserPanel { let attached = webView.superview == nil ? 0 : 1 let inWindow = webView.window == nil ? 0 : 1 let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 - return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" + let transitionTarget = developerToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" + let pendingTarget = pendingDeveloperToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh) tx=\(transitionTarget) pending=\(pendingTarget)" } func debugDeveloperToolsGeometrySummary() -> String { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index d11a67cf..9b950016 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3716,6 +3716,12 @@ struct WebViewRepresentable: NSViewRepresentable { final class HostContainerView: NSView { private final class HostedInspectorSideDockContainerView: NSView { override var isOpaque: Bool { false } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + // Managed side-docked DevTools use explicit frame updates from the host. + // Letting AppKit autoresize the WK siblings here makes them snap back to + // stale widths while the divider drag or pane resize is in flight. + } } var onDidMoveToWindow: (() -> Void)? @@ -3760,7 +3766,7 @@ struct WebViewRepresentable: NSViewRepresentable { } private static let hostedInspectorDividerHitExpansion: CGFloat = 10 - private static let minimumHostedInspectorWidth: CGFloat = 1 + private static let minimumHostedInspectorWidth: CGFloat = 120 private var trackingArea: NSTrackingArea? private var activeDividerCursorKind: DividerCursorKind? private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? @@ -4116,6 +4122,21 @@ struct WebViewRepresentable: NSViewRepresentable { layoutHostedInspectorSideDockIfNeeded(reason: "sideDock.activate") } + @discardableResult + func promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() -> Bool { + guard !isHostedInspectorSideDockActive(), + let slotView = localInlineSlotView, + let hit = hostedInspectorDividerCandidateUsingKnownWebViews(in: slotView) else { + return false + } + + // The inspector frontend sometimes reports its dock configuration a tick + // late after local-inline reattach. Promote the visible left/right split + // immediately so drag routing stays symmetric on both dock sides. + activateHostedInspectorSideDockIfNeeded(using: hit) + return isHostedInspectorSideDockActive() + } + private func deactivateHostedInspectorSideDockIfNeeded(reparentTo slotView: WindowBrowserSlotView?) { guard let slotView, let pageView = hostedInspectorSideDockPageView, @@ -4151,6 +4172,7 @@ struct WebViewRepresentable: NSViewRepresentable { inspectorView: inspectorView, dockSide: dockSide ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, reason: reason ) } @@ -4236,11 +4258,15 @@ struct WebViewRepresentable: NSViewRepresentable { override func layout() { super.layout() + _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() if let previousSize = lastHostedInspectorLayoutBoundsSize, Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) { - if isHostedInspectorSideDockActive() { - layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock.sameSize") - } else if !isHostedInspectorDividerDragActive && !hasStoredHostedInspectorWidthPreference { + // Origin-only frame churn is common while the surrounding split layout + // settles. Reapplying the side-docked inspector at the same size fights + // WebKit's own dock layout and shows up as visible flicker. + if !isHostedInspectorSideDockActive() && + !isHostedInspectorDividerDragActive && + !hasStoredHostedInspectorWidthPreference { captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize") } notifyGeometryChangedIfNeeded() @@ -4264,9 +4290,6 @@ struct WebViewRepresentable: NSViewRepresentable { override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) - if isHostedInspectorSideDockActive() { - layoutHostedInspectorSideDockIfNeeded(reason: "setFrameOrigin.sideDock") - } window?.invalidateCursorRects(for: self) notifyGeometryChangedIfNeeded() #if DEBUG @@ -4276,9 +4299,6 @@ struct WebViewRepresentable: NSViewRepresentable { override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - if isHostedInspectorSideDockActive() { - layoutHostedInspectorSideDockIfNeeded(reason: "setFrameSize.sideDock") - } window?.invalidateCursorRects(for: self) notifyGeometryChangedIfNeeded() #if DEBUG @@ -4419,6 +4439,7 @@ struct WebViewRepresentable: NSViewRepresentable { inspectorView: dragState.inspectorView, dockSide: dragState.dockSide ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, reason: "drag" ) #if DEBUG @@ -4698,6 +4719,7 @@ struct WebViewRepresentable: NSViewRepresentable { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.hostedInspectorReapplyWorkItem = nil + _ = self.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() if self.isHostedInspectorSideDockActive() { self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason) } else if !self.hasStoredHostedInspectorWidthPreference { @@ -4766,13 +4788,19 @@ struct WebViewRepresentable: NSViewRepresentable { } let currentInspectorWidth = max(0, hit.inspectorView.frame.width) guard abs(currentInspectorWidth - preferredWidth) > 0.5 else { return } - _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason) + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: hit, + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) } @discardableResult private func applyHostedInspectorDividerWidth( _ preferredWidth: CGFloat, to hit: HostedInspectorDividerHit, + minimumInspectorWidth: CGFloat, reason: String ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { let containerBounds = hit.containerView.bounds @@ -4781,7 +4809,7 @@ struct WebViewRepresentable: NSViewRepresentable { in: containerBounds, pageFrame: hit.pageView.frame, inspectorFrame: hit.inspectorView.frame, - minimumInspectorWidth: 0 + minimumInspectorWidth: minimumInspectorWidth ) let pageFrame = nextFrames.pageFrame let inspectorFrame = nextFrames.inspectorFrame diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3db83f10..3ef37061 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2493,6 +2493,10 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { return nil } + private func waitForDeveloperToolsTransitions() { + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { let (panel, inspector) = makePanelWithInspector() @@ -2574,6 +2578,37 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) } + func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + waitForDeveloperToolsTransitions() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + } + + func testRapidToggleQueuesHideAfterOpenTransitionSettles() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + waitForDeveloperToolsTransitions() + + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 1) + } + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { let (panel, _) = makePanelWithInspector() @@ -9282,6 +9317,45 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { XCTAssertGreaterThan(inspectorContainer.frame.minX, 0) } + func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, + "The custom DevTools divider should remain draggable at the top edge of the browser pane" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, + "The custom DevTools divider should remain draggable at the bottom edge of the browser pane" + ) + } + func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), @@ -9333,6 +9407,301 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { XCTAssertGreaterThan(inspectorContainer.frame.minX, 92) } + func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThanOrEqual( + inspectorContainer.frame.width, + 120, + "Shrinking the DevTools pane should clamp to a recoverable minimum width" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, + "After clamping, the DevTools divider should still be draggable near the top edge" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, + "After clamping, the DevTools divider should still be draggable near the bottom edge" + ) + } + + func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), + "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path" + ) + XCTAssertTrue( + pageView.superview === inspectorView.superview && pageView.superview !== slotView, + "Promotion should move both hosted inspector siblings into the managed side-dock container" + ) + XCTAssertEqual( + pageView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider" + ) + XCTAssertEqual( + inspectorView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Promotion should normalize the inspector height to the host height" + ) + } + + func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), + "The managed side-dock path should be active before drag assertions run" + ) + + let initialPageWidth = pageView.frame.width + let initialInspectorWidth = inspectorView.frame.width + let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan( + inspectorView.frame.width, + initialInspectorWidth, + "Right-docked DevTools should expand when the divider is dragged left" + ) + XCTAssertLessThan( + pageView.frame.width, + initialPageWidth, + "Expanding right-docked DevTools should shrink the page width" + ) + } + + func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil) + host.setFrameSize(NSSize(width: 210, height: host.frame.height)) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertGreaterThanOrEqual( + inspectorView.frame.width, + 120, + "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging" + ) + XCTAssertEqual( + inspectorView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Automatic shrink should keep the inspector vertically normalized to the host height" + ) + } + + func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + guard let managedContainer = pageView.superview else { + XCTFail("Expected managed side-dock container") + return + } + let draggedPageFrame = pageView.frame + let draggedInspectorFrame = inspectorView.frame + + managedContainer.setFrameSize( + NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24) + ) + + XCTAssertEqual( + pageView.frame.origin.x, + draggedPageFrame.origin.x, + accuracy: 0.5, + "Managed side-dock container should not autoresize the page back to a stale divider position" + ) + XCTAssertEqual( + pageView.frame.width, + draggedPageFrame.width, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout" + ) + XCTAssertEqual( + inspectorView.frame.origin.x, + draggedInspectorFrame.origin.x, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged inspector origin" + ) + XCTAssertEqual( + inspectorView.frame.width, + draggedInspectorFrame.width, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged inspector width" + ) + } + func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), @@ -11445,6 +11814,33 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) } + func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160)) + let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration()) + let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160)) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(webView) + slot.addSubview(inspectorContainer) + + webView.translatesAutoresizingMaskIntoConstraints = false + webView.autoresizingMask = [] + slot.pinHostedWebView(webView) + + XCTAssertEqual( + webView.frame.maxX, + inspectorContainer.frame.minX, + accuracy: 0.5, + "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split" + ) + XCTAssertLessThan( + webView.frame.width, + slot.bounds.width, + "The page frame should stay narrower than the full slot while a side-docked inspector is present" + ) + } + func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),