Fix SSH workspace priming and restore state

This commit is contained in:
Lawrence Chen 2026-03-13 20:01:26 -07:00
parent 2eae782739
commit 5e7458b920
7 changed files with 345 additions and 51 deletions

View file

@ -1987,16 +1987,26 @@ struct ContentView: View {
let isSelectedWorkspace = selectedWorkspaceId == tab.id let isSelectedWorkspace = selectedWorkspaceId == tab.id
let isRetiringWorkspace = retiringWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id
let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id) let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id)
let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace
let isWorkspaceVisibleToPanels = isRenderedVisible || shouldPrimeInBackground
let workspaceRenderOpacity: Double = {
if isRenderedVisible {
return 1
}
if shouldPrimeInBackground {
return 0.001
}
return 0
}()
// Keep the retiring workspace visible during handoff, but never input-active. // Keep the retiring workspace visible during handoff, but never input-active.
// Allowing both selected+retiring workspaces to be input-active lets the // Allowing both selected+retiring workspaces to be input-active lets the
// old workspace steal first responder (notably with WKWebView), which can // old workspace steal first responder (notably with WKWebView), which can
// delay handoff completion and make browser returns feel laggy. // delay handoff completion and make browser returns feel laggy.
let isInputActive = isSelectedWorkspace let isInputActive = isSelectedWorkspace
let isVisible = isSelectedWorkspace || isRetiringWorkspace
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
WorkspaceContentView( WorkspaceContentView(
workspace: tab, workspace: tab,
isWorkspaceVisible: isVisible, isWorkspaceVisible: isWorkspaceVisibleToPanels,
isWorkspaceInputActive: isInputActive, isWorkspaceInputActive: isInputActive,
workspacePortalPriority: portalPriority, workspacePortalPriority: portalPriority,
onThemeRefreshRequest: { reason, eventId, source, payloadHex in onThemeRefreshRequest: { reason, eventId, source, payloadHex in
@ -2009,9 +2019,9 @@ struct ContentView: View {
) )
} }
) )
.opacity(isVisible ? 1 : 0) .opacity(workspaceRenderOpacity)
.allowsHitTesting(isSelectedWorkspace) .allowsHitTesting(isSelectedWorkspace)
.accessibilityHidden(!isVisible) .accessibilityHidden(!isRenderedVisible)
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
.task(id: shouldPrimeInBackground ? tab.id : nil) { .task(id: shouldPrimeInBackground ? tab.id : nil) {
await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id) await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id)

View file

@ -1798,6 +1798,13 @@ final class BrowserPanel: Panel, ObservableObject {
private let developerToolsRestoreRetryMaxAttempts: Int = 40 private let developerToolsRestoreRetryMaxAttempts: Int = 40
private var remoteProxyEndpoint: BrowserProxyEndpoint? private var remoteProxyEndpoint: BrowserProxyEndpoint?
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
private let usesRemoteWorkspaceProxy: Bool
private struct PendingRemoteNavigation {
let request: URLRequest
let recordTypedNavigation: Bool
let preserveRestoredSessionHistory: Bool
}
private var pendingRemoteNavigation: PendingRemoteNavigation?
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
private var developerToolsDetachedOpenGraceDeadline: Date? private var developerToolsDetachedOpenGraceDeadline: Date?
private var developerToolsTransitionTargetVisible: Bool? private var developerToolsTransitionTargetVisible: Bool?
@ -2045,15 +2052,17 @@ final class BrowserPanel: Panel, ObservableObject {
initialURL: URL? = nil, initialURL: URL? = nil,
bypassInsecureHTTPHostOnce: String? = nil, bypassInsecureHTTPHostOnce: String? = nil,
proxyEndpoint: BrowserProxyEndpoint? = nil, proxyEndpoint: BrowserProxyEndpoint? = nil,
isRemoteWorkspace: Bool = false isRemoteWorkspace: Bool = false,
remoteWebsiteDataStoreIdentifier: UUID? = nil
) { ) {
self.id = UUID() self.id = UUID()
self.workspaceId = workspaceId self.workspaceId = workspaceId
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
self.remoteProxyEndpoint = proxyEndpoint self.remoteProxyEndpoint = proxyEndpoint
self.usesRemoteWorkspaceProxy = isRemoteWorkspace
self.browserThemeMode = BrowserThemeSettings.mode() self.browserThemeMode = BrowserThemeSettings.mode()
self.websiteDataStore = isRemoteWorkspace self.websiteDataStore = isRemoteWorkspace
? WKWebsiteDataStore(forIdentifier: self.id) ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId)
: .default() : .default()
let webView = Self.makeWebView(websiteDataStore: websiteDataStore) let webView = Self.makeWebView(websiteDataStore: websiteDataStore)
@ -2143,6 +2152,7 @@ final class BrowserPanel: Panel, ObservableObject {
guard remoteProxyEndpoint != endpoint else { return } guard remoteProxyEndpoint != endpoint else { return }
remoteProxyEndpoint = endpoint remoteProxyEndpoint = endpoint
applyRemoteProxyConfigurationIfAvailable() applyRemoteProxyConfigurationIfAvailable()
resumePendingRemoteNavigationIfNeeded()
} }
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
@ -2785,6 +2795,46 @@ final class BrowserPanel: Panel, ObservableObject {
preserveRestoredSessionHistory: Bool = false preserveRestoredSessionHistory: Bool = false
) { ) {
guard let url = request.url else { return } guard let url = request.url else { return }
if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil {
pendingRemoteNavigation = PendingRemoteNavigation(
request: request,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
)
shouldRenderWebView = true
currentURL = Self.remoteProxyDisplayURL(for: url) ?? url
navigationDelegate?.lastAttemptedURL = url
return
}
performNavigation(
request: request,
originalURL: url,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
)
}
private func resumePendingRemoteNavigationIfNeeded() {
guard remoteProxyEndpoint != nil,
let pendingRemoteNavigation else {
return
}
self.pendingRemoteNavigation = nil
guard let originalURL = pendingRemoteNavigation.request.url else { return }
performNavigation(
request: pendingRemoteNavigation.request,
originalURL: originalURL,
recordTypedNavigation: pendingRemoteNavigation.recordTypedNavigation,
preserveRestoredSessionHistory: pendingRemoteNavigation.preserveRestoredSessionHistory
)
}
private func performNavigation(
request: URLRequest,
originalURL: URL,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool
) {
if !preserveRestoredSessionHistory { if !preserveRestoredSessionHistory {
abandonRestoredSessionHistoryIfNeeded() abandonRestoredSessionHistoryIfNeeded()
} }
@ -2793,9 +2843,9 @@ final class BrowserPanel: Panel, ObservableObject {
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
shouldRenderWebView = true shouldRenderWebView = true
if recordTypedNavigation { if recordTypedNavigation {
BrowserHistoryStore.shared.recordTypedNavigation(url: url) BrowserHistoryStore.shared.recordTypedNavigation(url: originalURL)
} }
navigationDelegate?.lastAttemptedURL = url navigationDelegate?.lastAttemptedURL = originalURL
browserLoadRequest(effectiveRequest, in: webView) browserLoadRequest(effectiveRequest, in: webView)
} }

View file

