Address ssh review feedback and CI blockers
This commit is contained in:
parent
3c2de584ba
commit
de47345538
8 changed files with 884 additions and 42 deletions
124
CLI/cmux.swift
124
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 <user@host>. 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 <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 {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<UUID> = []
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@
|
|||
# Format: <ghostty_sha> <sha256>
|
||||
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
|
||||
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
|
||||
c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue