This commit is contained in:
austinpower1258 2026-03-11 22:13:45 -07:00
parent 6849b83f8d
commit 7d5d4d718d
4 changed files with 602 additions and 62 deletions

View file

@ -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()
}

View file

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

View file

@ -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

View file

@ -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),