Fix SSH workspace priming and restore state
This commit is contained in:
parent
2eae782739
commit
5e7458b920
7 changed files with 345 additions and 51 deletions
|
|
@ -1987,16 +1987,26 @@ struct ContentView: View {
|
|||
let isSelectedWorkspace = selectedWorkspaceId == tab.id
|
||||
let isRetiringWorkspace = retiringWorkspaceId == 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.
|
||||
// Allowing both selected+retiring workspaces to be input-active lets the
|
||||
// old workspace steal first responder (notably with WKWebView), which can
|
||||
// delay handoff completion and make browser returns feel laggy.
|
||||
let isInputActive = isSelectedWorkspace
|
||||
let isVisible = isSelectedWorkspace || isRetiringWorkspace
|
||||
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
|
||||
WorkspaceContentView(
|
||||
workspace: tab,
|
||||
isWorkspaceVisible: isVisible,
|
||||
isWorkspaceVisible: isWorkspaceVisibleToPanels,
|
||||
isWorkspaceInputActive: isInputActive,
|
||||
workspacePortalPriority: portalPriority,
|
||||
onThemeRefreshRequest: { reason, eventId, source, payloadHex in
|
||||
|
|
@ -2009,9 +2019,9 @@ struct ContentView: View {
|
|||
)
|
||||
}
|
||||
)
|
||||
.opacity(isVisible ? 1 : 0)
|
||||
.opacity(workspaceRenderOpacity)
|
||||
.allowsHitTesting(isSelectedWorkspace)
|
||||
.accessibilityHidden(!isVisible)
|
||||
.accessibilityHidden(!isRenderedVisible)
|
||||
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
|
||||
.task(id: shouldPrimeInBackground ? tab.id : nil) {
|
||||
await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id)
|
||||
|
|
|
|||
|
|
@ -1798,6 +1798,13 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
private let developerToolsRestoreRetryMaxAttempts: Int = 40
|
||||
private var remoteProxyEndpoint: BrowserProxyEndpoint?
|
||||
@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 var developerToolsDetachedOpenGraceDeadline: Date?
|
||||
private var developerToolsTransitionTargetVisible: Bool?
|
||||
|
|
@ -2045,15 +2052,17 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
initialURL: URL? = nil,
|
||||
bypassInsecureHTTPHostOnce: String? = nil,
|
||||
proxyEndpoint: BrowserProxyEndpoint? = nil,
|
||||
isRemoteWorkspace: Bool = false
|
||||
isRemoteWorkspace: Bool = false,
|
||||
remoteWebsiteDataStoreIdentifier: UUID? = nil
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.workspaceId = workspaceId
|
||||
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
|
||||
self.remoteProxyEndpoint = proxyEndpoint
|
||||
self.usesRemoteWorkspaceProxy = isRemoteWorkspace
|
||||
self.browserThemeMode = BrowserThemeSettings.mode()
|
||||
self.websiteDataStore = isRemoteWorkspace
|
||||
? WKWebsiteDataStore(forIdentifier: self.id)
|
||||
? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId)
|
||||
: .default()
|
||||
|
||||
let webView = Self.makeWebView(websiteDataStore: websiteDataStore)
|
||||
|
|
@ -2143,6 +2152,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
guard remoteProxyEndpoint != endpoint else { return }
|
||||
remoteProxyEndpoint = endpoint
|
||||
applyRemoteProxyConfigurationIfAvailable()
|
||||
resumePendingRemoteNavigationIfNeeded()
|
||||
}
|
||||
|
||||
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
|
||||
|
|
@ -2785,6 +2795,46 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
preserveRestoredSessionHistory: Bool = false
|
||||
) {
|
||||
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 {
|
||||
abandonRestoredSessionHistoryIfNeeded()
|
||||
}
|
||||
|
|
@ -2793,9 +2843,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
||||
shouldRenderWebView = true
|
||||
if recordTypedNavigation {
|
||||
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
|
||||
BrowserHistoryStore.shared.recordTypedNavigation(url: originalURL)
|
||||
}
|
||||
navigationDelegate?.lastAttemptedURL = url
|
||||
navigationDelegate?.lastAttemptedURL = originalURL
|
||||
browserLoadRequest(effectiveRequest, in: webView)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -943,6 +943,9 @@ class TabManager: ObservableObject {
|
|||
newWorkspace.owningTabManager = self
|
||||
wireClosedBrowserTracking(for: newWorkspace)
|
||||
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
|
||||
if eagerLoadTerminal && !select {
|
||||
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
|
||||
}
|
||||
var updatedTabs = snapshot.tabs
|
||||
if insertIndex >= 0 && insertIndex <= updatedTabs.count {
|
||||
updatedTabs.insert(newWorkspace, at: insertIndex)
|
||||
|
|
@ -959,8 +962,10 @@ class TabManager: ObservableObject {
|
|||
)
|
||||
}
|
||||
if eagerLoadTerminal {
|
||||
if select {
|
||||
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
}
|
||||
if select {
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
|
||||
|
|
@ -1169,21 +1174,33 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
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) {
|
||||
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>) {
|
||||
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>) {
|
||||
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>) {
|
||||
|
|
@ -4046,11 +4063,13 @@ extension TabManager {
|
|||
}
|
||||
|
||||
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
|
||||
let workspaceSnapshots = tabs
|
||||
let restorableTabs = tabs
|
||||
.filter { !$0.isRemoteWorkspace }
|
||||
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
|
||||
let workspaceSnapshots = restorableTabs
|
||||
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
|
||||
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
|
||||
tabs.firstIndex(where: { $0.id == selectedTabId })
|
||||
restorableTabs.firstIndex(where: { $0.id == selectedTabId })
|
||||
}
|
||||
return SessionTabManagerSnapshot(
|
||||
selectedWorkspaceIndex: selectedWorkspaceIndex,
|
||||
|
|
|
|||
|
|
@ -6256,9 +6256,12 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
private func remoteTerminalStartupCommand() -> String? {
|
||||
guard hasActiveRemoteTerminalSessions else { return nil }
|
||||
return remoteConfiguration?.terminalStartupCommand?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let command = remoteConfiguration?.terminalStartupCommand?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!command.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
/// Create a new browser panel split
|
||||
|
|
@ -6288,7 +6291,8 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
workspaceId: id,
|
||||
initialURL: url,
|
||||
proxyEndpoint: remoteProxyEndpoint,
|
||||
isRemoteWorkspace: isRemoteWorkspace
|
||||
isRemoteWorkspace: isRemoteWorkspace,
|
||||
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
|
||||
)
|
||||
panels[browserPanel.id] = browserPanel
|
||||
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
||||
|
|
@ -6357,7 +6361,8 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
initialURL: url,
|
||||
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
|
||||
proxyEndpoint: remoteProxyEndpoint,
|
||||
isRemoteWorkspace: isRemoteWorkspace
|
||||
isRemoteWorkspace: isRemoteWorkspace,
|
||||
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
|
||||
)
|
||||
panels[browserPanel.id] = browserPanel
|
||||
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
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() {
|
||||
let resolved = GhosttyConfig.resolveThemeName(
|
||||
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
|
||||
|
|
@ -312,48 +325,69 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||
currentConfigFileSize: nil,
|
||||
currentLegacyConfigFileSize: nil,
|
||||
releaseConfigFileSize: 128,
|
||||
releaseLegacyConfigFileSize: nil
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let releaseURL = try? writeAppSupportConfig(
|
||||
root: root,
|
||||
bundleIdentifier: "com.cmuxterm.app"
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||
appSupportDirectory: root
|
||||
),
|
||||
[releaseURL].compactMap { $0 }
|
||||
)
|
||||
}
|
||||
|
||||
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
||||
currentConfigFileSize: nil,
|
||||
currentLegacyConfigFileSize: 64,
|
||||
releaseConfigFileSize: 128,
|
||||
releaseLegacyConfigFileSize: nil
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.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")
|
||||
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() {
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.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",
|
||||
currentConfigFileSize: nil,
|
||||
currentLegacyConfigFileSize: nil,
|
||||
releaseConfigFileSize: 128,
|
||||
releaseLegacyConfigFileSize: nil
|
||||
)
|
||||
appSupportDirectory: root
|
||||
).count,
|
||||
1
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
||||
XCTAssertEqual(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||
currentConfigFileSize: nil,
|
||||
currentLegacyConfigFileSize: nil,
|
||||
releaseConfigFileSize: nil,
|
||||
releaseLegacyConfigFileSize: 0
|
||||
)
|
||||
appSupportDirectory: root.appendingPathComponent("missing", isDirectory: true)
|
||||
),
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -831,12 +865,79 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase {
|
|||
|
||||
@MainActor
|
||||
final class BrowserPanelRemoteStoreTests: XCTestCase {
|
||||
func testRemoteWorkspaceUsesDedicatedWebsiteDataStore() {
|
||||
func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() {
|
||||
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())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,30 @@ final class TabManagerSessionSnapshotTests: XCTestCase {
|
|||
XCTAssertEqual(manager.tabs.count, 1)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
tests_v2/test_workspace_create_background_starts_terminal.py
Normal file
83
tests_v2/test_workspace_create_background_starts_terminal.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue