diff --git a/Sources/WindowPaneChromePortal.swift b/Sources/WindowPaneChromePortal.swift index b401669d..49c003ce 100644 --- a/Sources/WindowPaneChromePortal.swift +++ b/Sources/WindowPaneChromePortal.swift @@ -122,7 +122,7 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr newTabCluster.setActions([active.onNewTab, active.onNewBrowserTab]) newTabCluster.isHidden = false - hostView.addSubview(newTabCluster) // keep above pane bars + ensureAboveBars(newTabCluster) newTabCluster.frame = NSRect( x: hostView.bounds.maxX - newTabCluster.preferredWidth - 8, y: y, @@ -148,7 +148,7 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr ) ) splitCluster.isHidden = false - hostView.addSubview(splitCluster) + ensureAboveBars(splitCluster) splitCluster.frame = NSRect( x: hostView.bounds.maxX - splitCluster.preferredWidth - 8, y: y, @@ -160,6 +160,26 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr } } + /// Re-adding an already-parented subview removes and re-inserts it, which + /// dirties layout — and updateClusters runs on every coalesced geometry + /// pass during animation storms. Reorder only when a pane bar (added on + /// top by updatePaneChrome) actually sits above the cluster. + private func ensureAboveBars(_ cluster: NSView) { + guard cluster.superview === hostView else { + hostView.addSubview(cluster) + return + } + let subviews = hostView.subviews + guard let clusterIndex = subviews.firstIndex(of: cluster) else { return } + let topBarIndex = subviews.enumerated() + .filter { $0.element is NativePaneTabBarView } + .map(\.offset) + .max() + if let topBarIndex, clusterIndex < topBarIndex { + hostView.addSubview(cluster) + } + } + func removePaneChrome(for paneID: PaneID, anchorView: NSView) { let matches = descriptors[paneID]?.anchorView === anchorView #if DEBUG @@ -177,11 +197,17 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr updateClusters() // Split-tree churn can register two anchor instances for one pane; the // dying instance publishes last and its dismantle lands here, deleting the - // survivor's registration. Ask live anchors to reassert on the next turn. - DispatchQueue.main.async { + // survivor's registration. Ask live anchors to reassert on the next turn — + // scoped to this window so workspace churn (a storm of pane closes) does + // not trigger app-wide republish storms. + // Capture the window itself: posting with a nil object would be an + // app-wide broadcast (the storm this scoping exists to prevent), and a + // window that died before the post has no anchors left to reassert. + DispatchQueue.main.async { [weak window = self.window] in + guard let window else { return } NotificationCenter.default.post( name: BonsplitPaneChromeAnchorNotifications.reassertRequest, - object: nil + object: window ) } } diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index c04717a5..ba824a28 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -155,8 +155,13 @@ private final class BonsplitPaneChromeAnchorView: NSView { forName: BonsplitPaneChromeAnchorNotifications.reassertRequest, object: nil, queue: .main - ) { [weak self] _ in - guard let self, self.window != nil else { return } + ) { [weak self] note in + guard let self, let window = self.window else { return } + // A request scoped to one window (object = NSWindow) must not fan + // out to every anchor in the app — workspace churn posts these per + // pane close, and app-wide republish storms cost typing latency. + // nil object stays a broadcast for compatibility. + if let target = note.object as? NSWindow, target !== window { return } self.onReassert?() } }