diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 97d90f6a..3b73b683 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1350,6 +1350,8 @@ final class WindowBrowserSlotView: NSView { private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero) private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero) private var searchOverlayHostingView: NSHostingView? + 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() diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4e2139e4..5f3c1826 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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 diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9a8d76c2..31961ce2 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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() 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" } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 05dfaf2e..4f8ff9f5 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -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 diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index adec500f..fe5d87cf 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -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, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1b649937..7a4bb58a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index e8c087ac..edb26258 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -69,6 +69,7 @@ struct WorkspaceContentView: View { ) PanelContentView( panel: panel, + paneId: paneId, isFocused: isFocused, isSelectedInPane: isSelectedInPane, isVisibleInUI: isVisibleInUI, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8526ceba..1f8981b6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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,