From de8c5120fa6d115f58872f4d13c7f6258e810eef Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 25 Feb 2026 17:54:28 -0800 Subject: [PATCH] Fix terminal pane render loss during tab drag reorder --- Sources/GhosttyTerminalView.swift | 33 +++- Sources/TerminalWindowPortal.swift | 173 +++++++++++++++++- ...test_terminal_resize_portal_regressions.py | 9 +- 3 files changed, 195 insertions(+), 20 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 37bd7ddd..79afd3f8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5191,11 +5191,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 } @@ -5297,10 +5297,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) } @@ -5348,7 +5368,6 @@ struct GhosttyTerminalView: NSViewRepresentable { TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) } ?? true let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate( - hostWindowAttached: hostWindowAttached, hostedViewHasSuperview: hostedView.superview != nil, isBoundToCurrentHost: isBoundToCurrentHost ) @@ -5369,10 +5388,6 @@ struct GhosttyTerminalView: NSViewRepresentable { ) } #endif - TerminalWindowPortalRegistry.updateEntryVisibility( - for: hostedView, - visibleInUI: isVisibleInUI - ) } } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 605b04c7..60232f9a 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] = [:] @@ -918,6 +925,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 @@ -931,6 +939,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 } @@ -971,7 +982,8 @@ final class WindowTerminalPortal: NSObject { hostedView: hostedView, anchorView: anchorView, visibleInUI: visibleInUI, - zPriority: zPriority + zPriority: zPriority, + transientRecoveryRetriesRemaining: 0 ) let didChangeAnchor: Bool = { @@ -1084,9 +1096,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 @@ -1102,6 +1146,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 } @@ -1114,7 +1166,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 } @@ -1140,8 +1220,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 = @@ -1170,6 +1281,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 @@ -1200,7 +1335,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 " + @@ -1212,6 +1347,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() @@ -1261,6 +1404,10 @@ final class WindowTerminalPortal: NSObject { hostedView.refreshSurfaceNow() } + if transientRecoveryReason == nil { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } + #if DEBUG dlog( "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + @@ -1280,13 +1427,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")