@ -943,6 +943,9 @@ class TabManager: ObservableObject {
newWorkspace.owningTabManager = self newWorkspace.owningTabManager = self
wireClosedBrowserTracking(for: newWorkspace) wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride) let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
if eagerLoadTerminal && !select {
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
}
var updatedTabs = snapshot.tabs var updatedTabs = snapshot.tabs
if insertIndex >= 0 && insertIndex <= updatedTabs.count { if insertIndex >= 0 && insertIndex <= updatedTabs.count {
updatedTabs.insert(newWorkspace, at: insertIndex) updatedTabs.insert(newWorkspace, at: insertIndex)
@ -959,8 +962,10 @@ class TabManager: ObservableObject {
) )
} }
if eagerLoadTerminal { if eagerLoadTerminal {
if select {
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
} }
}
if select { if select {
#if DEBUG #if DEBUG
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id) debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
@ -1169,21 +1174,33 @@ class TabManager: ObservableObject {
} }
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) { func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return } guard !pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return }
var updated = pendingBackgroundWorkspaceLoadIds
updated.insert(workspaceId)
pendingBackgroundWorkspaceLoadIds = updated
} }
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) { func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return } guard pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return }
var updated = pendingBackgroundWorkspaceLoadIds
updated.remove(workspaceId)
pendingBackgroundWorkspaceLoadIds = updated
} }
func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) { func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
guard !workspaceIds.isEmpty else { return } guard !workspaceIds.isEmpty else { return }
debugPinnedWorkspaceLoadIds.formUnion(workspaceIds) var updated = debugPinnedWorkspaceLoadIds
updated.formUnion(workspaceIds)
guard updated != debugPinnedWorkspaceLoadIds else { return }
debugPinnedWorkspaceLoadIds = updated
} }
func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) { func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
guard !workspaceIds.isEmpty else { return } guard !workspaceIds.isEmpty else { return }
debugPinnedWorkspaceLoadIds.subtract(workspaceIds) var updated = debugPinnedWorkspaceLoadIds
updated.subtract(workspaceIds)
guard updated != debugPinnedWorkspaceLoadIds else { return }
debugPinnedWorkspaceLoadIds = updated
} }
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) { func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
@ -4046,11 +4063,13 @@ extension TabManager {
} }
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
let workspaceSnapshots = tabs let restorableTabs = tabs
.filter { !$0.isRemoteWorkspace }
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
let workspaceSnapshots = restorableTabs
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) } .map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
tabs.firstIndex(where: { $0.id == selectedTabId }) restorableTabs.firstIndex(where: { $0.id == selectedTabId })
} }
return SessionTabManagerSnapshot( return SessionTabManagerSnapshot(
selectedWorkspaceIndex: selectedWorkspaceIndex, selectedWorkspaceIndex: selectedWorkspaceIndex,

View file

@ -6256,9 +6256,12 @@ final class Workspace: Identifiable, ObservableObject {
} }
private func remoteTerminalStartupCommand() -> String? { private func remoteTerminalStartupCommand() -> String? {
guard hasActiveRemoteTerminalSessions else { return nil } guard let command = remoteConfiguration?.terminalStartupCommand?
return remoteConfiguration?.terminalStartupCommand? .trimmingCharacters(in: .whitespacesAndNewlines),
.trimmingCharacters(in: .whitespacesAndNewlines) !command.isEmpty else {
return nil
}
return command
} }
/// Create a new browser panel split /// Create a new browser panel split
@ -6288,7 +6291,8 @@ final class Workspace: Identifiable, ObservableObject {
workspaceId: id, workspaceId: id,
initialURL: url, initialURL: url,
proxyEndpoint: remoteProxyEndpoint, proxyEndpoint: remoteProxyEndpoint,
isRemoteWorkspace: isRemoteWorkspace isRemoteWorkspace: isRemoteWorkspace,
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
) )
panels[browserPanel.id] = browserPanel panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle panelTitles[browserPanel.id] = browserPanel.displayTitle
@ -6357,7 +6361,8 @@ final class Workspace: Identifiable, ObservableObject {
initialURL: url, initialURL: url,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
proxyEndpoint: remoteProxyEndpoint, proxyEndpoint: remoteProxyEndpoint,
isRemoteWorkspace: isRemoteWorkspace isRemoteWorkspace: isRemoteWorkspace,
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
) )
panels[browserPanel.id] = browserPanel panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle panelTitles[browserPanel.id] = browserPanel.displayTitle

View file

@ -47,6 +47,19 @@ final class GhosttyConfigTests: XCTestCase {
let blue: Int let blue: Int
} }
private func writeAppSupportConfig(
root: URL,
bundleIdentifier: String,
name: String = "config",
contents: String = "font-size = 14\n"
) throws -> URL {
let directory = root.appendingPathComponent(bundleIdentifier, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(name, isDirectory: false)
try contents.write(to: url, atomically: true, encoding: .utf8)
return url
}
func testResolveThemeNamePrefersLightEntryForPairedTheme() { func testResolveThemeNamePrefersLightEntryForPairedTheme() {
let resolved = GhosttyConfig.resolveThemeName( let resolved = GhosttyConfig.resolveThemeName(
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
@ -312,48 +325,69 @@ final class GhosttyConfigTests: XCTestCase {
} }
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() { func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
XCTAssertTrue( let root = FileManager.default.temporaryDirectory
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
currentBundleIdentifier: "com.cmuxterm.app.debug", try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
currentConfigFileSize: nil, defer { try? FileManager.default.removeItem(at: root) }
currentLegacyConfigFileSize: nil,
releaseConfigFileSize: 128, let releaseURL = try? writeAppSupportConfig(
releaseLegacyConfigFileSize: nil root: root,
bundleIdentifier: "com.cmuxterm.app"
) )
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
appSupportDirectory: root
),
[releaseURL].compactMap { $0 }
) )
} }
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() { func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
XCTAssertFalse( let root = FileManager.default.temporaryDirectory
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829", try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
currentConfigFileSize: nil, defer { try? FileManager.default.removeItem(at: root) }
currentLegacyConfigFileSize: 64,
releaseConfigFileSize: 128, _ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
releaseLegacyConfigFileSize: nil let debugURL = try? writeAppSupportConfig(
root: root,
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
name: "config.ghostty"
) )
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
appSupportDirectory: root
),
[debugURL].compactMap { $0 }
) )
} }
func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() { func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() {
XCTAssertFalse( let root = FileManager.default.temporaryDirectory
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app", currentBundleIdentifier: "com.cmuxterm.app",
currentConfigFileSize: nil, appSupportDirectory: root
currentLegacyConfigFileSize: nil, ).count,
releaseConfigFileSize: 128, 1
releaseLegacyConfigFileSize: nil
)
) )
XCTAssertFalse( XCTAssertEqual(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug", currentBundleIdentifier: "com.cmuxterm.app.debug",
currentConfigFileSize: nil, appSupportDirectory: root.appendingPathComponent("missing", isDirectory: true)
currentLegacyConfigFileSize: nil, ),
releaseConfigFileSize: nil, []
releaseLegacyConfigFileSize: 0
)
) )
} }
@ -831,12 +865,79 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase {
@MainActor @MainActor
final class BrowserPanelRemoteStoreTests: XCTestCase { final class BrowserPanelRemoteStoreTests: XCTestCase {
func testRemoteWorkspaceUsesDedicatedWebsiteDataStore() { func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() {
let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false) let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false)
let remotePanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: true) let remoteWorkspaceId = UUID()
let firstRemotePanel = BrowserPanel(
workspaceId: remoteWorkspaceId,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
let secondRemotePanel = BrowserPanel(
workspaceId: remoteWorkspaceId,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertFalse(remotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) XCTAssertFalse(firstRemotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertTrue(
firstRemotePanel.webView.configuration.websiteDataStore ===
secondRemotePanel.webView.configuration.websiteDataStore
)
}
func testRemoteWorkspaceDefersInitialNavigationUntilProxyEndpointIsReady() {
let remoteWorkspaceId = UUID()
let url = URL(string: "http://localhost:3000/demo")!
let panel = BrowserPanel(
workspaceId: remoteWorkspaceId,
initialURL: url,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
XCTAssertNil(panel.webView.url)
panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876))
let deadline = Date().addingTimeInterval(1.0)
while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {}
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me")
}
func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws {
let workspace = Workspace()
let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId)
let configuration = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64000,
relayID: "relay-test",
relayToken: String(repeating: "a", count: 64),
localSocketPath: "/tmp/cmux-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(configuration, autoConnect: false)
_ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false)
workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort)
XCTAssertTrue(workspace.isRemoteWorkspace)
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0)
_ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false))
XCTAssertTrue(workspace.isRemoteWorkspace)
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1)
} }
} }

