Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
4483 Sources/cmuxApp.swift
4482 Sources/Panels/FilePreviewPanel.swift
4367 cmuxTests/BrowserPanelTests.swift
4283 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
4121 Sources/BrowserWindowPortal.swift
3964 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3934 Sources/Feed/FeedPanelView.swift
3926 cmuxTests/TabManagerUnitTests.swift
3896 cmuxTests/WindowAndDragTests.swift
Expand Down
19 changes: 18 additions & 1 deletion Sources/Canvas/CanvasPaneContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ final class CanvasPaneContentMount: CanvasPaneContentMounting {
func setRendering(_ rendering: Bool) {
switch content {
case .terminal(let panel):
panel.surface.setRendererPortalVisible(rendering)
if rendering {
panel.surface.realizeRenderer()
}
panel.surface.setOcclusion(rendering)
case .hosted(let panel, _):
// Offscreen browsers may hidden-discard their webview; coming
Expand All @@ -148,7 +152,20 @@ final class CanvasPaneContentMount: CanvasPaneContentMounting {
hostedView.setActive(false)
hostedView.setFocusHandler(nil)
hostedView.setInactiveOverlay(color: .clear, opacity: 0, visible: false)
panel.surface.setOcclusion(true)
// Hide through the authoritative portal path rather than poking the
// surface directly. `setVisibleInUI(false)` flips the scroll view's
// `visibleInUI` flag and marks the surface portal-hidden and
// occluded without realizing on hide. unmount() also runs when a
// canvas tab is deselected (`CanvasRootView.reconcileMount`), where
// no portal re-hosts the surface: leaving it portal-visible would pin
// `RendererRealizationController` off this surface forever and leak
// its GPU renderer. Poking `TerminalSurface` occlusion directly would
// also desync the portal — a later `setVisibleInUI(true)` re-host
// would see `wasVisible == true` and skip re-occluding, leaving the
// re-shown split terminal frozen. Going through the shared path makes
// re-hosting a real false→true transition that re-realizes and
// re-occludes; renderer release stays controller-driven.
hostedView.setVisibleInUI(false)
hostedView.removeFromSuperview()
case .hosted(let panel, let view):
if let browserPanel = panel as? BrowserPanel {
Expand Down
65 changes: 49 additions & 16 deletions cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import XCTest
import AppKit
import Testing

#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif

final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
func testImmediateStateUpdateAllowedWhenDesiredStateIsHidden() {
XCTAssertTrue(
@Suite
struct GhosttyTerminalViewVisibilityPolicyTests {
@Test func immediateStateUpdateAllowedWhenDesiredStateIsHidden() {
#expect(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
desiredVisibleInUI: false,
hostedViewHasSuperview: true,
Expand All @@ -17,8 +19,8 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
)
}

func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() {
XCTAssertTrue(
@Test func immediateStateUpdateAllowedWhenBoundToCurrentHost() {
#expect(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
desiredVisibleInUI: true,
hostedViewHasSuperview: true,
Expand All @@ -27,18 +29,18 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
)
}

func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() {
XCTAssertFalse(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
@Test func immediateStateUpdateSkippedForStaleHostBoundElsewhere() {
#expect(
!GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
desiredVisibleInUI: true,
hostedViewHasSuperview: true,
isBoundToCurrentHost: false
)
)
}

func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() {
XCTAssertTrue(
@Test func immediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() {
#expect(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
desiredVisibleInUI: true,
hostedViewHasSuperview: false,
Expand All @@ -47,21 +49,52 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
)
}

func testSwiftUIHostGeometryCallbackUsesImmediateSyncWithoutLayoutFlush() {
@Test func swiftUIHostGeometryCallbackUsesImmediateSyncWithoutLayoutFlush() {
switch GhosttyTerminalView.hostCallbackPortalGeometrySynchronizationAction(window: 3873) {
case .synchronizeWithoutLayoutFlush(let window):
XCTAssertEqual(window, 3873)
#expect(window == 3873)
case .skip:
XCTFail("Window-attached host callbacks should immediately reconcile portal geometry without layout flushes")
Issue.record("Window-attached host callbacks should immediately reconcile portal geometry without layout flushes")
}
}

func testSwiftUIHostGeometryCallbackSkipsWithoutWindow() {
@Test func swiftUIHostGeometryCallbackSkipsWithoutWindow() {
switch GhosttyTerminalView.hostCallbackPortalGeometrySynchronizationAction(window: Optional<Int>.none) {
case .synchronizeWithoutLayoutFlush:
XCTFail("Detached host callbacks must not synchronize terminal portal geometry")
Issue.record("Detached host callbacks must not synchronize terminal portal geometry")
case .skip:
break
}
}

@Test @MainActor func canvasTerminalRenderingDrivesRendererVisibility() {
let panel = TerminalPanel(workspaceId: UUID())
let container = NSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600))
let mount = CanvasPaneContentMount(
content: .terminal(panel),
panelId: panel.id,
container: container,
onFocusPanel: { _ in }
)

#expect(panel.surface.isRendererPortalVisible)

mount.setRendering(false)
#expect(!panel.surface.isRendererPortalVisible)
Comment thread
austinywang marked this conversation as resolved.

mount.setRendering(true)
#expect(panel.surface.isRendererPortalVisible)

mount.setRendering(false)
#expect(!panel.surface.isRendererPortalVisible)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// A canvas tab that is unmounted — deselected via
// `CanvasRootView.reconcileMount`, or handed back to the split when
// leaving canvas — is no longer on screen, so it must report
// portal-hidden. Otherwise `RendererRealizationController` skips
// `releaseRenderer()` for every backgrounded canvas tab and leaks its
// GPU renderer. Release stays controller-driven (idle/warm policy).
mount.unmount()
#expect(!panel.surface.isRendererPortalVisible)
}
Comment thread
austinywang marked this conversation as resolved.
}
Loading