Fix browser pane portal anchor sizing (#1094)

* Fix browser pane webview sizing

* Guard browser portal by pane ownership

* Keep browser portal frame during transient zero geometry

* Guard portal surfaces against duplicate hosts

* Defer terminal surface reflow during tab drags

* Fix browser panel pane ID unit tests
This commit is contained in:
Austin Wang 2026-03-09 13:28:05 -07:00 committed by GitHub
parent a636104fb9
commit c447bee602
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 444 additions and 91 deletions

View file

@ -1350,6 +1350,8 @@ final class WindowBrowserSlotView: NSView {
private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero)
private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero)
private var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
private weak var hostedWebView: WKWebView?
private var hostedWebViewConstraints: [NSLayoutConstraint] = []
private var forwardedDropZone: DropZone?
private var portalDragDropZone: DropZone?
private var displayedDropZone: DropZone?
@ -1460,6 +1462,34 @@ final class WindowBrowserSlotView: NSView {
searchOverlayHostingView = overlay
}
func pinHostedWebView(_ webView: WKWebView) {
guard webView.superview === self else { return }
let needsNewConstraints =
hostedWebView !== webView ||
hostedWebViewConstraints.isEmpty ||
webView.translatesAutoresizingMaskIntoConstraints
guard needsNewConstraints else {
needsLayout = true
layoutSubtreeIfNeeded()
return
}
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
hostedWebView = webView
webView.translatesAutoresizingMaskIntoConstraints = false
webView.autoresizingMask = []
hostedWebViewConstraints = [
webView.topAnchor.constraint(equalTo: topAnchor),
webView.bottomAnchor.constraint(equalTo: bottomAnchor),
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
webView.trailingAnchor.constraint(equalTo: trailingAnchor),
]
NSLayoutConstraint.activate(hostedWebViewConstraints)
needsLayout = true
layoutSubtreeIfNeeded()
}
func effectivePaneTopChromeHeight() -> CGFloat {
paneTopChromeHeight
}
@ -2241,11 +2271,11 @@ final class WindowBrowserPortal: NSObject {
} else {
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
}
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = containerView.bounds
containerView.pinHostedWebView(webView)
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
} else {
containerView.pinHostedWebView(webView)
}
if containerView.superview !== hostView {
@ -2496,10 +2526,10 @@ final class WindowBrowserPortal: NSObject {
} else {
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
}
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = containerView.bounds
containerView.pinHostedWebView(webView)
refreshReasons.append("syncAttachWebView")
} else {
containerView.pinHostedWebView(webView)
}
_ = synchronizeHostFrameToReference()
@ -2626,12 +2656,23 @@ final class WindowBrowserPortal: NSObject {
}
#endif
if shouldPreserveVisibleOnTransientGeometry {
let hasExistingVisibleFrame =
oldFrame.width > 1 &&
oldFrame.height > 1 &&
containerView.bounds.width > 1 &&
containerView.bounds.height > 1
#if DEBUG
dlog(
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame))"
"reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame)) " +
"keepFrame=\(hasExistingVisibleFrame ? 1 : 0)"
)
#endif
if hasExistingVisibleFrame {
containerView.setDropZoneOverlay(zone: nil)
containerView.setPaneDropContext(nil)
return
}
}
if !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
CATransaction.begin()

View file

