diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index aaea7a19..1e614aad 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5061,6 +5061,16 @@ struct GhosttyTerminalView: NSViewRepresentable { Coordinator() } + static func shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: Bool, + hostedViewHasSuperview: Bool, + isBoundToCurrentHost: Bool + ) -> Bool { + if !hostWindowAttached { return true } + if isBoundToCurrentHost { return true } + return !hostedViewHasSuperview + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false @@ -5103,8 +5113,6 @@ struct GhosttyTerminalView: NSViewRepresentable { // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) - hostedView.setVisibleInUI(isVisibleInUI) - hostedView.setActive(isActive) hostedView.setInactiveOverlay( color: inactiveOverlayColor, opacity: CGFloat(inactiveOverlayOpacity), @@ -5139,7 +5147,8 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration - if let host = nsView as? HostContainerView { + let hostContainer = nsView as? HostContainerView + if let host = hostContainer { host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } @@ -5190,6 +5199,28 @@ struct GhosttyTerminalView: NSViewRepresentable { ) } } + + let hostWindowAttached = hostContainer?.window != nil + let isBoundToCurrentHost = hostContainer.map { host in + TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) + } ?? true + let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: hostWindowAttached, + hostedViewHasSuperview: hostedView.superview != nil, + isBoundToCurrentHost: isBoundToCurrentHost + ) + + if shouldApplyImmediateHostedState { + hostedView.setVisibleInUI(isVisibleInUI) + hostedView.setActive(isActive) + } else { + // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. + // The currently bound host remains authoritative for immediate visible/active state. + TerminalWindowPortalRegistry.updateEntryVisibility( + for: hostedView, + visibleInUI: isVisibleInUI + ) + } } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index e3f19c3d..2daddf4b 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -934,6 +934,12 @@ final class WindowTerminalPortal: NSObject { 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 } @@ -1462,6 +1468,15 @@ enum TerminalWindowPortalRegistry { 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) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index f663ff5f..e54d5401 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6289,3 +6289,45 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase { ) } } + +final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { + func testImmediateStateUpdateAllowedWhenHostNotInWindow() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: false, + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: true, + isBoundToCurrentHost: true + ) + ) + } + + func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() { + XCTAssertFalse( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: false, + isBoundToCurrentHost: false + ) + ) + } +}