cmux/Sources/TerminalWindowPortal.swift
Austin Wang fd279bdcec
Fix splitter hitbox overlap and terminal scrollbar width resync (#1950)
* test: add splitter and scrollbar regressions

* fix: narrow sidebar overlap and resync terminal width

* test: unwrap pending surface width in scrollbar regression

* fix: restore hosted inspector divider drag path
2026-03-22 18:06:11 -07:00

1960 lines
80 KiB
Swift

import AppKit
import ObjectiveC
#if DEBUG
import Bonsplit
#endif
private var cmuxWindowTerminalPortalKey: UInt8 = 0
private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0
#if DEBUG
private func portalDebugToken(_ view: NSView?) -> String {
guard let view else { return "nil" }
let ptr = Unmanaged.passUnretained(view).toOpaque()
return String(describing: ptr)
}
private func portalDebugFrame(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
}
private func portalDebugFrameInWindow(_ view: NSView?) -> String {
guard let view else { return "nil" }
guard view.window != nil else { return "no-window" }
return portalDebugFrame(view.convert(view.bounds, to: nil))
}
#endif
final class WindowTerminalHostView: NSView {
private struct DividerRegion {
let rectInWindow: NSRect
let isVertical: Bool
}
private enum DividerCursorKind: Equatable {
case vertical
case horizontal
var cursor: NSCursor {
switch self {
case .vertical: return .resizeLeftRight
case .horizontal: return .resizeUpDown
}
}
}
override var isOpaque: Bool { false }
private static let sidebarLeadingEdgeEpsilon: CGFloat = 1
private static let minimumVisibleLeadingContentWidth: CGFloat = 24
private var cachedSidebarDividerX: CGFloat?
private var sidebarDividerMissCount = 0
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
#if DEBUG
private var lastDragRouteSignature: String?
#endif
deinit {
if let trackingArea {
removeTrackingArea(trackingArea)
}
clearActiveDividerCursor(restoreArrow: false)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearActiveDividerCursor(restoreArrow: false)
}
window?.invalidateCursorRects(for: self)
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
window?.invalidateCursorRects(for: self)
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
window?.invalidateCursorRects(for: self)
}
override func resetCursorRects() {
super.resetCursorRects()
guard let window, let rootView = window.contentView else { return }
var regions: [DividerRegion] = []
Self.collectSplitDividerRegions(in: rootView, into: &regions)
let expansion: CGFloat = 4
for region in regions {
var rectInHost = convert(region.rectInWindow, from: nil)
rectInHost = rectInHost.insetBy(
dx: region.isVertical ? -expansion : 0,
dy: region.isVertical ? 0 : -expansion
)
let clipped = rectInHost.intersection(bounds)
guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { continue }
addCursorRect(clipped, cursor: region.isVertical ? .resizeLeftRight : .resizeUpDown)
}
}
override func updateTrackingAreas() {
if let trackingArea {
removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [
.inVisibleRect,
.activeAlways,
.cursorUpdate,
.mouseMoved,
.mouseEnteredAndExited,
.enabledDuringMouseDrag,
]
let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
addTrackingArea(next)
trackingArea = next
super.updateTrackingAreas()
}
override func cursorUpdate(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
updateDividerCursor(at: point)
}
override func mouseMoved(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
updateDividerCursor(at: point)
}
override func mouseExited(with event: NSEvent) {
clearActiveDividerCursor(restoreArrow: true)
}
// PERF: hitTest is called on EVERY event including keyboard. Keep non-pointer
// path minimal. Do not add work outside the isPointerEvent guard.
override func hitTest(_ point: NSPoint) -> NSView? {
let currentEvent = NSApp.currentEvent
let isPointerEvent: Bool
switch currentEvent?.type {
case .mouseMoved, .mouseEntered, .mouseExited,
.leftMouseDown, .leftMouseUp, .leftMouseDragged,
.rightMouseDown, .rightMouseUp, .rightMouseDragged,
.otherMouseDown, .otherMouseUp, .otherMouseDragged,
.scrollWheel, .cursorUpdate:
isPointerEvent = true
default:
isPointerEvent = false
}
if isPointerEvent {
if shouldPassThroughToSidebarResizer(at: point) {
clearActiveDividerCursor(restoreArrow: false)
return nil
}
// Compute divider hit once and reuse for both cursor update and pass-through.
if let kind = splitDividerCursorKind(at: point) {
activeDividerCursorKind = kind
kind.cursor.set()
return nil
}
clearActiveDividerCursor(restoreArrow: true)
let dragPasteboardTypes = NSPasteboard(name: .drag).types
let eventType = currentEvent?.type
let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
pasteboardTypes: dragPasteboardTypes,
eventType: eventType
)
if shouldPassThrough {
#if DEBUG
logDragRouteDecision(
passThrough: true,
eventType: eventType,
pasteboardTypes: dragPasteboardTypes,
hitView: nil
)
#endif
return nil
}
let hitView = super.hitTest(point)
#if DEBUG
logDragRouteDecision(
passThrough: false,
eventType: currentEvent?.type,
pasteboardTypes: dragPasteboardTypes,
hitView: hitView
)
#endif
return hitView === self ? nil : hitView
}
// Non-pointer event: skip divider/drag routing, just do standard hit testing.
let hitView = super.hitTest(point)
return hitView === self ? nil : hitView
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// The sidebar resizer handle is implemented in SwiftUI. When terminals
// are portal-hosted, this AppKit host can otherwise sit above the handle
// and steal hover/mouse events.
let visibleHostedViews = subviews.compactMap { $0 as? GhosttySurfaceScrollView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 }
// If content is flush to the leading edge, sidebar is effectively hidden.
// In that state, treating any internal split edge as a sidebar divider
// steals split-divider cursor/drag behavior.
let hasLeadingContent = visibleHostedViews.contains {
$0.frame.minX <= Self.sidebarLeadingEdgeEpsilon
&& $0.frame.maxX > Self.minimumVisibleLeadingContentWidth
}
if hasLeadingContent {
if cachedSidebarDividerX != nil {
sidebarDividerMissCount += 1
if sidebarDividerMissCount >= 2 {
cachedSidebarDividerX = nil
sidebarDividerMissCount = 0
}
}
return false
}
// Ignore transient 0-origin hosts while layouts churn (e.g. workspace
// creation/switching). They can temporarily report minX=0 and would
// otherwise clear divider pass-through, causing hover flicker.
let dividerCandidates = visibleHostedViews
.map(\.frame.minX)
.filter { $0 > Self.sidebarLeadingEdgeEpsilon }
if let leftMostEdge = dividerCandidates.min() {
cachedSidebarDividerX = leftMostEdge
sidebarDividerMissCount = 0
} else if cachedSidebarDividerX != nil {
// Keep cache briefly for layout churn, but clear if we miss repeatedly
// so stale divider positions don't steal pointer routing.
sidebarDividerMissCount += 1
if sidebarDividerMissCount >= 4 {
cachedSidebarDividerX = nil
sidebarDividerMissCount = 0
}
}
guard let dividerX = cachedSidebarDividerX else {
return false
}
let regionMinX = dividerX - SidebarResizeInteraction.sidebarSideHitWidth
let regionMaxX = dividerX + SidebarResizeInteraction.contentSideHitWidth
return point.x >= regionMinX && point.x <= regionMaxX
}
private func updateDividerCursor(at point: NSPoint) {
if shouldPassThroughToSidebarResizer(at: point) {
clearActiveDividerCursor(restoreArrow: false)
return
}
guard let nextKind = splitDividerCursorKind(at: point) else {
clearActiveDividerCursor(restoreArrow: true)
return
}
activeDividerCursorKind = nextKind
nextKind.cursor.set()
}
private func clearActiveDividerCursor(restoreArrow: Bool) {
guard activeDividerCursorKind != nil else { return }
window?.invalidateCursorRects(for: self)
activeDividerCursorKind = nil
if restoreArrow {
NSCursor.arrow.set()
}
}
private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? {
guard let window else { return nil }
let windowPoint = convert(point, to: nil)
guard let rootView = window.contentView else { return nil }
return Self.dividerCursorKind(at: windowPoint, in: rootView)
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
splitDividerCursorKind(at: point) != nil
}
private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? {
guard !view.isHidden else { return nil }
if let splitView = view as? NSSplitView {
let pointInSplit = splitView.convert(windowPoint, from: nil)
if splitView.bounds.contains(pointInSplit) {
// Keep divider interactions reliable even when portal-hosted terminal frames
// temporarily overlap divider edges during rapid layout churn.
let expansion: CGFloat = 5
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
// Keep divider hit-testing active even when one side is nearly collapsed,
// so users can drag the divider back out from the border.
// But ignore transient states where both panes are effectively 0-width.
guard first.width > 1 || second.width > 1 else { continue }
let x = max(0, first.maxX)
dividerRect = NSRect(
x: x,
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
// Same behavior for horizontal splits with a near-zero-height pane.
guard first.height > 1 || second.height > 1 else { continue }
let y = max(0, first.maxY)
dividerRect = NSRect(
x: 0,
y: y,
width: splitView.bounds.width,
height: thickness
)
}
let expandedDividerRect = dividerRect.insetBy(dx: -expansion, dy: -expansion)
if expandedDividerRect.contains(pointInSplit) {
return splitView.isVertical ? .vertical : .horizontal
}
}
}
}
for subview in view.subviews.reversed() {
if let kind = dividerCursorKind(at: windowPoint, in: subview) {
return kind
}
}
return nil
}
private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) {
guard !view.isHidden else { return }
if let splitView = view as? NSSplitView {
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 1 || second.width > 1 else { continue }
let x = max(0, first.maxX)
dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height)
} else {
guard first.height > 1 || second.height > 1 else { continue }
let y = max(0, first.maxY)
dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness)
}
let dividerRectInWindow = splitView.convert(dividerRect, to: nil)
guard dividerRectInWindow.width > 0, dividerRectInWindow.height > 0 else { continue }
result.append(
DividerRegion(
rectInWindow: dividerRectInWindow,
isVertical: splitView.isVertical
)
)
}
}
for subview in view.subviews {
collectSplitDividerRegions(in: subview, into: &result)
}
}
#if DEBUG
private func logDragRouteDecision(
passThrough: Bool,
eventType: NSEvent.EventType?,
pasteboardTypes: [NSPasteboard.PasteboardType]?,
hitView: NSView?
) {
let hasRelevantTypes = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|| DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes)
guard passThrough || hasRelevantTypes else { return }
let targetClass = hitView.map { NSStringFromClass(type(of: $0)) } ?? "nil"
let signature = [
passThrough ? "1" : "0",
debugEventName(eventType),
debugPasteboardTypes(pasteboardTypes),
targetClass,
].joined(separator: "|")
guard lastDragRouteSignature != signature else { return }
lastDragRouteSignature = signature
dlog(
"portal.dragRoute passThrough=\(passThrough ? 1 : 0) " +
"event=\(debugEventName(eventType)) target=\(targetClass) " +
"types=\(debugPasteboardTypes(pasteboardTypes))"
)
}
private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String {
guard let types, !types.isEmpty else { return "-" }
return types.map(\.rawValue).joined(separator: ",")
}
private func debugEventName(_ eventType: NSEvent.EventType?) -> String {
guard let eventType else { return "none" }
switch eventType {
case .cursorUpdate: return "cursorUpdate"
case .appKitDefined: return "appKitDefined"
case .systemDefined: return "systemDefined"
case .applicationDefined: return "applicationDefined"
case .periodic: return "periodic"
case .mouseMoved: return "mouseMoved"
case .mouseEntered: return "mouseEntered"
case .mouseExited: return "mouseExited"
case .flagsChanged: return "flagsChanged"
case .leftMouseDragged: return "leftMouseDragged"
case .rightMouseDragged: return "rightMouseDragged"
case .otherMouseDragged: return "otherMouseDragged"
case .leftMouseDown: return "leftMouseDown"
case .leftMouseUp: return "leftMouseUp"
case .rightMouseDown: return "rightMouseDown"
case .rightMouseUp: return "rightMouseUp"
case .otherMouseDown: return "otherMouseDown"
case .otherMouseUp: return "otherMouseUp"
default: return "other(\(eventType.rawValue))"
}
}
#endif
}
private final class SplitDividerOverlayView: NSView {
private struct DividerSegment {
let rect: NSRect
let color: NSColor
let isVertical: Bool
}
override var isOpaque: Bool { false }
override var acceptsFirstResponder: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard let window, let rootView = window.contentView else { return }
var dividerSegments: [DividerSegment] = []
collectDividerSegments(in: rootView, into: &dividerSegments)
guard !dividerSegments.isEmpty else { return }
let hostedFrames = hostedFramesLikelyToOccludeDividers()
let visibleSegments = dividerSegments.filter { shouldRenderOverlay(for: $0, hostedFrames: hostedFrames) }
guard !visibleSegments.isEmpty else { return }
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
// Keep separators visible above portal-hosted surfaces while matching each split view's
// native divider color (avoids visible color shifts at tiny pane sizes).
for segment in visibleSegments where segment.rect.intersects(dirtyRect) {
segment.color.setFill()
let rect = segment.rect
let pixelAligned = NSRect(
x: floor(rect.origin.x),
y: floor(rect.origin.y),
width: max(1, round(rect.size.width)),
height: max(1, round(rect.size.height))
)
NSBezierPath(rect: pixelAligned).fill()
}
}
private func collectDividerSegments(in view: NSView, into result: inout [DividerSegment]) {
guard !view.isHidden else { return }
if let splitView = view as? NSSplitView {
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
let dividerColor = overlayDividerColor(for: splitView)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let thickness = max(splitView.dividerThickness, 1)
let dividerRectInSplit: NSRect
if splitView.isVertical {
dividerRectInSplit = NSRect(
x: first.maxX,
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
dividerRectInSplit = NSRect(
x: 0,
y: first.maxY,
width: splitView.bounds.width,
height: thickness
)
}
let dividerRectInWindow = splitView.convert(dividerRectInSplit, to: nil)
let dividerRectInOverlay = convert(dividerRectInWindow, from: nil)
if dividerRectInOverlay.intersects(bounds) {
result.append(
DividerSegment(
rect: dividerRectInOverlay,
color: dividerColor,
isVertical: splitView.isVertical
)
)
}
}
}
for subview in view.subviews {
collectDividerSegments(in: subview, into: &result)
}
}
private func hostedFramesLikelyToOccludeDividers() -> [NSRect] {
guard let hostView = superview else { return [] }
return hostView.subviews.compactMap { subview -> NSRect? in
guard let hosted = subview as? GhosttySurfaceScrollView else { return nil }
guard !hosted.isHidden, hosted.window != nil else { return nil }
return hosted.frame
}
}
private func shouldRenderOverlay(for segment: DividerSegment, hostedFrames: [NSRect]) -> Bool {
// Draw only when a hosted surface actually intrudes across the divider centerline.
// This preserves tiny-pane visibility fixes without darkening regular dividers.
let axisEpsilon: CGFloat = 0.01
let axis = segment.isVertical ? segment.rect.midX : segment.rect.midY
let extentRect = segment.rect.insetBy(
dx: segment.isVertical ? 0 : -1,
dy: segment.isVertical ? -1 : 0
)
for frame in hostedFrames where frame.intersects(extentRect) {
if segment.isVertical {
if frame.minX < axis - axisEpsilon && frame.maxX > axis + axisEpsilon {
return true
}
} else if frame.minY < axis - axisEpsilon && frame.maxY > axis + axisEpsilon {
return true
}
}
return false
}
private func overlayDividerColor(for splitView: NSSplitView) -> NSColor {
let divider = splitView.dividerColor.usingColorSpace(.deviceRGB) ?? splitView.dividerColor
let alpha = divider.alphaComponent
guard alpha < 0.999 else { return divider }
guard let bgColor = splitView.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)),
let bgRGB = bgColor.usingColorSpace(.deviceRGB) else {
return divider
}
let opaqueBG = bgRGB.withAlphaComponent(1)
let opaqueDivider = divider.withAlphaComponent(1)
return opaqueBG.blended(withFraction: alpha, of: opaqueDivider) ?? divider
}
}
@MainActor
final class WindowTerminalPortal: NSObject {
#if DEBUG
static var isPointerDragActiveForTesting = false
#endif
private static let tinyHideThreshold: CGFloat = 1
private static let minimumRevealWidth: CGFloat = 24
private static let minimumRevealHeight: CGFloat = 18
private static let transientRecoveryRetryBudget: Int = 12
#if CMUX_ISSUE_483_PORTAL_RECOVERY
private static let transientRecoveryEnabled = true
#else
private static let transientRecoveryEnabled = false
#endif
private weak var window: NSWindow?
private let hostView = WindowTerminalHostView(frame: .zero)
private let dividerOverlayView = SplitDividerOverlayView(frame: .zero)
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var installConstraints: [NSLayoutConstraint] = []
private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
#if DEBUG
private var lastLoggedBonsplitContainerSignature: String?
#endif
private struct Entry {
weak var hostedView: GhosttySurfaceScrollView?
weak var anchorView: NSView?
var visibleInUI: Bool
var zPriority: Int
var transientRecoveryRetriesRemaining: Int
}
private var entriesByHostedId: [ObjectIdentifier: Entry] = [:]
private var hostedByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
init(window: NSWindow) {
self.window = window
super.init()
hostView.wantsLayer = true
hostView.layer?.masksToBounds = true
hostView.postsFrameChangedNotifications = true
hostView.postsBoundsChangedNotifications = true
hostView.translatesAutoresizingMaskIntoConstraints = false
dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true
dividerOverlayView.autoresizingMask = [.width, .height]
installGeometryObservers(for: window)
_ = ensureInstalled()
}
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
let center = NotificationCenter.default
geometryObservers.append(center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSSplitView.didResizeSubviewsNotification,
object: nil,
queue: .main
) { [weak self] notification in
MainActor.assumeIsolated {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
self.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSView.frameDidChangeNotification,
object: hostView,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSView.boundsDidChangeNotification,
object: hostView,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
}
private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}
fileprivate func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive
let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let performSync = {
self.hasExternalGeometrySyncScheduled = false
self.synchronizeAllEntriesFromExternalGeometryChange()
}
if requiresSettledLayout {
DispatchQueue.main.async(execute: performSync)
} else {
performSync()
}
}
}
private func synchronizeLayoutHierarchy() {
installedContainerView?.layoutSubtreeIfNeeded()
installedReferenceView?.layoutSubtreeIfNeeded()
hostView.superview?.layoutSubtreeIfNeeded()
hostView.layoutSubtreeIfNeeded()
_ = synchronizeHostFrameToReference()
}
@discardableResult
private func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostView.frame = frameInContainer
CATransaction.commit()
#if DEBUG
dlog(
"portal.hostFrame.update host=\(portalDebugToken(hostView)) " +
"frame=\(portalDebugFrame(frameInContainer))"
)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
}
fileprivate func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
synchronizeAllHostedViews(excluding: nil)
// During live resize, AppKit can deliver frame churn where host/container geometry
// settles a tick before the terminal's own scroll/surface hierarchy. Only force an
// in-place surface refresh when reconciliation actually changed terminal geometry.
for entry in entriesByHostedId.values {
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
if hostedView.reconcileGeometryNow() {
hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync")
}
}
}
private func ensureDividerOverlayOnTop() {
if dividerOverlayView.superview !== hostView {
dividerOverlayView.frame = hostView.bounds
hostView.addSubview(dividerOverlayView, positioned: .above, relativeTo: nil)
} else if hostView.subviews.last !== dividerOverlayView {
hostView.addSubview(dividerOverlayView, positioned: .above, relativeTo: nil)
}
if !Self.rectApproximatelyEqual(dividerOverlayView.frame, hostView.bounds) {
dividerOverlayView.frame = hostView.bounds
}
dividerOverlayView.needsDisplay = true
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window else { return false }
guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window)
else { return false }
let browserHost = preferredBrowserHost(in: container)
if hostView.superview !== container ||
installedContainerView !== container ||
installedReferenceView !== reference {
NSLayoutConstraint.deactivate(installConstraints)
installConstraints.removeAll()
hostView.removeFromSuperview()
if let browserHost {
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
} else {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
installConstraints = [
hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor),
hostView.trailingAnchor.constraint(equalTo: reference.trailingAnchor),
hostView.topAnchor.constraint(equalTo: reference.topAnchor),
hostView.bottomAnchor.constraint(equalTo: reference.bottomAnchor),
]
NSLayoutConstraint.activate(installConstraints)
installedContainerView = container
installedReferenceView = reference
} else if let browserHost {
if !Self.isView(browserHost, above: hostView, in: container) {
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
}
} else if !Self.isView(hostView, above: reference, in: container) {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
// Keep the drag/mouse forwarding overlay above portal-hosted terminal views.
if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView,
overlay.superview === container,
!Self.isView(overlay, above: hostView, in: container) {
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
}
synchronizeLayoutHierarchy()
_ = synchronizeHostFrameToReference()
ensureDividerOverlayOnTop()
return true
}
private func installedTargetIfStillValid(for window: NSWindow) -> (container: NSView, reference: NSView)? {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return nil
}
guard hostView.superview === container,
container.window === window,
reference.window === window,
reference.superview === container else {
return nil
}
return (container, reference)
}
private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
guard let contentView = window.contentView else { return nil }
// If NSGlassEffectView wraps the original content view, install inside the glass view
// so terminals are above the glass background but below SwiftUI content.
if contentView.className == "NSGlassEffectView",
let foreground = contentView.subviews.first(where: { $0 !== hostView }) {
return (contentView, foreground)
}
guard let themeFrame = contentView.superview else { return nil }
return (themeFrame, contentView)
}
private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
if view.isHidden { return true }
var current = view.superview
while let v = current {
if v.isHidden { return true }
current = v.superview
}
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else {
return false
}
return viewIndex > referenceIndex
}
private func preferredBrowserHost(in container: NSView) -> WindowBrowserHostView? {
container.subviews.last(where: { $0 is WindowBrowserHostView }) as? WindowBrowserHostView
}
#if DEBUG
private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? {
var current: NSView? = anchorView
while let view = current {
let className = NSStringFromClass(type(of: view))
if className.contains("PaneDragContainerView") || className.contains("Bonsplit") {
return view
}
current = view.superview
}
return installedReferenceView
}
private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) {
guard let container = nearestBonsplitContainer(from: anchorView) else { return }
let containerFrame = container.convert(container.bounds, to: nil)
let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))"
guard signature != lastLoggedBonsplitContainerSignature else { return }
lastLoggedBonsplitContainerSignature = signature
let containerClass = NSStringFromClass(type(of: container))
dlog(
"portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " +
"class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " +
"host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))"
)
}
#endif
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
/// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when
/// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective
/// visible rect that should drive portal geometry.
private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
var current = anchorView.superview
while let ancestor = current {
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
let finiteAncestorBounds =
ancestorBoundsInWindow.origin.x.isFinite &&
ancestorBoundsInWindow.origin.y.isFinite &&
ancestorBoundsInWindow.size.width.isFinite &&
ancestorBoundsInWindow.size.height.isFinite
if finiteAncestorBounds {
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
if frameInWindow.isNull { return .zero }
}
if ancestor === installedReferenceView { break }
current = ancestor.superview
}
return frameInWindow
}
private func seededFrameInHost(for anchorView: NSView) -> NSRect? {
_ = synchronizeHostFrameToReference()
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
guard hasFiniteFrame else { return nil }
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
if hasFiniteHostBounds {
let clampedFrame = frameInHost.intersection(hostBounds)
if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 {
return clampedFrame
}
}
return frameInHost
}
func detachHostedView(withId hostedId: ObjectIdentifier) {
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
if let anchor = entry.anchorView {
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
#if DEBUG
let hadSuperview = (entry.hostedView?.superview === hostView) ? 1 : 0
dlog(
"portal.detach hosted=\(portalDebugToken(entry.hostedView)) " +
"anchor=\(portalDebugToken(entry.anchorView)) hadSuperview=\(hadSuperview)"
)
#endif
if let hostedView = entry.hostedView, hostedView.superview === hostView {
hostedView.removeFromSuperview()
}
}
/// Hide a portal entry without detaching it. Updates visibleInUI to false and
/// sets isHidden = true so subsequent synchronizeHostedView calls keep it hidden.
/// Used when a workspace is permanently unmounted (vs. transient bonsplit dismantles).
func hideEntry(forHostedId hostedId: ObjectIdentifier) {
guard var entry = entriesByHostedId[hostedId] else { return }
guard entry.visibleInUI else { return }
entry.visibleInUI = false
entry.transientRecoveryRetriesRemaining = 0
entriesByHostedId[hostedId] = entry
entry.hostedView?.isHidden = true
#if DEBUG
dlog("portal.hideEntry hosted=\(portalDebugToken(entry.hostedView)) reason=workspaceUnmount")
#endif
}
/// Update the visibleInUI flag on an existing entry without rebinding.
/// Used when a deferred bind is pending this ensures synchronizeHostedView
/// won't hide a view that updateNSView has already marked as visible.
func updateEntryVisibility(forHostedId hostedId: ObjectIdentifier, visibleInUI: Bool) {
guard var entry = entriesByHostedId[hostedId] else { return }
entry.visibleInUI = visibleInUI
if !visibleInUI {
entry.transientRecoveryRetriesRemaining = 0
}
entriesByHostedId[hostedId] = entry
}
func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool {
guard let entry = entriesByHostedId[hostedId],
let boundAnchor = entry.anchorView else { return false }
return boundAnchor === anchorView
}
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return }
let hostedId = ObjectIdentifier(hostedView)
let anchorId = ObjectIdentifier(anchorView)
let previousEntry = entriesByHostedId[hostedId]
if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId {
#if DEBUG
let previousToken = entriesByHostedId[previousHostedId]
.map { portalDebugToken($0.hostedView) }
?? String(describing: previousHostedId)
dlog(
"portal.bind.replace anchor=\(portalDebugToken(anchorView)) " +
"oldHosted=\(previousToken) newHosted=\(portalDebugToken(hostedView))"
)
#endif
detachHostedView(withId: previousHostedId)
}
if let oldEntry = entriesByHostedId[hostedId],
let oldAnchor = oldEntry.anchorView,
oldAnchor !== anchorView {
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor))
}
hostedByAnchorId[anchorId] = hostedId
entriesByHostedId[hostedId] = Entry(
hostedView: hostedView,
anchorView: anchorView,
visibleInUI: visibleInUI,
zPriority: zPriority,
transientRecoveryRetriesRemaining: 0
)
let didChangeAnchor: Bool = {
guard let previousAnchor = previousEntry?.anchorView else { return true }
return previousAnchor !== anchorView
}()
let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI
let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min)
#if DEBUG
if previousEntry == nil || didChangeAnchor || becameVisible || priorityIncreased || hostedView.superview !== hostView {
dlog(
"portal.bind hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) prevAnchor=\(portalDebugToken(previousEntry?.anchorView)) " +
"visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " +
"z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)"
)
}
#endif
_ = synchronizeHostFrameToReference()
// Seed frame/bounds before entering the window so a freshly reparented
// surface doesn't do a transient 800x600 size update on viewDidMoveToWindow.
if let seededFrame = seededFrameInHost(for: anchorView),
seededFrame.width > 0,
seededFrame.height > 0 {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = seededFrame
hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size)
CATransaction.commit()
} else {
// If anchor geometry is still unsettled, keep this hidden/zero-sized until
// synchronizeHostedView resolves a valid target frame on the next layout tick.
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = .zero
hostedView.bounds = .zero
CATransaction.commit()
hostedView.isHidden = true
}
// Keep inner scroll/surface geometry in sync with the seeded outer frame
// before the hosted view enters a window.
hostedView.reconcileGeometryNow()
if hostedView.superview !== hostView {
#if DEBUG
dlog(
"portal.reparent hosted=\(portalDebugToken(hostedView)) " +
"reason=attach super=\(portalDebugToken(hostedView.superview))"
)
#endif
hostView.addSubview(hostedView, positioned: .above, relativeTo: nil)
} else if (becameVisible || priorityIncreased), hostView.subviews.last !== hostedView {
// Refresh z-order only when a view becomes visible or gets a higher priority.
// Anchor-only churn is common during split tree updates; forcing remove/add there
// causes transient inWindow=0 -> 1 bounces that can flash black.
#if DEBUG
dlog(
"portal.reparent hosted=\(portalDebugToken(hostedView)) reason=raise " +
"didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " +
"priorityIncreased=\(priorityIncreased ? 1 : 0)"
)
#endif
hostView.addSubview(hostedView, positioned: .above, relativeTo: nil)
}
ensureDividerOverlayOnTop()
synchronizeHostedView(withId: hostedId)
scheduleDeferredFullSynchronizeAll()
pruneDeadEntries()
}
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
pruneDeadEntries()
let anchorId = ObjectIdentifier(anchorView)
let primaryHostedId = hostedByAnchorId[anchorId]
if let primaryHostedId {
synchronizeHostedView(withId: primaryHostedId)
}
// Failsafe: during aggressive divider drags/structural churn, one anchor can miss a
// geometry callback while another fires. Reconcile all mapped hosted views so no stale
// frame remains "stuck" onscreen until the next interaction.
synchronizeAllHostedViews(excluding: primaryHostedId)
scheduleDeferredFullSynchronizeAll()
}
private func scheduleDeferredFullSynchronizeAll() {
guard !hasDeferredFullSyncScheduled else { return }
hasDeferredFullSyncScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasDeferredFullSyncScheduled = false
self.synchronizeAllHostedViews(excluding: nil)
}
}
private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
pruneDeadEntries()
let hostedIds = Array(entriesByHostedId.keys)
for hostedId in hostedIds {
if hostedId == hostedIdToSkip { continue }
synchronizeHostedView(withId: hostedId)
}
}
private func resetTransientRecoveryRetryIfNeeded(forHostedId hostedId: ObjectIdentifier, entry: inout Entry) {
guard entry.transientRecoveryRetriesRemaining != 0 else { return }
entry.transientRecoveryRetriesRemaining = 0
entriesByHostedId[hostedId] = entry
}
private func scheduleTransientRecoveryRetryIfNeeded(
forHostedId hostedId: ObjectIdentifier,
entry: inout Entry,
hostedView: GhosttySurfaceScrollView,
reason: String
) -> Bool {
guard Self.transientRecoveryEnabled else { return false }
if entry.transientRecoveryRetriesRemaining == 0 {
entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget
}
guard entry.transientRecoveryRetriesRemaining > 0 else { return false }
entry.transientRecoveryRetriesRemaining -= 1
entriesByHostedId[hostedId] = entry
#if DEBUG
dlog(
"portal.sync.deferRecover hosted=\(portalDebugToken(hostedView)) " +
"reason=\(reason) remaining=\(entry.transientRecoveryRetriesRemaining)"
)
#endif
if entry.transientRecoveryRetriesRemaining > 0 {
scheduleDeferredFullSynchronizeAll()
}
return true
}
private func synchronizeHostedView(withId hostedId: ObjectIdentifier) {
guard ensureInstalled() else { return }
guard var entry = entriesByHostedId[hostedId] else { return }
guard let hostedView = entry.hostedView else {
entriesByHostedId.removeValue(forKey: hostedId)
return
}
guard let anchorView = entry.anchorView, let window else {
// Only hide if the entry is not marked visibleInUI. When a workspace is
// remounting, updateNSView sets visibleInUI=true before the deferred bind
// provides an anchor hiding here would race with that and cause a flash.
if !entry.visibleInUI {
#if DEBUG
if !hostedView.isHidden {
dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow")
}
#endif
hostedView.isHidden = true
resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry)
} else {
_ = scheduleTransientRecoveryRetryIfNeeded(
forHostedId: hostedId,
entry: &entry,
hostedView: hostedView,
reason: "missingAnchorOrWindow"
)
}
return
}
guard anchorView.window === window else {
#if DEBUG
if !hostedView.isHidden {
dlog(
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " +
"reason=anchorWindowMismatch anchorWindow=\(portalDebugToken(anchorView.window?.contentView))"
)
}
#endif
if entry.visibleInUI {
let shouldPreserveVisibleOnTransient = !hostedView.isHidden &&
scheduleTransientRecoveryRetryIfNeeded(
forHostedId: hostedId,
entry: &entry,
hostedView: hostedView,
reason: "anchorWindowMismatch"
)
if shouldPreserveVisibleOnTransient {
#if DEBUG
dlog(
"portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " +
"reason=anchorWindowMismatch frame=\(portalDebugFrame(hostedView.frame))"
)
#endif
return
}
} else {
resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry)
}
hostedView.isHidden = true
if entry.visibleInUI {
_ = scheduleTransientRecoveryRetryIfNeeded(
forHostedId: hostedId,
entry: &entry,
hostedView: hostedView,
reason: "anchorWindowMismatch"
)
}
return
}
_ = synchronizeHostFrameToReference()
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
#if DEBUG
logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView)
#endif
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
if !hostBoundsReady {
#if DEBUG
dlog(
"portal.sync.defer hosted=\(portalDebugToken(hostedView)) " +
"reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " +
"anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
)
#endif
if entry.visibleInUI {
let shouldPreserveVisibleOnTransient = !hostedView.isHidden &&
scheduleTransientRecoveryRetryIfNeeded(
forHostedId: hostedId,
entry: &entry,
hostedView: hostedView,
reason: "hostBoundsNotReady"
)
if shouldPreserveVisibleOnTransient {
#if DEBUG
dlog(
"portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " +
"reason=hostBoundsNotReady frame=\(portalDebugFrame(hostedView.frame))"
)
#endif
return
}
} else {
resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry)
}
hostedView.isHidden = true
if entry.visibleInUI {
if Self.transientRecoveryEnabled {
_ = scheduleTransientRecoveryRetryIfNeeded(
forHostedId: hostedId,
entry: &entry,
hostedView: hostedView,
reason: "hostBoundsNotReady"
)
} else {
scheduleDeferredFullSynchronizeAll()
}
}
return
}
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
let clampedFrame = frameInHost.intersection(hostBounds)
let hasVisibleIntersection =
!clampedFrame.isNull &&
clampedFrame.width > 1 &&
clampedFrame.height > 1
let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
let tinyFrame =
targetFrame.width <= Self.tinyHideThreshold ||
targetFrame.height <= Self.tinyHideThreshold
let revealReadyForDisplay =
targetFrame.width >= Self.minimumRevealWidth &&
targetFrame.height >= Self.minimumRevealHeight
let outsideHostBounds = !hasVisibleIntersection
let shouldHide =
!entry.visibleInUI ||
anchorHidden ||
tinyFrame ||
!hasFiniteFrame ||
outsideHostBounds
let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay
let transientRecoveryReason: String? = {
guard Self.transientRecoveryEnabled else { return nil }
guard entry.visibleInUI else { return nil }
if anchorHidden { return "anchorHidden" }
if !hasFiniteFrame { return "nonFiniteFrame" }
if outsideHostBounds { return "outsideHostBounds" }
if tinyFrame { return "tinyFrame" }
if shouldDeferReveal { return "deferReveal" }
return nil
}()
let didScheduleTransientRecovery: Bool = {
guard let transientRecoveryReason else { return false }
return scheduleTransientRecoveryRetryIfNeeded(
forHostedId: hostedId,
entry: &entry,
hostedView: hostedView,
reason: transientRecoveryReason
)
}()
let shouldPreserveVisibleOnTransientGeometry =
didScheduleTransientRecovery &&
shouldHide &&
entry.visibleInUI &&
!hostedView.isHidden
let oldFrame = hostedView.frame
#if DEBUG
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
if frameWasClamped {
dlog(
"portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) " +
"raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
}
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
if collapsedToTiny {
dlog(
"portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
)
} else if restoredFromTiny {
dlog(
"portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
)
}
#endif
// Hide before updating the frame when this entry should not be visible.
// This avoids a one-frame flash of unrendered terminal background when a portal
// briefly transitions through offscreen/tiny geometry during rapid split churn.
if shouldHide, !hostedView.isHidden, !shouldPreserveVisibleOnTransientGeometry {
#if DEBUG
dlog(
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
#endif
hostedView.isHidden = true
}
if shouldPreserveVisibleOnTransientGeometry {
#if DEBUG
dlog(
"portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " +
"reason=\(transientRecoveryReason ?? "unknown") frame=\(portalDebugFrame(hostedView.frame))"
)
#endif
}
if hasFiniteFrame {
let expectedBounds = NSRect(origin: .zero, size: targetFrame.size)
var geometryChanged = false
CATransaction.begin()
CATransaction.setDisableActions(true)
if !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
hostedView.frame = targetFrame
geometryChanged = true
}
if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) {
hostedView.bounds = expectedBounds
geometryChanged = true
}
CATransaction.commit()
if geometryChanged {
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow(reason: "portal.frameChange")
}
}
if shouldDeferReveal {
#if DEBUG
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
dlog(
"portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " +
"frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))"
)
}
#endif
}
if !shouldHide, hostedView.isHidden, revealReadyForDisplay {
#if DEBUG
dlog(
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
#endif
hostedView.isHidden = false
// A reveal can happen without any frame delta (same targetFrame), which means the
// normal frame-change refresh path won't run. Nudge geometry + redraw so newly
// revealed terminals don't sit on a stale/blank IOSurface until later focus churn.
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow(reason: "portal.reveal")
}
if transientRecoveryReason == nil {
resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry)
}
#if DEBUG
dlog(
"portal.sync.result hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " +
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
"old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " +
"target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " +
"hostBounds=\(portalDebugFrame(hostBounds))"
)
#endif
ensureDividerOverlayOnTop()
}
private func pruneDeadEntries() {
let currentWindow = window
let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in
guard entry.hostedView != nil else { return hostedId }
guard let anchor = entry.anchorView else {
return entry.visibleInUI ? nil : hostedId
}
let anchorInvalidForCurrentHost =
anchor.window !== currentWindow ||
anchor.superview == nil ||
(installedReferenceView.map { !anchor.isDescendant(of: $0) } ?? false)
if anchorInvalidForCurrentHost {
// During aggressive tab drag/reorder churn, SwiftUI/AppKit can briefly
// detach/rehome anchor hosts while the terminal should stay visible.
// Avoid pruning those visible entries so sync/bind recovery can reattach.
return entry.visibleInUI ? nil : hostedId
}
return nil
}
for hostedId in deadHostedIds {
detachHostedView(withId: hostedId)
}
let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in
entry.anchorView.map { ObjectIdentifier($0) }
})
hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) }
}
func hostedIds() -> Set<ObjectIdentifier> {
Set(entriesByHostedId.keys)
}
func tearDown() {
removeGeometryObservers()
for hostedId in Array(entriesByHostedId.keys) {
detachHostedView(withId: hostedId)
}
NSLayoutConstraint.deactivate(installConstraints)
installConstraints.removeAll()
hostView.removeFromSuperview()
installedContainerView = nil
installedReferenceView = nil
}
#if DEBUG
struct DebugStats {
let windowNumber: Int
let entryCount: Int
let hostSubviewCount: Int
let terminalSubviewCount: Int
let mappedTerminalSubviewCount: Int
let orphanTerminalSubviewCount: Int
let visibleOrphanTerminalSubviewCount: Int
let staleEntryCount: Int
}
func debugStats() -> DebugStats {
let terminalSubviews = hostView.subviews.compactMap { $0 as? GhosttySurfaceScrollView }
var mappedTerminalSubviewCount = 0
var orphanTerminalSubviewCount = 0
var visibleOrphanTerminalSubviewCount = 0
for hostedView in terminalSubviews {
let hostedId = ObjectIdentifier(hostedView)
if entriesByHostedId[hostedId] != nil {
mappedTerminalSubviewCount += 1
} else {
orphanTerminalSubviewCount += 1
if hostedView.window != nil,
!hostedView.isHidden,
hostedView.frame.width > Self.tinyHideThreshold,
hostedView.frame.height > Self.tinyHideThreshold {
visibleOrphanTerminalSubviewCount += 1
}
}
}
let staleEntryCount = entriesByHostedId.values.reduce(0) { partialResult, entry in
guard let hostedView = entry.hostedView else { return partialResult + 1 }
return hostedView.superview === hostView ? partialResult : partialResult + 1
}
return DebugStats(
windowNumber: window?.windowNumber ?? -1,
entryCount: entriesByHostedId.count,
hostSubviewCount: hostView.subviews.count,
terminalSubviewCount: terminalSubviews.count,
mappedTerminalSubviewCount: mappedTerminalSubviewCount,
orphanTerminalSubviewCount: orphanTerminalSubviewCount,
visibleOrphanTerminalSubviewCount: visibleOrphanTerminalSubviewCount,
staleEntryCount: staleEntryCount
)
}
func debugEntryCount() -> Int {
entriesByHostedId.count
}
func debugHostedSubviewCount() -> Int {
hostView.subviews.count
}
#endif
func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
// Restrict hit-testing to currently mapped entries so stale detached views
// can't steal file-drop/mouse routing.
for subview in hostView.subviews.reversed() {
guard let hostedView = subview as? GhosttySurfaceScrollView else { continue }
let hostedId = ObjectIdentifier(hostedView)
guard entriesByHostedId[hostedId] != nil else { continue }
guard !hostedView.isHidden else { continue }
guard hostedView.frame.contains(point) else { continue }
let localPoint = hostedView.convert(point, from: hostView)
return hostedView.hitTest(localPoint) ?? hostedView
}
return nil
}
func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
for subview in hostView.subviews.reversed() {
guard let hostedView = subview as? GhosttySurfaceScrollView else { continue }
let hostedId = ObjectIdentifier(hostedView)
guard entriesByHostedId[hostedId] != nil else { continue }
guard !hostedView.isHidden else { continue }
guard hostedView.frame.contains(point) else { continue }
let localPoint = hostedView.convert(point, from: hostView)
if let terminal = hostedView.terminalViewForDrop(at: localPoint) {
return terminal
}
}
return nil
}
}
@MainActor
enum TerminalWindowPortalRegistry {
#if DEBUG
static var isPointerDragActiveForTesting = false
#endif
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
private static var hasPendingExternalGeometrySyncForAllWindows = false
private static var interactiveGeometryResizeCount = 0
#if DEBUG
private static var blockedBindCount: Int = 0
private static var blockedBindReasons: [String: Int] = [:]
#endif
static var isInteractiveGeometryResizeActive: Bool {
#if DEBUG
if Self.isPointerDragActiveForTesting { return true }
#endif
return Self.interactiveGeometryResizeCount > 0
}
private static func bindBlockReason(
expectedSurfaceId: UUID?,
expectedGeneration: UInt64?,
actual: (surfaceId: UUID?, generation: UInt64?, state: String)
) -> String {
if actual.surfaceId == nil {
return "missingSurface"
}
if actual.state != "live" {
return "state_\(actual.state)"
}
if let expectedSurfaceId, actual.surfaceId != expectedSurfaceId {
return "surfaceMismatch"
}
if let expectedGeneration, actual.generation != expectedGeneration {
return "generationMismatch"
}
return "guardRejected"
}
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return }
let windowId = ObjectIdentifier(window)
let observer = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak window] _ in
MainActor.assumeIsolated {
if let window {
removePortal(for: window)
} else {
removePortal(windowId: windowId, window: nil)
}
}
}
objc_setAssociatedObject(
window,
&cmuxWindowTerminalPortalCloseObserverKey,
observer,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
private static func removePortal(for window: NSWindow) {
removePortal(windowId: ObjectIdentifier(window), window: window)
}
private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) {
if let portal = portalsByWindowId.removeValue(forKey: windowId) {
portal.tearDown()
}
hostedToWindowId = hostedToWindowId.filter { $0.value != windowId }
guard let window else { return }
if let observer = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) {
NotificationCenter.default.removeObserver(observer)
}
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, nil, .OBJC_ASSOCIATION_RETAIN)
}
private static func pruneHostedMappings(for windowId: ObjectIdentifier, validHostedIds: Set<ObjectIdentifier>) {
hostedToWindowId = hostedToWindowId.filter { hostedId, mappedWindowId in
mappedWindowId != windowId || validHostedIds.contains(hostedId)
}
}
private static func portal(for window: NSWindow) -> WindowTerminalPortal {
if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal {
portalsByWindowId[ObjectIdentifier(window)] = existing
installWindowCloseObserverIfNeeded(for: window)
return existing
}
let portal = WindowTerminalPortal(window: window)
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
portalsByWindowId[ObjectIdentifier(window)] = portal
installWindowCloseObserverIfNeeded(for: window)
return portal
}
private static func existingPortal(for window: NSWindow) -> WindowTerminalPortal? {
if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal {
portalsByWindowId[ObjectIdentifier(window)] = existing
installWindowCloseObserverIfNeeded(for: window)
return existing
}
return portalsByWindowId[ObjectIdentifier(window)]
}
static func bind(
hostedView: GhosttySurfaceScrollView,
to anchorView: NSView,
visibleInUI: Bool,
zPriority: Int = 0,
expectedSurfaceId: UUID? = nil,
expectedGeneration: UInt64? = nil
) {
guard let window = anchorView.window else { return }
let windowId = ObjectIdentifier(window)
let hostedId = ObjectIdentifier(hostedView)
let guardState = hostedView.portalBindingGuardState()
guard hostedView.canAcceptPortalBinding(
expectedSurfaceId: expectedSurfaceId,
expectedGeneration: expectedGeneration
) else {
if let oldWindowId = hostedToWindowId.removeValue(forKey: hostedId) {
portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId)
}
#if DEBUG
let reason = bindBlockReason(
expectedSurfaceId: expectedSurfaceId,
expectedGeneration: expectedGeneration,
actual: guardState
)
blockedBindCount += 1
blockedBindReasons[reason, default: 0] += 1
dlog(
"portal.bind.blocked hosted=\(portalDebugToken(hostedView)) " +
"reason=\(reason) expectedSurface=\(expectedSurfaceId?.uuidString.prefix(5) ?? "nil") " +
"expectedGeneration=\(expectedGeneration.map { String($0) } ?? "nil") " +
"actualSurface=\(guardState.surfaceId?.uuidString.prefix(5) ?? "nil") " +
"actualGeneration=\(guardState.generation.map { String($0) } ?? "nil") " +
"actualState=\(guardState.state)"
)
#endif
return
}
let nextPortal = portal(for: window)
if let oldWindowId = hostedToWindowId[hostedId],
oldWindowId != windowId {
portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId)
}
nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
hostedToWindowId[hostedId] = windowId
pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds())
}
static func synchronizeForAnchor(_ anchorView: NSView) {
guard let window = anchorView.window else { return }
let portal = portal(for: window)
portal.synchronizeHostedViewForAnchor(anchorView)
}
static func scheduleExternalGeometrySynchronize(for window: NSWindow) {
existingPortal(for: window)?.scheduleExternalGeometrySynchronize()
}
static func beginInteractiveGeometryResize() {
interactiveGeometryResizeCount += 1
}
static func endInteractiveGeometryResize() {
interactiveGeometryResizeCount = max(0, interactiveGeometryResizeCount - 1)
}
static func scheduleExternalGeometrySynchronizeForAllWindows() {
guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return }
Self.hasPendingExternalGeometrySyncForAllWindows = true
let isDragEvent = Self.isInteractiveGeometryResizeActive
DispatchQueue.main.async {
let performSync = {
Self.hasPendingExternalGeometrySyncForAllWindows = false
for portal in Self.portalsByWindowId.values {
portal.synchronizeAllEntriesFromExternalGeometryChange()
}
}
if isDragEvent {
performSync()
} else {
DispatchQueue.main.async(execute: performSync)
}
}
}
static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) {
let hostedId = ObjectIdentifier(hostedView)
guard let windowId = hostedToWindowId[hostedId],
let portal = portalsByWindowId[windowId] else { return }
portal.hideEntry(forHostedId: hostedId)
}
/// Permanently detach a hosted terminal view from the window-level portal.
/// Use this when a terminal panel is actually closing (not transient SwiftUI dismantle).
static func detach(hostedView: GhosttySurfaceScrollView) {
let hostedId = ObjectIdentifier(hostedView)
guard let windowId = hostedToWindowId.removeValue(forKey: hostedId) else { return }
portalsByWindowId[windowId]?.detachHostedView(withId: hostedId)
}
/// Update the visibleInUI flag on an existing portal entry without rebinding.
/// Called when a bind is deferred (host not yet in window) to prevent stale
/// portal syncs from hiding a view that is about to become visible.
static func updateEntryVisibility(for hostedView: GhosttySurfaceScrollView, visibleInUI: Bool) {
let hostedId = ObjectIdentifier(hostedView)
guard let windowId = hostedToWindowId[hostedId],
let portal = portalsByWindowId[windowId] else { return }
portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI)
}
static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool {
let hostedId = ObjectIdentifier(hostedView)
guard let window = anchorView.window else { return false }
let windowId = ObjectIdentifier(window)
guard hostedToWindowId[hostedId] == windowId,
let portal = portalsByWindowId[windowId] else { return false }
return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView)
}
static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? {
let portal = portal(for: window)
return portal.viewAtWindowPoint(windowPoint)
}
static func terminalViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> GhosttyNSView? {
let portal = portal(for: window)
return portal.terminalViewAtWindowPoint(windowPoint)
}
#if DEBUG
static func debugPortalCount() -> Int {
portalsByWindowId.count
}
static func debugPortalStats() -> [String: Any] {
var portals: [[String: Any]] = []
var totals: [String: Int] = [
"entry_count": 0,
"host_subview_count": 0,
"terminal_subview_count": 0,
"mapped_terminal_subview_count": 0,
"orphan_terminal_subview_count": 0,
"visible_orphan_terminal_subview_count": 0,
"stale_entry_count": 0,
"mapped_hosted_count": 0,
]
for (windowId, portal) in portalsByWindowId {
let stats = portal.debugStats()
let mappedHostedCount = hostedToWindowId.values.reduce(0) { partialResult, mappedWindowId in
partialResult + (mappedWindowId == windowId ? 1 : 0)
}
let integrityOK =
stats.orphanTerminalSubviewCount == 0 &&
stats.visibleOrphanTerminalSubviewCount == 0 &&
stats.staleEntryCount == 0 &&
mappedHostedCount == stats.entryCount
portals.append([
"window_number": stats.windowNumber,
"entry_count": stats.entryCount,
"mapped_hosted_count": mappedHostedCount,
"host_subview_count": stats.hostSubviewCount,
"terminal_subview_count": stats.terminalSubviewCount,
"mapped_terminal_subview_count": stats.mappedTerminalSubviewCount,
"orphan_terminal_subview_count": stats.orphanTerminalSubviewCount,
"visible_orphan_terminal_subview_count": stats.visibleOrphanTerminalSubviewCount,
"stale_entry_count": stats.staleEntryCount,
"integrity_ok": integrityOK,
])
totals["entry_count", default: 0] += stats.entryCount
totals["host_subview_count", default: 0] += stats.hostSubviewCount
totals["terminal_subview_count", default: 0] += stats.terminalSubviewCount
totals["mapped_terminal_subview_count", default: 0] += stats.mappedTerminalSubviewCount
totals["orphan_terminal_subview_count", default: 0] += stats.orphanTerminalSubviewCount
totals["visible_orphan_terminal_subview_count", default: 0] += stats.visibleOrphanTerminalSubviewCount
totals["stale_entry_count", default: 0] += stats.staleEntryCount
totals["mapped_hosted_count", default: 0] += mappedHostedCount
}
portals.sort {
let lhs = ($0["window_number"] as? Int) ?? Int.min
let rhs = ($1["window_number"] as? Int) ?? Int.min
return lhs < rhs
}
return [
"portal_count": portals.count,
"hosted_mapping_count": hostedToWindowId.count,
"guarded_bind_blocked_count": blockedBindCount,
"guarded_bind_blocked_reasons": blockedBindReasons,
"portals": portals,
"totals": totals,
]
}
#endif
}