diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2affe2858fa1..63e220cc347a 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,22 +1,22 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34136 CLI/cmux.swift +34207 CLI/cmux.swift 17914 Sources/AppDelegate.swift -16709 Sources/ContentView.swift +16705 Sources/ContentView.swift 14682 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift -12093 Sources/Workspace.swift +12223 Sources/Workspace.swift 12088 Sources/GhosttyTerminalView.swift 12046 cmuxTests/AppDelegateShortcutRoutingTests.swift 9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7911 Sources/Panels/BrowserPanelView.swift 7354 cmuxTests/WorkspaceUnitTests.swift -6944 cmuxTests/WorkspaceRemoteConnectionTests.swift +7221 cmuxTests/WorkspaceRemoteConnectionTests.swift 6363 cmuxTests/GhosttyConfigTests.swift 6317 cmuxTests/SessionPersistenceTests.swift 6153 CLI/cmux_open.swift -6078 Sources/TabManager.swift +6091 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift @@ -26,8 +26,8 @@ 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift 3937 Sources/Feed/FeedPanelView.swift +3926 cmuxTests/TabManagerUnitTests.swift 3903 cmuxTests/WindowAndDragTests.swift -3765 cmuxTests/TabManagerUnitTests.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift 3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3397 Sources/CmuxConfig.swift @@ -100,7 +100,7 @@ 893 Sources/WorkspaceContentView.swift 876 Sources/Panels/TerminalPanel.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift -852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +859 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift 841 Sources/Panels/MarkdownWebRenderer.swift @@ -113,7 +113,7 @@ 762 Packages/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift 760 Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift -752 Sources/TerminalController+ControlWorkspaceContext.swift +754 Sources/TerminalController+ControlWorkspaceContext.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 746 Sources/App/MenuBarExtraController.swift 738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift @@ -205,4 +205,5 @@ 505 cmuxUITests/DisplayResolutionRegressionUITests.swift 504 cmuxTests/TerminalNotificationSocketActionTests.swift 503 Sources/Settings/ConfigSource.swift +502 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift 502 Sources/CmuxEventPublishing.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5734068c17de..b2c79999dec2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10416,12 +10416,16 @@ struct CMUXCLI { } private final class VMPtyWebSocketBridge { + private static let keepaliveInterval: TimeInterval = 5.0 private let config: VMPtyWebSocketConfig private let debugEvent: ((String) -> Void)? private let sendQueue = DispatchQueue(label: "com.cmux.vm-pty.websocket.send") private let stopLock = NSLock() private var stopped = false private var task: URLSessionWebSocketTask? + private var keepaliveTimer: DispatchSourceTimer? + private var keepaliveTimeoutWorkItem: DispatchWorkItem? + private var keepaliveInFlight = false init(config: VMPtyWebSocketConfig, debugEvent: ((String) -> Void)? = nil) { self.config = config @@ -10462,6 +10466,8 @@ struct CMUXCLI { defer { rawMode?.restore() } let resizeSource = startResizeSource() defer { resizeSource.cancel() } + startKeepalive() + defer { stopKeepalive() } startInputPump() try receiveOutputLoop(delegate: delegate) } @@ -10575,6 +10581,71 @@ struct CMUXCLI { } } + private func startKeepalive() { + sendQueue.async { [weak self] in + guard let self else { return } + self.keepaliveTimer?.cancel() + self.keepaliveInFlight = false + let timer = DispatchSource.makeTimerSource(queue: self.sendQueue) + timer.schedule(deadline: .now() + Self.keepaliveInterval, repeating: Self.keepaliveInterval) + timer.setEventHandler { [weak self] in + self?.sendKeepalive() + } + self.keepaliveTimer = timer + timer.resume() + } + } + + private func stopKeepalive() { + sendQueue.async { [weak self] in + guard let self else { return } + self.keepaliveTimer?.cancel() + self.keepaliveTimer = nil + self.keepaliveTimeoutWorkItem?.cancel() + self.keepaliveTimeoutWorkItem = nil + self.keepaliveInFlight = false + } + } + + private func sendKeepalive() { + guard !isStopped, let task else { + keepaliveTimer?.cancel() + keepaliveTimer = nil + keepaliveTimeoutWorkItem?.cancel() + keepaliveTimeoutWorkItem = nil + keepaliveInFlight = false + return + } + + if keepaliveInFlight { + return + } + keepaliveInFlight = true + let timeoutWorkItem = DispatchWorkItem { [weak self] in + guard let self, !self.isStopped, self.keepaliveInFlight else { return } + self.debugEvent?("websocket.keepalive.timeout") + self.markStopped() + self.task?.cancel(with: .goingAway, reason: nil) + } + keepaliveTimeoutWorkItem?.cancel() + keepaliveTimeoutWorkItem = timeoutWorkItem + sendQueue.asyncAfter(deadline: .now() + Self.keepaliveInterval, execute: timeoutWorkItem) + + task.sendPing { [weak self] error in + self?.sendQueue.async { + guard let self, !self.isStopped else { return } + self.keepaliveTimeoutWorkItem?.cancel() + self.keepaliveTimeoutWorkItem = nil + self.keepaliveInFlight = false + if let error { + self.debugEvent?("websocket.keepalive.failed") + self.markStopped() + self.task?.cancel(with: .goingAway, reason: Data(error.localizedDescription.utf8)) + } + } + } + } + private func sendAsync(_ message: URLSessionWebSocketTask.Message) { sendQueue.async { [weak self] in try? self?.sendSync(message) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift index 8e22bc81a5e8..e7e5984c3f18 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift @@ -726,7 +726,14 @@ extension ControlCommandCoordinator { guard let workspaceID = resolution.workspaceID else { return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) } - return workspaceRemoteResult(context?.controlReconnectWorkspaceRemote(workspaceID: workspaceID)) + let surfaceID = uuid(params, "surface_id") + if hasNonNull(params, "surface_id"), surfaceID == nil { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + return workspaceRemoteResult(context?.controlReconnectWorkspaceRemote( + workspaceID: workspaceID, + surfaceID: surfaceID + )) } /// `workspace.remote.foreground_auth_ready` — arm/continue a pending connect. diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift index 023435a8f178..01efd2625aa7 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift @@ -215,9 +215,14 @@ public protocol ControlWorkspaceContext: AnyObject { /// Reconnects a remote workspace for `workspace.remote.reconnect`. /// - /// - Parameter workspaceID: The resolved workspace id. + /// - Parameters: + /// - workspaceID: The resolved workspace id. + /// - surfaceID: The optional reconnecting placeholder surface id. /// - Returns: The remote resolution (may signal `notConfigured`). - func controlReconnectWorkspaceRemote(workspaceID: UUID) -> ControlWorkspaceRemoteResolution + func controlReconnectWorkspaceRemote( + workspaceID: UUID, + surfaceID: UUID? + ) -> ControlWorkspaceRemoteResolution /// Notifies foreground-auth readiness for /// `workspace.remote.foreground_auth_ready`. diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index dd332ffa791f..d89ae727046d 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -312,7 +312,10 @@ extension ControlWorkspaceContext { clearConfiguration: Bool ) -> ControlWorkspaceRemoteResolution { .notFound(workspaceID: workspaceID) } - func controlReconnectWorkspaceRemote(workspaceID: UUID) -> ControlWorkspaceRemoteResolution { + func controlReconnectWorkspaceRemote( + workspaceID: UUID, + surfaceID: UUID? + ) -> ControlWorkspaceRemoteResolution { .notFound(workspaceID: workspaceID) } diff --git a/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Events.swift b/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Events.swift index 8517bb796c8d..1a1c646ef948 100644 --- a/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Events.swift +++ b/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Events.swift @@ -68,6 +68,62 @@ extension RemoteDaemonRPCClient { } } + func startWebSocketKeepaliveLocked() { + webSocketKeepaliveTimer?.cancel() + webSocketKeepaliveTimeoutWorkItem?.cancel() + webSocketKeepaliveTimeoutWorkItem = nil + webSocketKeepaliveInFlight = false + let timer = DispatchSource.makeTimerSource(queue: stateQueue) + timer.schedule( + deadline: .now() + Self.webSocketKeepaliveInterval, + repeating: Self.webSocketKeepaliveInterval + ) + timer.setEventHandler { [weak self] in + self?.sendWebSocketKeepaliveLocked() + } + webSocketKeepaliveTimer = timer + timer.resume() + } + + func stopWebSocketKeepaliveLocked() { + webSocketKeepaliveTimer?.cancel() + webSocketKeepaliveTimer = nil + webSocketKeepaliveTimeoutWorkItem?.cancel() + webSocketKeepaliveTimeoutWorkItem = nil + webSocketKeepaliveInFlight = false + } + + func sendWebSocketKeepaliveLocked() { + guard !isClosed, let task = webSocketTask else { + stopWebSocketKeepaliveLocked() + return + } + if webSocketKeepaliveInFlight { + return + } + + webSocketKeepaliveInFlight = true + let timeoutWorkItem = DispatchWorkItem { [weak self] in + guard let self, !self.isClosed, self.webSocketKeepaliveInFlight else { return } + self.handleWebSocketTermination("daemon websocket keepalive timed out") + } + webSocketKeepaliveTimeoutWorkItem?.cancel() + webSocketKeepaliveTimeoutWorkItem = timeoutWorkItem + stateQueue.asyncAfter(deadline: .now() + Self.webSocketKeepaliveInterval, execute: timeoutWorkItem) + task.sendPing { [weak self] error in + guard let self else { return } + self.stateQueue.async { + guard !self.isClosed else { return } + self.webSocketKeepaliveTimeoutWorkItem?.cancel() + self.webSocketKeepaliveTimeoutWorkItem = nil + self.webSocketKeepaliveInFlight = false + if let error { + self.handleWebSocketTermination("daemon websocket keepalive failed: \(error.localizedDescription)") + } + } + } + } + func consumeJSONPayload(_ data: Data) { guard let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return @@ -216,6 +272,7 @@ extension RemoteDaemonRPCClient { let capturedSession = webSocketSession isClosed = true + stopWebSocketKeepaliveLocked() webSocketTask = nil webSocketSession = nil webSocketDelegate = nil diff --git a/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Transport.swift b/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Transport.swift index e04db8676e46..d1fb6ac00c64 100644 --- a/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Transport.swift +++ b/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Transport.swift @@ -207,6 +207,7 @@ extension RemoteDaemonRPCClient { self.webSocketTask = task self.webSocketDelegate = delegate self.markTransportOpenLocked() + self.startWebSocketKeepaliveLocked() } stateQueue.async { diff --git a/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient.swift b/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient.swift index faf391eba2f6..bc65a8d95ee9 100644 --- a/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient.swift +++ b/Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient.swift @@ -40,6 +40,7 @@ public final class RemoteDaemonRPCClient: @unchecked Sendable { static let maxStdoutBufferBytes = 256 * 1024 static let bakedVMDaemonSocketPath = "/run/cmuxd-remote.sock" static let socketForwardStartupGracePeriod: TimeInterval = 0.75 + static let webSocketKeepaliveInterval: TimeInterval = 5.0 /// Wire capability required for push-based proxy streaming /// (`proxy.stream.push`; value is test-pinned, do not change). public static let requiredProxyStreamCapability = RemoteDaemonCapability.proxyStreamPush.rawValue @@ -89,6 +90,9 @@ public final class RemoteDaemonRPCClient: @unchecked Sendable { var webSocketSession: URLSession? var webSocketTask: URLSessionWebSocketTask? var webSocketDelegate: RemoteDaemonWebSocketDelegate? + var webSocketKeepaliveTimer: DispatchSourceTimer? + var webSocketKeepaliveTimeoutWorkItem: DispatchWorkItem? + var webSocketKeepaliveInFlight = false var isClosed = true var shouldReportTermination = true @@ -226,6 +230,7 @@ public final class RemoteDaemonRPCClient: @unchecked Sendable { let capturedWebSocketTask = webSocketTask let capturedWebSocketSession = webSocketSession + stopWebSocketKeepaliveLocked() process = nil stdinPipe = nil stdoutPipe = nil diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3d8b32cc6e5b..4c9bdc8ebb89 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -133861,6 +133861,102 @@ "state": "translated", "value": "연결됨" } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "متصل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Povezano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forbundet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbunden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecté" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connesso" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានភ្ជាប់" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilkoblet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połączono" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conectado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เชื่อมต่อแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlandı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已連線" + } } } }, @@ -133890,6 +133986,102 @@ "state": "translated", "value": "연결 중" } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الاتصال" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Povezivanje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opretter forbindelse" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung wird hergestellt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connexion" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connessione" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងភ្ជាប់" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kobler til" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Łączenie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conectando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключение" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังเชื่อมต่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlanıyor" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在連線" + } } } }, @@ -133919,6 +134111,102 @@ "state": "translated", "value": "다시 연결 중" } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إعادة الاتصال" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovno povezivanje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genopretter forbindelse" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung wird wiederhergestellt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reconectando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnexion" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riconnessione" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងភ្ជាប់ឡើងវិញ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kobler til på nytt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ponowne łączenie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reconectando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторное подключение" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังเชื่อมต่อใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden bağlanıyor" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在重新连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在重新連線" + } } } }, @@ -133937,306 +134225,1487 @@ "value": "切断済み" } }, - "uk": { + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відключено" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결 해제됨" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير متصل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prekinuto" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Afbrudt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Getrennt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconectado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déconnecté" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disconnesso" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានផ្តាច់" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Frakoblet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozłączono" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desconectado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตัดการเชื่อมต่อแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlantı kesildi" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已断开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已中斷連線" + } + } + } + }, + "remote.status.terminalDisconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote terminal session disconnected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートターミナルセッションが切断されました" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сеанс віддаленого термінала відключено" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격 터미널 세션 연결 해제됨" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم قطع جلسة الطرفية البعيدة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sesija udaljenog terminala je prekinuta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernterminalsessionen blev afbrudt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote-Terminalsitzung getrennt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión de terminal remoto desconectada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session de terminal distant déconnectée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sessione terminale remota disconnessa" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សម័យ terminal ពីចម្ងាយត្រូវបានផ្តាច់" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern terminaløkt frakoblet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sesja zdalnego terminala rozłączona" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sessão do terminal remoto desconectada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеанс удаленного терминала отключен" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซสชันเทอร์มินัลระยะไกลถูกตัดการเชื่อมต่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak terminal oturumu bağlantısı kesildi" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程终端会话已断开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端終端機工作階段已中斷連線" + } + } + } + }, + "remote.status.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Помилка" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오류" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fejl" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំហុស" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Feil" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hata" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "錯誤" + } + } + } + }, + "sidebar.remote.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بعيد • %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udaljeno • %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjern • %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote • %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote • %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Remoto • %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remote • %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Remote • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート • %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ពីចម្ងាយ • %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격 • %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern • %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zdalne • %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remoto • %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаленно • %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีโมต • %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak • %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалено • %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程 • %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端 • %@" + } + } + } + }, + "sidebar.remote.subtitleFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートワークスペース" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалена робоча область" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격 워크스페이스" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل بعيدة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udaljeni radni prostor" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernarbejdsområde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote-Arbeitsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo remoto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail distant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro remota" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កន្លែងធ្វើការពីចម្ងាយ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Eksternt arbeidsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zdalny obszar roboczy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Workspace remoto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаленная рабочая область" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซระยะไกล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak çalışma alanı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端工作區" + } + } + } + }, + "sidebar.remote.help.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote connected to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートは %@ に接続済み" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалене підключення до %@ встановлено" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격이 %@에 연결됨" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاتصال البعيد متصل بـ %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udaljeno povezano s %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernforbindelse til %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote mit %@ verbunden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Remoto conectado a %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remote connecté à %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Remote connesso a %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានភ្ជាប់ពីចម្ងាយទៅ %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern tilkoblet %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zdalnie połączono z %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remoto conectado a %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаленное подключение к %@ установлено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีโมตเชื่อมต่อกับ %@ แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak bağlantı %@ ile bağlı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程已连接到 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端已連線到 %@" + } + } + } + }, + "sidebar.remote.help.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote connecting to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートは %@ に接続中" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалене підключення до %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격이 %@에 연결 중" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاتصال البعيد يتصل بـ %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udaljeno povezivanje s %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernforbindelse opretter forbindelse til %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote verbindet mit %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Remoto conectando a %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remote en connexion à %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Remote in connessione a %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងភ្ជាប់ពីចម្ងាយទៅ %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern kobler til %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zdalne łączenie z %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remoto conectando a %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаленное подключение к %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีโมตกำลังเชื่อมต่อกับ %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak bağlantı %@ hedefine bağlanıyor" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程正在连接到 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端正在連線到 %@" + } + } + } + }, + "sidebar.remote.help.reconnecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote reconnecting to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートは %@ に再接続中" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалене повторне підключення до %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격이 %@에 다시 연결 중" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاتصال البعيد يعيد الاتصال بـ %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udaljeno ponovno povezivanje s %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernforbindelse genopretter forbindelse til %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote verbindet erneut mit %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Remoto reconectando a %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remote en reconnexion à %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Remote in riconnessione a %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងភ្ជាប់ឡើងវិញពីចម្ងាយទៅ %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern kobler til %@ på nytt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zdalne ponowne łączenie z %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remoto reconectando a %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаленное повторное подключение к %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีโมตกำลังเชื่อมต่อกับ %@ ใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uzak bağlantı %@ hedefine yeniden bağlanıyor" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程正在重新连接到 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端正在重新連線到 %@" + } + } + } + }, + "sidebar.remote.help.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote error for %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のリモートエラー" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Помилка віддаленого підключення для %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 원격 오류" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في الاتصال البعيد لـ %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška udaljene veze za %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernfejl for %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote-Fehler für %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error remoto para %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur remote pour %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore remote per %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំហុសពីចម្ងាយសម្រាប់ %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern feil for %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd zdalny dla %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro remoto para %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка удаленного подключения для %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาดรีโมตสำหรับ %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ için uzak bağlantı hatası" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 的远程错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 的遠端錯誤" + } + } + } + }, + "sidebar.remote.help.errorWithDetail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote error for %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のリモートエラー: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Помилка віддаленого підключення для %@: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 원격 오류: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في الاتصال البعيد لـ %@: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Greška udaljene veze za %@: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernfejl for %@: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote-Fehler für %@: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error remoto para %@: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur remote pour %@: %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Errore remote per %@: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំហុសពីចម្ងាយសម្រាប់ %@: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern feil for %@: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Błąd zdalny dla %@: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Erro remoto para %@: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка удаленного подключения для %@: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ข้อผิดพลาดรีโมตสำหรับ %@: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ için uzak bağlantı hatası: %@" + } + }, + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Відключено" + "value": "%@ 的远程错误: %@" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "연결 해제됨" + "value": "%@ 的遠端錯誤: %@" } } } }, - "remote.status.error": { + "sidebar.remote.help.disconnected": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Error" + "value": "Remote disconnected from %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "エラー" + "value": "リモートは %@ から切断済み" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Помилка" + "value": "Віддалене підключення до %@ відключено" } }, "ko": { "stringUnit": { "state": "translated", - "value": "오류" + "value": "원격이 %@에서 연결 해제됨" } - } - } - }, - "sidebar.remote.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ar": { "stringUnit": { "state": "translated", - "value": "SSH • %@" + "value": "الاتصال البعيد منقطع عن %@" } }, - "ja": { + "bs": { "stringUnit": { "state": "translated", - "value": "SSH • %@" + "value": "Udaljeno prekinuto s %@" } }, - "uk": { + "da": { "stringUnit": { "state": "translated", - "value": "SSH • %@" + "value": "Fjernforbindelse afbrudt fra %@" } }, - "ko": { + "de": { "stringUnit": { "state": "translated", - "value": "SSH • %@" + "value": "Remote von %@ getrennt" } - } - } - }, - "sidebar.remote.subtitleFallback": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "es": { "stringUnit": { "state": "translated", - "value": "SSH workspace" + "value": "Remoto desconectado de %@" } }, - "ja": { + "fr": { "stringUnit": { "state": "translated", - "value": "SSH ワークスペース" + "value": "Remote déconnecté de %@" } }, - "uk": { + "it": { "stringUnit": { "state": "translated", - "value": "SSH робоча область" + "value": "Remote disconnesso da %@" } }, - "ko": { + "km": { "stringUnit": { "state": "translated", - "value": "SSH 워크스페이스" + "value": "បានផ្តាច់ពីចម្ងាយពី %@" } - } - } - }, - "sidebar.remote.help.connected": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "nb": { "stringUnit": { "state": "translated", - "value": "SSH connected to %@" + "value": "Ekstern frakoblet %@" } }, - "ja": { + "pl": { "stringUnit": { "state": "translated", - "value": "SSH は %@ に接続済み" + "value": "Zdalnie rozłączono z %@" } }, - "uk": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "SSH підключено до %@" + "value": "Remoto desconectado de %@" } }, - "ko": { + "ru": { "stringUnit": { "state": "translated", - "value": "%@에 SSH 연결됨" + "value": "Удаленное подключение к %@ отключено" } - } - } - }, - "sidebar.remote.help.connecting": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "th": { "stringUnit": { "state": "translated", - "value": "SSH connecting to %@" + "value": "รีโมตตัดการเชื่อมต่อจาก %@ แล้ว" } }, - "ja": { + "tr": { "stringUnit": { "state": "translated", - "value": "SSH は %@ に接続中" + "value": "Uzak bağlantı %@ ile kesildi" } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "SSH підключення до %@" + "value": "远程已从 %@ 断开" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%@에 SSH 연결 중" + "value": "遠端已從 %@ 中斷連線" } } } }, - "sidebar.remote.help.reconnecting": { + "sidebar.remote.help.targetFallback": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "SSH reconnecting to %@" + "value": "remote host" } }, "ja": { "stringUnit": { "state": "translated", - "value": "SSH は %@ に再接続中" + "value": "リモートホスト" } }, "uk": { "stringUnit": { "state": "translated", - "value": "SSH повторно підключається до %@" + "value": "віддалений хост" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@에 SSH 다시 연결 중" + "value": "원격 호스트" } - } - } - }, - "sidebar.remote.help.error": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ar": { "stringUnit": { "state": "translated", - "value": "SSH error for %@" + "value": "مضيف بعيد" } }, - "ja": { + "bs": { "stringUnit": { "state": "translated", - "value": "%@ の SSH エラー" + "value": "udaljeni host" } }, - "uk": { + "da": { "stringUnit": { "state": "translated", - "value": "Помилка SSH для %@" + "value": "fjernvært" } }, - "ko": { + "de": { "stringUnit": { "state": "translated", - "value": "%@에 대한 SSH 오류" + "value": "Remote-Host" } - } - } - }, - "sidebar.remote.help.errorWithDetail": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "es": { "stringUnit": { "state": "translated", - "value": "SSH error for %@: %@" + "value": "host remoto" } }, - "ja": { + "fr": { "stringUnit": { "state": "translated", - "value": "%@ の SSH エラー: %@" + "value": "hôte distant" } }, - "uk": { + "it": { "stringUnit": { "state": "translated", - "value": "Помилка SSH для %@: %@" + "value": "host remoto" } }, - "ko": { + "km": { "stringUnit": { "state": "translated", - "value": "%@에 대한 SSH 오류: %@" + "value": "ម៉ាស៊ីនពីចម្ងាយ" } - } - } - }, - "sidebar.remote.help.disconnected": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "nb": { "stringUnit": { "state": "translated", - "value": "SSH disconnected from %@" + "value": "ekstern vert" } }, - "ja": { + "pl": { "stringUnit": { "state": "translated", - "value": "SSH は %@ から切断済み" + "value": "zdalny host" } }, - "uk": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "SSH відключено від %@" + "value": "host remoto" } }, - "ko": { + "ru": { "stringUnit": { "state": "translated", - "value": "%@에서 SSH 연결 해제됨" + "value": "удаленный хост" } - } - } - }, - "sidebar.remote.help.targetFallback": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "th": { "stringUnit": { "state": "translated", - "value": "remote host" + "value": "โฮสต์ระยะไกล" } }, - "ja": { + "tr": { "stringUnit": { "state": "translated", - "value": "リモートホスト" + "value": "uzak ana makine" } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "віддалений хост" + "value": "远程主机" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "원격 호스트" + "value": "遠端主機" } } } @@ -158287,115 +159756,121 @@ "en": { "stringUnit": { "state": "translated", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "ja": { "stringUnit": { "state": "translated", - "value": "[cmux] リモート SSH セッションが終了しました: %s" + "value": "[cmux] リモートセッションが切断されました: %s" } }, "zh-Hans": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "zh-Hant": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "ko": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "de": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "es": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "fr": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "it": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "da": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "pl": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "ru": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "bs": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "ar": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "nb": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "pt-BR": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "th": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "tr": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" } }, "uk": { "stringUnit": { "state": "new", - "value": "[cmux] remote ssh session ended: %s" + "value": "[cmux] remote session disconnected: %s" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "[cmux] remote session disconnected: %s" } } } @@ -158406,115 +159881,246 @@ "en": { "stringUnit": { "state": "translated", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "ja": { "stringUnit": { "state": "translated", - "value": "[cmux] ローカルシェルにフォールバックしました。元の cmux ssh または cmux vm attach コマンドで再接続してください。" + "value": "[cmux] Enter キーを押すと再接続します。それまではこのターミナルは切断されたままです。" } }, "zh-Hans": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "zh-Hant": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "ko": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "de": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "es": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "fr": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "it": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "da": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "pl": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "ru": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "bs": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "ar": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "nb": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "pt-BR": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "th": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "tr": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." } }, "uk": { "stringUnit": { "state": "new", - "value": "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." + } + } + } + }, + "remote.disconnectBanner.reconnectUnavailableHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Reconnect this workspace from the sidebar or by running the original cmux remote command again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[cmux] サイドバーからこのワークスペースを再接続するか、元の cmux リモートコマンドをもう一度実行してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 请从边栏重新连接此工作区,或再次运行原始 cmux 远程命令。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 請從側邊欄重新連接此工作區,或再次執行原始 cmux 遠端命令。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 사이드바에서 이 워크스페이스를 다시 연결하거나 원래 cmux 원격 명령을 다시 실행하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Verbinde diesen Arbeitsbereich über die Seitenleiste erneut oder führe den ursprünglichen cmux-Remote-Befehl noch einmal aus." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Vuelve a conectar este espacio de trabajo desde la barra lateral o ejecuta de nuevo el comando remoto original de cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Reconnectez cet espace de travail depuis la barre latérale ou relancez la commande distante cmux d'origine." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Riconnetti questo spazio di lavoro dalla barra laterale oppure esegui di nuovo il comando remoto cmux originale." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Genopret forbindelsen til dette arbejdsområde fra sidepanelet, eller kør den oprindelige cmux-fjernkommando igen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Połącz ponownie ten obszar roboczy z paska bocznego albo ponownie uruchom oryginalne polecenie zdalne cmux." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Повторно подключите это рабочее пространство из боковой панели или снова выполните исходную удаленную команду cmux." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Ponovo povežite ovaj radni prostor iz bočne trake ili ponovo pokrenite originalnu cmux udaljenu naredbu." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "[cmux] أعد توصيل مساحة العمل هذه من الشريط الجانبي أو شغّل أمر cmux البعيد الأصلي مرة أخرى." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Koble til dette arbeidsområdet på nytt fra sidefeltet, eller kjør den opprinnelige cmux-fjernkommandoen på nytt." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Reconecte este espaço de trabalho pela barra lateral ou execute novamente o comando remoto original do cmux." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "[cmux] เชื่อมต่อพื้นที่ทำงานนี้ใหม่จากแถบด้านข้าง หรือเรียกใช้คำสั่งรีโมต cmux เดิมอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Bu çalışma alanını kenar çubuğundan yeniden bağlayın veya özgün cmux uzak komutunu tekrar çalıştırın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Повторно підключіть цей робочий простір із бічної панелі або ще раз запустіть початкову віддалену команду cmux." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "[cmux] ភ្ជាប់កន្លែងធ្វើការនេះឡើងវិញពីរបារចំហៀង ឬដំណើរការពាក្យបញ្ជា cmux ពីចម្ងាយដើមម្តងទៀត។" } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f26e1c086307..88236f5a5043 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13467,17 +13467,12 @@ struct TabItemView: View, Equatable { } private var remoteWorkspaceSidebarText: String? { - // Keep the SSH row visible while auto-reconnect is suspended even if - // every remote terminal session already died — it hosts the manual - // Reconnect affordance. - guard tab.hasActiveRemoteTerminalSessions || tab.remoteConnectionState == .suspended else { - return nil - } + guard tab.isRemoteWorkspace else { return nil } let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) if let trimmedTarget, !trimmedTarget.isEmpty { return trimmedTarget } - return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "Remote workspace") } private var copyableSidebarSSHError: String? { @@ -14545,7 +14540,7 @@ struct TabItemView: View, Equatable { return String( format: String( localized: "sidebar.remote.help.connected", - defaultValue: "SSH connected to %@" + defaultValue: "Remote connected to %@" ), locale: .current, target @@ -14554,7 +14549,7 @@ struct TabItemView: View, Equatable { return String( format: String( localized: "sidebar.remote.help.connecting", - defaultValue: "SSH connecting to %@" + defaultValue: "Remote connecting to %@" ), locale: .current, target @@ -14563,7 +14558,7 @@ struct TabItemView: View, Equatable { return String( format: String( localized: "sidebar.remote.help.reconnecting", - defaultValue: "SSH reconnecting to %@" + defaultValue: "Remote reconnecting to %@" ), locale: .current, target @@ -14573,7 +14568,7 @@ struct TabItemView: View, Equatable { return String( format: String( localized: "sidebar.remote.help.errorWithDetail", - defaultValue: "SSH error for %@: %@" + defaultValue: "Remote error for %@: %@" ), locale: .current, target, @@ -14583,7 +14578,7 @@ struct TabItemView: View, Equatable { return String( format: String( localized: "sidebar.remote.help.error", - defaultValue: "SSH error for %@" + defaultValue: "Remote error for %@" ), locale: .current, target @@ -14592,7 +14587,7 @@ struct TabItemView: View, Equatable { return String( format: String( localized: "sidebar.remote.help.disconnected", - defaultValue: "SSH disconnected from %@" + defaultValue: "Remote disconnected from %@" ), locale: .current, target @@ -14658,7 +14653,8 @@ struct TabItemView: View, Equatable { remoteWorkspaceSidebarText: remoteWorkspaceSidebarText, remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: remoteStateHelpText, - showsRemoteReconnectAffordance: tab.remoteConnectionState == .suspended, + showsRemoteReconnectAffordance: tab.remoteConnectionState == .suspended + || tab.remoteConnectionState == .disconnected, copyableSidebarSSHError: copyableSidebarSSHError, latestConversationMessage: tab.latestConversationMessage, metadataEntries: detailVisibility.showsMetadata ? tab.sidebarStatusEntriesInDisplayOrder() : [], diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2a5a5476fe9b..5b9e0e22703d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2750,6 +2750,20 @@ class TabManager: ObservableObject { guard tab.panels[surfaceId] != nil else { return } let keepsPersistentRemoteSurfaceOpen = tab.shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(surfaceId) + if !keepsPersistentRemoteSurfaceOpen, + tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) { + let relayPort: Int? + if tab.remoteConfiguration?.transport == .ssh { + relayPort = tab.remoteConfiguration?.relayPort + } else { + relayPort = nil + } + tab.markRemoteTerminalSessionEnded( + surfaceId: surfaceId, + relayPort: relayPort, + allowUntracked: !tab.isRemoteTerminalSurface(surfaceId) + ) + } let handlesRemoteExitThroughWorkspace = tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) @@ -2770,9 +2784,8 @@ class TabManager: ObservableObject { return } - // Exiting the last non-persistent SSH surface should demote the workspace back to a - // local one. Route through Workspace close handling so remote teardown and replacement - // panel logic run before TabManager considers removing the workspace itself. + // Route the last remote child exit through Workspace close handling so remote teardown + // and replacement-panel logic run before TabManager considers removing the workspace. if handlesRemoteExitThroughWorkspace { closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) return diff --git a/Sources/TerminalController+ControlWorkspaceContext.swift b/Sources/TerminalController+ControlWorkspaceContext.swift index 1d468ad1059e..9ae901bffe1a 100644 --- a/Sources/TerminalController+ControlWorkspaceContext.swift +++ b/Sources/TerminalController+ControlWorkspaceContext.swift @@ -440,7 +440,10 @@ extension TerminalController: ControlWorkspaceContext { ) } - func controlReconnectWorkspaceRemote(workspaceID: UUID) -> ControlWorkspaceRemoteResolution { + func controlReconnectWorkspaceRemote( + workspaceID: UUID, + surfaceID: UUID? + ) -> ControlWorkspaceRemoteResolution { guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceID), let workspace = owner.tabs.first(where: { $0.id == workspaceID }) else { return .notFound(workspaceID: workspaceID) @@ -448,7 +451,7 @@ extension TerminalController: ControlWorkspaceContext { guard workspace.remoteConfiguration != nil else { return .notConfigured(workspaceID: workspaceID) } - workspace.reconnectRemoteConnection() + workspace.reconnectRemoteConnection(surfaceId: surfaceID) notifyRemotePTYControllerAvailabilityChanged() let windowId = AppDelegate.shared?.windowId(for: owner) return .resolved( @@ -749,4 +752,3 @@ extension TerminalController: ControlWorkspaceContext { return .string(uuid.uuidString) } } - diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 8b72d4f661f6..87adeade2afa 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -315,6 +315,22 @@ extension Workspace { panel?.close() } + let shouldPreserveRemoteDisconnectOnClose = + origin == "tab_close" || + origin == "pane_close" + if shouldPreserveRemoteDisconnectOnClose, + panel is TerminalPanel { + markRemoteTerminalSessionClosingIfLast(surfaceId: panelId) + } + let shouldRefreshRemoteDisconnectPlaceholder = + shouldPreserveRemoteDisconnectOnClose && + remoteDisconnectPlaceholderPanelIds.remove(panelId) != nil && + panels.count == 1 + if shouldRefreshRemoteDisconnectPlaceholder, + let remoteConfiguration { + rememberPendingRemoteDisconnectReplacement(configuration: remoteConfiguration) + } + panels.removeValue(forKey: panelId) untrackRemoteTerminalSurface(panelId) pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 368f05f945b0..05f24d97b866 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2752,11 +2752,17 @@ final class Workspace: Identifiable, ObservableObject { private var remoteRelaySurfaceIDAliases: [UUID: UUID] = [:] private var suppressRemoteTerminalStartupForSessionRestoreScaffold = false var pendingRemoteTerminalChildExitSurfaceIds: Set = [] - /// Display target of the remote workspace that just disconnected. Set right before - /// `createReplacementTerminalPanel()` so the replacement shell can print a banner - /// explaining that ssh ended (instead of the user seeing an unexplained local prompt - /// that looks identical to a healthy workspace). - private var pendingReplacementBannerRemoteTarget: String? + + private struct PendingRemoteDisconnectReplacement { + let target: String + let reconnectCommand: String? + } + + /// Display target and reconnect command for the remote terminal that just disconnected. + /// Set right before `createReplacementTerminalPanel()` so the replacement terminal stays + /// visibly disconnected instead of falling through to a local login shell. + private var pendingRemoteDisconnectReplacement: PendingRemoteDisconnectReplacement? + var remoteDisconnectPlaceholderPanelIds: Set = [] private static let remoteErrorStatusKey = "remote.error" private static let remotePortConflictStatusKey = "remote.port_conflicts" @@ -2856,8 +2862,9 @@ final class Workspace: Identifiable, ObservableObject { || lowered.contains("daemon transport") } - private var preservesSSHTerminalConnection: Bool { - activeRemoteTerminalSessionCount > 0 + private var preservesProxyFailureWhileSSHTerminalIsAlive: Bool { + remoteConfiguration?.transport == .ssh + && activeRemoteTerminalSessionCount > 0 && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } @@ -5287,6 +5294,22 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteTerminalSurfaceIds.contains(panelId) } + @MainActor + func markRemoteTerminalSessionClosingIfLast(surfaceId: UUID) { + guard !isDetachingCloseTransaction, + activeRemoteTerminalSurfaceIds.count == 1, + activeRemoteTerminalSurfaceIds.contains(surfaceId) else { + return + } + let relayPort: Int? + if remoteConfiguration?.transport == .ssh { + relayPort = remoteConfiguration?.relayPort + } else { + relayPort = nil + } + markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) + } + @MainActor func shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(_ panelId: UUID) -> Bool { guard remoteConfiguration?.preserveAfterTerminalExit == true else { return false } @@ -5487,6 +5510,8 @@ final class Workspace: Identifiable, ObservableObject { defer { TerminalController.shared.notifyRemotePTYControllerAvailabilityChanged() } let previousConfiguration = remoteConfiguration skipControlMasterCleanupAfterDetachedRemoteTransfer = false + pendingRemoteDisconnectReplacement = nil + let remoteDisconnectPlaceholderPanelIdsToClear = remoteDisconnectPlaceholderPanelIds if let previousConfiguration, previousConfiguration != configuration, !previousConfiguration.hasSamePersistentPTYIdentity(as: configuration) { @@ -5496,6 +5521,7 @@ final class Workspace: Identifiable, ObservableObject { } remoteConfiguration = configuration seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) + remoteDisconnectPlaceholderPanelIds.subtract(remoteDisconnectPlaceholderPanelIdsToClear) clearRemoteDetectedSurfacePorts() remoteDetectedPorts = [] remoteForwardedPorts = [] @@ -5566,8 +5592,19 @@ final class Workspace: Identifiable, ObservableObject { controller.start() } - func reconnectRemoteConnection() { + func reconnectRemoteConnection(surfaceId: UUID? = nil) { guard let configuration = remoteConfiguration else { return } + let reconnectingPlaceholderSurfaceId = surfaceId.flatMap { candidate -> UUID? in + guard remoteDisconnectPlaceholderPanelIds.contains(candidate), + panels[candidate] is TerminalPanel else { + return nil + } + return candidate + } + if let reconnectingPlaceholderSurfaceId { + remoteDisconnectPlaceholderPanelIds.remove(reconnectingPlaceholderSurfaceId) + trackRemoteTerminalSurface(reconnectingPlaceholderSurfaceId) + } configureRemoteConnection(configuration, autoConnect: true) } @@ -5596,7 +5633,7 @@ final class Workspace: Identifiable, ObservableObject { reconnectRemoteConnection() } - func disconnectRemoteConnection(clearConfiguration: Bool = false) { + func disconnectRemoteConnection(clearConfiguration: Bool = false, disconnectedDetail: String? = nil) { defer { TerminalController.shared.notifyRemotePTYControllerAvailabilityChanged() } let shouldCleanupControlMaster = clearConfiguration @@ -5624,7 +5661,7 @@ final class Workspace: Identifiable, ObservableObject { remoteHeartbeatCount = 0 remoteLastHeartbeatAt = nil remoteConnectionState = .disconnected - remoteConnectionDetail = nil + remoteConnectionDetail = disconnectedDetail remoteDaemonStatus = WorkspaceRemoteDaemonStatus() statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) @@ -5636,6 +5673,8 @@ final class Workspace: Identifiable, ObservableObject { endedPersistentRemotePTYAttachSurfaceIds.removeAll() clearRemoteRelayIDAliases() remoteConfiguration = nil + pendingRemoteDisconnectReplacement = nil + remoteDisconnectPlaceholderPanelIds.removeAll() skipControlMasterCleanupAfterDetachedRemoteTransfer = false } applyRemoteProxyEndpointUpdate(nil) @@ -5648,6 +5687,7 @@ final class Workspace: Identifiable, ObservableObject { private func clearRemoteConfigurationIfWorkspaceBecameLocal() { guard !isDetachingCloseTransaction, panels.isEmpty, remoteConfiguration != nil else { return } + guard pendingRemoteDisconnectReplacement == nil else { return } if remoteConfiguration?.preserveAfterTerminalExit == true { return } @@ -5660,10 +5700,17 @@ final class Workspace: Identifiable, ObservableObject { } guard activeRemoteTerminalSurfaceIds.isEmpty else { return } let terminalIds = panels.compactMap { panelId, panel in - panel is TerminalPanel ? panelId : nil + panel is TerminalPanel && !remoteDisconnectPlaceholderPanelIds.contains(panelId) + ? panelId + : nil + } + if terminalIds.count == 1, let initialPanelId = terminalIds.first { + trackRemoteTerminalSurface(initialPanelId) + return + } + if let focusedPanelId, terminalIds.contains(focusedPanelId) { + trackRemoteTerminalSurface(focusedPanelId) } - guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } - trackRemoteTerminalSurface(initialPanelId) } private func trackRemoteTerminalSurface(_ panelId: UUID) { @@ -6224,25 +6271,75 @@ final class Workspace: Identifiable, ObservableObject { return true } - func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + private func remoteTerminalSessionEndMatchesCurrentConfiguration( + surfaceId: UUID, + relayPort: Int?, + configuration: WorkspaceRemoteConfiguration, + allowUntracked: Bool + ) -> Bool { + guard activeRemoteTerminalSurfaceIds.contains(surfaceId) || + (allowUntracked && activeRemoteTerminalSurfaceIds.isEmpty) else { + return false + } + if let relayPort, relayPort > 0 { + return configuration.relayPort == relayPort + } + return true + } + + private func disconnectRemoteConnectionAfterTerminalExit() { + disconnectRemoteConnection( + clearConfiguration: false, + disconnectedDetail: String( + localized: "remote.status.terminalDisconnected", + defaultValue: "Remote terminal session disconnected" + ) + ) + } + + func rememberPendingRemoteDisconnectReplacement(configuration: WorkspaceRemoteConfiguration) { + let reconnectCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + pendingRemoteDisconnectReplacement = PendingRemoteDisconnectReplacement( + target: configuration.displayTarget, + reconnectCommand: reconnectCommand?.isEmpty == false ? reconnectCommand : nil + ) + } + + func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?, allowUntracked: Bool = false) { if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { return } - guard let relayPort, - relayPort > 0, - remoteConfiguration?.relayPort == relayPort else { + guard let configuration = remoteConfiguration, + remoteTerminalSessionEndMatchesCurrentConfiguration( + surfaceId: surfaceId, + relayPort: relayPort, + configuration: configuration, + allowUntracked: allowUntracked + ) else { return } - // Arm the replacement-banner before ownership of `remoteConfiguration` drains - // away through `untrackRemoteTerminalSurface` → `disconnectRemoteConnection`. - // The banner only matters if we end up demoting this workspace to local, so - // `createReplacementTerminalPanel` consumes and clears the value. - if remoteConfiguration?.preserveAfterTerminalExit != true, - let displayTarget = remoteConfiguration?.displayTarget { - pendingReplacementBannerRemoteTarget = displayTarget + let preservesRemotePTYSession = configuration.preserveAfterTerminalExit + if !preservesRemotePTYSession { + rememberPendingRemoteDisconnectReplacement(configuration: configuration) } pendingRemoteTerminalChildExitSurfaceIds.insert(surfaceId) - untrackRemoteTerminalSurface(surfaceId) + if activeRemoteTerminalSurfaceIds.remove(surfaceId) != nil { + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + } + if activeRemoteTerminalSurfaceIds.isEmpty { + guard !preservesRemotePTYSession else { return } + let shouldCleanupControlMaster = + configuration.relayPort != nil && + configuration.transport == .ssh && + !isDetachingCloseTransaction && + pendingDetachedSurfaces.isEmpty && + !skipControlMasterCleanupAfterDetachedRemoteTransfer + disconnectRemoteConnectionAfterTerminalExit() + if shouldCleanupControlMaster { + Self.requestSSHControlMasterCleanupIfNeeded(configuration: configuration) + } + } } func teardownRemoteConnection() { @@ -6332,10 +6429,10 @@ final class Workspace: Identifiable, ObservableObject { let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false let preserveConnectedStateForRetry = (state == .connecting || state == .reconnecting) && - preservesSSHTerminalConnection && + preservesProxyFailureWhileSSHTerminalIsAlive && hasProxyOnlyRemoteSidebarError let effectiveState: WorkspaceRemoteConnectionState - if state == .error && proxyOnlyError && preservesSSHTerminalConnection { + if state == .error && proxyOnlyError && preservesProxyFailureWhileSSHTerminalIsAlive { effectiveState = .connected } else if preserveConnectedStateForRetry { effectiveState = .connected @@ -9184,14 +9281,12 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Utility - /// Writes a small shell wrapper that prints a banner ("remote ssh ended — target X"), - /// then execs the user's `$SHELL`. Returned path goes to `initialCommand`, which Ghostty - /// runs as the PTY command. The banner survives as text in scrollback so the user can - /// see it after the replacement local shell starts. - private static func replacementShellScriptWithBanner(target: String) -> String { + /// Writes a small shell wrapper that keeps a disconnected remote terminal visible. + /// Returned path goes to `initialCommand`, which Ghostty runs as the PTY command. + private static func remoteDisconnectPlaceholderScript(target: String, reconnectCommand: String?) -> String { let tempDir = FileManager.default.temporaryDirectory let scriptURL = tempDir.appendingPathComponent( - "cmux-remote-disconnect-banner-\(UUID().uuidString.lowercased()).sh" + "cmux-remote-disconnect-\(UUID().uuidString.lowercased()).sh" ) // Encode the target as base64 and decode it inside the shell. This sidesteps every // layer of shell quoting: no matter what the target contains (`$(id)`, backticks, @@ -9203,17 +9298,23 @@ final class Workspace: Identifiable, ObservableObject { // POSIX printf inside the shell wrapper, not by Swift's String(format:). let endedLineFormat = String( localized: "remote.disconnectBanner.sessionEnded", - defaultValue: "[cmux] remote ssh session ended: %s" + defaultValue: "[cmux] remote session disconnected: %s" ) let reconnectLine = String( localized: "remote.disconnectBanner.reconnectHint", - defaultValue: "[cmux] falling back to a local shell. Reconnect with the original cmux ssh or cmux vm attach command." + defaultValue: "[cmux] Press Enter to reconnect. This terminal will stay disconnected until then." + ) + let reconnectUnavailableLine = String( + localized: "remote.disconnectBanner.reconnectUnavailableHint", + defaultValue: "[cmux] Reconnect this workspace from the sidebar or by running the original cmux remote command again." ) // Encode the localized lines the same way as the target, so a translator using // backticks or $(…) in a translation string can't unexpectedly execute in the // user's local shell. Decoded inline at wrapper startup, then fed to printf. let encodedEndedFormat = Data(endedLineFormat.utf8).base64EncodedString() let encodedReconnectLine = Data(reconnectLine.utf8).base64EncodedString() + let encodedReconnectUnavailableLine = Data(reconnectUnavailableLine.utf8).base64EncodedString() + let encodedReconnectCommand = Data((reconnectCommand ?? "").utf8).base64EncodedString() let body = """ #!/bin/sh cmux_disconnect_decode() { @@ -9222,18 +9323,40 @@ final class Workspace: Identifiable, ObservableObject { cmux_disconnect_target="$(cmux_disconnect_decode '\(encodedTarget)')" cmux_disconnect_ended_format="$(cmux_disconnect_decode '\(encodedEndedFormat)')" cmux_disconnect_reconnect_line="$(cmux_disconnect_decode '\(encodedReconnectLine)')" + cmux_disconnect_reconnect_unavailable_line="$(cmux_disconnect_decode '\(encodedReconnectUnavailableLine)')" + cmux_disconnect_reconnect_command="$(cmux_disconnect_decode '\(encodedReconnectCommand)')" # Append newline + color codes ourselves rather than trusting the translator to # preserve them in every locale. printf '\\033[1;33m' printf "$cmux_disconnect_ended_format" "$cmux_disconnect_target" printf '\\033[0m\\n' >&2 - printf '\\033[2m%s\\033[0m\\n' "$cmux_disconnect_reconnect_line" >&2 - printf '\\n' - unset cmux_disconnect_target cmux_disconnect_ended_format cmux_disconnect_reconnect_line - unset -f cmux_disconnect_decode 2>/dev/null || true # Remove ourselves so /tmp doesn't accumulate these wrappers across sessions. rm -f -- "$0" 2>/dev/null || true - exec "${SHELL:-/bin/sh}" -l + if [ -n "$cmux_disconnect_reconnect_command" ]; then + printf '\\033[2m%s\\033[0m\\n\\n' "$cmux_disconnect_reconnect_line" >&2 + IFS= read -r _ || exit 0 + cmux_reconnect_cli="${CMUX_BUNDLED_CLI_PATH:-}" + if [ -z "$cmux_reconnect_cli" ] || [ ! -x "$cmux_reconnect_cli" ]; then + cmux_reconnect_cli="$(command -v cmux 2>/dev/null || true)" + fi + cmux_reconnect_socket="${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}" + if [ -n "$cmux_reconnect_cli" ] && [ -n "$cmux_reconnect_socket" ] && [ -n "${CMUX_WORKSPACE_ID:-}" ]; then + cmux_reconnect_payload="{\\"workspace_id\\":\\"$CMUX_WORKSPACE_ID\\"" + if [ -n "${CMUX_SURFACE_ID:-}" ]; then + cmux_reconnect_payload="$cmux_reconnect_payload,\\"surface_id\\":\\"$CMUX_SURFACE_ID\\"" + fi + cmux_reconnect_payload="$cmux_reconnect_payload}" + if "$cmux_reconnect_cli" --socket "$cmux_reconnect_socket" rpc workspace.remote.reconnect "$cmux_reconnect_payload" >/dev/null 2>&1; then + exec /bin/sh -lc "$cmux_disconnect_reconnect_command" + fi + fi + printf '\\033[2m%s\\033[0m\\n' "$cmux_disconnect_reconnect_unavailable_line" >&2 + while IFS= read -r _; do :; done + exit 0 + fi + printf '\\033[2m%s\\033[0m\\n' "$cmux_disconnect_reconnect_unavailable_line" >&2 + while IFS= read -r _; do :; done + exit 0 """ do { @@ -9248,22 +9371,27 @@ final class Workspace: Identifiable, ObservableObject { /// Create a new terminal panel (used when replacing the last panel) @discardableResult func createReplacementTerminalPanel() -> TerminalPanel { - let inheritedConfig = inheritedTerminalConfig( + var replacementConfig = inheritedTerminalConfig( preferredPanelId: focusedPanelId, inPane: bonsplitController.focusedPaneId ) - // If the previous surface was a remote ssh terminal that just exited, spawn a - // local shell that first prints a clearly-coloured banner explaining what happened. - // Without this banner a dead VM surfaces as an ordinary local `lawrence@mac ~ %` - // prompt, which looks identical to "I never connected" and was mis-read during - // dogfood as "cmux disconnected silently". - let bannerTarget = pendingReplacementBannerRemoteTarget - pendingReplacementBannerRemoteTarget = nil - let replacementInitialCommand: String? = bannerTarget.map { Self.replacementShellScriptWithBanner(target: $0) } + let pendingRemoteDisconnect = pendingRemoteDisconnectReplacement + pendingRemoteDisconnectReplacement = nil + let replacementInitialCommand: String? = pendingRemoteDisconnect.map { + Self.remoteDisconnectPlaceholderScript( + target: $0.target, + reconnectCommand: $0.reconnectCommand + ) + } + if replacementInitialCommand != nil { + var config = replacementConfig ?? CmuxSurfaceConfigTemplate() + config.waitAfterCommand = true + replacementConfig = config + } let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: inheritedConfig, + configTemplate: replacementConfig, portOrdinal: portOrdinal, initialCommand: replacementInitialCommand, additionalEnvironment: startupEnvironmentMergingWorkspaceEnvironment([:]) @@ -9271,7 +9399,10 @@ final class Workspace: Identifiable, ObservableObject { configureNewTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + if replacementInitialCommand != nil { + remoteDisconnectPlaceholderPanelIds.insert(newPanel.id) + } + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: replacementConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -11364,14 +11495,14 @@ extension Workspace: BonsplitDelegate { if panels.isEmpty { if isDetaching { // Detach path also doesn't create a replacement panel this turn, so any - // pending banner state would survive and leak into a later close. Drop it. - pendingReplacementBannerRemoteTarget = nil + // pending disconnect placeholder state would survive and leak into a later close. + pendingRemoteDisconnectReplacement = nil scheduleTerminalGeometryReconcile() return } #if DEBUG - dlog("replacement.banner.fire target=\(pendingReplacementBannerRemoteTarget ?? "nil")") + dlog("replacement.remoteDisconnect.fire target=\(pendingRemoteDisconnectReplacement?.target ?? "nil")") #endif let replacement = createReplacementTerminalPanel() if let replacementTabId = surfaceIdFromPanelId(replacement.id), @@ -11386,10 +11517,9 @@ extension Workspace: BonsplitDelegate { } // A remote terminal exited but sibling panels are still alive, so we won't spawn a - // replacement right now. Drop the banner-target — without this, a later unrelated - // close (e.g. a local pane shuts down its shell) would inherit the stale value and - // print "remote ssh session ended" for a flow that had nothing to do with the VM. - pendingReplacementBannerRemoteTarget = nil + // replacement right now. Drop the placeholder — without this, a later unrelated + // close could inherit stale remote-disconnect state. + pendingRemoteDisconnectReplacement = nil if let selectTabId, bonsplitController.allPaneIds.contains(pane), diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 78d8f9e260b4..cbfbfe2ae711 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -270,7 +270,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { ) } - func testChildExitOnLastRemotePanelKeepsWorkspaceAndDemotesToLocal() throws { + func testChildExitOnLastRemotePanelKeepsWorkspaceDisconnected() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let remotePanelId = workspace.focusedPanelId else { @@ -304,13 +304,70 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertEqual(manager.tabs.count, 1) XCTAssertEqual(manager.selectedTabId, workspace.id) XCTAssertEqual(manager.tabs.first?.id, workspace.id) - XCTAssertFalse(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) XCTAssertNil(workspace.panels[remotePanelId]) XCTAssertEqual(workspace.panels.count, 1) XCTAssertNotEqual(workspace.focusedPanelId, remotePanelId) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) } + func testManualCloseOnLastRemotePanelKeepsWorkspaceDisconnected() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let remotePanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64017, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ), + autoConnect: false + ) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId)) + + XCTAssertTrue(workspace.closePanel(remotePanelId, force: true)) + drainMainQueue() + drainMainQueue() + + let replacement = try XCTUnwrap(workspace.focusedTerminalPanel) + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertNil(workspace.panels[remotePanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertNotEqual(replacement.id, remotePanelId) + XCTAssertNotNil(replacement.surface.initialCommand) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + + let firstPlaceholderId = replacement.id + XCTAssertTrue(workspace.closePanel(firstPlaceholderId, force: true)) + drainMainQueue() + drainMainQueue() + + let secondReplacement = try XCTUnwrap(workspace.focusedTerminalPanel) + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertNil(workspace.panels[firstPlaceholderId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertNotEqual(secondReplacement.id, firstPlaceholderId) + XCTAssertNotNil(secondReplacement.surface.initialCommand) + } + func testChildExitOnLastPersistentRemotePanelKeepsExitedSurfaceVisibleAndClearsPTYState() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, @@ -359,6 +416,57 @@ final class TabManagerChildExitCloseTests: XCTestCase { ) } + func testPaneCloseOnLastRemotePanelKeepsWorkspaceDisconnected() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let remotePanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + transport: .websocket, + destination: "vm:issue-4509", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "cmux remote websocket" + ), + autoConnect: false + ) + + guard let browserPanel = workspace.newBrowserSplit( + from: remotePanelId, + orientation: .horizontal, + focus: false, + creationPolicy: .restoration + ), + let remotePaneId = workspace.paneId(forPanelId: remotePanelId) else { + XCTFail("Expected split browser and remote terminal panes") + return + } + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertEqual(workspace.remoteConnectionState, .connected) + + XCTAssertTrue(workspace.bonsplitController.closePane(remotePaneId)) + drainMainQueue() + drainMainQueue() + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertNil(workspace.panels[remotePanelId]) + XCTAssertNotNil(workspace.panels[browserPanel.id]) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + } + func testChildExitAfterPersistentAttachEndKeepsExitedSurfaceVisible() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, @@ -465,7 +573,58 @@ final class TabManagerChildExitCloseTests: XCTestCase { ) } - func testChildExitAfterRemoteSessionEndKeepsWorkspaceAndDemotesToLocal() throws { + func testFocusedRemoteChildExitWithMultipleTerminalsDisconnectsWorkspace() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + guard let splitPanel = workspace.newTerminalSplit( + from: initialPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected split terminal panel") + return + } + XCTAssertEqual(workspace.focusedPanelId, initialPanelId) + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + transport: .websocket, + destination: "vm:issue-4509-untracked", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "cmux remote websocket" + ), + autoConnect: false + ) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteTerminalSurface(initialPanelId)) + XCTAssertFalse(workspace.isRemoteTerminalSurface(splitPanel.id)) + XCTAssertEqual(workspace.remoteConnectionState, .connected) + + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: initialPanelId) + drainMainQueue() + drainMainQueue() + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertNil(workspace.panels[initialPanelId]) + XCTAssertNotNil(workspace.panels[splitPanel.id]) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + } + + func testChildExitAfterRemoteSessionEndKeepsWorkspaceDisconnected() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let remotePanelId = workspace.focusedPanelId else { @@ -491,7 +650,8 @@ final class TabManagerChildExitCloseTests: XCTestCase { workspace.markRemoteTerminalSessionEnded(surfaceId: remotePanelId, relayPort: 64016) - XCTAssertFalse(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) drainMainQueue() @@ -500,7 +660,8 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertEqual(manager.tabs.count, 1) XCTAssertEqual(manager.selectedTabId, workspace.id) XCTAssertEqual(manager.tabs.first?.id, workspace.id) - XCTAssertFalse(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) XCTAssertNil(workspace.panels[remotePanelId]) XCTAssertEqual(workspace.panels.count, 1) XCTAssertNotEqual(workspace.focusedPanelId, remotePanelId) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index aaa590a539d2..75476e2b56a9 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -1,3 +1,4 @@ +import Combine import XCTest import CmuxCore import CmuxRemoteDaemon @@ -1096,6 +1097,185 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) } + @MainActor + func testWebSocketRemoteTerminalEndLeavesConnectedStateWithinBoundedTime() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + transport: .websocket, + destination: "vm:issue-4509", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "cmux vm-pty-attach --id issue-4509", + daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint( + url: "wss://vm.example.invalid/daemon", + headers: [:], + token: "token", + sessionId: "session", + expiresAtUnix: 4_102_444_800 + ), + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + workspace.applyRemoteConnectionStateUpdate( + .connected, + detail: "Connected to vm:issue-4509 via shared local proxy 127.0.0.1:59999", + target: "vm:issue-4509" + ) + XCTAssertTrue(workspace.isRemoteTerminalSurface(panelID)) + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, true) + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: nil) + + let deadline = Date().addingTimeInterval(0.5) + while workspace.remoteConnectionState == .connected && Date() < deadline { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + + XCTAssertNotEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, false) + } + + @MainActor + func testWebSocketRemoteTerminalEndWithoutStartupCommandStillDisconnects() throws { + let workspace = Workspace() + let initialConfig = WorkspaceRemoteConfiguration( + transport: .websocket, + destination: "vm:issue-4509-no-startup", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "cmux vm-pty-attach --id issue-4509-no-startup", + daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint( + url: "wss://vm.example.invalid/daemon", + headers: [:], + token: "token", + sessionId: "session", + expiresAtUnix: 4_102_444_800 + ), + skipDaemonBootstrap: true + ) + let config = WorkspaceRemoteConfiguration( + transport: .websocket, + destination: "vm:issue-4509-no-startup", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: nil, + daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint( + url: "wss://vm.example.invalid/daemon", + headers: [:], + token: "token", + sessionId: "session", + expiresAtUnix: 4_102_444_800 + ), + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(initialConfig, autoConnect: false) + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + workspace.applyRemoteConnectionStateUpdate( + .connected, + detail: "Connected to vm:issue-4509-no-startup", + target: "vm:issue-4509-no-startup" + ) + XCTAssertTrue(workspace.isRemoteTerminalSurface(panelID)) + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: nil) + + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, false) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + } + + @MainActor + func testRemoteTerminalSessionEndPublishesDisconnectedDetailWithoutTransientNil() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64007, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + workspace.applyRemoteConnectionStateUpdate( + .connected, + detail: "Connected to cmux-macmini", + target: "cmux-macmini" + ) + + let expectedDetail = String( + localized: "remote.status.terminalDisconnected", + defaultValue: "Remote terminal session disconnected" + ) + var publishedDetails: [String?] = [] + let cancellable = workspace.$remoteConnectionDetail + .dropFirst() + .sink { publishedDetails.append($0) } + defer { cancellable.cancel() } + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64007) + + XCTAssertEqual(workspace.remoteConnectionDetail, expectedDetail) + XCTAssertEqual(publishedDetails, [expectedDetail]) + } + + @MainActor + func testRemoteTerminalSessionEndIgnoresDuplicateRelayCallbackAfterSurfaceProcessed() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64034, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: nil) + let replacement = workspace.createReplacementTerminalPanel() + let firstReplacementCommand = replacement.surface.initialCommand + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64034) + let secondReplacement = workspace.createReplacementTerminalPanel() + + XCTAssertNotNil(firstReplacementCommand) + XCTAssertNil(secondReplacement.surface.initialCommand) + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() @@ -1220,7 +1400,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor - func testRemoteTerminalSessionEndRequestsControlMasterCleanupWhenWorkspaceDemotes() throws { + func testRemoteTerminalSessionEndRequestsControlMasterCleanupAndLeavesWorkspaceDisconnected() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -1255,7 +1435,9 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { wait(for: [cleanupRequested], timeout: 1.0) - XCTAssertFalse(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, false) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) XCTAssertEqual( capturedArguments, @@ -1272,6 +1454,56 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @MainActor + func testRemoteTerminalSessionEndWithoutCallbackRelayPortStillCleansControlMaster() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + ], + localProxyPort: nil, + relayPort: 64035, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + let cleanupRequested = expectation(description: "control master cleanup requested") + var capturedArguments: [String] = [] + + Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in + capturedArguments = arguments + cleanupRequested.fulfill() + } + defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + + workspace.configureRemoteConnection(config, autoConnect: false) + + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: nil) + + wait(for: [cleanupRequested], timeout: 1.0) + + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertEqual( + capturedArguments, + [ + "-o", "BatchMode=yes", + "-o", "ControlMaster=no", + "-p", "2222", + "-i", "/Users/test/.ssh/id_ed25519", + "-o", "ControlPath=/tmp/cmux-ssh-%C", + "-O", "exit", + "cmux-macmini", + ] + ) + } + @MainActor func testRemoteTerminalSessionEndPreservesPersistentPTYWorkspace() throws { let workspace = Workspace() @@ -1692,7 +1924,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor - func testRemoteTerminalSessionEndSkipsControlMasterCleanupWhenBrowserPanelsKeepWorkspaceRemote() throws { + func testRemoteTerminalSessionEndDisconnectsWorkspaceWhenBrowserPanelsRemain() throws { let workspace = Workspace() let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) let initialTerminalID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) @@ -1713,7 +1945,6 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { terminalStartupCommand: "ssh cmux-macmini" ) let cleanupRequested = expectation(description: "control master cleanup requested") - cleanupRequested.isInverted = true Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in cleanupRequested.fulfill() @@ -1728,6 +1959,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { wait(for: [cleanupRequested], timeout: 1.0) XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, false) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) } @@ -3263,6 +3496,50 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { "unavailable" ) } + + @MainActor + func testWebSocketDaemonTransportErrorClearsConnectedSidebarState() { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + transport: .websocket, + destination: "vm:issue-4509", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "cmux vm-pty-attach --id issue-4509", + daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint( + url: "wss://vm.example.invalid/daemon", + headers: [:], + token: "token", + sessionId: "session", + expiresAtUnix: 4_102_444_800 + ), + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + workspace.applyRemoteConnectionStateUpdate( + .connected, + detail: "Connected to vm:issue-4509 via shared local proxy 127.0.0.1:59999", + target: "vm:issue-4509" + ) + + let proxyError = "Remote proxy to vm:issue-4509 unavailable: Remote daemon transport failed: daemon websocket keepalive timed out" + workspace.applyRemoteConnectionStateUpdate(.error, detail: proxyError, target: "vm:issue-4509") + + XCTAssertEqual(workspace.remoteConnectionState, .error) + XCTAssertEqual(workspace.remoteConnectionDetail, proxyError) + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, false) + XCTAssertEqual( + ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), + "error" + ) + } } final class CLINotifyProcessIntegrationTests: XCTestCase {