diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 10428aae..6f710549 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6241,6 +6241,80 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent writeGotoSplitTestData(updates) } + private func recordGotoSplitZoomIfNeeded() { + guard isGotoSplitUITestRecordingEnabled() else { return } + recordGotoSplitZoomRetry(attempt: 0) + } + + private func recordGotoSplitZoomRetry(attempt: Int) { + let delays: [Double] = [0.05, 0.1, 0.2, 0.35, 0.5] + let delay = attempt < delays.count ? delays[attempt] : delays.last! + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, + let workspace = self.tabManager?.selectedWorkspace else { return } + + let browserPanel = workspace.panels.values.compactMap { $0 as? BrowserPanel }.first + let otherTerminal = workspace.panels.values.compactMap { $0 as? TerminalPanel }.first + let browserSnapshot = browserPanel.flatMap { + BrowserWindowPortalRegistry.debugSnapshot(for: $0.webView) + } + + var updates = self.gotoSplitFindStateSnapshot(for: workspace) + updates["splitZoomedAfterToggle"] = workspace.bonsplitController.isSplitZoomed ? "true" : "false" + updates["zoomedPaneIdAfterToggle"] = workspace.bonsplitController.zoomedPaneId?.description ?? "" + updates["browserPanelIdAfterToggle"] = browserPanel?.id.uuidString ?? "" + updates["browserContainerHiddenAfterToggle"] = browserSnapshot.map { $0.containerHidden ? "true" : "false" } ?? "" + updates["browserVisibleFlagAfterToggle"] = browserSnapshot.map { $0.visibleInUI ? "true" : "false" } ?? "" + updates["browserFrameAfterToggle"] = browserSnapshot.map { + String( + format: "%.1f,%.1f %.1fx%.1f", + $0.frameInWindow.origin.x, + $0.frameInWindow.origin.y, + $0.frameInWindow.size.width, + $0.frameInWindow.size.height + ) + } ?? "" + updates["otherTerminalPanelIdAfterToggle"] = otherTerminal?.id.uuidString ?? "" + updates["otherTerminalHostHiddenAfterToggle"] = otherTerminal.map { $0.hostedView.isHidden ? "true" : "false" } ?? "" + updates["otherTerminalVisibleFlagAfterToggle"] = otherTerminal.map { $0.hostedView.debugPortalVisibleInUI ? "true" : "false" } ?? "" + updates["otherTerminalFrameAfterToggle"] = otherTerminal.map { + let frame = $0.hostedView.debugPortalFrameInWindow + return String( + format: "%.1f,%.1f %.1fx%.1f", + frame.origin.x, + frame.origin.y, + frame.size.width, + frame.size.height + ) + } ?? "" + + let settled: Bool = { + if workspace.bonsplitController.isSplitZoomed { + if let focusedPanelId = workspace.focusedPanelId, + workspace.terminalPanel(for: focusedPanelId) != nil { + guard let browserSnapshot else { return false } + return browserSnapshot.containerHidden && !browserSnapshot.visibleInUI + } + guard let otherTerminal else { return true } + return otherTerminal.hostedView.isHidden && !otherTerminal.hostedView.debugPortalVisibleInUI + } + let browserRestored = browserSnapshot.map { !$0.containerHidden && $0.visibleInUI } ?? true + let terminalRestored = otherTerminal.map { + !$0.hostedView.isHidden && $0.hostedView.debugPortalVisibleInUI + } ?? true + return browserRestored && terminalRestored + }() + + if !settled && attempt < delays.count - 1 { + self.recordGotoSplitZoomRetry(attempt: attempt + 1) + return + } + + self.writeGotoSplitTestData(updates) + } + } + private func writeGotoSplitTestData(_ updates: [String: String]) { guard let path = gotoSplitUITestDataPath() else { return } var payload = loadGotoSplitTestData(at: path) @@ -7544,6 +7618,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) { _ = tabManager?.toggleFocusedSplitZoom() +#if DEBUG + recordGotoSplitZoomIfNeeded() +#endif return true } diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 97151a08..d9a8cf26 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -2856,6 +2856,19 @@ final class WindowBrowserPortal: NSObject { } #endif + func debugSnapshot(forWebViewId webViewId: ObjectIdentifier) -> BrowserWindowPortalRegistry.DebugSnapshot? { + guard let entry = entriesByWebViewId[webViewId] else { return nil } + let frameInWindow: CGRect = { + guard let container = entry.containerView, container.window != nil else { return .zero } + return container.convert(container.bounds, to: nil) + }() + return BrowserWindowPortalRegistry.DebugSnapshot( + visibleInUI: entry.visibleInUI, + containerHidden: entry.containerView?.isHidden ?? true, + frameInWindow: frameInWindow + ) + } + func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? { guard ensureInstalled() else { return nil } let point = hostView.convert(windowPoint, from: nil) @@ -2875,6 +2888,12 @@ final class WindowBrowserPortal: NSObject { @MainActor enum BrowserWindowPortalRegistry { + struct DebugSnapshot { + let visibleInUI: Bool + let containerHidden: Bool + let frameInWindow: CGRect + } + private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] @@ -3038,6 +3057,13 @@ enum BrowserWindowPortalRegistry { portal.forceRefreshWebView(withId: webViewId, reason: reason) } + static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return nil } + return portal.debugSnapshot(forWebViewId: webViewId) + } + #if DEBUG static func debugPortalCount() -> Int { portalsByWindowId.count diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4f4059ec..49c059f0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6382,6 +6382,15 @@ final class GhosttySurfaceScrollView: NSView { } } + var debugPortalVisibleInUI: Bool { + surfaceView.isVisibleInUI + } + + var debugPortalFrameInWindow: CGRect { + guard window != nil else { return .zero } + return convert(bounds, to: nil) + } + func setActive(_ active: Bool) { let wasActive = isActive isActive = active diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index aaad9f23..d9c06dc6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1720,7 +1720,13 @@ final class BrowserPanel: Panel, ObservableObject { let inWindow: Bool let area: CGFloat } + private struct PortalHostLock { + let hostId: ObjectIdentifier + let paneId: UUID + } private var activePortalHostLease: PortalHostLease? + private var pendingDistinctPortalHostReplacementPaneId: UUID? + private var lockedPortalHost: PortalHostLock? private var webViewCancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? @@ -1773,6 +1779,22 @@ final class BrowserPanel: Panel, ObservableObject { lease.inWindow && lease.area > portalHostAreaThreshold } + func preparePortalHostReplacementForNextDistinctClaim( + inPane paneId: PaneID, + reason: String + ) { + pendingDistinctPortalHostReplacementPaneId = paneId.id + if lockedPortalHost?.paneId == paneId.id { + lockedPortalHost = nil + } +#if DEBUG + dlog( + "browser.portal.host.rearm panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) pane=\(paneId.id.uuidString.prefix(5))" + ) +#endif + } + func claimPortalHost( hostId: ObjectIdentifier, paneId: PaneID, @@ -1788,6 +1810,11 @@ final class BrowserPanel: Panel, ObservableObject { ) if let current = activePortalHostLease { + if let lock = lockedPortalHost, + (lock.hostId != current.hostId || lock.paneId != current.paneId) { + lockedPortalHost = nil + } + if current.hostId == hostId { activePortalHostLease = next return true @@ -1795,12 +1822,47 @@ final class BrowserPanel: Panel, ObservableObject { let currentUsable = Self.portalHostIsUsable(current) let nextUsable = Self.portalHostIsUsable(next) + let isSamePaneReplacement = current.paneId == paneId.id + let shouldForceDistinctReplacement = + isSamePaneReplacement && + pendingDistinctPortalHostReplacementPaneId == paneId.id && + inWindow + if shouldForceDistinctReplacement { +#if DEBUG + dlog( + "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " + + "replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area)) " + + "forced=1" + ) +#endif + activePortalHostLease = next + pendingDistinctPortalHostReplacementPaneId = nil + lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id) + return true + } + + let lockBlocksSamePaneReplacement = + isSamePaneReplacement && + currentUsable && + lockedPortalHost?.hostId == current.hostId && + lockedPortalHost?.paneId == current.paneId let shouldReplace = current.paneId != paneId.id || !currentUsable || - (nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio)) + ( + !lockBlocksSamePaneReplacement && + nextUsable && + next.area > (current.area * Self.portalHostReplacementAreaGainRatio) + ) if shouldReplace { + if lockedPortalHost?.hostId == current.hostId && + lockedPortalHost?.paneId == current.paneId { + lockedPortalHost = nil + } #if DEBUG dlog( "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + @@ -1820,7 +1882,8 @@ final class BrowserPanel: Panel, ObservableObject { "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " + - "ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area))" + "ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area)) " + + "locked=\(lockBlocksSamePaneReplacement ? 1 : 0)" ) #endif return false @@ -1842,6 +1905,9 @@ final class BrowserPanel: Panel, ObservableObject { func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool { guard let current = activePortalHostLease, current.hostId == hostId else { return false } activePortalHostLease = nil + if lockedPortalHost?.hostId == hostId { + lockedPortalHost = nil + } #if DEBUG dlog( "browser.portal.host.release panel=\(id.uuidString.prefix(5)) " + diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1888ff62..8962b2ec 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3247,9 +3247,28 @@ final class Workspace: Identifiable, ObservableObject { @discardableResult func toggleSplitZoom(panelId: UUID) -> Bool { + let wasSplitZoomed = bonsplitController.isSplitZoomed guard let paneId = paneId(forPanelId: panelId) else { return false } guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false } focusPanel(panelId) + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") + scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4) + scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( + remainingPasses: 4, + reason: "workspace.toggleSplitZoom" + ) + scheduleTerminalGeometryReconcile() + if let browserPanel = browserPanel(for: panelId) { + browserPanel.preparePortalHostReplacementForNextDistinctClaim( + inPane: paneId, + reason: "workspace.toggleSplitZoom" + ) + scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4) + if wasSplitZoomed && !bonsplitController.isSplitZoomed { + scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4) + } + } return true } @@ -3512,6 +3531,248 @@ final class Workspace: Identifiable, ObservableObject { } } + private func renderedVisiblePanelIdsForCurrentLayout() -> Set { + let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds + var visiblePanelIds: Set = [] + + for paneId in renderedPaneIds { + let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first + guard let selectedTab, + let panelId = panelIdFromSurfaceId(selectedTab.id), + panels[panelId] != nil else { + continue + } + visiblePanelIds.insert(panelId) + } + + if let focusedPanelId, + panels[focusedPanelId] != nil, + let focusedPaneId = paneId(forPanelId: focusedPanelId), + renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) { + visiblePanelIds.insert(focusedPanelId) + } + + return visiblePanelIds + } + + private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) + terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) + terminalPanel.hostedView.setActive(shouldBeVisible && focusedPanelId == terminalPanel.id) + TerminalWindowPortalRegistry.updateEntryVisibility( + for: terminalPanel.hostedView, + visibleInUI: shouldBeVisible + ) + } + } + + private func terminalPortalVisibilityNeedsFollowUp() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) + let hostedView = terminalPanel.hostedView + + if shouldBeVisible { + if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil { + return true + } + } else if !hostedView.isHidden { + return true + } + } + + return false + } + + private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + + self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + + if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { + self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom( + remainingPasses: remainingPasses - 1 + ) + } + } + } + + private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + let shouldBeVisible = visiblePanelIds.contains(browserPanel.id) + if shouldBeVisible { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: browserPanel.webView, + visibleInUI: true, + zPriority: 2 + ) + let anchorView = browserPanel.portalAnchorView + let anchorReady = + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + if anchorReady { + BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + } + } else { + BrowserWindowPortalRegistry.updateEntryVisibility( + for: browserPanel.webView, + visibleInUI: false, + zPriority: 0 + ) + BrowserWindowPortalRegistry.hide( + webView: browserPanel.webView, + source: reason + ) + } + } + } + + private func browserPortalVisibilityNeedsFollowUp() -> Bool { + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + guard visiblePanelIds.contains(browserPanel.id) else { continue } + let anchorView = browserPanel.portalAnchorView + let anchorReady = + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + if !anchorReady || + browserPanel.webView.window == nil || + browserPanel.webView.superview == nil || + !BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) { + return true + } + } + + return false + } + + private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( + remainingPasses: Int, + reason: String + ) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + + self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + + if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { + self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( + remainingPasses: remainingPasses - 1, + reason: reason + ) + } + } + } + + // Browser panes host WKWebView in the window portal. After pane zoom toggles, + // force a few post-layout sync passes so the portal does not outlive the omnibar chrome. + private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self, let browserPanel = self.browserPanel(for: panelId) else { return } + + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + + let anchorView = browserPanel.portalAnchorView + let anchorReady = + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + + if anchorReady { + BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: "workspace.toggleSplitZoom" + ) + } + + let portalNeedsFollowUpPass = + !anchorReady || + browserPanel.webView.window == nil || + browserPanel.webView.superview == nil + if portalNeedsFollowUpPass { + self.scheduleBrowserPortalReconcileAfterSplitZoom( + panelId: panelId, + remainingPasses: remainingPasses - 1 + ) + } + } + } + + // Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is + // still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles + // so the SwiftUI chrome does not remain hidden until another browser focus command runs. + private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) { + guard remainingPasses > 0 else { return } + DispatchQueue.main.async { [weak self] in + guard let self, self.browserPanel(for: panelId) != nil else { return } + guard let paneId = self.paneId(forPanelId: panelId), + let tabId = self.surfaceIdFromPanelId(panelId) else { return } + + let selectionConverged = + self.bonsplitController.focusedPaneId == paneId && + self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId + let anchorReady: Bool = { + guard let browserPanel = self.browserPanel(for: panelId) else { return false } + let anchorView = browserPanel.portalAnchorView + return + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + }() + + if !selectionConverged { + self.focusPanel(panelId) + self.scheduleFocusReconcile() + } + + if !selectionConverged || !anchorReady { + self.scheduleBrowserSplitZoomExitFocusReassert( + panelId: panelId, + remainingPasses: remainingPasses - 1 + ) + } + } + } + private func scheduleMovedTerminalRefresh(panelId: UUID) { guard terminalPanel(for: panelId) != nil else { return } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index edb26258..700e65c0 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -106,6 +106,10 @@ struct WorkspaceContentView: View { workspace.bonsplitController.focusPane(paneId) } } + // Split zoom swaps Bonsplit between the full split tree and a single pane view. + // Recreate the Bonsplit subtree on zoom enter/exit so stale pre-zoom pane chrome + // cannot remain stacked above portal-hosted browser content. + .id(splitZoomRenderIdentity) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { syncBonsplitNotificationBadges() @@ -174,6 +178,10 @@ struct WorkspaceContentView: View { } } + private var splitZoomRenderIdentity: String { + workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed" + } + static func resolveGhosttyAppearanceConfig( reason: String = "unspecified", backgroundOverride: NSColor? = nil, diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index 5f76cb57..e024151c 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -554,6 +554,150 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testCmdShiftEnterKeepsBrowserOmnibarHittableAcrossZoomRoundTripWhenWebViewFocused() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0), + "Expected goto_split setup data to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + guard let browserPanelId = setup["browserPanelId"] else { + XCTFail("Missing browserPanelId in goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + let pill = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarPill").firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field before zoom") + XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill before zoom") + + // Reproduce the loaded-page state from the bug report before toggling zoom. + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue(waitForElementToBecomeHittable(pill, timeout: 6.0), "Expected browser omnibar pill before navigation") + pill.click() + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + app.typeText(zoomRoundTripPageURL) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0), + "Expected browser to finish navigating to the regression page before zoom. value=\(String(describing: omnibar.value))" + ) + + let browserPane = app.otherElements["BrowserPanelContent.\(browserPanelId)"].firstMatch + XCTAssertTrue(browserPane.waitForExistence(timeout: 6.0), "Expected browser pane content before zoom") + browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForDataMatch(timeout: 8.0) { data in + data["splitZoomedAfterToggle"] == "true" && + data["otherTerminalHostHiddenAfterToggle"] == "true" && + data["otherTerminalVisibleFlagAfterToggle"] == "false" + }, + "Expected Cmd+Shift+Enter zoom-in to hide the non-browser terminal portal. data=\(loadData() ?? [:])" + ) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForDataMatch(timeout: 8.0) { data in + data["splitZoomedAfterToggle"] == "false" && + data["otherTerminalHostHiddenAfterToggle"] == "false" && + data["otherTerminalVisibleFlagAfterToggle"] == "true" + }, + "Expected Cmd+Shift+Enter zoom-out to restore the non-browser terminal portal. data=\(loadData() ?? [:])" + ) + + XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field after Cmd+Shift+Enter zoom round-trip") + XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill after Cmd+Shift+Enter zoom round-trip") + XCTAssertTrue( + waitForElementToBecomeHittable(pill, timeout: 6.0), + "Expected browser omnibar to stay hittable after Cmd+Shift+Enter zoom round-trip" + ) + let page = app.webViews.firstMatch + XCTAssertTrue(page.waitForExistence(timeout: 6.0), "Expected browser web area after Cmd+Shift+Enter") + XCTAssertLessThanOrEqual( + pill.frame.maxY, + page.frame.minY + 12, + "Expected browser omnibar to remain above the web content after Cmd+Shift+Enter. pill=\(pill.frame) page=\(page.frame)" + ) + + pill.click() + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + app.typeText("issue1144") + + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "issue1144", timeout: 4.0), + "Expected browser omnibar to stay editable after Cmd+Shift+Enter. value=\(String(describing: omnibar.value))" + ) + } + + func testCmdShiftEnterHidesBrowserPortalWhenTerminalPaneZooms() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData(keys: ["terminalPaneId", "browserPanelId", "webViewFocused"], timeout: 10.0), + "Expected goto_split setup data to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + guard let expectedTerminalPaneId = setup["terminalPaneId"] else { + XCTFail("Missing terminalPaneId in goto_split setup data") + return + } + + app.typeKey("h", modifierFlags: [.command, .control]) + + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["focusedPaneId"] == expectedTerminalPaneId && data["focusedPanelKind"] == "terminal" + }, + "Expected Cmd+Ctrl+H to focus the terminal pane before zoom. data=\(loadData() ?? [:])" + ) + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForDataMatch(timeout: 8.0) { data in + data["splitZoomedAfterToggle"] == "true" && + data["browserContainerHiddenAfterToggle"] == "true" && + data["browserVisibleFlagAfterToggle"] == "false" + }, + "Expected Cmd+Shift+Enter zoom-in on the terminal pane to hide the browser portal. data=\(loadData() ?? [:])" + ) + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForDataMatch(timeout: 8.0) { data in + data["splitZoomedAfterToggle"] == "false" && + data["browserContainerHiddenAfterToggle"] == "false" && + data["browserVisibleFlagAfterToggle"] == "true" + }, + "Expected Cmd+Shift+Enter zoom-out from the terminal pane to restore the browser portal. data=\(loadData() ?? [:])" + ) + } + func testCmdDSplitsRightWhenOmnibarFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath @@ -806,10 +950,25 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { return value.contains(expectedSubstring) } + private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if element.exists && element.isHittable { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return element.exists && element.isHittable + } + private var autofocusRacePageURL: String { "data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E" } + private var zoomRoundTripPageURL: String { + "data:text/html,%3Ctitle%3EIssue%201144%3C/title%3E%3Cbody%20style%3D%22margin:0;background:%231d1f24;color:white;font-family:system-ui;height:2200px%22%3E%3Cmain%20style%3D%22padding:32px%22%3E%3Ch1%3EIssue%201144%20Regression%20Page%3C/h1%3E%3Cp%3EZoom%20should%20not%20leave%20stale%20split%20chrome%20above%20the%20browser%20omnibar.%3C/p%3E%3C/main%3E%3C/body%3E" + } + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { app.launch() XCTAssertTrue(