diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fa65d6dcf094..9433a7217294 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -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 diff --git a/Sources/Canvas/CanvasPaneContent.swift b/Sources/Canvas/CanvasPaneContent.swift index 48ddd5a8a728..01238fc8120d 100644 --- a/Sources/Canvas/CanvasPaneContent.swift +++ b/Sources/Canvas/CanvasPaneContent.swift @@ -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 @@ -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 { diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 5c239d824cf8..40d6369b09ac 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -1,4 +1,5 @@ -import XCTest +import AppKit +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -6,9 +7,10 @@ import XCTest @testable import cmux #endif -final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { - func testImmediateStateUpdateAllowedWhenDesiredStateIsHidden() { - XCTAssertTrue( +@Suite +struct GhosttyTerminalViewVisibilityPolicyTests { + @Test func immediateStateUpdateAllowedWhenDesiredStateIsHidden() { + #expect( GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( desiredVisibleInUI: false, hostedViewHasSuperview: true, @@ -17,8 +19,8 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) } - func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() { - XCTAssertTrue( + @Test func immediateStateUpdateAllowedWhenBoundToCurrentHost() { + #expect( GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( desiredVisibleInUI: true, hostedViewHasSuperview: true, @@ -27,9 +29,9 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) } - func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() { - XCTAssertFalse( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + @Test func immediateStateUpdateSkippedForStaleHostBoundElsewhere() { + #expect( + !GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( desiredVisibleInUI: true, hostedViewHasSuperview: true, isBoundToCurrentHost: false @@ -37,8 +39,8 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) } - func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { - XCTAssertTrue( + @Test func immediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { + #expect( GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( desiredVisibleInUI: true, hostedViewHasSuperview: false, @@ -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.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) + + mount.setRendering(true) + #expect(panel.surface.isRendererPortalVisible) + + mount.setRendering(false) + #expect(!panel.surface.isRendererPortalVisible) + + // 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) + } }