@ -2052,8 +2052,14 @@ final class TerminalSurface: Identifiable, ObservableObject {
case closing
case closed
}
private struct PortalHostLease {
let hostId: ObjectIdentifier
let inWindow: Bool
let area: CGFloat
}
private var portalLifecycleState: PortalLifecycleState = .live
private var portalLifecycleGeneration: UInt64 = 1
private var activePortalHostLease: PortalHostLease?
@Published var searchState: SearchState? = nil {
didSet {
if let searchState {
@ -2144,6 +2150,90 @@ final class TerminalSurface: Identifiable, ObservableObject {
return true
}
private static let portalHostAreaThreshold: CGFloat = 4
private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2
private static func portalHostArea(for bounds: CGRect) -> CGFloat {
max(0, bounds.width) * max(0, bounds.height)
}
private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool {
lease.inWindow && lease.area > portalHostAreaThreshold
}
func claimPortalHost(
hostId: ObjectIdentifier,
inWindow: Bool,
bounds: CGRect,
reason: String
) -> Bool {
let next = PortalHostLease(
hostId: hostId,
inWindow: inWindow,
area: Self.portalHostArea(for: bounds)
)
if let current = activePortalHostLease {
if current.hostId == hostId {
activePortalHostLease = next
return true
}
let currentUsable = Self.portalHostIsUsable(current)
let nextUsable = Self.portalHostIsUsable(next)
let shouldReplace =
!currentUsable ||
(nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio))
if shouldReplace {
#if DEBUG
dlog(
"terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " +
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"replacingHost=\(current.hostId) replacingInWin=\(current.inWindow ? 1 : 0) " +
"replacingArea=\(String(format: "%.1f", current.area))"
)
#endif
activePortalHostLease = next
return true
}
#if DEBUG
dlog(
"terminal.portal.host.skip surface=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " +
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"ownerHost=\(current.hostId) ownerInWin=\(current.inWindow ? 1 : 0) " +
"ownerArea=\(String(format: "%.1f", current.area))"
)
#endif
return false
}
activePortalHostLease = next
#if DEBUG
dlog(
"terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " +
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) replacingHost=nil"
)
#endif
return true
}
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) {
guard let current = activePortalHostLease, current.hostId == hostId else { return }
activePortalHostLease = nil
#if DEBUG
dlog(
"terminal.portal.host.release surface=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) inWin=\(current.inWindow ? 1 : 0) " +
"area=\(String(format: "%.1f", current.area))"
)
#endif
}
func beginPortalCloseLifecycle(reason: String) {
guard portalLifecycleState != .closed else { return }
guard portalLifecycleState != .closing else { return }
@ -2902,6 +2992,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
.fileURL,
.URL
]
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
fileprivate static func focusLog(_ message: String) {
@ -3246,6 +3338,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return currentBounds
}
private static func hasActiveTabDragPasteboard() -> Bool {
let types = NSPasteboard(name: .drag).types ?? []
return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType)
}
@discardableResult
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
guard let terminalSurface = terminalSurface else { return false }
@ -3265,6 +3362,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return false
}
pendingSurfaceSize = size
guard !Self.hasActiveTabDragPasteboard() else {
#if DEBUG
let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
if lastSizeSkipSignature != signature {
dlog(
"surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=tabDrag " +
"size=\(String(format: "%.1fx%.1f", size.width, size.height)) " +
"inWindow=\(window != nil ? 1 : 0)"
)
lastSizeSkipSignature = signature
}
#endif
return false
}
guard let window else {
#if DEBUG
let signature = "noWindow-\(Int(size.width))x\(Int(size.height))"
@ -5074,6 +5185,13 @@ final class GhosttySurfaceScrollView: NSView {
)
}
func releaseOwnedPortalHost(hostId: ObjectIdentifier, reason: String) {
surfaceView.terminalSurface?.releasePortalHostIfOwned(
hostId: hostId,
reason: reason
)
}
init(surfaceView: GhosttyNSView) {
self.surfaceView = surfaceView
backgroundView = NSView(frame: .zero)
@ -6938,18 +7056,30 @@ struct GhosttyTerminalView: NSViewRepresentable {
}
#endif
let hostContainer = nsView as? HostContainerView
let hostOwnsPortalNow = hostContainer.map { host in
terminalSurface.claimPortalHost(
hostId: ObjectIdentifier(host),
inWindow: host.window != nil,
bounds: host.bounds,
reason: "update"
)
} ?? true
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
hostedView.attachSurface(terminalSurface)
hostedView.setInactiveOverlay(
color: inactiveOverlayColor,
opacity: CGFloat(inactiveOverlayOpacity),
visible: showsInactiveOverlay
)
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)
hostedView.setSearchOverlay(searchState: searchState)
hostedView.setKeyboardCopyModeIndicator(visible: terminalSurface.keyboardCopyModeActive)
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
hostedView.setTriggerFlashHandler(onTriggerFlash)
if hostOwnsPortalNow {
hostedView.setInactiveOverlay(
color: inactiveOverlayColor,
opacity: CGFloat(inactiveOverlayOpacity),
visible: showsInactiveOverlay
)
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)
hostedView.setSearchOverlay(searchState: searchState)
hostedView.setKeyboardCopyModeIndicator(visible: terminalSurface.keyboardCopyModeActive)
}
let portalExpectedSurfaceId = terminalSurface.id
let portalExpectedGeneration = terminalSurface.portalBindingGeneration()
let forwardedDropZone = isVisibleInUI ? paneDropZone : nil
@ -6972,16 +7102,23 @@ struct GhosttyTerminalView: NSViewRepresentable {
)
}
#endif
hostedView.setDropZoneOverlay(zone: forwardedDropZone)
if hostOwnsPortalNow {
hostedView.setDropZoneOverlay(zone: forwardedDropZone)
}
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
let hostContainer = nsView as? HostContainerView
if let host = hostContainer {
host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in
guard let host, let hostedView, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard terminalSurface.claimPortalHost(
hostId: ObjectIdentifier(host),
inWindow: host.window != nil,
bounds: host.bounds,
reason: "didMoveToWindow"
) else { return }
guard host.window != nil else { return }
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
@ -7000,9 +7137,16 @@ struct GhosttyTerminalView: NSViewRepresentable {
host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in
guard let host, let hostedView, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return }
guard terminalSurface.claimPortalHost(
hostId: ObjectIdentifier(host),
inWindow: host.window != nil,
bounds: host.bounds,
reason: "geometryChanged"
) else { return }
let hostId = ObjectIdentifier(host)
if host.window != nil,
!TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) {
(coordinator.lastBoundHostId != hostId ||
!TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) {
#if DEBUG
dlog(
"ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " +
@ -7018,7 +7162,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
expectedSurfaceId: portalExpectedSurfaceId,
expectedGeneration: portalExpectedGeneration
)
coordinator.lastBoundHostId = ObjectIdentifier(host)
coordinator.lastBoundHostId = hostId
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
hostedView.setActive(coordinator.desiredIsActive)
hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing)
@ -7027,7 +7171,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
}
if host.window != nil {
if host.window != nil, hostOwnsPortalNow {
let hostId = ObjectIdentifier(host)
let geometryRevision = host.geometryRevision
let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
@ -7062,7 +7206,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
}
} else {
} else if hostOwnsPortalNow {
// Bind is deferred until host moves into a window. Update the
// existing portal entry's visibleInUI now so that any portal sync
// that runs before the deferred bind completes won't hide the view.
@ -7087,7 +7231,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
let isBoundToCurrentHost = hostContainer.map { host in
TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
} ?? true
let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate(
let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate(
hostedViewHasSuperview: hostedView.superview != nil,
isBoundToCurrentHost: isBoundToCurrentHost
)
@ -7102,7 +7246,8 @@ struct GhosttyTerminalView: NSViewRepresentable {
if desiredStateChanged {
dlog(
"ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " +
"reason=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " +
"hostWindow=\(hostWindowAttached ? 1 : 0) " +
"boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " +
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
)
@ -7140,6 +7285,10 @@ struct GhosttyTerminalView: NSViewRepresentable {
if let host = nsView as? HostContainerView {
host.onDidMoveToWindow = nil
host.onGeometryChanged = nil
hostedView?.releaseOwnedPortalHost(
hostId: ObjectIdentifier(host),
reason: "dismantle"
)
}
// SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split

View file

@ -1714,6 +1714,13 @@ final class BrowserPanel: Panel, ObservableObject {
}
private var searchNeedleCancellable: AnyCancellable?
let portalAnchorView = BrowserPortalAnchorView(frame: .zero)
private struct PortalHostLease {
let hostId: ObjectIdentifier
let paneId: UUID
let inWindow: Bool
let area: CGFloat
}
private var activePortalHostLease: PortalHostLease?
private var webViewCancellables = Set<AnyCancellable>()
private var navigationDelegate: BrowserNavigationDelegate?
private var uiDelegate: BrowserUIDelegate?
@ -1755,6 +1762,94 @@ final class BrowserPanel: Panel, ObservableObject {
return String(localized: "browser.newTab", defaultValue: "New tab")
}
private static let portalHostAreaThreshold: CGFloat = 4
private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2
private static func portalHostArea(for bounds: CGRect) -> CGFloat {
max(0, bounds.width) * max(0, bounds.height)
}
private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool {
lease.inWindow && lease.area > portalHostAreaThreshold
}
func claimPortalHost(
hostId: ObjectIdentifier,
paneId: PaneID,
inWindow: Bool,
bounds: CGRect,
reason: String
) -> Bool {
let next = PortalHostLease(
hostId: hostId,
paneId: paneId.id,
inWindow: inWindow,
area: Self.portalHostArea(for: bounds)
)
if let current = activePortalHostLease {
if current.hostId == hostId {
activePortalHostLease = next
return true
}
let currentUsable = Self.portalHostIsUsable(current)
let nextUsable = Self.portalHostIsUsable(next)
let shouldReplace =
current.paneId != paneId.id ||
!currentUsable ||
(nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio))
if shouldReplace {
#if DEBUG
dlog(
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " +
"replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area))"
)
#endif
activePortalHostLease = next
return true
}
#if DEBUG
dlog(
"browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " +
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area))"
)
#endif
return false
}
activePortalHostLease = next
#if DEBUG
dlog(
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"replacingHost=nil"
)
#endif
return true
}
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) {
guard let current = activePortalHostLease, current.hostId == hostId else { return }
activePortalHostLease = nil
#if DEBUG
dlog(
"browser.portal.host.release panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " +
"inWin=\(current.inWindow ? 1 : 0) area=\(String(format: "%.1f", current.area))"
)
#endif
}
var displayIcon: String? {
"globe"
}

View file

@ -206,6 +206,7 @@ func resolvedBrowserOmnibarPillBackgroundColor(
/// View for rendering a browser panel with address bar
struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
let paneId: PaneID
let isFocused: Bool
let isVisibleInUI: Bool
let portalPriority: Int
@ -312,13 +313,23 @@ struct BrowserPanelView: View {
)
}
private var isCurrentPaneOwner: Bool {
guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }),
let currentPaneId = workspace.paneId(forPanelId: panel.id) else {
return false
}
return currentPaneId.id == paneId.id
}
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
.fixedSize(horizontal: false, vertical: true)
webView
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay {
// Keep Cmd+F usable when the browser is still in the empty new-tab
// state (no WKWebView mounted yet). WebView-backed cases are hosted
@ -795,7 +806,8 @@ struct BrowserPanelView: View {
if panel.shouldRenderWebView {
WebViewRepresentable(
panel: panel,
shouldAttachWebView: isVisibleInUI,
paneId: paneId,
shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority,
@ -813,8 +825,9 @@ struct BrowserPanelView: View {
)
.accessibilityIdentifier("BrowserWebViewSurface")
// Keep the host stable for normal pane churn, but force a remount when
// BrowserPanel replaces its underlying WKWebView after process termination.
.id(panel.webViewInstanceID)
// BrowserPanel replaces its underlying WKWebView after process termination
// or when the browser moves to a different Bonsplit pane host.
.id("\(panel.webViewInstanceID.uuidString)-\(paneId.id.uuidString)")
.contentShape(Rectangle())
.accessibilityIdentifier(browserContentAccessibilityIdentifier)
.simultaneousGesture(TapGesture().onEnded {
@ -839,6 +852,8 @@ struct BrowserPanelView: View {
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.layoutPriority(1)
.zIndex(0)
}
@ -3502,6 +3517,7 @@ private struct OmnibarSuggestionsView: View {
/// NSViewRepresentable wrapper for WKWebView
struct WebViewRepresentable: NSViewRepresentable {
let panel: BrowserPanel
let paneId: PaneID
let shouldAttachWebView: Bool
let shouldFocusWebView: Bool
let isPanelFocused: Bool
@ -4271,35 +4287,83 @@ struct WebViewRepresentable: NSViewRepresentable {
guard host.window != nil else { return }
if anchorView.superview !== host {
anchorView.removeFromSuperview()
anchorView.frame = host.bounds
anchorView.translatesAutoresizingMaskIntoConstraints = true
anchorView.autoresizingMask = [.width, .height]
anchorView.translatesAutoresizingMaskIntoConstraints = false
host.addSubview(anchorView)
} else if anchorView.frame != host.bounds {
anchorView.frame = host.bounds
NSLayoutConstraint.activate([
anchorView.topAnchor.constraint(equalTo: host.topAnchor),
anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
])
} else if anchorView.translatesAutoresizingMaskIntoConstraints {
anchorView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
anchorView.topAnchor.constraint(equalTo: host.topAnchor),
anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
])
}
host.layoutSubtreeIfNeeded()
}
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
guard let host = nsView as? HostContainerView else { return }
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
guard let host = nsView as? HostContainerView else { return false }
let coordinator = context.coordinator
let paneDropContext = currentPaneDropContext()
let isCurrentPaneOwner = paneDropContext?.paneId.id == paneId.id
let hostId = ObjectIdentifier(host)
let previousVisible = coordinator.desiredPortalVisibleInUI
let previousZPriority = coordinator.desiredPortalZPriority
coordinator.desiredPortalVisibleInUI = shouldAttachWebView
coordinator.desiredPortalVisibleInUI = shouldAttachWebView && isCurrentPaneOwner
coordinator.desiredPortalZPriority = portalZPriority
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil
let activePaneDropContext = coordinator.desiredPortalVisibleInUI ? paneDropContext : nil
let activeSearchOverlay = coordinator.desiredPortalVisibleInUI ? searchOverlay : nil
let portalAnchorView = panel.portalAnchorView
if host.window != nil {
if !shouldAttachWebView || !isCurrentPaneOwner {
panel.releasePortalHostIfOwned(
hostId: hostId,
reason: !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden"
)
}
let portalHostAccepted =
shouldAttachWebView &&
isCurrentPaneOwner &&
panel.claimPortalHost(
hostId: hostId,
paneId: paneId,
inWindow: host.window != nil,
bounds: host.bounds,
reason: "update"
)
#if DEBUG
if !isCurrentPaneOwner && (shouldAttachWebView || host.window != nil) {
dlog(
"browser.portal.owner.skip panel=\(panel.id.uuidString.prefix(5)) " +
"viewPane=\(paneId.id.uuidString.prefix(5)) " +
"currentPane=\(paneDropContext?.paneId.id.uuidString.prefix(5) ?? "nil") " +
"host=\(Self.objectID(host)) hostInWin=\(host.window != nil ? 1 : 0)"
)
}
#endif
if host.window != nil, portalHostAccepted {
Self.installPortalAnchorView(portalAnchorView, in: host)
}
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
guard let host, let webView, let coordinator, let portalAnchorView else { return }
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in
guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return }
guard coordinator.attachGeneration == generation else { return }
guard currentPaneDropContext()?.paneId.id == paneId.id else { return }
guard browserPanel.claimPortalHost(
hostId: ObjectIdentifier(host),
paneId: paneId,
inWindow: host.window != nil,
bounds: host.bounds,
reason: "didMoveToWindow"
) else { return }
guard host.window != nil else { return }
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.bind(
@ -4312,18 +4376,26 @@ struct WebViewRepresentable: NSViewRepresentable {
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
coordinator.lastPortalHostId = ObjectIdentifier(host)
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
}
host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
guard let host, let webView, let coordinator, let portalAnchorView else { return }
host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in
guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
guard currentPaneDropContext()?.paneId.id == paneId.id else { return }
guard browserPanel.claimPortalHost(
hostId: ObjectIdentifier(host),
paneId: paneId,
inWindow: host.window != nil,
bounds: host.bounds,
reason: "geometryChanged"
) else { return }
guard host.window != nil else { return }
let hostId = ObjectIdentifier(host)
Self.installPortalAnchorView(portalAnchorView, in: host)
if host.window != nil,
if coordinator.lastPortalHostId != hostId ||
!BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) {
BrowserWindowPortalRegistry.bind(
webView: webView,
@ -4335,9 +4407,9 @@ struct WebViewRepresentable: NSViewRepresentable {
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
coordinator.lastPortalHostId = ObjectIdentifier(host)
coordinator.lastPortalHostId = hostId
}
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
@ -4349,8 +4421,7 @@ struct WebViewRepresentable: NSViewRepresentable {
panel.syncDeveloperToolsPreferenceFromInspector()
}
if host.window != nil {
let hostId = ObjectIdentifier(host)
if host.window != nil, portalHostAccepted {
let geometryRevision = host.geometryRevision
let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView)
let shouldBindNow =
@ -4372,7 +4443,7 @@ struct WebViewRepresentable: NSViewRepresentable {
}
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: shouldAttachWebView ? paneTopChromeHeight : 0
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
if !shouldBindNow,
@ -4380,7 +4451,7 @@ struct WebViewRepresentable: NSViewRepresentable {
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
}
} else {
} else if portalHostAccepted {
// Bind is deferred until host moves into a window. Keep the current
// portal entry's desired state in sync so stale callbacks cannot keep
// the previous anchor visible while this host is temporarily off-window.
@ -4391,19 +4462,21 @@ struct WebViewRepresentable: NSViewRepresentable {
)
}
BrowserWindowPortalRegistry.updateDropZoneOverlay(
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)
if portalHostAccepted {
BrowserWindowPortalRegistry.updateDropZoneOverlay(
for: webView,
zone: coordinator.desiredPortalVisibleInUI ? paneDropZone : nil
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(
for: webView,
context: activePaneDropContext
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
}
panel.restoreDeveloperToolsAfterAttachIfNeeded()
@ -4416,11 +4489,13 @@ struct WebViewRepresentable: NSViewRepresentable {
details: Self.attachContext(webView: webView, host: host)
)
#endif
return portalHostAccepted
}
func updateNSView(_ nsView: NSView, context: Context) {
let webView = panel.webView
let coordinator = context.coordinator
let isCurrentPaneOwner = currentPaneDropContext()?.paneId.id == paneId.id
if let previousWebView = coordinator.webView, previousWebView !== webView {
BrowserWindowPortalRegistry.detach(webView: previousWebView)
coordinator.lastPortalHostId = nil
@ -4428,21 +4503,21 @@ struct WebViewRepresentable: NSViewRepresentable {
}
coordinator.panel = panel
coordinator.webView = webView
Self.clearPortalCallbacks(for: nsView)
let hostOwnsPortal = updateUsingWindowPortal(nsView, context: context, webView: webView)
Self.applyWebViewFirstResponderPolicy(
panel: panel,
webView: webView,
isPanelFocused: isPanelFocused
isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal
)
Self.clearPortalCallbacks(for: nsView)
updateUsingWindowPortal(nsView, context: context, webView: webView)
Self.applyFocus(
panel: panel,
webView: webView,
nsView: nsView,
shouldFocusWebView: shouldFocusWebView,
isPanelFocused: isPanelFocused
shouldFocusWebView: shouldFocusWebView && isCurrentPaneOwner && hostOwnsPortal,
isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal
)
}
@ -4527,6 +4602,12 @@ struct WebViewRepresentable: NSViewRepresentable {
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachGeneration += 1
clearPortalCallbacks(for: nsView)
if let panel = coordinator.panel, let host = nsView as? HostContainerView {
panel.releasePortalHostIfOwned(
hostId: ObjectIdentifier(host),
reason: "dismantle"
)
}
guard let webView = coordinator.webView else { return }
let panel = coordinator.panel

View file

@ -1,9 +1,11 @@
import SwiftUI
import Foundation
import Bonsplit
/// View that renders the appropriate panel view based on panel type
struct PanelContentView: View {
let panel: any Panel
let paneId: PaneID
let isFocused: Bool
let isSelectedInPane: Bool
let isVisibleInUI: Bool
@ -35,6 +37,7 @@ struct PanelContentView: View {
if let browserPanel = panel as? BrowserPanel {
BrowserPanelView(
panel: browserPanel,
paneId: paneId,
isFocused: isFocused,
isVisibleInUI: isVisibleInUI,
portalPriority: portalPriority,

View file

@ -2924,7 +2924,6 @@ final class Workspace: Identifiable, ObservableObject {
scheduleFocusReconcile()
}
scheduleTerminalGeometryReconcile()
scheduleMovedBrowserRefresh(panelId: detached.panelId)
#if DEBUG
dlog(
@ -3509,25 +3508,6 @@ final class Workspace: Identifiable, ObservableObject {
runRefreshPass(0.03)
}
private func scheduleMovedBrowserRefresh(panelId: UUID) {
guard browserPanel(for: panelId) != nil else { return }
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard let self, let browser = self.browserPanel(for: panelId) else { return }
BrowserWindowPortalRegistry.refresh(
webView: browser.webView,
reason: "workspace.movedBrowserRefresh"
)
}
}
// Mirror terminal moved-surface refreshes so round-trip pane drags get
// another render pass after bonsplit has settled its reparenting.
runRefreshPass(0)
runRefreshPass(0.03)
}
private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) {
for tabId in tabIds {
if skipPinned,
@ -4195,7 +4175,6 @@ extension Workspace: BonsplitDelegate {
#endif
if let movedPanelId = panelIdFromSurfaceId(tab.id) {
scheduleMovedTerminalRefresh(panelId: movedPanelId)
scheduleMovedBrowserRefresh(panelId: movedPanelId)
}
#if DEBUG
let selectedAfter = controller.selectedTab(inPane: destination)

View file

@ -69,6 +69,7 @@ struct WorkspaceContentView: View {
)
PanelContentView(
panel: panel,
paneId: paneId,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,

View file

@ -2513,6 +2513,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() {
let (panel, _) = makePanelWithInspector()
let paneId = PaneID(id: UUID())
XCTAssertTrue(panel.showDeveloperTools())
let window = NSWindow(
@ -2534,6 +2535,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
let representable = WebViewRepresentable(
panel: panel,
paneId: paneId,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
@ -2552,6 +2554,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() {
let (panel, _) = makePanelWithInspector()
let paneId = PaneID(id: UUID())
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
let window = NSWindow(
@ -2573,6 +2576,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
let representable = WebViewRepresentable(
panel: panel,
paneId: paneId,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,