From de473455388aaa69856ad71032294e48f51bc131 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:03:53 -0700 Subject: [PATCH] Address ssh review feedback and CI blockers --- CLI/cmux.swift | 124 +++++++-- Resources/Localizable.xcstrings | 204 ++++++++++++++ Sources/ContentView.swift | 100 ++++++- Sources/GhosttyTerminalView.swift | 4 + Sources/TerminalController.swift | 50 +++- Sources/Workspace.swift | 430 ++++++++++++++++++++++++++++-- Sources/cmuxApp.swift | 13 + scripts/ghosttykit-checksums.txt | 1 + 8 files changed, 884 insertions(+), 42 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5c5d2843..602b6a73 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1276,6 +1276,8 @@ struct CMUXCLI { case "ssh": try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + case "ssh-session-end": + try runSSHSessionEnd(commandArgs: commandArgs, client: client) case "new-workspace": let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") @@ -2883,8 +2885,12 @@ struct CMUXCLI { prepareSSHTerminfoIfNeeded(sshOptions) let sshCommand = buildSSHCommandText(sshOptions) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() - let sshStartupCommand = buildSSHStartupCommand(sshCommand: sshCommand, shellFeatures: shellFeaturesValue) - let remoteSSHOptions = sshOptionsWithControlSocketDefaults( + let sshStartupCommand = buildSSHStartupCommand( + sshCommand: sshCommand, + shellFeatures: shellFeaturesValue, + remoteRelayPort: sshOptions.remoteRelayPort + ) + let remoteSSHOptions = effectiveSSHOptions( sshOptions.sshOptions, remoteRelayPort: sshOptions.remoteRelayPort ) @@ -2939,6 +2945,7 @@ struct CMUXCLI { configureParams["relay_port"] = sshOptions.remoteRelayPort configureParams["local_socket_path"] = sshOptions.localSocketPath } + configureParams["terminal_startup_command"] = sshStartupCommand cliDebugLog( "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + @@ -2960,7 +2967,12 @@ struct CMUXCLI { cliDebugLog( "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" ) - _ = try? client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) + do { + _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) + } catch { + let warning = "Warning: failed to rollback workspace \(workspaceId): \(error)\n" + FileHandle.standardError.write(Data(warning.utf8)) + } throw error } @@ -3055,12 +3067,6 @@ struct CMUXCLI { guard let destination else { throw CLIError(message: "ssh requires a destination (example: cmux ssh user@host)") } - if destination.hasPrefix("-") { - throw CLIError( - message: "ssh: destination must be . Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args." - ) - } - return SSHCommandOptions( destination: destination, port: port, @@ -3075,6 +3081,7 @@ struct CMUXCLI { private func buildSSHCommandText(_ options: SSHCommandOptions) -> String { var parts = baseSSHArguments(options) + let shellFeaturesValue = scopedGhosttyShellFeaturesValue() if options.extraArguments.isEmpty { // No explicit remote command provided. Use RemoteCommand to bootstrap @@ -3085,7 +3092,7 @@ struct CMUXCLI { if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") { parts += [ "-o", - "RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort))", + "RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))", ] } parts.append(options.destination) @@ -3096,11 +3103,21 @@ struct CMUXCLI { return parts.map(shellQuote).joined(separator: " ") } - private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int) -> String { + private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] { + var merged = sshOptionsWithControlSocketDefaults(options, remoteRelayPort: remoteRelayPort) + if !hasSSHOptionKey(merged, key: "StrictHostKeyChecking") { + merged.append("StrictHostKeyChecking=accept-new") + } + return merged + } + + private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String { let relayExport = remoteRelayPort > 0 ? "export CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort)" : nil + let remoteEnvExports = interactiveRemoteShellExports(shellFeatures: shellFeatures) let innerCommand = [ + remoteEnvExports, "export PATH=\"$HOME/.cmux/bin:$PATH\"", relayExport, "exec \"${SHELL:-/bin/zsh}\" -i", @@ -3115,6 +3132,7 @@ struct CMUXCLI { " exec \"$CMUX_LOGIN_SHELL\" -lc \(shellQuote(innerCommand))", " ;;", " *)", + remoteEnvExports, " export PATH=\"$HOME/.cmux/bin:$PATH\"", relayExport, " exec \"$CMUX_LOGIN_SHELL\" -i", @@ -3127,15 +3145,36 @@ struct CMUXCLI { return outerCommand } + private func interactiveRemoteShellExports(shellFeatures: String) -> String { + let environment = ProcessInfo.processInfo.environment + let term = Self.normalizedEnvValue(environment["TERM"]) ?? "xterm-ghostty" + let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" + let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" + let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) + ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) + ?? "" + let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + + var exports: [String] = [ + "export TERM=\(shellQuote(term))", + "export COLORTERM=\(shellQuote(colorTerm))", + "export TERM_PROGRAM=\(shellQuote(termProgram))", + ] + if !termProgramVersion.isEmpty { + exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") + } + if !trimmedShellFeatures.isEmpty { + exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") + } + return exports.joined(separator: "; ") + } + private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { - let effectiveSSHOptions = sshOptionsWithControlSocketDefaults( + let effectiveSSHOptions = effectiveSSHOptions( options.sshOptions, remoteRelayPort: options.remoteRelayPort ) var parts: [String] = ["ssh"] - if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { - parts += ["-o", "StrictHostKeyChecking=accept-new"] - } if !hasSSHOptionKey(effectiveSSHOptions, key: "SetEnv") { parts += ["-o", "SetEnv COLORTERM=truecolor"] } @@ -3228,17 +3267,68 @@ struct CMUXCLI { return merged.joined(separator: ",") } - private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String) -> String { + private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String { let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty ? "" : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" - let script = [shellFeaturesBootstrap, "command \(sshCommand); exec ${SHELL:-/bin/zsh} -l"] + let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) + let script = [ + shellFeaturesBootstrap, + "CMUX_SSH_SESSION_ENDED=0", + "cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }", + "trap 'cmux_ssh_session_end' EXIT HUP INT TERM", + "command \(sshCommand)", + "trap - EXIT HUP INT TERM", + "cmux_ssh_session_end", + "exec ${SHELL:-/bin/zsh} -l", + ] .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } .joined(separator: "\n") return "/bin/zsh -ilc \(shellQuote(script))" } + private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { + [ + "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", + "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", + "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", + "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", + "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "elif command -v cmux >/dev/null 2>&1", + "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", + "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "fi", + ].joined(separator: " ") + } + + private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws { + guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"), + let relayPort = Int(relayPortRaw), + relayPort > 0 else { + throw CLIError(message: "ssh-session-end requires --relay-port ") + } + let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + guard let workspaceRaw, + let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client), + !workspaceId.isEmpty else { + throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID") + } + guard let surfaceRaw, + let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId), + !surfaceId.isEmpty else { + throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID") + } + _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "relay_port": relayPort, + ]) + } + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { let loweredKey = key.lowercased() for option in options { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b7c73485..9d096941 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -25452,6 +25452,57 @@ } } }, + "contextMenu.copyError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーをコピー" + } + } + } + }, + "contextMenu.copyErrors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Errors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーをコピー" + } + } + } + }, + "contextMenu.copySshError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy SSH Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSHエラーをコピー" + } + } + } + }, "contextMenu.moveDown": { "extractionState": "manual", "localizations": { @@ -42792,6 +42843,40 @@ } } }, + "settings.app.showSSH": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show SSH in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにSSHを表示" + } + } + } + }, + "settings.app.showSSH.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display the SSH target for remote workspaces in its own row." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートワークスペースのSSHターゲットを専用の行に表示します。" + } + } + } + }, "settings.app.showPorts.subtitle": { "extractionState": "manual", "localizations": { @@ -61336,6 +61421,125 @@ } } }, + "sidebar.remote.badge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + } + } + }, + "remote.status.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続済み" + } + } + } + }, + "remote.status.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続中" + } + } + } + }, + "remote.status.disconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切断済み" + } + } + } + }, + "remote.status.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + } + } + }, + "sidebar.remote.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH • %@" + } + } + } + }, + "sidebar.remote.subtitleFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH ワークスペース" + } + } + } + }, "sidebar.workspace.moveDownAction": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6b55d5ef..64e72a7f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -74,6 +74,47 @@ func cmuxAccentColor() -> Color { Color(nsColor: cmuxAccentNSColor()) } +struct SidebarRemoteErrorCopyEntry: Equatable { + let workspaceTitle: String + let target: String + let detail: String +} + +enum SidebarRemoteErrorCopySupport { + static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1 { + return String(localized: "contextMenu.copyError", defaultValue: "Copy Error") + } + return String(localized: "contextMenu.copyErrors", defaultValue: "Copy Errors") + } + + static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1, let entry = entries.first { + return "SSH error (\(entry.target)): \(entry.detail)" + } + + return entries.enumerated().map { index, entry in + "\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)" + }.joined(separator: "\n") + } + + static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("SSH error") else { return nil } + + if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) { + return (String(match.1), String(match.2)) + } + if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) { + guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil } + return (fallbackTarget, String(match.1)) + } + return nil + } +} + func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { cmuxAccentNSColor(for: colorScheme) } @@ -1929,6 +1970,7 @@ struct ContentView: View { lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) .frame(width: sidebarWidth) + .frame(maxHeight: .infinity, alignment: .topLeading) } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -7292,6 +7334,7 @@ struct VerticalTabsSidebar: View { #endif draggedTabId = nil } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func debugShortSidebarTabId(_ id: UUID?) -> String { @@ -9485,6 +9528,7 @@ private struct TabItemView: View, Equatable { @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -9587,6 +9631,15 @@ private struct TabItemView: View, Equatable { ) } + private var remoteWorkspaceSidebarText: String? { + guard tab.hasActiveRemoteTerminalSessions 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") + } + private var copyableSidebarSSHError: String? { let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { @@ -9601,6 +9654,48 @@ private struct TabItemView: View, Equatable { return nil } + private var remoteConnectionStatusText: String { + switch tab.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + } + } + + @ViewBuilder + private var remoteWorkspaceSection: some View { + if sidebarShowSSH, let remoteWorkspaceSidebarText { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "sidebar.remote.badge", defaultValue: "SSH")) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(activeSecondaryColor(0.62)) + .textCase(.uppercase) + + HStack(spacing: 6) { + Text(remoteWorkspaceSidebarText) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + Text(remoteConnectionStatusText) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.58)) + .lineLimit(1) + } + } + .padding(.top, latestNotificationText == nil ? 1 : 2) + .safeHelp(remoteStateHelpText) + } + } + private func copyTextToPasteboard(_ text: String) { let pasteboard = NSPasteboard.general pasteboard.clearContents() @@ -9613,6 +9708,7 @@ private struct TabItemView: View, Equatable { let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") let latestNotificationSubtitle = latestNotificationText + let effectiveSubtitle = latestNotificationSubtitle let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest) ? tab.sidebarOrderedPanelIds() : nil @@ -9716,7 +9812,7 @@ private struct TabItemView: View, Equatable { .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } - if let subtitle = latestNotificationSubtitle { + if let subtitle = effectiveSubtitle { Text(subtitle) .font(.system(size: 10)) .foregroundColor(activeSecondaryColor(0.8)) @@ -9725,6 +9821,8 @@ private struct TabItemView: View, Equatable { .multilineTextAlignment(.leading) } + remoteWorkspaceSection + if sidebarShowMetadata { let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ce5d5dd9..a9bbb0a9 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2811,6 +2811,10 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_PANEL_ID"] = id.uuidString env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + if let bundledCLIPath = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux").path, + !bundledCLIPath.isEmpty { + env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIPath + } if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { env["CMUX_BUNDLE_ID"] = bundleId } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6a2f35e3..917840a2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1741,6 +1741,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) case "workspace.remote.status": return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) + case "workspace.remote.terminal_session_end": + return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) // Settings case "settings.open": @@ -2103,6 +2105,7 @@ class TerminalController { "workspace.remote.reconnect", "workspace.remote.disconnect", "workspace.remote.status", + "workspace.remote.terminal_session_end", "settings.open", "feedback.open", "feedback.submit", @@ -3339,6 +3342,8 @@ class TerminalController { let autoConnect = v2Bool(params, "auto_connect") ?? true let relayPort = v2Int(params, "relay_port") let localSocketPath = v2RawString(params, "local_socket_path") + let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? + .trimmingCharacters(in: .whitespacesAndNewlines) #if DEBUG dlog( @@ -3368,7 +3373,8 @@ class TerminalController { sshOptions: sshOptions, localProxyPort: localProxyPort, relayPort: relayPort, - localSocketPath: localSocketPath + localSocketPath: localSocketPath, + terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand ) workspace.configureRemoteConnection(config, autoConnect: autoConnect) @@ -3503,6 +3509,48 @@ class TerminalController { return result } + private func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let surfaceId = v2UUID(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let relayPort = v2Int(params, "relay_port"), + relayPort > 0 else { + return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "relay_port": relayPort, + ]) + + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "relay_port": relayPort, + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index cd580588..c1253fb8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1469,11 +1469,7 @@ private final class WorkspaceRemoteDaemonProxyTunnel { if let streamID { rpcClient.closeStream(streamID: streamID) } - if reason != nil { - connection.cancel() - } else { - connection.cancel() - } + connection.cancel() onClose(id) } @@ -1959,6 +1955,10 @@ private final class WorkspaceRemoteSessionController { private var daemonReady = false private var daemonBootstrapVersion: String? private var daemonRemotePath: String? + private var reverseRelayProcess: Process? + private var reverseRelayStderrPipe: Pipe? + private var reverseRelayRestartWorkItem: DispatchWorkItem? + private var reverseRelayStderrBuffer = "" private var reconnectRetryCount = 0 private var reconnectWorkItem: DispatchWorkItem? private var heartbeatWorkItem: DispatchWorkItem? @@ -1998,7 +1998,10 @@ private final class WorkspaceRemoteSessionController { reconnectWorkItem?.cancel() reconnectWorkItem = nil reconnectRetryCount = 0 + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil stopHeartbeatLocked(reset: true) + stopReverseRelayLocked() proxyLease?.release() proxyLease = nil @@ -2045,6 +2048,8 @@ private final class WorkspaceRemoteSessionController { capabilities: hello.capabilities, remotePath: hello.remotePath ) + prepareRemoteCLISessionLocked(remotePath: hello.remotePath) + startReverseRelayLocked(remotePath: hello.remotePath) startProxyLocked() } catch { daemonReady = false @@ -2083,6 +2088,129 @@ private final class WorkspaceRemoteSessionController { proxyLease = lease } + private func prepareRemoteCLISessionLocked(remotePath: String) { + createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) + writeRemoteRelayDaemonPathLocked(remotePath: remotePath) + } + + private func startReverseRelayLocked(remotePath: String) { + guard !isStopping else { return } + guard daemonReady else { return } + guard let relayPort = configuration.relayPort, relayPort > 0, + let localSocketPath = configuration.localSocketPath? + .trimmingCharacters(in: .whitespacesAndNewlines), + !localSocketPath.isEmpty else { + return + } + guard reverseRelayProcess == nil else { return } + + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + Self.killOrphanedRelayProcesses( + relayPort: relayPort, + socketPath: localSocketPath, + destination: configuration.destination + ) + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = reverseRelayArguments(relayPort: relayPort, localSocketPath: localSocketPath) + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } + } + } + } + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTerminationLocked(process: terminated) + } + } + + do { + try process.run() + reverseRelayProcess = process + reverseRelayStderrPipe = stderrPipe + reverseRelayStderrBuffer = "" + debugLog( + "remote.relay.start relayPort=\(relayPort) localSocket=\(localSocketPath) " + + "target=\(configuration.displayTarget)" + ) + + queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + guard self.reverseRelayProcess === process, process.isRunning else { return } + self.writeRemoteSocketAddrLocked(relayPort: relayPort) + self.writeRemoteRelayDaemonPathLocked(remotePath: remotePath) + } + } catch { + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) localSocket=\(localSocketPath) " + + "error=\(error.localizedDescription)" + ) + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + } + + private func handleReverseRelayTerminationLocked(process: Process) { + guard reverseRelayProcess === process else { return } + let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + + guard !isStopping else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let detail = stderrDetail ?? "status=\(process.terminationStatus)" + debugLog("remote.relay.exit \(detail)") + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + + private func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { + guard !isStopping else { return } + reverseRelayRestartWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reverseRelayRestartWorkItem = nil + guard !self.isStopping else { return } + guard self.reverseRelayProcess == nil else { return } + guard self.daemonReady else { return } + self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) + } + reverseRelayRestartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func stopReverseRelayLocked() { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if let reverseRelayProcess, reverseRelayProcess.isRunning { + reverseRelayProcess.terminate() + } + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + reverseRelayStderrBuffer = "" + } + private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { guard !isStopping else { return } switch update { @@ -2262,6 +2390,21 @@ private final class WorkspaceRemoteSessionController { } } + private func reverseRelayArguments(relayPort: Int, localSocketPath: String) -> [String] { + // `-o ControlPath=none` is not enough on macOS OpenSSH, the client can still + // attach to an existing master and exit immediately with its status. + // `-S none` forces a standalone transport for the reverse relay. + var args: [String] = ["-N", "-T", "-S", "none"] + args += sshCommonArguments(batchMode: true) + args += [ + "-o", "ExitOnForwardFailure=no", + "-o", "RequestTTY=no", + "-R", "127.0.0.1:\(relayPort):\(localSocketPath)", + configuration.destination, + ] + return args + } + private func sshCommonArguments(batchMode: Bool) -> [String] { let effectiveSSHOptions: [String] = { if batchMode { @@ -2385,9 +2528,38 @@ private final class WorkspaceRemoteSessionController { process.standardInput = FileHandle.nullDevice } + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + let captureQueue = DispatchQueue(label: "cmux.remote.process.capture") + var stdoutData = Data() + var stderrData = Data() + + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + captureQueue.sync { + if data.isEmpty { + handle.readabilityHandler = nil + } else { + stdoutData.append(data) + } + } + } + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + captureQueue.sync { + if data.isEmpty { + handle.readabilityHandler = nil + } else { + stderrData.append(data) + } + } + } + do { try process.run() } catch { + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil debugLog( "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + "error=\(error.localizedDescription)" @@ -2425,8 +2597,14 @@ private final class WorkspaceRemoteSessionController { ]) } - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + captureQueue.sync { + stdoutData.append(stdoutHandle.readDataToEndOfFile()) + stderrData.append(stderrHandle.readDataToEndOfFile()) + } + try? stdoutHandle.close() + try? stderrHandle.close() let stdout = String(data: stdoutData, encoding: .utf8) ?? "" let stderr = String(data: stderrData, encoding: .utf8) ?? "" debugLog( @@ -2469,6 +2647,68 @@ private final class WorkspaceRemoteSessionController { return hello } + private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedRemotePath.isEmpty else { return } + + let script = """ + mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" + ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" + ln -sf "$HOME/.cmux/bin/cmuxd-remote-current" "$HOME/.cmux/bin/cmux" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.wrapper.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.wrapper.error \(error.localizedDescription)") + } + } + + private func writeRemoteSocketAddrLocked(relayPort: Int) { + let script = """ + mkdir -p "$HOME/.cmux" + printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.socketAddr.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.socketAddr.error \(error.localizedDescription)") + } + } + + private func writeRemoteRelayDaemonPathLocked(remotePath: String) { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let trimmedRemotePath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedRemotePath.isEmpty else { return } + + let script = """ + mkdir -p "$HOME/.cmux/relay" + printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + debugLog( + "remote.relay.daemonPath.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" + ) + } + } catch { + debugLog("remote.relay.daemonPath.error \(error.localizedDescription)") + } + } + private func resolveRemotePlatformLocked() throws -> RemotePlatform { let script = "uname -s; uname -m" let command = "sh -c \(Self.shellSingleQuoted(script))" @@ -2784,6 +3024,20 @@ private final class WorkspaceRemoteSessionController { ".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote" } + private static func killOrphanedRelayProcesses(relayPort: Int, socketPath: String, destination: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):\(socketPath).*\(destination)"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + } catch { + // Best effort cleanup only. + } + } + private static func which(_ executable: String) -> String? { let path = ProcessInfo.processInfo.environment["PATH"] ?? "" for component in path.split(separator: ":") { @@ -2950,6 +3204,7 @@ struct WorkspaceRemoteConfiguration: Equatable { let localProxyPort: Int? let relayPort: Int? let localSocketPath: String? + let terminalStartupCommand: String? var displayTarget: String { guard let port else { return destination } @@ -3326,12 +3581,14 @@ final class Workspace: Identifiable, ObservableObject { @Published var remoteHeartbeatCount: Int = 0 @Published var remoteLastHeartbeatAt: Date? @Published var listeningPorts: [Int] = [] + @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 var surfaceTTYNames: [UUID: String] = [:] private var remoteSessionController: WorkspaceRemoteSessionController? fileprivate var activeRemoteSessionControllerID: UUID? private var remoteLastErrorFingerprint: String? private var remoteLastDaemonErrorFingerprint: String? private var remoteLastPortConflictFingerprint: String? + private var activeRemoteTerminalSurfaceIds: Set = [] private static let remoteErrorStatusKey = "remote.error" private static let remotePortConflictStatusKey = "remote.port_conflicts" @@ -3377,7 +3634,19 @@ final class Workspace: Identifiable, ObservableObject { } private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { - bonsplitAppearance(from: config.backgroundColor) + bonsplitAppearance( + from: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity + ) + } + + static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { + let themedColor = GhosttyBackgroundTheme.color( + backgroundColor: backgroundColor, + opacity: backgroundOpacity + ) + let includeAlpha = themedColor.alphaComponent < 0.999 + return themedColor.hexString(includeAlpha: includeAlpha) } nonisolated static func resolvedChromeColors( @@ -3386,40 +3655,52 @@ final class Workspace: Identifiable, ObservableObject { .init(backgroundHex: backgroundColor.hexString()) } - private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { - let chromeColors = resolvedChromeColors(from: backgroundColor) - return BonsplitConfiguration.Appearance( + private static func bonsplitAppearance( + from backgroundColor: NSColor, + backgroundOpacity: Double + ) -> BonsplitConfiguration.Appearance { + BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, - chromeColors: chromeColors + chromeColors: .init( + backgroundHex: Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + ) ) } func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { - applyGhosttyChrome(backgroundColor: config.backgroundColor, reason: reason) + applyGhosttyChrome( + backgroundColor: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity, + reason: reason + ) } - func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { + func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { + let nextHex = Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) let currentChromeColors = bonsplitController.configuration.appearance.chromeColors - let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor) - let isNoOp = currentChromeColors.backgroundHex == nextChromeColors.backgroundHex && - currentChromeColors.borderHex == nextChromeColors.borderHex + let isNoOp = currentChromeColors.backgroundHex == nextHex if GhosttyApp.shared.backgroundLogEnabled { let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" - let nextBackgroundHex = nextChromeColors.backgroundHex ?? "nil" GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" + "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" ) } if isNoOp { return } - bonsplitController.configuration.appearance.chromeColors = nextChromeColors + bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( - "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" ) } } @@ -3448,7 +3729,10 @@ final class Workspace: Identifiable, ObservableObject { // and keep split entry instantaneous. // Avoid re-reading/parsing Ghostty config on every new workspace; this hot path // runs for socket/CLI workspace creation and can cause visible typing lag. - let appearance = Self.bonsplitAppearance(from: GhosttyApp.shared.defaultBackgroundColor) + let appearance = Self.bonsplitAppearance( + from: GhosttyApp.shared.defaultBackgroundColor, + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, @@ -4225,6 +4509,10 @@ final class Workspace: Identifiable, ObservableObject { remoteConfiguration?.displayTarget } + var hasActiveRemoteTerminalSessions: Bool { + activeRemoteTerminalSessionCount > 0 + } + func remoteStatusPayload() -> [String: Any] { let heartbeatAgeSeconds: Any = { guard let last = remoteLastHeartbeatAt else { return NSNull() } @@ -4238,6 +4526,7 @@ final class Workspace: Identifiable, ObservableObject { "enabled": remoteConfiguration != nil, "state": remoteConnectionState.rawValue, "connected": remoteConnectionState == .connected, + "active_terminal_sessions": activeRemoteTerminalSessionCount, "daemon": remoteDaemonStatus.payload(), "detected_ports": remoteDetectedPorts, "forwarded_ports": remoteForwardedPorts, @@ -4294,6 +4583,7 @@ final class Workspace: Identifiable, ObservableObject { func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { remoteConfiguration = configuration + seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) remoteDetectedPorts = [] remoteForwardedPorts = [] remotePortConflicts = [] @@ -4345,6 +4635,8 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteSessionControllerID = nil remoteSessionController = nil previousController?.stop() + activeRemoteTerminalSurfaceIds.removeAll() + activeRemoteTerminalSessionCount = 0 remoteDetectedPorts = [] remoteForwardedPorts = [] remotePortConflicts = [] @@ -4367,6 +4659,51 @@ final class Workspace: Identifiable, ObservableObject { recomputeListeningPorts() } + private func clearRemoteConfigurationIfWorkspaceBecameLocal() { + guard panels.isEmpty, remoteConfiguration != nil else { return } + disconnectRemoteConnection(clearConfiguration: true) + } + + private func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { + guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return + } + guard activeRemoteTerminalSurfaceIds.isEmpty else { return } + let terminalIds = panels.compactMap { panelId, panel in + panel is TerminalPanel ? panelId : nil + } + guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } + trackRemoteTerminalSurface(initialPanelId) + } + + private func trackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + } + + private func untrackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() + } + + private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { + guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } + let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } + if !hasBrowserPanels { + disconnectRemoteConnection(clearConfiguration: true) + } + } + + func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return + } + untrackRemoteTerminalSurface(surfaceId) + } + func teardownRemoteConnection() { disconnectRemoteConnection(clearConfiguration: true) } @@ -4681,16 +5018,21 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit @@ -4716,6 +5058,9 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -4758,6 +5103,7 @@ final class Workspace: Identifiable, ObservableObject { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Create new terminal panel let newPanel = TerminalPanel( @@ -4766,10 +5112,14 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, workingDirectory: workingDirectory, portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand, additionalEnvironment: startupEnvironment ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit @@ -4783,6 +5133,9 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -4801,6 +5154,12 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } + private func remoteTerminalStartupCommand() -> String? { + guard hasActiveRemoteTerminalSessions else { return nil } + return remoteConfiguration?.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + /// Create a new browser panel split @discardableResult func newBrowserSplit( @@ -5037,6 +5396,27 @@ final class Workspace: Identifiable, ObservableObject { return markdownPanel } + /// Tear down all panels in this workspace, freeing their Ghostty surfaces. + /// Called before the workspace is removed from TabManager to ensure child + /// processes receive SIGHUP even if ARC deallocation is delayed. + func teardownAllPanels() { + let panelEntries = Array(panels) + for (panelId, panel) in panelEntries { + panelSubscriptions.removeValue(forKey: panelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + panel.close() + } + + panels.removeAll(keepingCapacity: false) + surfaceIdToPanelId.removeAll(keepingCapacity: false) + panelSubscriptions.removeAll(keepingCapacity: false) + pruneSurfaceMetadata(validSurfaceIds: []) + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) + lastTerminalConfigInheritancePanelId = nil + lastTerminalConfigInheritanceFontPoints = nil + } + /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { @@ -7163,6 +7543,7 @@ extension Workspace: BonsplitDelegate { } panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) @@ -7180,6 +7561,7 @@ extension Workspace: BonsplitDelegate { if lastTerminalConfigInheritancePanelId == panelId { lastTerminalConfigInheritancePanelId = nil } + clearRemoteConfigurationIfWorkspaceBecameLocal() // Keep the workspace invariant for normal close paths. // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can @@ -7309,6 +7691,7 @@ extension Workspace: BonsplitDelegate { for panelId in closedPanelIds { panels[panelId]?.close() panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) panelPullRequests.removeValue(forKey: panelId) @@ -7326,6 +7709,7 @@ extension Workspace: BonsplitDelegate { let closedSet = Set(closedPanelIds) surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() + clearRemoteConfigurationIfWorkspaceBecameLocal() if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e2c65a34..36bc6a05 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3086,6 +3086,7 @@ struct SettingsView: View { private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey) private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -3697,6 +3698,17 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), + subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.") + ) { + Toggle("", isOn: $sidebarShowSSH) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") @@ -4382,6 +4394,7 @@ struct SettingsView: View { sidebarShowPullRequest = true openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + sidebarShowSSH = true sidebarShowPorts = true sidebarShowLog = true sidebarShowProgress = true diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 8ab36d3b..522da07a 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -3,3 +3,4 @@ # Format: 7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d +c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df