Fix browser portal anchor churn during pane drag

This commit is contained in:
austinpower1258 2026-03-05 21:55:26 -08:00
parent 9b215eddab
commit 2427a2a736
4 changed files with 408 additions and 70 deletions

View file

@ -1,6 +1,7 @@
import AppKit
import Bonsplit
import ObjectiveC
import SwiftUI
import WebKit
private var cmuxWindowBrowserPortalKey: UInt8 = 0
@ -359,6 +360,14 @@ private final class BrowserDropZoneOverlayView: NSView {
}
}
struct BrowserPortalSearchOverlayConfiguration {
let panelId: UUID
let searchState: BrowserSearchState
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
}
struct BrowserPaneDropContext: Equatable {
let workspaceId: UUID
let panelId: UUID
@ -419,16 +428,23 @@ enum BrowserPaneDropAction: Equatable {
}
enum BrowserPaneDropRouting {
static func zone(for location: CGPoint, in size: CGSize) -> DropZone {
private static let padding: CGFloat = 4
private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize {
CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight))
}
static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone {
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
let edgeRatio: CGFloat = 0.25
let horizontalEdge = max(80, size.width * edgeRatio)
let verticalEdge = max(80, size.height * edgeRatio)
let horizontalEdge = max(80, fullPaneSize.width * edgeRatio)
let verticalEdge = max(80, fullPaneSize.height * edgeRatio)
if location.x < horizontalEdge {
return .left
} else if location.x > size.width - horizontalEdge {
} else if location.x > fullPaneSize.width - horizontalEdge {
return .right
} else if location.y > size.height - verticalEdge {
} else if location.y > fullPaneSize.height - verticalEdge {
return .top
} else if location.y < verticalEdge {
return .bottom
@ -437,6 +453,47 @@ enum BrowserPaneDropRouting {
}
}
static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect {
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
switch zone {
case .center:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height - padding * 2
)
case .left:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width / 2 - padding,
height: fullPaneSize.height - padding * 2
)
case .right:
return CGRect(
x: fullPaneSize.width / 2,
y: padding,
width: fullPaneSize.width / 2 - padding,
height: fullPaneSize.height - padding * 2
)
case .top:
return CGRect(
x: padding,
y: fullPaneSize.height / 2,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height / 2 - padding
)
case .bottom:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height / 2 - padding
)
}
}
static func action(
for transfer: BrowserPaneDragTransfer,
target: BrowserPaneDropContext,
@ -556,7 +613,11 @@ final class BrowserPaneDropTargetView: NSView {
}
let location = convert(sender.draggingLocation, from: nil)
let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size)
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
guard let action = BrowserPaneDropRouting.action(
for: transfer,
target: dropContext,
@ -612,7 +673,11 @@ final class BrowserPaneDropTargetView: NSView {
}
let location = convert(sender.draggingLocation, from: nil)
let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size)
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
activeZone = zone
slotView?.setPortalDragDropZone(zone)
#if DEBUG
@ -669,11 +734,13 @@ final class WindowBrowserSlotView: NSView {
override var isOpaque: Bool { false }
private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero)
private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero)
private var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
private var forwardedDropZone: DropZone?
private var portalDragDropZone: DropZone?
private var displayedDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
private var isRefreshingInteractionLayers = false
private var paneTopChromeHeight: CGFloat = 0
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
@ -691,7 +758,6 @@ final class WindowBrowserSlotView: NSView {
dropZoneOverlayView.layer?.cornerRadius = 8
dropZoneOverlayView.isHidden = true
addSubview(paneDropTargetView, positioned: .above, relativeTo: nil)
addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
}
@available(*, unavailable)
@ -705,6 +771,12 @@ final class WindowBrowserSlotView: NSView {
applyResolvedDropZoneOverlay()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
attachDropZoneOverlayIfNeeded()
applyResolvedDropZoneOverlay()
}
func setDropZoneOverlay(zone: DropZone?) {
forwardedDropZone = zone
applyResolvedDropZoneOverlay()
@ -719,9 +791,62 @@ final class WindowBrowserSlotView: NSView {
paneDropTargetView.dropContext = context
}
func setPaneTopChromeHeight(_ height: CGFloat) {
let resolvedHeight = max(0, height)
guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
paneTopChromeHeight = resolvedHeight
applyResolvedDropZoneOverlay()
}
func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
guard let configuration else {
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
return
}
let rootView = BrowserSearchOverlay(
panelId: configuration.panelId,
searchState: configuration.searchState,
onNext: configuration.onNext,
onPrevious: configuration.onPrevious,
onClose: configuration.onClose
)
if let overlay = searchOverlayHostingView {
overlay.rootView = rootView
if overlay.superview !== self {
overlay.removeFromSuperview()
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
return
}
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
searchOverlayHostingView = overlay
}
func effectivePaneTopChromeHeight() -> CGFloat {
paneTopChromeHeight
}
override func didAddSubview(_ subview: NSView) {
super.didAddSubview(subview)
guard subview !== paneDropTargetView, subview !== dropZoneOverlayView else { return }
guard subview !== paneDropTargetView else { return }
bringInteractionLayersToFrontIfNeeded()
}
@ -729,6 +854,17 @@ final class WindowBrowserSlotView: NSView {
portalDragDropZone ?? forwardedDropZone
}
private func overlayContainerView() -> NSView {
superview ?? self
}
private func attachDropZoneOverlayIfNeeded() {
let container = overlayContainerView()
guard dropZoneOverlayView.superview !== container else { return }
dropZoneOverlayView.removeFromSuperview()
container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
}
private func applyResolvedDropZoneOverlay() {
let resolvedZone = activeDropZone
if resolvedZone != nil, (bounds.width <= 2 || bounds.height <= 2) {
@ -764,6 +900,7 @@ final class WindowBrowserSlotView: NSView {
}
return
}
attachDropZoneOverlayIfNeeded()
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame)
@ -805,7 +942,6 @@ final class WindowBrowserSlotView: NSView {
private func interactionLayerPriority(of view: NSView) -> Int {
if view === paneDropTargetView { return 1 }
if view === dropZoneOverlayView { return 2 }
return 0
}
@ -817,8 +953,11 @@ final class WindowBrowserSlotView: NSView {
if paneDropTargetView.superview !== self {
addSubview(paneDropTargetView, positioned: .above, relativeTo: nil)
}
if dropZoneOverlayView.superview !== self {
addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
let overlayContainer = overlayContainerView()
if dropZoneOverlayView.superview !== overlayContainer {
attachDropZoneOverlayIfNeeded()
} else if overlayContainer.subviews.last !== dropZoneOverlayView {
overlayContainer.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
}
let context = Unmanaged.passUnretained(self).toOpaque()
@ -841,19 +980,13 @@ final class WindowBrowserSlotView: NSView {
}
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let padding: CGFloat = 4
switch zone {
case .center:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
case .left:
return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .right:
return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .top:
return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
case .bottom:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
}
let localFrame = BrowserPaneDropRouting.overlayFrame(
for: zone,
in: size,
topChromeHeight: paneTopChromeHeight
)
guard let superview else { return localFrame }
return superview.convert(localFrame, from: self)
}
private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool {
@ -884,6 +1017,9 @@ final class WindowBrowserPortal: NSObject {
var zPriority: Int
var dropZone: DropZone?
var paneDropContext: BrowserPaneDropContext?
var searchOverlay: BrowserPortalSearchOverlayConfiguration?
var paneTopChromeHeight: CGFloat
var transientRecoveryReason: String?
var transientRecoveryRetriesRemaining: Int
}
@ -1142,10 +1278,14 @@ final class WindowBrowserPortal: NSObject {
private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView {
if let existing = entry.containerView {
existing.setPaneDropContext(entry.paneDropContext)
existing.setSearchOverlay(entry.searchOverlay)
existing.setPaneTopChromeHeight(entry.paneTopChromeHeight)
return existing
}
let created = WindowBrowserSlotView(frame: .zero)
created.setPaneDropContext(entry.paneDropContext)
created.setSearchOverlay(entry.searchOverlay)
created.setPaneTopChromeHeight(entry.paneTopChromeHeight)
#if DEBUG
dlog(
"browser.portal.container.create web=\(browserPortalDebugToken(webView)) " +
@ -1281,6 +1421,25 @@ final class WindowBrowserPortal: NSObject {
entry.containerView?.setPaneDropContext(context)
}
func updateSearchOverlay(
forWebViewId webViewId: ObjectIdentifier,
configuration: BrowserPortalSearchOverlayConfiguration?
) {
guard var entry = entriesByWebViewId[webViewId] else { return }
entry.searchOverlay = configuration
entriesByWebViewId[webViewId] = entry
entry.containerView?.setSearchOverlay(configuration)
}
func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
guard var entry = entriesByWebViewId[webViewId] else { return }
let resolvedHeight = max(0, height)
guard abs(entry.paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
entry.paneTopChromeHeight = resolvedHeight
entriesByWebViewId[webViewId] = entry
entry.containerView?.setPaneTopChromeHeight(resolvedHeight)
}
func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return }
@ -1296,6 +1455,9 @@ final class WindowBrowserPortal: NSObject {
zPriority: 0,
dropZone: nil,
paneDropContext: nil,
searchOverlay: nil,
paneTopChromeHeight: 0,
transientRecoveryReason: nil,
transientRecoveryRetriesRemaining: 0
),
webView: webView
@ -1329,6 +1491,9 @@ final class WindowBrowserPortal: NSObject {
zPriority: zPriority,
dropZone: previousEntry?.dropZone,
paneDropContext: previousEntry?.paneDropContext,
searchOverlay: previousEntry?.searchOverlay,
paneTopChromeHeight: previousEntry?.paneTopChromeHeight ?? 0,
transientRecoveryReason: previousEntry?.transientRecoveryReason,
transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0
)
@ -1446,7 +1611,8 @@ final class WindowBrowserPortal: NSObject {
}
private func resetTransientRecoveryRetryIfNeeded(forWebViewId webViewId: ObjectIdentifier, entry: inout Entry) {
guard entry.transientRecoveryRetriesRemaining != 0 else { return }
guard entry.transientRecoveryRetriesRemaining != 0 || entry.transientRecoveryReason != nil else { return }
entry.transientRecoveryReason = nil
entry.transientRecoveryRetriesRemaining = 0
entriesByWebViewId[webViewId] = entry
}
@ -1457,9 +1623,18 @@ final class WindowBrowserPortal: NSObject {
webView: WKWebView,
reason: String
) -> Bool {
if entry.transientRecoveryRetriesRemaining == 0 {
if entry.transientRecoveryReason != reason {
entry.transientRecoveryReason = reason
entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget
}
#if DEBUG
if entry.transientRecoveryRetriesRemaining <= 0 {
dlog(
"browser.portal.sync.deferRecover.skip web=\(browserPortalDebugToken(webView)) " +
"reason=\(reason) exhausted=1"
)
}
#endif
guard entry.transientRecoveryRetriesRemaining > 0 else { return false }
entry.transientRecoveryRetriesRemaining -= 1
@ -1494,15 +1669,31 @@ final class WindowBrowserPortal: NSObject {
}
return
}
guard let anchorView = entry.anchorView, let window else {
if entry.visibleInUI {
_ = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: "missingAnchorOrWindow"
func scheduleTransientDetachRecovery(reason: String) -> Bool {
guard entry.visibleInUI else { return false }
let didSchedule = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: reason
)
let shouldPreserve = didSchedule && !containerView.isHidden
#if DEBUG
if shouldPreserve {
dlog(
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=\(reason) frame=\(browserPortalDebugFrame(containerView.frame))"
)
} else {
}
#endif
return shouldPreserve
}
guard let anchorView = entry.anchorView, let window else {
if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") {
containerView.setDropZoneOverlay(zone: nil)
return
}
if !entry.visibleInUI {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
#if DEBUG
@ -1513,11 +1704,17 @@ final class WindowBrowserPortal: NSObject {
)
}
#endif
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
return
}
guard anchorView.window === window else {
if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") {
containerView.setDropZoneOverlay(zone: nil)
return
}
#if DEBUG
if !containerView.isHidden {
dlog(
@ -1527,16 +1724,11 @@ final class WindowBrowserPortal: NSObject {
)
}
#endif
if entry.visibleInUI {
_ = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: "anchorWindowMismatch"
)
} else {
if !entry.visibleInUI {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
return
@ -1617,6 +1809,7 @@ final class WindowBrowserPortal: NSObject {
} else {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
if entry.visibleInUI {
@ -1629,6 +1822,7 @@ final class WindowBrowserPortal: NSObject {
} else {
scheduleDeferredFullSynchronizeAll()
}
containerView.setPaneTopChromeHeight(0)
return
}
let oldFrame = containerView.frame
@ -1788,6 +1982,8 @@ final class WindowBrowserPortal: NSObject {
#endif
containerView.isHidden = false
}
containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight)
containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay)
containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone)
if revealedForDisplay {
refreshReasons.append("reveal")
@ -2011,6 +2207,23 @@ enum BrowserWindowPortalRegistry {
portal.updatePaneDropContext(forWebViewId: webViewId, context: context)
}
static func updateSearchOverlay(
for webView: WKWebView,
configuration: BrowserPortalSearchOverlayConfiguration?
) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
}
static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updatePaneTopChromeHeight(forWebViewId: webViewId, height: height)
}
static func detach(webView: WKWebView) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }

View file

@ -1244,6 +1244,15 @@ final class BrowserSearchState: ObservableObject {
}
}
final class BrowserPortalAnchorView: NSView {
override var acceptsFirstResponder: Bool { false }
override var isOpaque: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
@MainActor
final class BrowserPanel: Panel, ObservableObject {
/// Shared process pool for cookie sharing across all browser panels
@ -1481,6 +1490,7 @@ final class BrowserPanel: Panel, ObservableObject {
}
}
private var searchNeedleCancellable: AnyCancellable?
let portalAnchorView = BrowserPortalAnchorView(frame: .zero)
private var webViewCancellables = Set<AnyCancellable>()
private var navigationDelegate: BrowserNavigationDelegate?
private var uiDelegate: BrowserUIDelegate?

View file

@ -230,6 +230,7 @@ struct BrowserPanelView: View {
@State private var focusFlashOpacity: Double = 0.0
@State private var focusFlashAnimationGeneration: Int = 0
@State private var omnibarPillFrame: CGRect = .zero
@State private var addressBarHeight: CGFloat = 0
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var isBrowserThemeMenuPresented = false
@State private var ghosttyBackgroundGeneration: Int = 0
@ -306,6 +307,8 @@ struct BrowserPanelView: View {
}
var body: some View {
// Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit
// container. Rendering it here can hide it behind the portal-hosted WKWebView.
VStack(spacing: 0) {
addressBar
webView
@ -317,17 +320,6 @@ struct BrowserPanelView: View {
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.overlay {
if let searchState = panel.searchState {
BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() }
)
}
}
.overlay(alignment: .topLeading) {
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
OmnibarSuggestionsView(
@ -354,6 +346,9 @@ struct BrowserPanelView: View {
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
omnibarPillFrame = frame
}
.onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in
addressBarHeight = height
}
.onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in
// Only handle clicks from our own webview.
guard let webView = note.object as? CmuxWebView else { return false }
@ -495,6 +490,15 @@ struct BrowserPanelView: View {
.padding(.horizontal, 8)
.padding(.vertical, addressBarVerticalPadding)
.background(browserChromeBackground)
.background {
GeometryReader { geo in
Color.clear
.preference(
key: BrowserAddressBarHeightPreferenceKey.self,
value: geo.size.height
)
}
}
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
.zIndex(1)
.environment(\.colorScheme, browserChromeColorScheme)
@ -739,7 +743,17 @@ struct BrowserPanelView: View {
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority,
paneDropZone: paneDropZone
paneDropZone: paneDropZone,
searchOverlay: panel.searchState.map { searchState in
BrowserPortalSearchOverlayConfiguration(
panelId: panel.id,
searchState: searchState,
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() }
)
},
paneTopChromeHeight: addressBarHeight
)
// Keep the host stable for normal pane churn, but force a remount when
// BrowserPanel replaces its underlying WKWebView after process termination.
@ -1935,6 +1949,14 @@ private struct OmnibarPillFramePreferenceKey: PreferenceKey {
}
}
private struct BrowserAddressBarHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
// MARK: - Omnibar State Machine
struct OmnibarState: Equatable {
@ -3039,6 +3061,8 @@ struct WebViewRepresentable: NSViewRepresentable {
let isPanelFocused: Bool
let portalZPriority: Int
let paneDropZone: DropZone?
let searchOverlay: BrowserPortalSearchOverlayConfiguration?
let paneTopChromeHeight: CGFloat
final class Coordinator {
weak var panel: BrowserPanel?
@ -3199,6 +3223,18 @@ struct WebViewRepresentable: NSViewRepresentable {
host.onGeometryChanged = nil
}
private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
if anchorView.superview !== host {
anchorView.removeFromSuperview()
anchorView.frame = host.bounds
anchorView.translatesAutoresizingMaskIntoConstraints = true
anchorView.autoresizingMask = [.width, .height]
host.addSubview(anchorView)
} else if anchorView.frame != host.bounds {
anchorView.frame = host.bounds
}
}
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
guard let host = nsView as? HostContainerView else { return }
@ -3210,25 +3246,35 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil
let portalAnchorView = panel.portalAnchorView
Self.installPortalAnchorView(portalAnchorView, in: host)
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
guard let host, let webView, let coordinator else { return }
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
guard let host, let webView, let coordinator, let portalAnchorView else { return }
guard coordinator.attachGeneration == generation else { return }
Self.installPortalAnchorView(portalAnchorView, in: host)
guard host.window != nil else { return }
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
coordinator.lastPortalHostId = ObjectIdentifier(host)
}
host.onGeometryChanged = { [weak host, weak coordinator] in
guard let host, let coordinator else { return }
host.onGeometryChanged = { [weak host, weak coordinator, weak portalAnchorView] in
guard let host, let coordinator, let portalAnchorView else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
}
if !shouldAttachWebView {
@ -3245,15 +3291,21 @@ struct WebViewRepresentable: NSViewRepresentable {
previousVisible != shouldAttachWebView ||
previousZPriority != portalZPriority
if shouldBindNow {
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastPortalHostId = hostId
}
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: shouldAttachWebView ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
} else {
// Bind is deferred until host moves into a window. Keep the current
// portal entry's desired state in sync so stale callbacks cannot keep
@ -3269,10 +3321,15 @@ struct WebViewRepresentable: NSViewRepresentable {
for: webView,
zone: shouldAttachWebView ? paneDropZone : nil
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: shouldAttachWebView ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(
for: webView,
context: paneDropContext
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
@ -3391,7 +3448,9 @@ struct WebViewRepresentable: NSViewRepresentable {
// rearrangement. Do not detach the portal-hosted WKWebView here; explicit detach
// still happens on real web view replacement and panel teardown.
BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(for: webView, height: 0)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil)
coordinator.lastPortalHostId = nil
}

View file

@ -2450,7 +2450,9 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
paneDropZone: nil
paneDropZone: nil,
searchOverlay: nil,
paneTopChromeHeight: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
@ -2487,7 +2489,9 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
paneDropZone: nil
paneDropZone: nil,
searchOverlay: nil,
paneTopChromeHeight: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
@ -7776,6 +7780,27 @@ final class BrowserPaneDropRoutingTests: XCTestCase {
)
}
func testTopChromeHeightPushesTopSplitThresholdIntoWebView() {
let size = CGSize(width: 240, height: 180)
XCTAssertEqual(
BrowserPaneDropRouting.zone(
for: CGPoint(x: size.width * 0.5, y: 110),
in: size,
topChromeHeight: 36
),
.center
)
XCTAssertEqual(
BrowserPaneDropRouting.zone(
for: CGPoint(x: size.width * 0.5, y: 150),
in: size,
topChromeHeight: 36
),
.top
)
}
func testHitTestingCapturesOnlyForRelevantDragEvents() {
XCTAssertTrue(
BrowserPaneDropTargetView.shouldCaptureHitTesting(
@ -7873,22 +7898,24 @@ final class WindowBrowserSlotViewTests: XCTestCase {
}
func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() {
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
let slot = WindowBrowserSlotView(frame: container.bounds)
container.addSubview(slot)
let child = CapturingView(frame: slot.bounds)
child.autoresizingMask = [.width, .height]
slot.addSubview(child)
slot.setDropZoneOverlay(zone: .right)
slot.layoutSubtreeIfNeeded()
container.layoutSubtreeIfNeeded()
guard let overlay = slot.subviews.first(where: {
$0 !== child && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
guard let overlay = container.subviews.first(where: {
$0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
}) else {
XCTFail("Expected browser slot drop-zone overlay")
return
}
XCTAssertTrue(slot.subviews.last === overlay, "Overlay should stay above the hosted web view")
XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view")
XCTAssertFalse(overlay.isHidden)
XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5)
XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5)
@ -7901,6 +7928,35 @@ final class WindowBrowserSlotViewTests: XCTestCase {
advanceAnimations()
XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay")
}
func testTopDropZoneOverlayUsesFullBrowserContentHeight() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
let slot = WindowBrowserSlotView(frame: container.bounds)
container.addSubview(slot)
slot.setPaneTopChromeHeight(20)
slot.setDropZoneOverlay(zone: .top)
container.layoutSubtreeIfNeeded()
guard let overlay = container.subviews.first(where: {
String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
}) else {
XCTFail("Expected browser slot drop-zone overlay")
return
}
XCTAssertFalse(overlay.isHidden)
XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5)
XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5)
XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5)
XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5)
XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY)
XCTAssertEqual(slot.layer?.masksToBounds, true)
slot.setDropZoneOverlay(zone: nil)
advanceAnimations()
XCTAssertEqual(slot.layer?.masksToBounds, true)
}
}
@MainActor