diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 07dd94c04a76..e7e428d363a4 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -5,8 +5,8 @@ 17894 Sources/AppDelegate.swift 16714 Sources/ContentView.swift 14829 Sources/TerminalController.swift -13358 Sources/Panels/BrowserPanel.swift -13017 Sources/Workspace.swift +13381 Sources/Panels/BrowserPanel.swift +13048 Sources/Workspace.swift 12124 Sources/GhosttyTerminalView.swift 12115 cmuxTests/AppDelegateShortcutRoutingTests.swift 9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -96,7 +96,7 @@ 926 Sources/DockPanelView.swift 920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 918 cmuxTests/WorkspaceGroupTests.swift -912 Sources/WorkspaceContentView.swift +914 Sources/WorkspaceContentView.swift 905 Sources/CmuxSSHURLRequest.swift 901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 878 Sources/Panels/TerminalPanel.swift diff --git a/Sources/Canvas/CanvasPaneContent.swift b/Sources/Canvas/CanvasPaneContent.swift index 321b7ac85459..af48ef676063 100644 --- a/Sources/Canvas/CanvasPaneContent.swift +++ b/Sources/Canvas/CanvasPaneContent.swift @@ -69,6 +69,10 @@ final class CanvasPaneContentMount: CanvasPaneContentMounting { // to the pane hierarchy. if let browserPanel = panel as? BrowserPanel { browserPanel.canvasInlineHostingActive = true + _ = browserPanel.setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + false, + reason: "canvas.mount" + ) browserPanel.noteWebViewVisibility(true, reason: "canvas.mount") } } @@ -110,10 +114,16 @@ final class CanvasPaneContentMount: CanvasPaneContentMounting { case .hosted(let panel, _): // Offscreen browsers may hidden-discard their webview; coming // back into the render region restores it. - (panel as? BrowserPanel)?.noteWebViewVisibility( - rendering, - reason: rendering ? "canvas.render" : "canvas.occlude" - ) + if let browserPanel = panel as? BrowserPanel { + _ = browserPanel.setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + false, + reason: rendering ? "canvas.render" : "canvas.occlude" + ) + browserPanel.noteWebViewVisibility( + rendering, + reason: rendering ? "canvas.render" : "canvas.occlude" + ) + } } } @@ -129,6 +139,10 @@ final class CanvasPaneContentMount: CanvasPaneContentMounting { case .hosted(let panel, let view): if let browserPanel = panel as? BrowserPanel { browserPanel.canvasInlineHostingActive = false + _ = browserPanel.setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + false, + reason: "canvas.unmount" + ) browserPanel.noteWebViewVisibility(false, reason: "canvas.unmount") } view.removeFromSuperview() diff --git a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift index 4c318ceb29fa..47838de84e51 100644 --- a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift +++ b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift @@ -21,6 +21,7 @@ protocol BrowserHiddenWebViewDiscardManagerDelegate: AnyObject { final class BrowserHiddenWebViewDiscardManager { struct BlockerSnapshot { let isClosing: Bool + let isProtectedByWorkspaceVisibility: Bool let isVisibleInUI: Bool let shouldRenderWebView: Bool let hasPendingRemoteNavigation: Bool @@ -38,6 +39,48 @@ final class BrowserHiddenWebViewDiscardManager { let hasPopups: Bool let isCapturingMedia: Bool let isPlayingMedia: Bool + + init( + isClosing: Bool, + isProtectedByWorkspaceVisibility: Bool = false, + isVisibleInUI: Bool, + shouldRenderWebView: Bool, + hasPendingRemoteNavigation: Bool, + hasCurrentURL: Bool, + isLoading: Bool, + webViewIsLoading: Bool, + hasActiveMainFrameProvisionalNavigation: Bool, + isDownloading: Bool, + activeDownloadCount: Int, + preferredDeveloperToolsVisible: Bool, + isDeveloperToolsVisible: Bool, + isElementFullscreenActive: Bool, + isReactGrabActive: Bool, + isVisualAutomationCaptureActive: Bool, + hasPopups: Bool, + isCapturingMedia: Bool, + isPlayingMedia: Bool + ) { + self.isClosing = isClosing + self.isProtectedByWorkspaceVisibility = isProtectedByWorkspaceVisibility + self.isVisibleInUI = isVisibleInUI + self.shouldRenderWebView = shouldRenderWebView + self.hasPendingRemoteNavigation = hasPendingRemoteNavigation + self.hasCurrentURL = hasCurrentURL + self.isLoading = isLoading + self.webViewIsLoading = webViewIsLoading + self.hasActiveMainFrameProvisionalNavigation = hasActiveMainFrameProvisionalNavigation + self.isDownloading = isDownloading + self.activeDownloadCount = activeDownloadCount + self.preferredDeveloperToolsVisible = preferredDeveloperToolsVisible + self.isDeveloperToolsVisible = isDeveloperToolsVisible + self.isElementFullscreenActive = isElementFullscreenActive + self.isReactGrabActive = isReactGrabActive + self.isVisualAutomationCaptureActive = isVisualAutomationCaptureActive + self.hasPopups = hasPopups + self.isCapturingMedia = isCapturingMedia + self.isPlayingMedia = isPlayingMedia + } } weak var delegate: BrowserHiddenWebViewDiscardManagerDelegate? @@ -71,6 +114,7 @@ final class BrowserHiddenWebViewDiscardManager { if isSystemSleeping { blockers.append("system_sleeping") } if snapshot.isClosing { blockers.append("closing") } if isDiscardedForMemory { blockers.append("already_discarded") } + if snapshot.isProtectedByWorkspaceVisibility { blockers.append("workspace_visible") } if snapshot.isVisibleInUI { blockers.append("visible") } if !snapshot.shouldRenderWebView { blockers.append("not_rendered") } if snapshot.hasPendingRemoteNavigation { blockers.append("pending_remote_navigation") } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index e61f9f19e188..80904cc7614b 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3132,6 +3132,7 @@ final class BrowserPanel: Panel, ObservableObject { private var pendingInteractiveBrowserPrompts: [PendingInteractiveBrowserPrompt] = [] private var isPresentingPendingInteractiveBrowserPrompt = false private var isWebViewVisibleInUI: Bool = false + private var isProtectedByWorkspaceVisibilityForResourceCleanup: Bool = false private var isClosingWebViewLifecycle: Bool = false /// True while a canvas pane hosts this browser's webview inline (in the @@ -3453,6 +3454,23 @@ final class BrowserPanel: Panel, ObservableObject { } } + @discardableResult + func setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + _ isProtected: Bool, + reason: String, + now: Date = Date() + ) -> Bool { + guard isProtectedByWorkspaceVisibilityForResourceCleanup != isProtected else { return false } + isProtectedByWorkspaceVisibilityForResourceCleanup = isProtected + if !isProtected && !isWebViewVisibleInUI { + webViewLastHiddenAt = now + webViewLastVisibilityChangeAt = now + webViewLastVisibilityChangeReason = "workspace_visibility.\(reason)" + } + reevaluateHiddenWebViewDiscardScheduling(reason: "workspace_visibility.\(reason)") + return true + } + func webViewLifecycleTopPayload(now: Date = Date()) -> [String: Any] { let discardBlockers = hiddenWebViewDiscardBlockers() return [ @@ -6105,6 +6123,7 @@ extension BrowserPanel: BrowserHiddenWebViewDiscardManagerDelegate { var hiddenWebViewDiscardSnapshot: BrowserHiddenWebViewDiscardManager.BlockerSnapshot { BrowserHiddenWebViewDiscardManager.BlockerSnapshot( isClosing: isClosingWebViewLifecycle, + isProtectedByWorkspaceVisibility: isProtectedByWorkspaceVisibilityForResourceCleanup, isVisibleInUI: isWebViewVisibleInUI, shouldRenderWebView: shouldRenderWebView, hasPendingRemoteNavigation: pendingRemoteNavigation != nil, @@ -8426,6 +8445,10 @@ private extension BrowserPanel { extension BrowserPanel { func hideBrowserPortalView(source: String) { + _ = setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + false, + reason: "portal.\(source)" + ) noteWebViewVisibility( false, reason: "portal.\(source)", diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f7268aa363cc..ce681dd0c481 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3656,6 +3656,7 @@ final class Workspace: Identifiable, ObservableObject { private var layoutFollowUpStalledAttemptCount = 0 private var pendingReparentFocusSuppressionViews: [ObjectIdentifier: GhosttySurfaceScrollView] = [:] private var portalRenderingEnabled = true + private var workspacePresentationVisible = false private var agentHibernationAutoResumePresentationVisible = true private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false @@ -9882,6 +9883,12 @@ final class Workspace: Identifiable, ObservableObject { reconcileTerminalPortalVisibilityForCurrentRenderedLayout() } + func setWorkspacePresentationVisible(_ isVisible: Bool) { + guard workspacePresentationVisible != isVisible else { return } + workspacePresentationVisible = isVisible + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.presentationVisibility") + } + // MARK: - Utility /// Writes a small shell wrapper that keeps a disconnected remote terminal visible. @@ -10611,6 +10618,10 @@ final class Workspace: Identifiable, ObservableObject { return renderedVisiblePanelIdsForCurrentLayout() } + private func presentationVisiblePanelIdsForCurrentLayout() -> Set { + workspacePresentationVisible ? renderedVisiblePanelIdsForCurrentLayout() : [] + } + @discardableResult func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() -> Bool { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() @@ -10674,15 +10685,29 @@ final class Workspace: Identifiable, ObservableObject { @discardableResult func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + let visiblePanelIds = presentationVisiblePanelIdsForCurrentLayout() var didChange = false for panel in panels.values { guard let browserPanel = panel as? BrowserPanel else { continue } // Canvas-inline-hosted webviews live in the pane hierarchy; portal // rebinds/refreshes here would steal them back into the portal. - if browserPanel.canvasInlineHostingActive { continue } + if browserPanel.canvasInlineHostingActive { + if browserPanel.setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + false, + reason: "canvasInline.\(reason)" + ) { + didChange = true + } + continue + } let shouldBeVisible = visiblePanelIds.contains(browserPanel.id) + if browserPanel.setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + shouldBeVisible, + reason: reason + ) { + didChange = true + } let anchorView = browserPanel.portalAnchorView let snapshot = BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView) if shouldBeVisible { @@ -10737,7 +10762,7 @@ final class Workspace: Identifiable, ObservableObject { } private func browserPortalVisibilityNeedsFollowUp() -> Bool { - let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + let visiblePanelIds = presentationVisiblePanelIdsForCurrentLayout() for panel in panels.values { guard let browserPanel = panel as? BrowserPanel else { continue } @@ -10759,6 +10784,12 @@ final class Workspace: Identifiable, ObservableObject { return false } +#if DEBUG + func debugBrowserPortalVisibilityNeedsFollowUpForTesting() -> Bool { + browserPortalVisibilityNeedsFollowUp() + } +#endif + private func scheduleMovedTerminalRefresh(panelId: UUID) { guard terminalPanel(for: panelId) != nil else { return } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 9cd5960249cf..d5452109c48d 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -335,6 +335,7 @@ struct WorkspaceContentView: View { updateAgentHibernationPresentationVisibility() } .onDisappear { + workspace.setWorkspacePresentationVisible(false) workspace.setAgentHibernationAutoResumePresentationVisible(false) } .onChange(of: notificationStore.notifications) { _, _ in @@ -626,6 +627,7 @@ struct WorkspaceContentView: View { } private func updateAgentHibernationPresentationVisibility() { + workspace.setWorkspacePresentationVisible(isWorkspaceVisible) workspace.setAgentHibernationAutoResumePresentationVisible(isWorkspaceVisible && isWorkspaceInputActive) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b002263baff8..6b4bbbaae3de 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */; }; C0DE58990000000000000003 /* BrowserWebKitKeyDownDispatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE58990000000000000001 /* BrowserWebKitKeyDownDispatch.swift */; }; A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; + B6189001000000000000001 /* BrowserWorkspaceVisibleDiscardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6189001000000000000002 /* BrowserWorkspaceVisibleDiscardTests.swift */; }; C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35530000000000000102 /* BundledCLILinkageTests.swift */; }; CA52B0010000000000000000 /* CanvasAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52C0010000000000000000 /* CanvasAction.swift */; }; CA52B0050000000000000000 /* CanvasHostedPanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52C0050000000000000000 /* CanvasHostedPanelContentView.swift */; }; @@ -1144,6 +1145,7 @@ C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebContentProcessTests.swift; sourceTree = ""; }; C0DE58990000000000000001 /* BrowserWebKitKeyDownDispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebKitKeyDownDispatch.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; + B6189001000000000000002 /* BrowserWorkspaceVisibleDiscardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWorkspaceVisibleDiscardTests.swift; sourceTree = ""; }; C0DE35530000000000000102 /* BundledCLILinkageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledCLILinkageTests.swift; sourceTree = ""; }; CA52C0010000000000000000 /* CanvasAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CanvasAction.swift"; sourceTree = ""; }; CA52C0050000000000000000 /* CanvasHostedPanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CanvasHostedPanelContentView.swift"; sourceTree = ""; }; @@ -2804,6 +2806,7 @@ 43F90FAF3FD44F11BF547BE9 /* CmuxWebViewMouseNavigationButtonTests.swift */, D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */, D3622101A1B2C3D4E5F60718 /* EditableTextViewArrowKeyForwardingTests.swift */, + B6189001000000000000002 /* BrowserWorkspaceVisibleDiscardTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, 19D536229C6194FD62B44503 /* BrowserHistorySuggestionCacheTests.swift */, 48D6AE11906A4DF3BEB8CDFE /* OmnibarSubmitDecisionTests.swift */, @@ -4092,6 +4095,7 @@ 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, C7B800062D4202A13C962D2D /* BrowserSystemProxyMirrorTests.swift in Sources */, C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */, + B6189001000000000000001 /* BrowserWorkspaceVisibleDiscardTests.swift in Sources */, C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */, diff --git a/cmuxTests/BrowserWorkspaceVisibleDiscardTests.swift b/cmuxTests/BrowserWorkspaceVisibleDiscardTests.swift new file mode 100644 index 000000000000..e92cbf75c646 --- /dev/null +++ b/cmuxTests/BrowserWorkspaceVisibleDiscardTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +private func withHiddenWebViewDiscardPolicyEnabled(_ body: () throws -> Void) rethrows { + let defaults = UserDefaults.standard + let previousEnabled = defaults.object(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + defaults.set(true, forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + defer { + if let previousEnabled { + defaults.set(previousEnabled, forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + } else { + defaults.removeObject(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + } + } + try body() +} + +@MainActor +@Suite(.serialized) +struct BrowserWorkspaceVisibleDiscardTests { + @Test func workspaceVisibleBrowserPanelBlocksHiddenWebViewDiscard() throws { + try withHiddenWebViewDiscardPolicyEnabled { + let workspace = Workspace() + let paneId = try #require(workspace.bonsplitController.focusedPaneId) + let panel = try #require( + workspace.newBrowserSurface( + inPane: paneId, + url: URL(string: "about:blank"), + focus: true + ) + ) + + panel.noteWebViewVisibility( + false, + reason: "test.transientSwiftUIHide", + now: Date(timeIntervalSinceNow: -7200) + ) + workspace.setWorkspacePresentationVisible(true) + + let lifecyclePayload = panel.webViewLifecycleTopPayload() + let blockers = try #require(lifecyclePayload["discard_blockers"] as? [String]) + #expect(blockers.contains("workspace_visible")) + #expect(!panel.discardHiddenWebViewForMemory(reason: "test.discard")) + + workspace.setPortalRenderingEnabled(false, reason: "test.workspaceRetire") + + let retiredLifecyclePayload = panel.webViewLifecycleTopPayload() + let retiredBlockers = try #require(retiredLifecyclePayload["discard_blockers"] as? [String]) + #expect(!retiredBlockers.contains("workspace_visible")) + } + } + + @Test func clearingWorkspaceVisibleProtectionRestartsHiddenDiscardClock() throws { + try withHiddenWebViewDiscardPolicyEnabled { + let workspace = Workspace() + let paneId = try #require(workspace.bonsplitController.focusedPaneId) + let panel = try #require( + workspace.newBrowserSurface( + inPane: paneId, + url: URL(string: "about:blank"), + focus: true + ) + ) + let staleHiddenAt = Date(timeIntervalSince1970: 1_000) + let handoffAt = Date(timeIntervalSince1970: 10_000) + + panel.noteWebViewVisibility( + false, + reason: "test.transientSwiftUIHide", + now: staleHiddenAt + ) + _ = panel.setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + true, + reason: "test.visible", + now: staleHiddenAt + ) + + _ = panel.setWorkspaceVisibilityProtectsHiddenWebViewDiscard( + false, + reason: "test.realHide", + now: handoffAt + ) + + #expect(panel.hiddenWebViewDiscardHiddenAt == handoffAt) + } + } + + @Test func canvasInlineBrowserPanelDoesNotGetWorkspaceVisibleDiscardBlocker() throws { + try withHiddenWebViewDiscardPolicyEnabled { + let workspace = Workspace() + let paneId = try #require(workspace.bonsplitController.focusedPaneId) + let panel = try #require( + workspace.newBrowserSurface( + inPane: paneId, + url: URL(string: "about:blank"), + focus: true + ) + ) + + workspace.setWorkspacePresentationVisible(true) + let visibleLifecyclePayload = panel.webViewLifecycleTopPayload() + let visibleBlockers = try #require(visibleLifecyclePayload["discard_blockers"] as? [String]) + #expect(visibleBlockers.contains("workspace_visible")) + + panel.canvasInlineHostingActive = true + panel.noteWebViewVisibility( + false, + reason: "test.canvasOcclude", + now: Date(timeIntervalSinceNow: -7200) + ) + _ = workspace.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "test.reconcile") + + let lifecyclePayload = panel.webViewLifecycleTopPayload() + let blockers = try #require(lifecyclePayload["discard_blockers"] as? [String]) + #expect(!blockers.contains("workspace_visible")) + } + } + + @Test func hiddenMountedWorkspaceDoesNotGetWorkspaceVisibleDiscardBlocker() throws { + try withHiddenWebViewDiscardPolicyEnabled { + let workspace = Workspace() + let paneId = try #require(workspace.bonsplitController.focusedPaneId) + let panel = try #require( + workspace.newBrowserSurface( + inPane: paneId, + url: URL(string: "about:blank"), + focus: true + ) + ) + + panel.noteWebViewVisibility( + false, + reason: "test.backgroundMount", + now: Date(timeIntervalSinceNow: -7200) + ) + workspace.setWorkspacePresentationVisible(false) + _ = workspace.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "test.backgroundReconcile") + + let lifecyclePayload = panel.webViewLifecycleTopPayload() + let blockers = try #require(lifecyclePayload["discard_blockers"] as? [String]) + #expect(!blockers.contains("workspace_visible")) + #expect(!workspace.debugBrowserPortalVisibilityNeedsFollowUpForTesting()) + } + } +}