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/TerminalWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions cmuxTests/TerminalAndGhosttyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading