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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
)
Expand All @@ -5369,10 +5388,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
)
}
#endif
TerminalWindowPortalRegistry.updateEntryVisibility(
for: hostedView,
visibleInUI: isVisibleInUI
)
}
}

Expand Down
173 changes: 163 additions & 10 deletions Sources/TerminalWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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] = [:]
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -971,7 +982,8 @@ final class WindowTerminalPortal: NSObject {
hostedView: hostedView,
anchorView: anchorView,
visibleInUI: visibleInUI,
zPriority: zPriority
zPriority: zPriority,
transientRecoveryRetriesRemaining: 0
)

let didChangeAnchor: Bool = {
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
}

Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 " +
Expand All @@ -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()
Expand Down Expand Up @@ -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)) " +
Expand All @@ -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
}
Expand Down
9 changes: 8 additions & 1 deletion tests/test_terminal_resize_portal_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down