Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Sources/App/HostedInspectorDockControlScript.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@ struct HostedInspectorDockControlScript {
return true;
return String(configuration).toLowerCase() === literal;
}
function disableFrontendDockedResizer() {
if (typeof document === "undefined" || !document.getElementById)
return;
const dockedResizer = document.getElementById("docked-resizer");
if (dockedResizer && dockedResizer.style)
dockedResizer.style.pointerEvents = "none";
}
function enforceDockControls() {
disableFrontendDockedResizer();
const disallowSideDock = !WI.__cmuxAllowSideDock;
const dockConfiguration = WI.DockConfiguration || {};
const dockedLeft = dockMatches(dockConfiguration.Left, "left");
Expand Down
362 changes: 181 additions & 181 deletions Sources/BrowserWindowPortal.swift

Large diffs are not rendered by default.

431 changes: 210 additions & 221 deletions Sources/Panels/BrowserPanelView.swift

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions Sources/Panels/HostedInspectorAttachedSizeSync.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import AppKit
import WebKit

@MainActor
struct HostedInspectorAttachedSizeSync {
static func sync(frontendWebView: WKWebView?, dockSide: HostedInspectorDockSide, extent: CGFloat) {
Comment on lines +5 to +6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Static Inspector Sync Namespace

This new production type is only a static helper surface, so the WebKit size-sync behavior has no owning host instance to construct or inject. The repo rule asks production Swift to keep behavior on the scoped owner; here the operation is always called from the inspector-hosting views after a drag, so it should live behind that owner instead of a new ambient namespace.

Rule Used: Flag new ambient global state in production Swift:... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional: the sync is invoked from two independent hosts (window-portal WindowBrowserHostView and local-inline HostContainerView) after their drag loops, and per the repo's shared-behavior policy that must be one shared mutation path, not per-host copies. It is stateless (pure JS construction + fire-and-forget evaluate), takes the frontend webview explicitly rather than reaching into ambient state, and follows the existing pattern of BrowserDeveloperToolsDockControlNormalizer/HostedInspectorDockControlScript. Happy to fold it into a host-owned instance if a future refactor gives the two hosts a common owner.

guard let frontendWebView else {
#if DEBUG
cmuxDebugLog("browser.inspector.attachedSizeSync skip=nilFrontend dock=\(dockSide) extent=\(String(format: "%.1f", extent))")
#endif
return
}
#if DEBUG
let script = javaScript(dockSide: dockSide, extent: extent)
frontendWebView.evaluateJavaScript(script) { result, error in
cmuxDebugLog(
"browser.inspector.attachedSizeSync dock=\(dockSide) extent=\(String(format: "%.1f", extent)) " +
"result=\(String(describing: result)) error=\(error.map { String(describing: $0) } ?? "nil")"
)
}
#else
frontendWebView.evaluateJavaScript(
javaScript(dockSide: dockSide, extent: extent),
completionHandler: nil
)
#endif
}

static func sync(pageWebView: WKWebView?, dockSide: HostedInspectorDockSide, extent: CGFloat) {
sync(
frontendWebView: pageWebView?.cmuxInspectorFrontendWebView(),
dockSide: dockSide,
extent: extent
)
}

nonisolated static func javaScript(dockSide: HostedInspectorDockSide, extent: CGFloat) -> String {
let method = dockSide.isHorizontalDivider ? "setAttachedWindowHeight" : "setAttachedWindowWidth"
let roundedExtent = max(0, Int(extent.rounded()))
return """
(() => {
if (typeof InspectorFrontendHost === "undefined") { return false; }
if (typeof InspectorFrontendHost.\(method) !== "function") { return false; }
InspectorFrontendHost.\(method)(\(roundedExtent));
return true;
})();
"""
}
}
313 changes: 313 additions & 0 deletions Sources/Panels/HostedInspectorResizeGeometry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
import AppKit

struct HostedInspectorMinimumSizePolicy: Equatable {
let minimumInspectorExtent: CGFloat
let minimumPageExtent: CGFloat

static let sideDock = HostedInspectorMinimumSizePolicy(
minimumInspectorExtent: 120,
minimumPageExtent: 120
)

static let bottomDock = HostedInspectorMinimumSizePolicy(
minimumInspectorExtent: 100,
minimumPageExtent: 80
)

init(minimumInspectorExtent: CGFloat, minimumPageExtent: CGFloat) {
self.minimumInspectorExtent = max(0, minimumInspectorExtent)
self.minimumPageExtent = max(0, minimumPageExtent)
}

init(dockSide: HostedInspectorDockSide) {
self = dockSide == .bottom ? .bottomDock : .sideDock
}

func clampedInspectorExtent(_ proposedExtent: CGFloat, containerExtent: CGFloat) -> CGFloat {
let extent = max(0, containerExtent)
let effectiveMinInspector = min(minimumInspectorExtent, extent / 2)
let effectiveMinPage = min(minimumPageExtent, extent / 2)
let maxInspector = max(effectiveMinInspector, extent - effectiveMinPage)
return min(maxInspector, max(effectiveMinInspector, proposedExtent))
}
}

/// WebKit's WebInspectorUIProxy observes the inspected webview's frame-change
/// notifications and asynchronously re-applies its stored attachment size.
/// During a cmux divider drag that stored size is stale, so every one of our
/// per-event frame writes triggered a reset to the pre-drag layout (verified
/// via the browser.portal.manualInspectorDrag debug log: oldPageFrame kept
/// reverting between events). Silence the inspected view's frame notifications
/// for exactly the drag's duration so cmux is the only layout writer.
@MainActor
final class HostedInspectorDragFrameNotificationSilencer {
/// Deinit-safe restore token: the silencer's owner is an NSView whose
/// deinit is nonisolated, so restoration must not require MainActor
/// isolation proof at the call site.
private final class Restore: @unchecked Sendable {
private weak var view: NSView?
private let value: Bool

@MainActor
init(view: NSView) {
self.view = view
self.value = view.postsFrameChangedNotifications
}

func run() {
if Thread.isMainThread {
MainActor.assumeIsolated { view?.postsFrameChangedNotifications = value }
} else {
DispatchQueue.main.async { [self] in
MainActor.assumeIsolated { view?.postsFrameChangedNotifications = value }
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

private var restore: Restore?

func begin(_ inspectedView: NSView) {
end()
restore = Restore(view: inspectedView)
inspectedView.postsFrameChangedNotifications = false
}

func end() {
restore?.run()
restore = nil
}

deinit {
// A drag that never sees mouseUp (owner torn down mid-drag) must not
// leave WebKit's inspector layout observer permanently deaf.
restore?.run()
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

enum HostedInspectorDockSide {
case leading
case trailing
case bottom

static func resolve(
pageFrame: NSRect,
inspectorFrame: NSRect,
epsilon: CGFloat = 4
) -> Self? {
let verticalOverlap = verticalOverlap(between: pageFrame, and: inspectorFrame)
let horizontalOverlap = horizontalOverlap(between: pageFrame, and: inspectorFrame)
if verticalOverlap > 0, pageFrame.maxX <= inspectorFrame.minX + epsilon {
return .trailing
}
if verticalOverlap > 0, inspectorFrame.maxX <= pageFrame.minX + epsilon {
return .leading
}
if horizontalOverlap > 0, inspectorFrame.maxY <= pageFrame.minY + epsilon {
return .bottom
}
return nil
}

var isHorizontalDivider: Bool {
self == .bottom
}

func dividerPosition(pageFrame: NSRect, inspectorFrame: NSRect) -> CGFloat {
switch self {
case .leading:
return inspectorFrame.maxX
case .trailing:
return inspectorFrame.minX
case .bottom:
return inspectorFrame.maxY
}
}

func dividerHitRect(
in bounds: NSRect,
pageFrame: NSRect,
inspectorFrame: NSRect,
expansion: CGFloat
) -> NSRect {
let position = dividerPosition(pageFrame: pageFrame, inspectorFrame: inspectorFrame)
switch self {
case .leading, .trailing:
return NSRect(
x: position - expansion,
y: bounds.minY,
width: expansion * 2,
height: max(0, bounds.height)
)
case .bottom:
return NSRect(
x: bounds.minX,
y: position - expansion,
width: max(0, bounds.width),
height: expansion * 2
)
}
}

func clampedDividerPosition(
_ proposedDividerPosition: CGFloat,
containerBounds: NSRect,
pageFrame: NSRect,
inspectorFrame: NSRect,
policy: HostedInspectorMinimumSizePolicy
) -> CGFloat {
let proposedExtent = inspectorExtent(
forDividerPosition: proposedDividerPosition,
in: containerBounds
)
let clampedExtent = policy.clampedInspectorExtent(
proposedExtent,
containerExtent: containerExtent(in: containerBounds)
)
return dividerPosition(forInspectorExtent: clampedExtent, in: containerBounds)
}

func inspectorExtent(forDividerPosition dividerPosition: CGFloat, in containerBounds: NSRect) -> CGFloat {
switch self {
case .leading:
return max(0, dividerPosition - containerBounds.minX)
case .trailing:
return max(0, containerBounds.maxX - dividerPosition)
case .bottom:
return max(0, dividerPosition - containerBounds.minY)
}
}

func inspectorExtent(inspectorFrame: NSRect, in containerBounds: NSRect) -> CGFloat {
switch self {
case .leading, .trailing:
return min(max(0, containerBounds.width), max(0, inspectorFrame.width))
case .bottom:
return min(max(0, containerBounds.height), max(0, inspectorFrame.height))
}
}

func resizedFrames(
preferredExtent: CGFloat,
in containerBounds: NSRect,
pageFrame: NSRect,
inspectorFrame: NSRect,
policy: HostedInspectorMinimumSizePolicy
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
let clampedExtent = policy.clampedInspectorExtent(
preferredExtent,
containerExtent: containerExtent(in: containerBounds)
)
let dividerPosition = dividerPosition(forInspectorExtent: clampedExtent, in: containerBounds)
switch self {
case .leading:
return horizontalFrames(
dividerX: dividerPosition,
containerBounds: containerBounds,
pageFrame: pageFrame,
inspectorFrame: inspectorFrame,
inspectorOnLeadingEdge: true
)
case .trailing:
return horizontalFrames(
dividerX: dividerPosition,
containerBounds: containerBounds,
pageFrame: pageFrame,
inspectorFrame: inspectorFrame,
inspectorOnLeadingEdge: false
)
case .bottom:
var nextPageFrame = pageFrame
nextPageFrame.origin.x = containerBounds.minX
nextPageFrame.origin.y = dividerPosition
nextPageFrame.size.width = max(0, containerBounds.width)
nextPageFrame.size.height = max(0, containerBounds.maxY - dividerPosition)

var nextInspectorFrame = inspectorFrame
nextInspectorFrame.origin.x = containerBounds.minX
nextInspectorFrame.origin.y = containerBounds.minY
nextInspectorFrame.size.width = max(0, containerBounds.width)
nextInspectorFrame.size.height = max(0, dividerPosition - containerBounds.minY)
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
}
}

func hasSufficientCrossAxisOverlap(
pageFrame: NSRect,
inspectorFrame: NSRect,
containerBounds: NSRect
) -> Bool {
let overlap = crossAxisOverlap(pageFrame: pageFrame, inspectorFrame: inspectorFrame)
let containerCrossExtent = isHorizontalDivider ? containerBounds.width : containerBounds.height
return overlap > min(8, max(0, containerCrossExtent) * 0.25)
}

func crossAxisOverlap(pageFrame: NSRect, inspectorFrame: NSRect) -> CGFloat {
switch self {
case .leading, .trailing:
return Self.verticalOverlap(between: pageFrame, and: inspectorFrame)
case .bottom:
return Self.horizontalOverlap(between: pageFrame, and: inspectorFrame)
}
}

func pageExtent(pageFrame: NSRect) -> CGFloat {
switch self {
case .leading, .trailing:
return max(0, pageFrame.width)
case .bottom:
return max(0, pageFrame.height)
}
}

private func containerExtent(in bounds: NSRect) -> CGFloat {
isHorizontalDivider ? bounds.height : bounds.width
}

private func dividerPosition(forInspectorExtent inspectorExtent: CGFloat, in bounds: NSRect) -> CGFloat {
switch self {
case .leading:
return bounds.minX + inspectorExtent
case .trailing:
return bounds.maxX - inspectorExtent
case .bottom:
return bounds.minY + inspectorExtent
}
}

private func horizontalFrames(
dividerX: CGFloat,
containerBounds: NSRect,
pageFrame: NSRect,
inspectorFrame: NSRect,
inspectorOnLeadingEdge: Bool
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
var nextPageFrame = pageFrame
var nextInspectorFrame = inspectorFrame
nextPageFrame.origin.y = containerBounds.minY
nextPageFrame.size.height = max(0, containerBounds.height)
nextInspectorFrame.origin.y = containerBounds.minY
nextInspectorFrame.size.height = max(0, containerBounds.height)

if inspectorOnLeadingEdge {
nextInspectorFrame.origin.x = containerBounds.minX
nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX)
nextPageFrame.origin.x = dividerX
nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX)
} else {
nextPageFrame.origin.x = containerBounds.minX
nextPageFrame.size.width = max(0, dividerX - containerBounds.minX)
nextInspectorFrame.origin.x = dividerX
nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
}
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
}

private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
}

private static func horizontalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
max(0, min(lhs.maxX, rhs.maxX) - max(lhs.minX, rhs.minX))
}
}
Loading
Loading