diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 4c4588a8..4d5d843d 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1211,18 +1211,33 @@ final class WindowTerminalPortal: NSObject { return } guard let anchorView = entry.anchorView, let window else { - // Only hide if the entry is not marked visibleInUI. When a workspace is - // remounting, updateNSView sets visibleInUI=true before the deferred bind - // provides an anchor — hiding here would race with that and cause a flash. - if !entry.visibleInUI { + if entry.visibleInUI { + let shouldPreserveVisibleOnTransient = !hostedView.isHidden && + scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "missingAnchorOrWindow" + ) + if shouldPreserveVisibleOnTransient { #if DEBUG - if !hostedView.isHidden { - dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") - } + dlog( + "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + + "reason=missingAnchorOrWindow frame=\(portalDebugFrame(hostedView.frame))" + ) #endif - hostedView.isHidden = true - resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + 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, diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 24ec48a6..7bbdddc6 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2596,6 +2596,71 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView") } + func testDeferredSyncHidesVisibleHostedViewAfterAnchorDisappears() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + var retiredAnchor: NSView? = NSView(frame: NSRect(x: 24, y: 28, width: 96, height: 180)) + contentView.addSubview(retiredAnchor!) + + let retiredTerminal = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 96, height: 180)) + let retiredHosted = GhosttySurfaceScrollView(surfaceView: retiredTerminal) + portal.bind(hostedView: retiredHosted, to: retiredAnchor!, visibleInUI: true) + portal.synchronizeHostedViewForAnchor(retiredAnchor!) + + let retiredWindowPoint = retiredAnchor!.convert( + NSPoint(x: retiredAnchor!.bounds.midX, y: retiredAnchor!.bounds.midY), + to: nil + ) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(retiredWindowPoint) === retiredTerminal, + "Initial hit-testing should resolve the first hosted terminal at its anchor" + ) + + retiredAnchor?.removeFromSuperview() + retiredAnchor = nil + + let activeAnchor = NSView(frame: NSRect(x: 184, y: 28, width: 280, height: 180)) + contentView.addSubview(activeAnchor) + + let activeTerminal = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 280, height: 180)) + let activeHosted = GhosttySurfaceScrollView(surfaceView: activeTerminal) + portal.bind(hostedView: activeHosted, to: activeAnchor, visibleInUI: true) + portal.synchronizeHostedViewForAnchor(activeAnchor) + + XCTAssertTrue( + retiredHosted.isHidden, + "A visible hosted terminal whose anchor vanished should hide as soon as the replacement anchor sync runs" + ) + // Drain the queued full-sync turn so the portal clears any stale hit-test region left by the rebind. + drainMainQueue() + + let activeWindowPoint = activeAnchor.convert( + NSPoint(x: activeAnchor.bounds.midX, y: activeAnchor.bounds.midY), + to: nil + ) + XCTAssertNil( + portal.terminalViewAtWindowPoint(retiredWindowPoint), + "Restore-like rebinds should clear stale portal hit regions on the queued portal resync" + ) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(activeWindowPoint) === activeTerminal, + "The active terminal should remain visible after the stale hosted view is hidden" + ) + } + func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() { let window = ContentViewCountingWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),