Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions Sources/WindowPaneChromePortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?()
}
}
Expand Down
Loading