View file

@ -46,4 +46,30 @@ final class TabManagerSessionSnapshotTests: XCTestCase {
XCTAssertEqual(manager.tabs.count, 1) XCTAssertEqual(manager.tabs.count, 1)
XCTAssertNotNil(manager.selectedTabId) XCTAssertNotNil(manager.selectedTabId)
} }
func testSessionSnapshotExcludesRemoteWorkspacesFromRestore() throws {
let manager = TabManager()
let remoteWorkspace = manager.addWorkspace(select: true)
let configuration = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64001,
relayID: "relay-test",
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false)
let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first)
_ = remoteWorkspace.newBrowserSurface(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false)
let snapshot = manager.sessionSnapshot(includeScrollback: false)
XCTAssertEqual(snapshot.workspaces.count, 1)
XCTAssertNil(snapshot.selectedWorkspaceIndex)
XCTAssertFalse(snapshot.workspaces.contains { $0.processTitle == remoteWorkspace.title })
}
} }

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Regression: background workspace.create should start its initial terminal before selection."""
from __future__ import annotations
import os
import shlex
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_for_file_text(path: Path, needle: str, timeout_s: float = 8.0) -> str:
deadline = time.time() + timeout_s
last_text = ""
while time.time() < deadline:
if path.exists():
last_text = path.read_text(encoding="utf-8", errors="replace")
if needle in last_text:
return last_text
time.sleep(0.1)
raise cmuxError(f"Timed out waiting for {needle!r} in background workspace file: {last_text!r}")
def main() -> int:
with cmux(SOCKET_PATH) as c:
baseline_workspace = c.current_workspace()
created_workspace = ""
marker_path = Path(tempfile.gettempdir()) / f"cmux-bg-start-{int(time.time() * 1000)}.txt"
try:
token = f"CMUX_BG_START_{int(time.time() * 1000)}"
initial_command = (
"python3 -c " +
shlex.quote(
f"from pathlib import Path; Path({marker_path.as_posix()!r}).write_text({token!r}, encoding='utf-8')"
)
)
payload = c._call(
"workspace.create",
{"initial_command": initial_command},
) or {}
created_workspace = str(payload.get("workspace_id") or "")
_must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}")
_must(
c.current_workspace() == baseline_workspace,
"workspace.create should preserve selected workspace",
)
text = _wait_for_file_text(marker_path, token)
_must(token in text, f"Background workspace did not run its initial command: {text!r}")
_must(
c.current_workspace() == baseline_workspace,
"background eager load should not switch the selected workspace",
)
finally:
try:
marker_path.unlink()
except FileNotFoundError:
pass
if created_workspace:
try:
c.close_workspace(created_workspace)
except Exception:
pass
print("PASS: workspace.create eager background load starts the initial terminal without focus")
return 0
if __name__ == "__main__":
raise SystemExit(main())