-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Fix attached Web Inspector resize: bottom-dock divider, small-pane minimum-size policy, WebKit size sync #7771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
austinywang
wants to merge
9
commits into
main
Choose a base branch
from
issue-7762-web-inspector-resize
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
4275c01
test: attached Web Inspector bottom-dock divider is dead to drag (#77…
austinywang 5efa879
Fix attached Web Inspector resize: bottom-dock divider, small-pane po…
austinywang e793f50
Address review: bottom-dock seam guard in inline host, widen pinned t…
austinywang c690206
Never treat an already-bottom-docked inspector as needing adaptive bo…
austinywang ac5e54d
Fix remaining under-minimum resize and drag glitches from dogfood
austinywang 04b13e9
Silence inspected-view frame notifications during divider drags; addr…
austinywang fe46db8
Drop per-event WebKit size sync; make drag-frame silencer restore dei…
austinywang ac75bf3
Cancel active divider drags synchronously when a host leaves its window
austinywang 61aea05
Portal: capture cross-axis preference when stored extent is for the o…
austinywang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| 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; | ||
| })(); | ||
| """ | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } | ||
| } | ||
| } | ||
|
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() | ||
| } | ||
| } | ||
|
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)) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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!
There was a problem hiding this comment.
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
WindowBrowserHostViewand local-inlineHostContainerView) 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 ofBrowserDeveloperToolsDockControlNormalizer/HostedInspectorDockControlScript. Happy to fold it into a host-owned instance if a future refactor gives the two hosts a common owner.