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: ®ions) 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.. 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.. 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: ÷rSegments) 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.. [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 externalGeometrySyncGeneration: UInt64 = 0 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() { // Coalesce to the latest request so ancestor/frame churn (for example // sidebar toggles) doesn't resize the PTY at stale intermediate widths. externalGeometrySyncGeneration &+= 1 let generation = externalGeometrySyncGeneration 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 = { if self.externalGeometrySyncGeneration != generation { self.hasExternalGeometrySyncScheduled = false self.scheduleExternalGeometrySynchronize() return } 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 { if entry.visibleInUI { let shouldPreserveVisibleOnTransient = !hostedView.isHidden && scheduleTransientRecoveryRetryIfNeeded( forHostedId: hostedId, entry: &entry, hostedView: hostedView, reason: "missingAnchorOrWindow" ) if shouldPreserveVisibleOnTransient { #if DEBUG dlog( "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + "reason=missingAnchorOrWindow frame=\(portalDebugFrame(hostedView.frame))" ) #endif return } } else { resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) } #if DEBUG if !hostedView.isHidden { dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") } #endif hostedView.isHidden = true if entry.visibleInUI { _ = 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 { 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 externalGeometrySyncForAllWindowsGeneration: UInt64 = 0 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) { 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() { // Same latest-request-wins coalescing for callers that don't have a // concrete window handle yet. Self.externalGeometrySyncForAllWindowsGeneration &+= 1 let generation = Self.externalGeometrySyncForAllWindowsGeneration guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } Self.hasPendingExternalGeometrySyncForAllWindows = true let isDragEvent = Self.isInteractiveGeometryResizeActive DispatchQueue.main.async { let performSync = { if Self.externalGeometrySyncForAllWindowsGeneration != generation { Self.hasPendingExternalGeometrySyncForAllWindows = false Self.scheduleExternalGeometrySynchronizeForAllWindows() return } 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 }