diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4e899f92..b3d2b256 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -967,6 +967,16 @@ struct ContentView: View { workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil let retiring = retiringWorkspaceId + + // Hide terminal portal views for the retiring workspace BEFORE clearing + // retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts + // the workspace — but dismantleNSView intentionally doesn't hide portal views + // (to avoid blackouts during transient bonsplit dismantles). Hiding here + // prevents stale portal-hosted terminals from covering browser panes. + if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { + workspace.hideAllTerminalPortalViews() + } + retiringWorkspaceId = nil tabManager.completePendingWorkspaceUnfocus(reason: reason) #if DEBUG diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2507edb7..d1818956 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4089,6 +4089,14 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.lastBoundHostId = hostId } TerminalWindowPortalRegistry.synchronizeForAnchor(host) + } else { + // Bind is deferred until host moves into a window. Update the + // existing portal entry's visibleInUI now so that any portal sync + // that runs before the deferred bind completes won't hide the view. + TerminalWindowPortalRegistry.updateEntryVisibility( + for: hostedView, + visibleInUI: coordinator.desiredIsVisibleInUI + ) } } } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index d64cc7b1..3f4e4dd0 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -209,6 +209,29 @@ final class WindowTerminalPortal: NSObject { } } + /// Hide a portal entry without detaching it. Updates visibleInUI to false and + /// sets isHidden = true so subsequent synchronizeHostedView calls keep it hidden. + /// Used when a workspace is permanently unmounted (vs. transient bonsplit dismantles). + func hideEntry(forHostedId hostedId: ObjectIdentifier) { + guard var entry = entriesByHostedId[hostedId] else { return } + guard entry.visibleInUI else { return } + entry.visibleInUI = false + entriesByHostedId[hostedId] = entry + entry.hostedView?.isHidden = true +#if DEBUG + dlog("portal.hideEntry hosted=\(portalDebugToken(entry.hostedView)) reason=workspaceUnmount") +#endif + } + + /// Update the visibleInUI flag on an existing entry without rebinding. + /// Used when a deferred bind is pending — this ensures synchronizeHostedView + /// won't hide a view that updateNSView has already marked as visible. + func updateEntryVisibility(forHostedId hostedId: ObjectIdentifier, visibleInUI: Bool) { + guard var entry = entriesByHostedId[hostedId] else { return } + entry.visibleInUI = visibleInUI + entriesByHostedId[hostedId] = entry + } + func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -329,12 +352,17 @@ 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 DEBUG - if !hostedView.isHidden { - dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") - } + if !hostedView.isHidden { + dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") + } #endif - hostedView.isHidden = true + hostedView.isHidden = true + } return } guard anchorView.window === window else { @@ -587,6 +615,23 @@ enum TerminalWindowPortalRegistry { portal.synchronizeHostedViewForAnchor(anchorView) } + static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) { + let hostedId = ObjectIdentifier(hostedView) + guard let windowId = hostedToWindowId[hostedId], + let portal = portalsByWindowId[windowId] else { return } + portal.hideEntry(forHostedId: hostedId) + } + + /// Update the visibleInUI flag on an existing portal entry without rebinding. + /// Called when a bind is deferred (host not yet in window) to prevent stale + /// portal syncs from hiding a view that is about to become visible. + static func updateEntryVisibility(for hostedView: GhosttySurfaceScrollView, visibleInUI: Bool) { + let hostedId = ObjectIdentifier(hostedView) + guard let windowId = hostedToWindowId[hostedId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI) + } + static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? { let portal = portal(for: window) return portal.viewAtWindowPoint(windowPoint) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9709f541..654cfbf3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -966,6 +966,19 @@ final class Workspace: Identifiable, ObservableObject { triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true) } + // MARK: - Portal Lifecycle + + /// Hide all terminal portal views for this workspace. + /// Called before the workspace is unmounted to prevent portal-hosted terminal + /// views from covering browser panes in the newly selected workspace. + func hideAllTerminalPortalViews() { + for panel in panels.values { + guard let terminal = panel as? TerminalPanel else { continue } + terminal.hostedView.setVisibleInUI(false) + TerminalWindowPortalRegistry.hideHostedView(terminal.hostedView) + } + } + // MARK: - Utility /// Create a new terminal panel (used when replacing the last panel)