diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e24f7cc1..2050c593 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5252,11 +5252,11 @@ struct GhosttyTerminalView: NSViewRepresentable { } static func shouldApplyImmediateHostedStateUpdate( - hostWindowAttached: Bool, hostedViewHasSuperview: Bool, isBoundToCurrentHost: Bool ) -> Bool { - if !hostWindowAttached { return true } + // If this update originates from a stale/replaced host while the hosted view is + // already attached elsewhere, do not mutate visibility/active state here. if isBoundToCurrentHost { return true } return !hostedViewHasSuperview } @@ -5358,10 +5358,30 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } - host.onGeometryChanged = { [weak host, weak coordinator] in - guard let host, let coordinator else { return } + host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in + guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return } + if host.window != nil, + !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) { +#if DEBUG + dlog( + "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" + ) +#endif + TerminalWindowPortalRegistry.bind( + hostedView: hostedView, + to: host, + visibleInUI: coordinator.desiredIsVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastBoundHostId = ObjectIdentifier(host) + hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) + hostedView.setActive(coordinator.desiredIsActive) + hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) + } TerminalWindowPortalRegistry.synchronizeForAnchor(host) } @@ -5409,7 +5429,6 @@ struct GhosttyTerminalView: NSViewRepresentable { TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) } ?? true let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate( - hostWindowAttached: hostWindowAttached, hostedViewHasSuperview: hostedView.superview != nil, isBoundToCurrentHost: isBoundToCurrentHost ) @@ -5430,10 +5449,6 @@ struct GhosttyTerminalView: NSViewRepresentable { ) } #endif - TerminalWindowPortalRegistry.updateEntryVisibility( - for: hostedView, - visibleInUI: isVisibleInUI - ) } } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 0e0ec7a2..8e8dc306 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -538,6 +538,12 @@ final class WindowTerminalPortal: NSObject { 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) @@ -557,6 +563,7 @@ final class WindowTerminalPortal: NSObject { weak var anchorView: NSView? var visibleInUI: Bool var zPriority: Int + var transientRecoveryRetriesRemaining: Int } private var entriesByHostedId: [ObjectIdentifier: Entry] = [:] @@ -935,6 +942,7 @@ final class WindowTerminalPortal: NSObject { 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 @@ -948,6 +956,9 @@ final class WindowTerminalPortal: NSObject { 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 } @@ -988,7 +999,8 @@ final class WindowTerminalPortal: NSObject { hostedView: hostedView, anchorView: anchorView, visibleInUI: visibleInUI, - zPriority: zPriority + zPriority: zPriority, + transientRecoveryRetriesRemaining: 0 ) let didChangeAnchor: Bool = { @@ -1101,9 +1113,41 @@ final class WindowTerminalPortal: NSObject { } } + 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 let entry = entriesByHostedId[hostedId] else { return } + guard var entry = entriesByHostedId[hostedId] else { return } guard let hostedView = entry.hostedView else { entriesByHostedId.removeValue(forKey: hostedId) return @@ -1119,6 +1163,14 @@ final class WindowTerminalPortal: NSObject { } #endif hostedView.isHidden = true + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } else { + _ = scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "missingAnchorOrWindow" + ) } return } @@ -1131,7 +1183,35 @@ final class WindowTerminalPortal: NSObject { ) } #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 } @@ -1157,8 +1237,39 @@ final class WindowTerminalPortal: NSObject { "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 - scheduleDeferredFullSynchronizeAll() + if entry.visibleInUI { + if Self.transientRecoveryEnabled { + _ = scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "hostBoundsNotReady" + ) + } else { + scheduleDeferredFullSynchronizeAll() + } + } return } let hasFiniteFrame = @@ -1187,6 +1298,30 @@ final class WindowTerminalPortal: NSObject { !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 @@ -1217,7 +1352,7 @@ final class WindowTerminalPortal: NSObject { // 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 { + if shouldHide, !hostedView.isHidden, !shouldPreserveVisibleOnTransientGeometry { #if DEBUG dlog( "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + @@ -1229,6 +1364,14 @@ final class WindowTerminalPortal: NSObject { #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 && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() @@ -1278,6 +1421,10 @@ final class WindowTerminalPortal: NSObject { hostedView.refreshSurfaceNow() } + if transientRecoveryReason == nil { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } + #if DEBUG dlog( "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + @@ -1297,13 +1444,19 @@ final class WindowTerminalPortal: NSObject { 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 hostedId } - if anchor.window !== currentWindow || anchor.superview == nil { - return hostedId + guard let anchor = entry.anchorView else { + return entry.visibleInUI ? nil : hostedId } - if let reference = installedReferenceView, - !anchor.isDescendant(of: reference) { - return 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 } diff --git a/tests/test_terminal_resize_portal_regressions.py b/tests/test_terminal_resize_portal_regressions.py index 055b5e54..f42f7af9 100644 --- a/tests/test_terminal_resize_portal_regressions.py +++ b/tests/test_terminal_resize_portal_regressions.py @@ -72,13 +72,20 @@ def main() -> int: "let hostBounds = hostView.bounds", "let clampedFrame = frameInHost.intersection(hostBounds)", "let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost", - "scheduleDeferredFullSynchronizeAll()", "hostedView.reconcileGeometryNow()", "hostedView.refreshSurfaceNow()", ]: if required not in sync_block: failures.append(f"terminal portal sync missing: {required}") + if ( + "scheduleDeferredFullSynchronizeAll()" not in sync_block + and "scheduleTransientRecoveryRetryIfNeeded(" not in sync_block + ): + failures.append( + "terminal portal sync no longer schedules deferred recovery for transient geometry states" + ) + terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift" terminal_view_source = terminal_view_path.read_text(encoding="utf-8")