diff --git a/CLAUDE.md b/CLAUDE.md index 01c526271264..55093a6f0ff5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,7 @@ The app has a **Debug** menu in the macOS menu bar (only in DEBUG builds). Use i - **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn. - **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. - **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc. +- **Shortcut policy:** Every new cmux-owned keyboard shortcut must be added to `KeyboardShortcutSettings`, visible/editable in Settings, supported in `~/.config/cmux/settings.json`, and documented in the keyboard shortcut and configuration docs. ## Test quality policy diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 83c84b330ffb..7d24c6621eb0 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit import libzig_crypto import libzig_keychain import Darwin @@ -515,6 +516,13 @@ private final class ClaudeHookSessionStore { } } +private let codexHookWrapperProcessNames: Set = [ + "sh", + "bash", + "zsh", + "env" +] + enum CLIIDFormat: String { case refs case uuids @@ -850,14 +858,26 @@ private enum CLISocketPathResolver { } final class SocketClient { + private struct RelayEndpoint { + let host: String + let port: UInt16 + } + + private struct RelayCredentials { + let relayID: String + let relayToken: Data + } + private let path: String private var socketFD: Int32 = -1 private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0 private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12 + private static let maxSocketTimeoutSeconds: TimeInterval = 9_007_199_254_740_991 private static let responseTimeoutSeconds: TimeInterval = { let env = ProcessInfo.processInfo.environment if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"], let seconds = Double(raw), + seconds.isFinite, seconds > 0 { return seconds } @@ -872,6 +892,32 @@ final class SocketClient { path } + private var relayEndpoint: RelayEndpoint? { + Self.parseRelayEndpoint(path) + } + + private static func trimmedEnvValue(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func socketTimeval(for timeout: TimeInterval) -> timeval { + let sanitizedTimeout = timeout.isFinite ? timeout : defaultResponseTimeoutSeconds + let clampedTimeout = min(max(sanitizedTimeout, 0.01), maxSocketTimeoutSeconds) + let seconds = floor(clampedTimeout) + let microseconds = min( + max(Int((clampedTimeout - seconds) * 1_000_000), 0), + 999_999 + ) + return timeval( + tv_sec: Int(seconds), + tv_usec: __darwin_suseconds_t(microseconds) + ) + } + func connect() throws { if socketFD >= 0 { return } try connectOnce() @@ -885,15 +931,24 @@ final class SocketClient { } func send(command: String) throws -> String { + if relayEndpoint != nil, socketFD < 0 { + try connect() + } guard socketFD >= 0 else { throw CLIError(message: "Not connected") } - let payload = command + "\n" - try payload.withCString { ptr in - let sent = Darwin.write(socketFD, ptr, strlen(ptr)) - if sent < 0 { - throw CLIError(message: "Failed to write to socket") + let shouldCloseAfterSend = relayEndpoint != nil + defer { + if shouldCloseAfterSend { + close() } } + let payload = command + "\n" + try writeAll( + Data(payload.utf8), + timeoutMessage: "Command timed out", + failureMessage: "Failed to write to socket" + ) + var data = Data() var sawNewline = false @@ -935,6 +990,11 @@ final class SocketClient { } private func connectOnce() throws { + if let relayEndpoint { + try connectToRelay(endpoint: relayEndpoint) + return + } + // Verify socket is owned by the current user to prevent fake-socket attacks. var st = stat() guard stat(path, &st) == 0 else { @@ -951,6 +1011,12 @@ final class SocketClient { if socketFD < 0 { throw CLIError(message: "Failed to create socket") } + do { + try configureSocketWriteSafety(Self.responseTimeoutSeconds) + } catch { + close() + throw error + } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) @@ -979,11 +1045,258 @@ final class SocketClient { ) } - private func configureReceiveTimeout(_ timeout: TimeInterval) throws { - var interval = timeval( - tv_sec: Int(timeout.rounded(.down)), - tv_usec: __darwin_suseconds_t((timeout - floor(timeout)) * 1_000_000) + private static func parseRelayEndpoint(_ raw: String) -> RelayEndpoint? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + !trimmed.hasPrefix("/") else { + return nil + } + let components = trimmed.split(separator: ":", omittingEmptySubsequences: false) + guard components.count == 2, + let port = UInt16(components[1]), + port > 0 else { + return nil + } + let host = String(components[0]).lowercased() + guard host == "127.0.0.1" || host == "localhost" else { + return nil + } + return RelayEndpoint(host: host == "localhost" ? "127.0.0.1" : host, port: port) + } + + private static func relayCredentials(for endpoint: RelayEndpoint) throws -> RelayCredentials { + let environment = ProcessInfo.processInfo.environment + if let relayID = trimmedEnvValue(environment["CMUX_RELAY_ID"]), + let relayTokenHex = trimmedEnvValue(environment["CMUX_RELAY_TOKEN"]), + let relayToken = hexData(from: relayTokenHex) { + return RelayCredentials(relayID: relayID, relayToken: relayToken) + } + + let authURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent(".cmux/relay/\(endpoint.port).auth", isDirectory: false) + guard let authData = try? Data(contentsOf: authURL), + let authObject = try? JSONSerialization.jsonObject(with: authData) as? [String: Any], + let relayID = trimmedEnvValue(authObject["relay_id"] as? String), + let relayTokenHex = trimmedEnvValue(authObject["relay_token"] as? String), + let relayToken = hexData(from: relayTokenHex) else { + throw CLIError(message: "Missing relay auth metadata for \(endpoint.host):\(endpoint.port)") + } + + return RelayCredentials(relayID: relayID, relayToken: relayToken) + } + + private static func hexData(from string: String) -> Data? { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, + normalized.count.isMultiple(of: 2) else { + return nil + } + + var data = Data(capacity: normalized.count / 2) + var cursor = normalized.startIndex + while cursor < normalized.endIndex { + let next = normalized.index(cursor, offsetBy: 2) + guard let byte = UInt8(normalized[cursor.. String { + data.map { String(format: "%02x", $0) }.joined() + } + + private func connectToRelay(endpoint: RelayEndpoint) throws { + let credentials = try Self.relayCredentials(for: endpoint) + + socketFD = socket(AF_INET, SOCK_STREAM, 0) + guard socketFD >= 0 else { + throw CLIError(message: "Failed to create relay socket") + } + do { + try configureSocketWriteSafety(Self.responseTimeoutSeconds) + try configureReceiveTimeout(Self.responseTimeoutSeconds) + } catch { + close() + throw error + } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.stride) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = endpoint.port.bigEndian + let parsedAddress = withUnsafeMutablePointer(to: &address.sin_addr) { pointer in + endpoint.host.withCString { hostPointer in + inet_pton(AF_INET, hostPointer, pointer) + } + } + guard parsedAddress == 1 else { + close() + throw CLIError(message: "Invalid relay endpoint \(endpoint.host):\(endpoint.port)") + } + + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.connect(socketFD, sockaddrPointer, socklen_t(MemoryLayout.stride)) + } + } + if result != 0 { + let connectErrno = errno + close() + throw CLIError( + message: "Failed to connect to relay at \(endpoint.host):\(endpoint.port) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" + ) + } + + do { + try authenticateRelay(credentials: credentials) + } catch { + close() + throw error + } + } + + private func authenticateRelay(credentials: RelayCredentials) throws { + let challengeLine = try readLine() + guard let challengeData = challengeLine.data(using: .utf8), + let challenge = try JSONSerialization.jsonObject(with: challengeData) as? [String: Any], + (challenge["protocol"] as? String) == "cmux-relay-auth", + let version = challenge["version"] as? Int, + let relayID = challenge["relay_id"] as? String, + relayID == credentials.relayID, + let nonce = challenge["nonce"] as? String, + !nonce.isEmpty else { + throw CLIError(message: "Invalid relay authentication challenge") + } + + let authMessage = Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) + let key = SymmetricKey(data: credentials.relayToken) + let mac = Data(HMAC.authenticationCode(for: authMessage, using: key)) + let authPayload = try JSONSerialization.data(withJSONObject: [ + "relay_id": relayID, + "mac": Self.hexString(from: mac), + ]) + try writeAll( + authPayload + Data([0x0A]), + timeoutMessage: "Relay command timed out", + failureMessage: "Failed to write to relay socket" ) + + let authResponseLine = try readLine() + guard let authResponseData = authResponseLine.data(using: .utf8), + let authResponse = try JSONSerialization.jsonObject(with: authResponseData) as? [String: Any], + (authResponse["ok"] as? Bool) == true else { + throw CLIError(message: "Relay authentication failed") + } + } + + private func writeAll( + _ data: Data, + timeoutMessage: String, + failureMessage: String + ) throws { + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return + } + var offset = 0 + while offset < data.count { + let written = Darwin.write(socketFD, baseAddress.advanced(by: offset), data.count - offset) + if written < 0 { + let errorCode = errno + if errorCode == EINTR { + continue + } + close() + if errorCode == EAGAIN || errorCode == EWOULDBLOCK || errorCode == ETIMEDOUT { + throw CLIError(message: timeoutMessage) + } + let reason = String(cString: strerror(errorCode)) + throw CLIError( + message: "\(failureMessage) (\(reason), errno \(errorCode))" + ) + } + if written == 0 { + close() + throw CLIError(message: failureMessage) + } + offset += written + } + } + } + + private func configureSocketWriteSafety(_ timeout: TimeInterval) throws { + var interval = Self.socketTimeval(for: timeout) + let sendTimeoutResult = withUnsafePointer(to: &interval) { ptr in + setsockopt( + socketFD, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + guard sendTimeoutResult == 0 else { + throw CLIError(message: "Failed to configure socket write timeout") + } + +#if os(macOS) + var noSigPipe: Int32 = 1 + let noSigPipeResult = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + socketFD, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout.size) + ) + } + guard noSigPipeResult == 0 else { + throw CLIError(message: "Failed to disable SIGPIPE on socket") + } +#endif + } + + private func readLine(maxBytes: Int = 16 * 1024) throws -> String { + var data = Data() + + while data.count < maxBytes { + try configureReceiveTimeout(Self.responseTimeoutSeconds) + + var byte: UInt8 = 0 + let count = Darwin.read(socketFD, &byte, 1) + if count < 0 { + if errno == EINTR { + continue + } + if errno == EAGAIN || errno == EWOULDBLOCK { + throw CLIError(message: "Relay command timed out") + } + throw CLIError(message: "Relay socket read error") + } + if count == 0 { + break + } + if byte == 0x0A { + break + } + data.append(byte) + } + + guard !data.isEmpty else { + throw CLIError(message: "Unexpected EOF from relay") + } + guard let line = String(data: data, encoding: .utf8) else { + throw CLIError(message: "Invalid UTF-8 relay response") + } + return line.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func configureReceiveTimeout(_ timeout: TimeInterval) throws { + var interval = Self.socketTimeval(for: timeout) let result = withUnsafePointer(to: &interval) { ptr in setsockopt( socketFD, @@ -1001,6 +1314,9 @@ final class SocketClient { static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient { let client = SocketClient(path: path) if (try? client.connect()) != nil { + if client.relayEndpoint != nil { + client.close() + } return client } @@ -1554,6 +1870,16 @@ struct CMUXCLI { let response = try client.sendV2(method: "system.capabilities") print(jsonString(formatIDs(response, mode: idFormat))) + case "rpc": + guard let method = commandArgs.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !method.isEmpty else { + throw CLIError(message: "Usage: cmux rpc [json-params]") + } + let params = try parseRPCParams(Array(commandArgs.dropFirst())) + let response = try client.sendV2(method: method, params: params) + let output: Any = idFormatArg == nil ? response : formatIDs(response, mode: idFormat) + print(jsonString(output)) + case "identify": var params: [String: Any] = [:] let includeCaller = !hasFlag(commandArgs, name: "--no-caller") @@ -1704,9 +2030,10 @@ struct CMUXCLI { case "new-workspace": let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") let (cwdOpt, rem1) = parseOption(rem0, name: "--cwd") - let (nameOpt, remaining) = parseOption(rem1, name: "--name") + let (nameOpt, rem2) = parseOption(rem1, name: "--name") + let (descriptionOpt, remaining) = parseOption(rem2, name: "--description") if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { - throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --name , --command <text>, --cwd <path>") + throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --name <title>, --description <text>, --command <text>, --cwd <path>") } var params: [String: Any] = [:] if let cwdOpt { @@ -1716,6 +2043,9 @@ struct CMUXCLI { if let nameOpt { params["title"] = nameOpt } + if let descriptionOpt { + params["description"] = descriptionOpt + } let response = try client.sendV2(method: "workspace.create", params: params) let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" print("OK \(wsId)") @@ -1873,6 +2203,12 @@ struct CMUXCLI { case "refresh-surfaces": let response = try sendV1Command("refresh_surfaces", client: client) print(response) + case "reload-config": + if let unexpected = commandArgs.first { + throw CLIError(message: "reload-config does not accept arguments. Unexpected argument '\(unexpected)'") + } + let response = try sendV1Command("reload_config", client: client) + print(response) case "surface-health": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) @@ -2026,11 +2362,14 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "current-workspace": - let response = try sendV1Command("current_workspace", client: client) + let response = try client.sendV2(method: "workspace.current") if jsonOutput { - print(jsonString(["workspace_id": response])) + print(jsonString(formatIDs(response, mode: idFormat))) } else { - print(response) + let handle = formatHandle(response, kind: "workspace", idFormat: idFormat) + ?? (response["workspace_id"] as? String) + ?? "" + print(handle) } case "read-screen": @@ -3410,8 +3749,9 @@ struct CMUXCLI { let (actionOpt, rem1) = parseOption(rem0, name: "--action") let (titleOpt, rem2) = parseOption(rem1, name: "--title") let (colorOpt, rem3) = parseOption(rem2, name: "--color") + let (descriptionOpt, rem4) = parseOption(rem3, name: "--description") - var positional = rem3 + var positional = rem4 let actionRaw: String if let actionOpt { actionRaw = actionOpt @@ -3430,7 +3770,8 @@ struct CMUXCLI { let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - let inferredPositional = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + let inferredPositionalRaw = positional.joined(separator: " ") + let inferredPositional = inferredPositionalRaw.trimmingCharacters(in: .whitespacesAndNewlines) let title = (titleOpt ?? (action == "rename" && !inferredPositional.isEmpty ? inferredPositional : nil))?.trimmingCharacters(in: .whitespacesAndNewlines) if action == "rename", (title?.isEmpty ?? true) { @@ -3444,6 +3785,13 @@ struct CMUXCLI { throw CLIError(message: "workspace-action set-color requires --color <name|#hex> (or a trailing color)") } + let description = ( + descriptionOpt ?? (action == "set_description" && !inferredPositional.isEmpty ? inferredPositionalRaw : nil) + )?.trimmingCharacters(in: .whitespacesAndNewlines) + if action == "set_description", (description?.isEmpty ?? true) { + throw CLIError(message: "workspace-action set-description requires --description <text> (or trailing text)") + } + var params: [String: Any] = ["action": action] if let workspaceId { params["workspace_id"] = workspaceId @@ -3454,6 +3802,9 @@ struct CMUXCLI { if let color, !color.isEmpty { params["color"] = color } + if let description, !description.isEmpty { + params["description"] = description + } let payload = try client.sendV2(method: "workspace.action", params: params) var summaryParts = ["OK", "action=\(action)"] @@ -3686,6 +4037,10 @@ struct CMUXCLI { "source=\(terminfoSource == nil ? 0 : 1)" ) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() + let remoteSSHOptions = effectiveSSHOptions( + sshOptions.sshOptions, + remoteRelayPort: sshOptions.remoteRelayPort + ) let initialSSHCommand = buildSSHCommandText(sshOptions) let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty ? buildInteractiveRemoteShellScript( @@ -3698,6 +4053,22 @@ struct CMUXCLI { sshOptions, remoteBootstrapScript: remoteTerminalBootstrapScript ) + let deferredRemoteReconnectToken = UUID().uuidString.lowercased() + let deferredRemoteReconnectCommand = deferredRemoteReconnectLocalCommand( + in: remoteSSHOptions, + localCLIPath: resolvedExecutableURL()?.path, + foregroundAuthToken: deferredRemoteReconnectToken + ) + let configuredForegroundAuthToken = deferredRemoteReconnectCommand == nil ? nil : deferredRemoteReconnectToken + let startupInitialSSHCommand = buildSSHCommandText( + sshOptions, + localCommand: deferredRemoteReconnectCommand + ) + let startupRemoteTerminalSSHCommand = buildSSHCommandText( + sshOptions, + remoteBootstrapScript: remoteTerminalBootstrapScript, + localCommand: deferredRemoteReconnectCommand + ) let initialSSHStartupCommand: String let remoteTerminalSSHStartupCommand: String if let remoteTerminalBootstrapScript, !remoteTerminalBootstrapScript.isEmpty { @@ -3705,27 +4076,23 @@ struct CMUXCLI { options: sshOptions, remoteBootstrapScript: remoteTerminalBootstrapScript, shellFeatures: shellFeaturesValue, - remoteRelayPort: sshOptions.remoteRelayPort + remoteRelayPort: sshOptions.remoteRelayPort, + localCommand: deferredRemoteReconnectCommand ) initialSSHStartupCommand = bootstrapSSHStartupCommand remoteTerminalSSHStartupCommand = bootstrapSSHStartupCommand } else { initialSSHStartupCommand = try buildSSHStartupCommand( - sshCommand: initialSSHCommand, + sshCommand: startupInitialSSHCommand, shellFeatures: "", remoteRelayPort: sshOptions.remoteRelayPort ) remoteTerminalSSHStartupCommand = try buildSSHStartupCommand( - sshCommand: remoteTerminalSSHCommand, + sshCommand: startupRemoteTerminalSSHCommand, shellFeatures: shellFeaturesValue, remoteRelayPort: sshOptions.remoteRelayPort ) } - let remoteSSHOptions = effectiveSSHOptions( - sshOptions.sshOptions, - remoteRelayPort: sshOptions.remoteRelayPort - ) - cliDebugLog( "cli.ssh.start target=\(sshOptions.destination) port=\(sshOptions.port.map(String.init) ?? "nil") " + "relayPort=\(sshOptions.remoteRelayPort) localSocket=\(sshOptions.localSocketPath) " + @@ -3766,8 +4133,11 @@ struct CMUXCLI { var configureParams: [String: Any] = [ "workspace_id": workspaceId, "destination": sshOptions.destination, - "auto_connect": true, + "auto_connect": deferredRemoteReconnectCommand == nil, ] + if let configuredForegroundAuthToken { + configureParams["foreground_auth_token"] = configuredForegroundAuthToken + } if let port = sshOptions.port { configureParams["port"] = port } @@ -3789,6 +4159,7 @@ struct CMUXCLI { "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + "target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + + "deferredReconnect=\(deferredRemoteReconnectCommand == nil ? 0 : 1) " + "sshOptions=\(remoteSSHOptions.joined(separator: "|"))" ) let configureStartedAt = Date() @@ -3937,16 +4308,20 @@ struct CMUXCLI { func buildSSHCommandText( _ options: SSHCommandOptions, - remoteBootstrapScript: String? = nil + remoteBootstrapScript: String? = nil, + localCommand: String? = nil ) -> String { - var parts = baseSSHArguments(options) + var parts = baseSSHArguments(options, localCommand: localCommand) let trimmedRemoteBootstrap = remoteBootstrapScript? .trimmingCharacters(in: .whitespacesAndNewlines) if options.extraArguments.isEmpty { if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty { let remoteCommand = sshPercentEscapedRemoteCommand( - encodedRemoteBootstrapCommand(trimmedRemoteBootstrap) + encodedRemoteBootstrapCommand( + trimmedRemoteBootstrap, + remoteRelayPort: options.remoteRelayPort + ) ) parts += ["-o", "RemoteCommand=\(remoteCommand)"] } @@ -3965,11 +4340,13 @@ struct CMUXCLI { options: SSHCommandOptions, remoteBootstrapScript: String, shellFeatures: String, - remoteRelayPort: Int + remoteRelayPort: Int, + localCommand: String? = nil ) throws -> String { let commandSnippet = buildSSHBootstrapCommandSnippet( options: options, - remoteBootstrapScript: remoteBootstrapScript + remoteBootstrapScript: remoteBootstrapScript, + localCommand: localCommand ) return try buildSSHStartupCommand( sshCommand: commandSnippet, @@ -3981,28 +4358,34 @@ struct CMUXCLI { private func buildSSHBootstrapCommandSnippet( options: SSHCommandOptions, - remoteBootstrapScript: String + remoteBootstrapScript: String, + localCommand: String? = nil ) -> String { let encodedBootstrapScript = Data(remoteBootstrapScript.utf8).base64EncodedString() - let sshPrefix = baseSSHArguments(options).map(shellQuote).joined(separator: " ") - let remoteCommandBase64Placeholder = "__CMUX_REMOTE_BOOTSTRAP_B64_RUNTIME__" + let installSSHPrefix = baseSSHArguments(options, localCommand: localCommand).map(shellQuote).joined(separator: " ") + let sessionSSHPrefix = baseSSHArguments(options).map(shellQuote).joined(separator: " ") let remoteCommandTemplate = sshPercentEscapedRemoteCommand( - runtimeEncodedRemoteBootstrapCommandShell( - base64Placeholder: remoteCommandBase64Placeholder + stagedRemoteBootstrapCommandShell( + remoteRelayPort: options.remoteRelayPort ) ) + let remoteBootstrapInstallCommand = "/bin/sh -c " + shellQuote( + remoteBootstrapInstallShell(remoteRelayPort: options.remoteRelayPort) + ) var lines: [String] = [ "cmux_workspace_id=\"${CMUX_WORKSPACE_ID:-}\"", "cmux_surface_id=\"${CMUX_SURFACE_ID:-}\"", "cmux_remote_bootstrap_b64=\(shellQuote(encodedBootstrapScript))", "cmux_remote_bootstrap=\"$(printf %s \"$cmux_remote_bootstrap_b64\" | base64 -d 2>/dev/null || printf %s \"$cmux_remote_bootstrap_b64\" | base64 -D 2>/dev/null)\"", "cmux_remote_bootstrap=\"$(printf '%s' \"$cmux_remote_bootstrap\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id/g\")\"", - "cmux_remote_bootstrap_b64_runtime=\"$(printf '%s' \"$cmux_remote_bootstrap\" | base64 | tr -d '\\n')\"", + "if ! printf '%s' \"$cmux_remote_bootstrap\" | command \(installSSHPrefix) -T \(shellQuote(options.destination)) \(shellQuote(remoteBootstrapInstallCommand)); then", + " exit 1", + "fi", "cmux_remote_command_template=\(shellQuote(remoteCommandTemplate))", - "cmux_remote_command=\"$(printf '%s' \"$cmux_remote_command_template\" | sed \"s|\(remoteCommandBase64Placeholder)|$cmux_remote_bootstrap_b64_runtime|g\")\"", + "cmux_remote_command=\"$(printf '%s' \"$cmux_remote_command_template\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id/g\")\"", ] - var sshInvocation = "command \(sshPrefix) -o \"RemoteCommand=$cmux_remote_command\"" + var sshInvocation = "command \(sessionSSHPrefix) -o \"RemoteCommand=$cmux_remote_command\"" if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { sshInvocation += " -tt" } @@ -4011,8 +4394,31 @@ struct CMUXCLI { return lines.joined(separator: "\n") } - private func runtimeEncodedRemoteBootstrapCommandShell(base64Placeholder: String) -> String { - return [ + private func stagedRemoteBootstrapCommandShell( + remoteRelayPort: Int + ) -> String { + var lines = remoteBootstrapTTYCaptureLines(remoteRelayPort: remoteRelayPort, includeRelayRPC: true) + lines.append("/bin/sh \"$HOME/.cmux/relay/\(remoteRelayPort).bootstrap.sh\"") + return lines.joined(separator: "\n") + } + + private func remoteBootstrapInstallShell(remoteRelayPort: Int) -> String { + [ + "set -eu", + "umask 077", + "cmux_bootstrap_path=\"$HOME/.cmux/relay/\(remoteRelayPort).bootstrap.sh\"", + "mkdir -p \"$HOME/.cmux/relay\"", + "cat > \"$cmux_bootstrap_path\"", + "chmod 700 \"$cmux_bootstrap_path\" >/dev/null 2>&1 || true", + ].joined(separator: "\n") + } + + private func runtimeEncodedRemoteBootstrapCommandShell( + base64Placeholder: String, + remoteRelayPort: Int + ) -> String { + var lines = remoteBootstrapTTYCaptureLines(remoteRelayPort: remoteRelayPort, includeRelayRPC: false) + lines += [ "cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", "(printf %s '\(base64Placeholder)' | base64 -d 2>/dev/null || printf %s '\(base64Placeholder)' | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }", "chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true", @@ -4020,7 +4426,45 @@ struct CMUXCLI { "cmux_status=$?", "rm -f \"$cmux_tmp\"", "exit $cmux_status", - ].joined(separator: "; ") + ] + return lines.joined(separator: "\n") + } + + private func remoteBootstrapTTYCaptureLines( + remoteRelayPort: Int, + includeRelayRPC: Bool + ) -> [String] { + guard remoteRelayPort > 0 else { return [] } + + var lines: [String] = [ + "cmux_bootstrap_tty=\"$(tty 2>/dev/null || true)\"", + "cmux_bootstrap_tty=\"${cmux_bootstrap_tty##*/}\"", + "if [ -n \"$cmux_bootstrap_tty\" ] && [ \"$cmux_bootstrap_tty\" != \"not a tty\" ]; then", + " mkdir -p \"$HOME/.cmux/relay\" >/dev/null 2>&1 || true", + " printf '%s' \"$cmux_bootstrap_tty\" > \"$HOME/.cmux/relay/\(remoteRelayPort).tty\" 2>/dev/null || true", + " export CMUX_BOOTSTRAP_TTY=\"$cmux_bootstrap_tty\"", + ] + + if includeRelayRPC { + lines += [ + " cmux_relay_cli=\"$HOME/.cmux/bin/cmux\"", + " if [ ! -x \"$cmux_relay_cli\" ]; then cmux_relay_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", + " if [ -n \"$cmux_relay_cli\" ]; then", + " cmux_relay_report_tty='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"tty_name\":\"'$cmux_bootstrap_tty'\"}'", + " cmux_relay_ports_kick='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"reason\":\"command\"}'", + " if [ -n \"__CMUX_SURFACE_ID__\" ]; then", + " cmux_relay_report_tty='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"surface_id\":\"__CMUX_SURFACE_ID__\",\"tty_name\":\"'$cmux_bootstrap_tty'\"}'", + " cmux_relay_ports_kick='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"surface_id\":\"__CMUX_SURFACE_ID__\",\"reason\":\"command\"}'", + " fi", + " CMUX_SOCKET_PATH=\"127.0.0.1:\(remoteRelayPort)\" CMUX_SOCKET=\"127.0.0.1:\(remoteRelayPort)\" \"$cmux_relay_cli\" rpc surface.report_tty \"$cmux_relay_report_tty\" >/dev/null 2>&1 || true", + " CMUX_SOCKET_PATH=\"127.0.0.1:\(remoteRelayPort)\" CMUX_SOCKET=\"127.0.0.1:\(remoteRelayPort)\" \"$cmux_relay_cli\" rpc surface.ports_kick \"$cmux_relay_ports_kick\" >/dev/null 2>&1 || true", + " unset cmux_relay_cli cmux_relay_report_tty cmux_relay_ports_kick", + " fi", + ] + } + + lines.append("fi") + return lines } private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] { @@ -4038,41 +4482,72 @@ struct CMUXCLI { ) -> String { let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource) let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures) + let shellStateDir = shellStateDirForRemoteRelayPort(remoteRelayPort) let remoteCallerExportLines = [ "if [ -n '__CMUX_WORKSPACE_ID__' ]; then export CMUX_WORKSPACE_ID='__CMUX_WORKSPACE_ID__'; fi", - "if [ -n '__CMUX_SURFACE_ID__' ]; then export CMUX_SURFACE_ID='__CMUX_SURFACE_ID__'; fi", + "if [ -n '__CMUX_WORKSPACE_ID__' ]; then export CMUX_TAB_ID='__CMUX_WORKSPACE_ID__'; fi", + "if [ -n '__CMUX_SURFACE_ID__' ]; then export CMUX_SURFACE_ID='__CMUX_SURFACE_ID__'; export CMUX_PANEL_ID='__CMUX_SURFACE_ID__'; fi", ] let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil - let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" - var commonShellLines = remoteTerminalLines - commonShellLines.append(contentsOf: remoteEnvExportLines) - commonShellLines.append("export PATH=\"$HOME/.cmux/bin:$PATH\"") + var commonShellExportLines = remoteTerminalLines + commonShellExportLines.append(contentsOf: remoteEnvExportLines) + commonShellExportLines.append("export PATH=\"$HOME/.cmux/bin:$PATH\"") + commonShellExportLines.append("export CMUX_BUNDLED_CLI_PATH=\"$HOME/.cmux/bin/cmux\"") + commonShellExportLines.append("export CMUX_SHELL_INTEGRATION_DIR=\"\(shellStateDir)\"") if let relaySocket { - commonShellLines.append("export CMUX_SOCKET_PATH=\(relaySocket)") + commonShellExportLines.append("export CMUX_SOCKET_PATH=\(relaySocket)") + commonShellExportLines.append("export CMUX_SOCKET=\(relaySocket)") } - commonShellLines.append(contentsOf: remoteCallerExportLines) - commonShellLines.append(contentsOf: [ + commonShellExportLines.append(contentsOf: remoteCallerExportLines) + commonShellExportLines.append(contentsOf: [ "hash -r >/dev/null 2>&1 || true", "rehash >/dev/null 2>&1 || true", ]) + var zshShellLines = commonShellExportLines + zshShellLines.append( + #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh"; fi"# + ) + var bashShellLines = commonShellExportLines + bashShellLines.append( + #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash"; fi"# + ) let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir) let zshEnvLines = zshBootstrap.zshEnvLines let zshProfileLines = zshBootstrap.zshProfileLines - let zshRCLines = zshBootstrap.zshRCLines(commonShellLines: commonShellLines) + let zshRCLines = zshBootstrap.zshRCLines(commonShellLines: zshShellLines) let zshLoginLines = zshBootstrap.zshLoginLines + let bundledZshIntegration = bundledShellIntegrationScript(named: "cmux-zsh-integration.zsh") + let bundledBashIntegration = bundledShellIntegrationScript(named: "cmux-bash-integration.bash") let bashRCLines = [ "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi", "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", - ] + commonShellLines + ] + bashShellLines let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) var outerLines: [String] = [ + "mkdir -p \"$HOME/.cmux/relay\"", + "cmux_shell_dir=\"\(shellStateDir)\"", + "mkdir -p \"$cmux_shell_dir\"", + ] + if let bundledZshIntegration { + outerLines += [ + "cat > \"$cmux_shell_dir/cmux-zsh-integration.zsh\" <<'CMUXCMUXZSH'", + bundledZshIntegration, + "CMUXCMUXZSH", + ] + } + if let bundledBashIntegration { + outerLines += [ + "cat > \"$cmux_shell_dir/cmux-bash-integration.bash\" <<'CMUXCMUXBASH'", + bundledBashIntegration, + "CMUXCMUXBASH", + ] + } + outerLines.append(contentsOf: commonShellExportLines) + outerLines += [ "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", "case \"${CMUX_LOGIN_SHELL##*/}\" in", " zsh)", - " mkdir -p \"$HOME/.cmux/relay\"", - " cmux_shell_dir=\"\(shellStateDir)\"", - " mkdir -p \"$cmux_shell_dir\"", " cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'", ] outerLines.append(contentsOf: zshEnvLines) @@ -4102,9 +4577,6 @@ struct CMUXCLI { " exec \"$CMUX_LOGIN_SHELL\" -il", " ;;", " bash)", - " mkdir -p \"$HOME/.cmux/relay\"", - " cmux_shell_dir=\"\(shellStateDir)\"", - " mkdir -p \"$cmux_shell_dir\"", " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", ] outerLines.append(contentsOf: bashRCLines) @@ -4118,7 +4590,7 @@ struct CMUXCLI { " ;;", " *)", ] - outerLines.append(contentsOf: commonShellLines) + outerLines.append(contentsOf: commonShellExportLines) outerLines.append(contentsOf: relayWarmupLines) outerLines += [ "exec \"$CMUX_LOGIN_SHELL\" -i", @@ -4129,6 +4601,64 @@ struct CMUXCLI { return outerLines.joined(separator: "\n") } + private func shellStateDirForRemoteRelayPort(_ remoteRelayPort: Int) -> String { + "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" + } + + private func bundledShellIntegrationScript(named fileName: String) -> String? { + let fileManager = FileManager.default + var candidates: [URL] = [] + + if let executableURL = resolvedExecutableURL() { + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.lastPathComponent == "Contents" { + candidates.append( + current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("shell-integration", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + ) + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) + if fileManager.fileExists(atPath: projectMarker.path) { + candidates.append( + current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("shell-integration", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + ) + break + } + + guard let parent = parentSearchURL(for: current) else { + break + } + current = parent + } + } + + if let resourceURL = Bundle.main.resourceURL { + candidates.append( + resourceURL + .appendingPathComponent("shell-integration", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + ) + } + + for url in candidates { + guard fileManager.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let contents = String(data: data, encoding: .utf8) else { + continue + } + return contents + } + + return nil + } + func buildInteractiveRemoteShellCommand( remoteRelayPort: Int, shellFeatures: String, @@ -4190,11 +4720,34 @@ struct CMUXCLI { } private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] { - guard remoteRelayPort > 0 else { return [] } - return [] + guard remoteRelayPort > 0 else { + return [] + } + return [ + "cmux_relay_cli=\"${CMUX_BUNDLED_CLI_PATH:-$HOME/.cmux/bin/cmux}\"", + "if [ ! -x \"$cmux_relay_cli\" ]; then cmux_relay_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", + "cmux_relay_tty=\"${CMUX_BOOTSTRAP_TTY:-}\"", + "if [ -z \"$cmux_relay_tty\" ]; then cmux_relay_tty=\"$(tty 2>/dev/null || true)\"; fi", + "cmux_relay_tty=\"${cmux_relay_tty##*/}\"", + "if [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", + " mkdir -p \"$HOME/.cmux/relay\" >/dev/null 2>&1 || true", + " printf '%s' \"$cmux_relay_tty\" > \"$HOME/.cmux/relay/\(remoteRelayPort).tty\" 2>/dev/null || true", + "fi", + "if [ -n \"$cmux_relay_cli\" ] && [ -n \"$CMUX_WORKSPACE_ID\" ] && [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", + " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", + " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", + " if [ -n \"$CMUX_SURFACE_ID\" ]; then", + " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", + " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", + " fi", + " \"$cmux_relay_cli\" rpc surface.report_tty \"$cmux_relay_report_tty\" >/dev/null 2>&1 || true", + " \"$cmux_relay_cli\" rpc surface.ports_kick \"$cmux_relay_ports_kick\" >/dev/null 2>&1 || true", + "fi", + "unset CMUX_BOOTSTRAP_TTY cmux_relay_cli cmux_relay_tty cmux_relay_report_tty cmux_relay_ports_kick", + ] } - private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { + private func baseSSHArguments(_ options: SSHCommandOptions, localCommand: String? = nil) -> [String] { let effectiveSSHOptions = effectiveSSHOptions( options.sshOptions, remoteRelayPort: options.remoteRelayPort @@ -4224,6 +4777,11 @@ struct CMUXCLI { for option in effectiveSSHOptions { parts += ["-o", option] } + if let localCommand, !localCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let escapedLocalCommand = localCommand.replacingOccurrences(of: "%", with: "%%") + parts += ["-o", "PermitLocalCommand=yes"] + parts += ["-o", "LocalCommand=\(escapedLocalCommand)"] + } return parts } @@ -4281,10 +4839,14 @@ struct CMUXCLI { return merged.joined(separator: ",") } - func encodedRemoteBootstrapCommand(_ remoteBootstrapScript: String) -> String { + func encodedRemoteBootstrapCommand( + _ remoteBootstrapScript: String, + remoteRelayPort: Int + ) -> String { let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString() let encodedLiteral = shellQuote(encodedScript) - return [ + var lines = remoteBootstrapTTYCaptureLines(remoteRelayPort: remoteRelayPort, includeRelayRPC: false) + lines += [ "cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", "(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }", "chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true", @@ -4292,7 +4854,8 @@ struct CMUXCLI { "cmux_status=$?", "rm -f \"$cmux_tmp\"", "exit $cmux_status", - ].joined(separator: "; ") + ] + return lines.joined(separator: "\n") } func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String { @@ -4571,6 +5134,58 @@ struct CMUXCLI { return false } + private func deferredRemoteReconnectLocalCommand( + in options: [String], + localCLIPath: String?, + foregroundAuthToken: String + ) -> String? { + guard shouldDeferRemoteReconnect(in: options) else { return nil } + let preferredCLIPath = localCLIPath?.trimmingCharacters(in: .whitespacesAndNewlines) + let escapedForegroundAuthToken = foregroundAuthToken + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return [ + preferredCLIPath.map { "cmux_reconnect_cli=\(shellQuote($0));" } ?? "cmux_reconnect_cli=\"\";", + "cmux_reconnect_socket=\"${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}\";", + "if [ -z \"$cmux_reconnect_cli\" ] && [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]; then cmux_reconnect_cli=\"$CMUX_BUNDLED_CLI_PATH\"; fi;", + "if [ ! -x \"$cmux_reconnect_cli\" ]; then cmux_reconnect_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi;", + "if [ -n \"${CMUX_WORKSPACE_ID:-}\" ]; then", + "if [ -z \"$cmux_reconnect_socket\" ]; then printf '%s\\n' 'cmux: deferred SSH reconnect skipped, local cmux socket not found' >&2;", + "elif [ -z \"$cmux_reconnect_cli\" ] || [ ! -x \"$cmux_reconnect_cli\" ]; then printf '%s\\n' 'cmux: deferred SSH reconnect skipped, local cmux CLI not found' >&2;", + "else", + "cmux_reconnect_payload=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"foreground_auth_token\\\":\\\"\(escapedForegroundAuthToken)\\\"}\";", + "\"$cmux_reconnect_cli\" --socket \"$cmux_reconnect_socket\" rpc workspace.remote.foreground_auth_ready \"$cmux_reconnect_payload\" >/dev/null 2>&1 || true;", + "unset cmux_reconnect_payload;", + "fi;", + "fi;", + "unset cmux_reconnect_socket cmux_reconnect_cli;", + ].joined(separator: " ") + } + + private func shouldDeferRemoteReconnect(in options: [String]) -> Bool { + guard !hasSSHOptionKey(options, key: "LocalCommand"), + !hasSSHOptionKey(options, key: "PermitLocalCommand") else { + return false + } + + guard let controlPath = sshOptionValue(named: "ControlPath", in: options)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !controlPath.isEmpty, + controlPath.lowercased() != "none" else { + return false + } + + let controlMaster = sshOptionValue(named: "ControlMaster", in: options)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "auto" + switch controlMaster { + case "no", "false", "off": + return false + default: + return true + } + } + private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { if let remoteRelayPort, remoteRelayPort > 0 { return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C" @@ -4604,10 +5219,16 @@ struct CMUXCLI { for option in options { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } - let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) - if parts.count == 2, - parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { - return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split( + maxSplits: 1, + omittingEmptySubsequences: true, + whereSeparator: { $0 == "=" || $0.isWhitespace } + ) + if parts.count == 2, parts[0].lowercased() == loweredKey { + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return value + } } } return nil @@ -6103,6 +6724,13 @@ struct CMUXCLI { Print server capabilities as JSON. """ + case "rpc": + return """ + Usage: cmux rpc <method> [json-params] + + Call a raw v2 method with an optional JSON object for params. + Example: cmux rpc surface.report_tty '{"workspace_id":"...","surface_id":"...","tty_name":"ttys001"}' + """ case "help": return """ Usage: cmux help @@ -6359,6 +6987,7 @@ struct CMUXCLI { Actions: pin | unpin rename | clear-name + set-description | clear-description move-up | move-down | move-top close-others | close-above | close-below mark-read | mark-unread @@ -6369,6 +6998,7 @@ struct CMUXCLI { --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID) --title <text> Title for rename --color <name|#hex> Color for set-color (name or #RRGGBB hex) + --description <text> Description for set-description Named colors: Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua, @@ -6381,6 +7011,8 @@ struct CMUXCLI { cmux workspace-action --action set-color --color blue cmux workspace-action --action set-color --color "#C0392B" cmux workspace-action set-color Amber + cmux workspace-action --action set-description --description "Ship checklist" + cmux workspace-action --action set-description $'Ship checklist\n- verify build\n- post notes' cmux workspace-action clear-color """ case "tab-action": @@ -6435,18 +7067,20 @@ struct CMUXCLI { """ case "new-workspace": return """ - Usage: cmux new-workspace [--name <title>] [--cwd <path>] [--command <text>] + Usage: cmux new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] Create a new workspace in the current window. Flags: - --name <title> Set a custom name for the new workspace - --cwd <path> Set the working directory for the new workspace + --name <title> Set a custom name for the new workspace + --description <text> Set a custom description for the new workspace + --cwd <path> Set the working directory for the new workspace --command <text> Send text+Enter to the new workspace after creation Example: cmux new-workspace cmux new-workspace --name "Build Server" + cmux new-workspace --name "Launch" --description "Ship checklist" cmux new-workspace --cwd ~/projects/myapp cmux new-workspace --cwd . --command "npm test" """ @@ -6639,6 +7273,16 @@ struct CMUXCLI { Refresh surface snapshots for the focused workspace. """ + case "reload-config": + return """ + Usage: cmux reload-config + + Run the same configuration reload as the Reload Configuration shortcut. + This reloads Ghostty config, re-reads ~/.config/cmux/settings.json, and refreshes terminals. + + Example: + cmux reload-config + """ case "surface-health": return """ Usage: cmux surface-health [--workspace <id|ref>] @@ -8766,6 +9410,25 @@ struct CMUXCLI { return output } + private func parseRPCParams(_ args: [String]) throws -> [String: Any] { + guard !args.isEmpty else { return [:] } + let raw = args.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { return [:] } + guard let data = raw.data(using: .utf8) else { + throw CLIError(message: "rpc params must be valid UTF-8 JSON") + } + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data, options: []) + } catch { + throw CLIError(message: "rpc params must be valid JSON: \(error.localizedDescription)") + } + guard let params = object as? [String: Any] else { + throw CLIError(message: "rpc params must be a JSON object") + } + return params + } + private struct TmuxParsedArguments { var flags: Set<String> = [] var options: [String: [String]] = [:] @@ -12917,12 +13580,21 @@ struct CMUXCLI { workspaceId: workspaceId, client: client ) + let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId) + let codexPid = inferredCodexAgentPID() if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, - cwd: parsedInput.cwd + cwd: parsedInput.cwd, + pid: codexPid + ) + } + if let codexPid { + _ = try? sendV1Command( + "set_agent_pid \(agentPIDKey) \(codexPid) --tab=\(workspaceId)", + client: client ) } print("{}") @@ -12935,6 +13607,23 @@ struct CMUXCLI { fallback: workspaceArg, client: client ) + let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) + let codexPid = mappedSession?.pid ?? inferredCodexAgentPID() + if let sessionId = parsedInput.sessionId, let mappedSession { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: mappedSession.surfaceId, + cwd: parsedInput.cwd ?? mappedSession.cwd, + pid: codexPid + ) + } + if let codexPid { + _ = try? sendV1Command( + "set_agent_pid \(agentPIDKey) \(codexPid) --tab=\(workspaceId)", + client: client + ) + } _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) try setCodexStatus( client: client, @@ -12960,11 +13649,13 @@ struct CMUXCLI { workspaceId: workspaceId, client: client ) + let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) // Build completion notification from Codex stop payload let lastMessage = parsedInput.object?["last_assistant_message"] as? String ?? parsedInput.object?["lastAssistantMessage"] as? String let cwd = parsedInput.cwd ?? mappedSession?.cwd + let codexPid = mappedSession?.pid ?? inferredCodexAgentPID() let projectName: String? = { guard let cwd, !cwd.isEmpty else { return nil } return URL(fileURLWithPath: NSString(string: cwd).expandingTildeInPath).lastPathComponent @@ -12976,10 +13667,17 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, cwd: cwd, + pid: codexPid, lastSubtitle: "Completed", lastBody: lastMessage.map { truncate($0, maxLength: 200) } ) } + if let codexPid { + _ = try? sendV1Command( + "set_agent_pid \(agentPIDKey) \(codexPid) --tab=\(workspaceId)", + client: client + ) + } // Send completion notification var subtitle = "Completed" @@ -13029,6 +13727,67 @@ struct CMUXCLI { _ = try client.send(command: cmd) } + private func codexAgentPIDKey(sessionId: String?) -> String { + guard let sessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionId.isEmpty else { + return "codex" + } + return "codex.\(sessionId)" + } + + private func inferredCodexAgentPID() -> Int? { + var candidate = getppid() + var remainingWrapperSkips = 8 + + while candidate > 1, remainingWrapperSkips > 0 { + guard let processName = processName(for: candidate) else { break } + if !codexHookWrapperProcessNames.contains(processName) { + break + } + let next = parentPID(of: candidate) + guard next > 1, next != candidate else { break } + candidate = next + remainingWrapperSkips -= 1 + } + + return candidate > 1 ? Int(candidate) : nil + } + + private func parentPID(of pid: pid_t) -> pid_t { + var info = kinfo_proc() + var size = MemoryLayout<kinfo_proc>.size + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + guard sysctl(&mib, 4, &info, &size, nil, 0) == 0 else { + return -1 + } + return info.kp_eproc.e_ppid + } + + private func processName(for pid: pid_t) -> String? { + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-p", String(pid), "-o", "comm="] + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty else { + return nil + } + return URL(fileURLWithPath: output).lastPathComponent.lowercased() + } + private func versionSummary() -> String { let info = resolvedVersionInfo() let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) } @@ -13416,6 +14175,7 @@ struct CMUXCLI { ping version capabilities + rpc <method> [json-params] identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] list-windows current-window @@ -13424,9 +14184,9 @@ struct CMUXCLI { close-window --window <id> move-workspace-to-window --workspace <id|ref> --window <id|ref> reorder-workspace --workspace <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--window <id|ref|index>] - workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] [--color <name|#hex>] + workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] [--color <name|#hex>] [--description <text>] list-workspaces - new-workspace [--name <title>] [--cwd <path>] [--command <text>] + new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>] remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] @@ -13443,6 +14203,7 @@ struct CMUXCLI { rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title> drag-surface-to-split --surface <id|ref> <left|right|up|down> refresh-surfaces + reload-config surface-health [--workspace <id|ref>] trigger-flash [--workspace <id|ref>] [--surface <id|ref>] list-panels [--workspace <id|ref>] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index a59f14d3783f..c27daa3d7d3c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; + A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */; }; A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; }; A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; }; A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; }; @@ -94,6 +95,7 @@ B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; + E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */; }; B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; @@ -263,6 +265,7 @@ A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = "<group>"; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; }; A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = "<group>"; }; + A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsFileStore.swift; sourceTree = "<group>"; }; A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; }; A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = "<group>"; }; A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = "<group>"; }; @@ -288,6 +291,7 @@ B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; }; B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = "<group>"; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; }; + E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDescriptionUITests.swift; sourceTree = "<group>"; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = "<group>"; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; }; @@ -480,6 +484,7 @@ B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, + A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, A5001511 /* UITestRecorder.swift */, @@ -587,6 +592,7 @@ 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */, + E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, @@ -806,6 +812,7 @@ B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, + A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, @@ -887,6 +894,7 @@ B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */, + E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 91bc2ca68239..b36bb5631de4 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -37177,6 +37177,23 @@ } } }, + "menu.app.openCmuxSettingsFile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.jsonを開く" + } + } + } + }, "menu.checkForUpdates": { "extractionState": "manual", "localizations": { @@ -47850,6 +47867,23 @@ } } }, + "settings.app.settingsFile.openButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.jsonを開く" + } + } + } + }, "settings.app.commandPaletteSearchAllSurfaces": { "extractionState": "manual", "localizations": { @@ -63315,13 +63349,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Click a shortcut value to record a new shortcut." + "value": "Click a shortcut value to record. Press a second key for a chord, or click again to keep the first key." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ショートカット値をクリックして新しいショートカットを記録します。" + "value": "ショートカット値をクリックして記録します。コードにするには続けて2つ目のキーを押し、1キーのままにするにはもう一度クリックします。" } }, "zh-Hans": { @@ -63428,6 +63462,74 @@ } } }, + "settings.shortcuts.managedByFile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Managed in settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.json で管理" + } + } + } + }, + "settings.shortcuts.chords": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shortcut Chords" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットコード" + } + } + } + }, + "settings.shortcuts.chords.docsButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chord docs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コードの説明" + } + } + } + }, + "settings.shortcuts.chords.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"]." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.json で tmux 風の2段階ショートカットを追加できます。例: [\"ctrl+b\", \"c\"]。" + } + } + } + }, "settings.shortcuts.showHints": { "extractionState": "manual", "localizations": { @@ -64499,6 +64601,57 @@ } } }, + "settings.workspaceColors.customEntry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Named palette entry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前付きのパレット項目です。" + } + } + } + }, + "settings.workspaceColors.dictionaryNote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前付きカラーの追加や削除は settings.json を編集してください。「カスタムカラーを選択…」は引き続きローカルの Custom N エントリを追加します。" + } + } + } + }, + "settings.workspaceColors.emptyPalette": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パレット項目はありません。settings.json でカラーを追加するか、ワークスペースのコンテキストメニューから「カスタムカラーを選択…」を使ってください。" + } + } + } + }, "settings.workspaceColors.indicator": { "extractionState": "manual", "localizations": { @@ -65332,6 +65485,23 @@ } } }, + "settings.workspaceColors.resetPalette.subtitleV2": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore the built-in palette and remove extra named colors." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "組み込みパレットを復元し、追加した名前付きカラーを削除します。" + } + } + } + }, "shortcut.closeWindow.label": { "extractionState": "manual", "localizations": { @@ -67117,6 +67287,23 @@ } } }, + "shortcut.key.tab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + } + } + }, "shortcut.pressShortcut.prompt": { "extractionState": "manual", "localizations": { @@ -67236,6 +67423,23 @@ } } }, + "shortcut.recorder.pendingChord": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ …" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ …" + } + } + } + }, "shortcut.previousSurface.label": { "extractionState": "manual", "localizations": { @@ -86745,6 +86949,142 @@ } } } + }, + "shortcut.editWorkspaceDescription.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Workspace Description" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの説明を編集" + } + } + } + }, + "menu.view.editWorkspaceDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Workspace Description…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの説明を編集…" + } + } + } + }, + "command.editWorkspaceDescription.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Workspace Description…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの説明を編集…" + } + } + } + }, + "command.clearWorkspaceDescription.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Workspace Description" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの説明を消去" + } + } + } + }, + "commandPalette.description.workspacePlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace description" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの説明" + } + } + } + }, + "commandPalette.description.workspaceInputHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Enter で保存します。Shift-Enter で改行、Escape でキャンセルします。" + } + } + } + }, + "contextMenu.editWorkspaceDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Workspace Description…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの説明を編集…" + } + } + } + }, + "contextMenu.clearWorkspaceDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Workspace Description" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの説明を消去" + } + } + } } } } diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index ef021e064cf1..8be136ccf7f6 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -32,6 +32,107 @@ _cmux_send() { esac } +_cmux_socket_is_unix() { + [[ -n "$CMUX_SOCKET_PATH" && -S "$CMUX_SOCKET_PATH" ]] +} + +_cmux_relay_cli_path() { + if [[ -n "${CMUX_BUNDLED_CLI_PATH:-}" && -x "${CMUX_BUNDLED_CLI_PATH}" ]]; then + printf '%s\n' "${CMUX_BUNDLED_CLI_PATH}" + return 0 + fi + command -v cmux 2>/dev/null +} + +_cmux_socket_uses_remote_relay() { + [[ -n "$CMUX_SOCKET_PATH" ]] || return 1 + [[ "$CMUX_SOCKET_PATH" == /* ]] && return 1 + [[ "$CMUX_SOCKET_PATH" == *:* ]] || return 1 + [[ -n "$(_cmux_relay_cli_path)" ]] +} + +_cmux_has_port_scan_transport() { + _cmux_socket_is_unix && return 0 + _cmux_socket_uses_remote_relay +} + +_cmux_json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s\n' "$value" +} + +_cmux_relay_rpc_bg() { + local method="$1" + local params="$2" + local relay_cli="" + _cmux_socket_uses_remote_relay || return 1 + relay_cli="$(_cmux_relay_cli_path)" || return 1 + { + "$relay_cli" rpc "$method" "$params" >/dev/null 2>&1 || true + } >/dev/null 2>&1 & + disown 2>/dev/null || true +} + +_cmux_relay_rpc() { + local method="$1" + local params="$2" + local relay_cli="" + local response="" + _cmux_socket_uses_remote_relay || return 1 + # Relay `cmux rpc` exits nonzero on server error. The real remote CLI prints + # only the JSON result payload on success, while some test stubs return the + # full `{"ok":...}` envelope. Retry only on explicit `ok:false`. + relay_cli="$(_cmux_relay_cli_path)" || return 1 + response="$("$relay_cli" rpc "$method" "$params" 2>/dev/null)" || return 1 + response="${response//$'\n'/}" + response="${response//$'\r'/}" + [[ "$response" == *'"ok":false'* || "$response" == *'"ok": false'* ]] && return 1 + return 0 +} + +_cmux_relay_workspace_id() { + if [[ -n "$CMUX_WORKSPACE_ID" ]]; then + printf '%s\n' "$CMUX_WORKSPACE_ID" + return 0 + fi + [[ -n "$CMUX_TAB_ID" ]] || return 1 + printf '%s\n' "$CMUX_TAB_ID" +} + +_cmux_report_tty_via_relay() { + _cmux_socket_uses_remote_relay || return 1 + local workspace_id="" + workspace_id="$(_cmux_relay_workspace_id)" || return 1 + [[ -n "$_CMUX_TTY_NAME" ]] || return 1 + + local tty_name_json params + tty_name_json="$(_cmux_json_escape "$_CMUX_TTY_NAME")" + params="{\"workspace_id\":\"$workspace_id\",\"tty_name\":\"$tty_name_json\"" + if [[ -n "$CMUX_PANEL_ID" ]]; then + params+=",\"surface_id\":\"$CMUX_PANEL_ID\"" + fi + params+="}" + _cmux_relay_rpc "surface.report_tty" "$params" +} + +_cmux_ports_kick_via_relay() { + local reason="${1:-command}" + _cmux_socket_uses_remote_relay || return 1 + local workspace_id="" + workspace_id="$(_cmux_relay_workspace_id)" || return 1 + local params="{\"workspace_id\":\"$workspace_id\",\"reason\":\"$reason\"" + if [[ -n "$CMUX_PANEL_ID" ]]; then + params+=",\"surface_id\":\"$CMUX_PANEL_ID\"" + fi + params+="}" + _cmux_relay_rpc_bg "surface.ports_kick" "$params" +} + _cmux_restore_scrollback_once() { local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" [[ -n "$path" ]] || return 0 @@ -272,16 +373,23 @@ _cmux_report_tty_once() { # Send the TTY name to the app once per session so the batched port scanner # knows which TTY belongs to this panel. (( _CMUX_TTY_REPORTED )) && return 0 - [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + _cmux_has_port_scan_transport || return 0 - local payload="" - payload="$(_cmux_report_tty_payload)" - [[ -n "$payload" ]] || return 0 - - _CMUX_TTY_REPORTED=1 - { - _cmux_send "$payload" - } >/dev/null 2>&1 & disown + if _cmux_socket_is_unix; then + local payload="" + payload="$(_cmux_report_tty_payload)" + [[ -n "$payload" ]] || return 0 + _CMUX_TTY_REPORTED=1 + { + _cmux_send "$payload" + } >/dev/null 2>&1 & disown + else + [[ -n "$_CMUX_TTY_NAME" ]] || return 0 + # Keep the first relay TTY report synchronous so the server can resolve + # the target surface before command-start kicks begin their scan burst. + _cmux_report_tty_via_relay || return 0 + _CMUX_TTY_REPORTED=1 + fi } _cmux_report_shell_activity_state() { @@ -298,15 +406,22 @@ _cmux_report_shell_activity_state() { } _cmux_ports_kick() { + local reason="${1:-command}" # Lightweight: just tell the app to run a batched scan for this panel. # The app coalesces kicks across all panels and runs a single ps+lsof. - [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + _cmux_has_port_scan_transport || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 + if _cmux_socket_is_unix; then + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + fi _CMUX_PORTS_LAST_RUN="$(_cmux_now)" - { - _cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & disown + if _cmux_socket_is_unix; then + { + _cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID --reason=$reason" + } >/dev/null 2>&1 & disown + else + _cmux_ports_kick_via_relay "$reason" + fi } _cmux_clear_pr_for_panel() { @@ -805,7 +920,9 @@ _cmux_preexec_command() { local cmd="${1:-${BASH_COMMAND:-}}" _cmux_tmux_sync_cmux_environment - [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + local cmux_has_unix_socket=0 + _cmux_socket_is_unix && cmux_has_unix_socket=1 + (( cmux_has_unix_socket )) || _cmux_has_port_scan_transport || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 _cmux_record_pr_command_hint "$cmd" @@ -819,7 +936,7 @@ _cmux_preexec_command() { _cmux_report_shell_activity_state running _cmux_report_tty_once - _cmux_ports_kick + _cmux_ports_kick command _cmux_halt_pr_poll_loop if _cmux_command_starts_nested_shell "$cmd"; then return 0 @@ -834,13 +951,33 @@ _cmux_prompt_command() { local last_status=$? _cmux_tmux_sync_cmux_environment - [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + local cmux_has_unix_socket=0 + _cmux_socket_is_unix && cmux_has_unix_socket=1 + (( cmux_has_unix_socket )) || _cmux_has_port_scan_transport || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 - _cmux_report_shell_activity_state prompt + + if [[ -z "$_CMUX_TTY_NAME" ]]; then + local t + t="$(tty 2>/dev/null || true)" + t="${t##*/}" + [[ "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" + fi + + if [[ -n "$CMUX_PANEL_ID" ]]; then + _cmux_report_shell_activity_state prompt + fi + _cmux_report_tty_once local now now="$(_cmux_now)" + if (( ! cmux_has_unix_socket )); then + if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then + _cmux_ports_kick refresh + fi + return 0 + fi + + [[ -n "$CMUX_PANEL_ID" ]] || return 0 local pwd="$PWD" # Post-wake socket writes can occasionally leave a probe process wedged. @@ -947,7 +1084,7 @@ _cmux_prompt_command() { # Ports: lightweight kick to the app's batched scanner every ~10s. if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then - _cmux_ports_kick + _cmux_ports_kick refresh fi } diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index f103c17341e5..83b2a30efe2d 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -42,6 +42,104 @@ _cmux_send_bg() { fi } +_cmux_socket_is_unix() { + [[ -n "$CMUX_SOCKET_PATH" && -S "$CMUX_SOCKET_PATH" ]] +} + +_cmux_relay_cli_path() { + if [[ -n "${CMUX_BUNDLED_CLI_PATH:-}" && -x "${CMUX_BUNDLED_CLI_PATH}" ]]; then + print -r -- "${CMUX_BUNDLED_CLI_PATH}" + return 0 + fi + command -v cmux 2>/dev/null +} + +_cmux_socket_uses_remote_relay() { + [[ -n "$CMUX_SOCKET_PATH" ]] || return 1 + [[ "$CMUX_SOCKET_PATH" == /* ]] && return 1 + [[ "$CMUX_SOCKET_PATH" == *:* ]] || return 1 + [[ -n "$(_cmux_relay_cli_path)" ]] +} + +_cmux_has_port_scan_transport() { + _cmux_socket_is_unix && return 0 + _cmux_socket_uses_remote_relay +} + +_cmux_json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + print -r -- "$value" +} + +_cmux_relay_rpc_bg() { + local method="$1" + local params="$2" + local relay_cli="" + _cmux_socket_uses_remote_relay || return 1 + relay_cli="$(_cmux_relay_cli_path)" || return 1 + { "$relay_cli" rpc "$method" "$params" >/dev/null 2>&1 || true } >/dev/null 2>&1 &! +} + +_cmux_relay_rpc() { + local method="$1" + local params="$2" + local relay_cli="" + local response="" + _cmux_socket_uses_remote_relay || return 1 + # Relay `cmux rpc` exits nonzero on server error. The real remote CLI prints + # only the JSON result payload on success, while some test stubs return the + # full `{"ok":...}` envelope. Retry only on explicit `ok:false`. + relay_cli="$(_cmux_relay_cli_path)" || return 1 + response="$("$relay_cli" rpc "$method" "$params" 2>/dev/null)" || return 1 + response="${response//$'\n'/}" + response="${response//$'\r'/}" + [[ "$response" == *'"ok":false'* || "$response" == *'"ok": false'* ]] && return 1 + return 0 +} + +_cmux_relay_workspace_id() { + if [[ -n "$CMUX_WORKSPACE_ID" ]]; then + print -r -- "$CMUX_WORKSPACE_ID" + return 0 + fi + [[ -n "$CMUX_TAB_ID" ]] || return 1 + print -r -- "$CMUX_TAB_ID" +} + +_cmux_report_tty_via_relay() { + _cmux_socket_uses_remote_relay || return 1 + local workspace_id="" + workspace_id="$(_cmux_relay_workspace_id)" || return 1 + [[ -n "$_CMUX_TTY_NAME" ]] || return 1 + + local tty_name_json params + tty_name_json="$(_cmux_json_escape "$_CMUX_TTY_NAME")" + params="{\"workspace_id\":\"$workspace_id\",\"tty_name\":\"$tty_name_json\"" + if [[ -n "$CMUX_PANEL_ID" ]]; then + params+=",\"surface_id\":\"$CMUX_PANEL_ID\"" + fi + params+="}" + _cmux_relay_rpc "surface.report_tty" "$params" +} + +_cmux_ports_kick_via_relay() { + local reason="${1:-command}" + _cmux_socket_uses_remote_relay || return 1 + local workspace_id="" + workspace_id="$(_cmux_relay_workspace_id)" || return 1 + local params="{\"workspace_id\":\"$workspace_id\",\"reason\":\"$reason\"" + if [[ -n "$CMUX_PANEL_ID" ]]; then + params+=",\"surface_id\":\"$CMUX_PANEL_ID\"" + fi + params+="}" + _cmux_relay_rpc_bg "surface.ports_kick" "$params" +} + _cmux_restore_scrollback_once() { local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" [[ -n "$path" ]] || return 0 @@ -54,6 +152,10 @@ _cmux_restore_scrollback_once() { } _cmux_restore_scrollback_once +_cmux_now() { + print -r -- "${EPOCHSECONDS:-$SECONDS}" +} + typeset -g _CMUX_CLAUDE_WRAPPER="" _cmux_install_claude_wrapper() { local integration_dir="${CMUX_SHELL_INTEGRATION_DIR:-}" @@ -371,14 +473,21 @@ _cmux_report_tty_once() { # Send the TTY name to the app once per session so the batched port scanner # knows which TTY belongs to this panel. (( _CMUX_TTY_REPORTED )) && return 0 - [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 - - local payload="" - payload="$(_cmux_report_tty_payload)" - [[ -n "$payload" ]] || return 0 - - _CMUX_TTY_REPORTED=1 - _cmux_send_bg "$payload" + _cmux_has_port_scan_transport || return 0 + + if _cmux_socket_is_unix; then + local payload="" + payload="$(_cmux_report_tty_payload)" + [[ -n "$payload" ]] || return 0 + _CMUX_TTY_REPORTED=1 + _cmux_send_bg "$payload" + else + [[ -n "$_CMUX_TTY_NAME" ]] || return 0 + # Keep the first relay TTY report synchronous so the server can resolve + # the target surface before command-start kicks begin their scan burst. + _cmux_report_tty_via_relay || return 0 + _CMUX_TTY_REPORTED=1 + fi } _cmux_report_shell_activity_state() { @@ -393,13 +502,20 @@ _cmux_report_shell_activity_state() { } _cmux_ports_kick() { + local reason="${1:-command}" # Lightweight: just tell the app to run a batched scan for this panel. # The app coalesces kicks across all panels and runs a single ps+lsof. - [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + _cmux_has_port_scan_transport || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 - _CMUX_PORTS_LAST_RUN=$EPOCHSECONDS - _cmux_send_bg "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + if _cmux_socket_is_unix; then + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + fi + _CMUX_PORTS_LAST_RUN="$(_cmux_now)" + if _cmux_socket_is_unix; then + _cmux_send_bg "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID --reason=$reason" + else + _cmux_ports_kick_via_relay "$reason" + fi } _cmux_report_git_branch_for_path() { @@ -958,7 +1074,7 @@ _cmux_preexec() { [[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" fi - _CMUX_CMD_START=$EPOCHSECONDS + _CMUX_CMD_START="$(_cmux_now)" _cmux_report_shell_activity_state running _cmux_record_pr_command_hint "$cmd" @@ -971,7 +1087,7 @@ _cmux_preexec() { # Register TTY + kick batched port scan for foreground commands (servers). _cmux_report_tty_once - _cmux_ports_kick + _cmux_ports_kick command _cmux_halt_pr_poll_loop _cmux_stop_git_head_watch if _cmux_command_starts_nested_shell "$cmd"; then @@ -985,11 +1101,13 @@ _cmux_precmd() { _cmux_stop_git_head_watch _cmux_tmux_sync_cmux_environment - # Skip if socket doesn't exist yet - [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + local cmux_has_unix_socket=0 + _cmux_socket_is_unix && cmux_has_unix_socket=1 + (( cmux_has_unix_socket )) || _cmux_has_port_scan_transport || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 - [[ -n "$CMUX_PANEL_ID" ]] || return 0 - _cmux_report_shell_activity_state prompt + if [[ -n "$CMUX_PANEL_ID" ]]; then + _cmux_report_shell_activity_state prompt + fi # Handle cases where Ghostty integration initializes after this file. (( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw @@ -1003,10 +1121,23 @@ _cmux_precmd() { _cmux_report_tty_once - local now=$EPOCHSECONDS - local pwd="$PWD" + local now="$(_cmux_now)" local cmd_start="$_CMUX_CMD_START" _CMUX_CMD_START=0 + local cmd_dur=0 + if [[ -n "$cmd_start" && "$cmd_start" != 0 ]]; then + cmd_dur=$(( now - cmd_start )) + fi + + if (( ! cmux_has_unix_socket )); then + if (( cmd_dur >= 2 || now - _CMUX_PORTS_LAST_RUN >= 10 )); then + _cmux_ports_kick refresh + fi + return 0 + fi + + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + local pwd="$PWD" _cmux_prompt_wrap_guard "$cmd_start" "$pwd" @@ -1116,13 +1247,8 @@ _cmux_precmd() { # Ports: lightweight kick to the app's batched scanner. # - Periodic scan to avoid stale values. # - Forced scan when a long-running command returns to the prompt (common when stopping a server). - local cmd_dur=0 - if [[ -n "$cmd_start" && "$cmd_start" != 0 ]]; then - cmd_dur=$(( now - cmd_start )) - fi - if (( cmd_dur >= 2 || now - _CMUX_PORTS_LAST_RUN >= 10 )); then - _cmux_ports_kick + _cmux_ports_kick refresh fi } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d588bc80b4cb..4d71f790f96c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1646,6 +1646,15 @@ func commandPaletteSelectionDeltaForKeyboardNavigation( return nil } +func shouldRouteCommandPaletteSelectionNavigation( + delta: Int?, + isInteractive: Bool, + usesInlineTextHandling: Bool +) -> Bool { + guard delta != nil, isInteractive else { return false } + return !usesInlineTextHandling +} + func shouldConsumeShortcutWhileCommandPaletteVisible( isCommandPaletteVisible: Bool, normalizedFlags: NSEvent.ModifierFlags, @@ -1691,21 +1700,31 @@ func shouldConsumeShortcutWhileCommandPaletteVisible( func shouldSubmitCommandPaletteWithReturn( keyCode: UInt16, - flags: NSEvent.ModifierFlags + flags: NSEvent.ModifierFlags, + mode: String ) -> Bool { guard keyCode == 36 || keyCode == 76 else { return false } let normalizedFlags = flags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) - return normalizedFlags == [] || normalizedFlags == [.shift] + if normalizedFlags.isEmpty { + return true + } + if normalizedFlags == [.shift] { + return mode != "workspace_description_input" + } + return false } func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { - guard let editor = window.firstResponder as? NSTextView, - editor.isFieldEditor else { - return false + if let editor = window.firstResponder as? NSTextView { + return editor.hasMarkedText() + } + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView { + return editor.hasMarkedText() } - return editor.hasMarkedText() + return false } func shouldHandleCommandPaletteShortcutEvent( @@ -2193,6 +2212,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var shortcutDefaultsObserver: NSObjectProtocol? private var menuBarVisibilityObserver: NSObjectProtocol? private var splitButtonTooltipRefreshScheduled = false + private struct PendingConfiguredShortcutChord { + let firstStroke: ShortcutStroke + let windowNumber: Int? + } + private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? + private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? + private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -2900,6 +2926,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillResignActive(_ notification: Notification) { guard !isTerminatingApp else { return } + clearConfiguredShortcutChordState() _ = saveSessionSnapshot(includeScrollback: false) } @@ -4703,6 +4730,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + func requestCommandPaletteEditWorkspaceDescription( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteEditWorkspaceDescription" + ) { + postCommandPaletteRequest( + name: .commandPaletteEditWorkspaceDescriptionRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } + private func clearCommandPalettePendingOpen(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } @@ -4869,6 +4908,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return commandPaletteVisibilityByWindowId[windowId] ?? false } + func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { + isCommandPaletteEffectivelyVisible(in: window) + } + func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( window: NSWindow, responder: NSResponder? @@ -5336,6 +5379,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return isCommandPaletteResponder(responder) } + private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { + guard let textView = window.firstResponder as? NSTextView, + !textView.isFieldEditor else { + return false + } + return isCommandPaletteResponder(textView) + } + private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { if let textView = window.firstResponder as? NSTextView, isCommandPaletteResponder(textView), @@ -5446,15 +5497,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { - if let window = event.window, isMainTerminalWindow(window) { + if let window = resolvedShortcutEventWindow(event), + isMainTerminalWindow(window) { return window } - let eventWindowNumber = event.windowNumber - if eventWindowNumber > 0, - let numberedWindow = NSApp.window(withWindowNumber: eventWindowNumber), - isMainTerminalWindow(numberedWindow) { - return numberedWindow - } if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { return keyWindow } @@ -5464,6 +5510,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } + private func resolvedShortcutEventWindow(_ event: NSEvent) -> NSWindow? { + if let window = event.window { + return window + } + let eventWindowNumber = event.windowNumber + guard eventWindowNumber > 0 else { return nil } + return NSApp.window(withWindowNumber: eventWindowNumber) + } + /// Re-sync app-level active window pointers from the currently focused main terminal window. /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. @discardableResult @@ -6095,16 +6150,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if shortcutEventHasAddressableWindow(event) { + if let eventWindow = resolvedShortcutEventWindow(event), + cmuxWindowShouldOwnCloseShortcut(eventWindow) { + // Auxiliary cmux windows do not own a terminal tab manager. Let them fall back + // to the active main terminal window so app shortcuts like Cmd+W still route. + } else { #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: "shortcut.routing", - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil - ) + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) #endif - return nil + return nil + } } if let keyWindow = NSApp.keyWindow, @@ -9210,15 +9271,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func installShortcutDefaultsObserver() { guard shortcutDefaultsObserver == nil else { return } + refreshConfiguredShortcutChordActions() shortcutDefaultsObserver = NotificationCenter.default.addObserver( forName: KeyboardShortcutSettings.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in + self?.refreshConfiguredShortcutChordActions() + self?.clearConfiguredShortcutChordState() self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() } } + private func refreshConfiguredShortcutChordActions() { + configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { + KeyboardShortcutSettings.shortcut(for: $0).hasChord + } + } + + private func clearConfiguredShortcutChordState() { + pendingConfiguredShortcutChord = nil + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } + /// Coalesce shortcut-default changes and refresh on the next runloop turn to /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { @@ -9445,6 +9520,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let isControlOnly = hasControl && !hasCommand && !hasOption let controlDChar = chars == "d" || event.characters == "\u{04}" let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) + let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) + if let pendingConfiguredShortcutChord, + pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { + activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke + } else { + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } + pendingConfiguredShortcutChord = nil + defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } #if DEBUG if isControlD { writeChildExitKeyboardProbe( @@ -9525,6 +9609,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent commandPaletteInteractiveInTargetWindow || commandPalettePendingOpenInTargetWindow +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + dlog( + "shortcut.return.raw " + + "interactive=\(commandPaletteInteractiveInTargetWindow ? 1 : 0) " + + "effective=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "shortcutWindow={\(debugWindowToken(commandPaletteShortcutWindow))} " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + } +#endif + if normalizedFlags.isEmpty, event.keyCode == 53 { let activePaletteWindow = activeCommandPaletteWindow() let escapePaletteWindow: NSWindow? = { @@ -9607,12 +9707,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } - if let delta = commandPaletteSelectionDeltaForKeyboardNavigation( + let paletteUsesInlineTextHandling = commandPaletteShortcutWindow.map { + isCommandPaletteMultilineTextResponderActive(in: $0) + } ?? false + + let paletteSelectionDelta = commandPaletteSelectionDeltaForKeyboardNavigation( flags: event.modifierFlags, chars: chars, keyCode: event.keyCode + ) + + if shouldRouteCommandPaletteSelectionNavigation( + delta: paletteSelectionDelta, + isInteractive: commandPaletteInteractiveInTargetWindow, + usesInlineTextHandling: paletteUsesInlineTextHandling ), - commandPaletteInteractiveInTargetWindow, + let delta = paletteSelectionDelta, let paletteWindow = commandPaletteShortcutWindow { NotificationCenter.default.post( name: .commandPaletteMoveSelection, @@ -9625,6 +9735,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if commandPaletteInteractiveInTargetWindow, let paletteWindow = commandPaletteShortcutWindow { let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) + let paletteSnapshot = mainWindowId(for: paletteWindow).map(commandPaletteSnapshot(windowId:)) ?? .empty + let paletteUsesInlineReturnHandling = paletteUsesInlineTextHandling if normalizedFlags.isEmpty, event.keyCode == 53 { if paletteFieldEditorHasMarkedText { return false @@ -9633,10 +9745,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if shouldSubmitCommandPaletteWithReturn( + let shouldSubmitPalette = shouldSubmitCommandPaletteWithReturn( keyCode: event.keyCode, - flags: event.modifierFlags - ) { + flags: event.modifierFlags, + mode: paletteSnapshot.mode + ) +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + dlog( + "shortcut.palette.return target={\(debugWindowToken(paletteWindow))} " + + "mode=\(paletteSnapshot.mode) " + + "inline=\(paletteUsesInlineReturnHandling ? 1 : 0) " + + "submit=\(shouldSubmitPalette ? 1 : 0) " + + "marked=\(paletteFieldEditorHasMarkedText ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + } +#endif + if paletteUsesInlineReturnHandling, + event.keyCode == 36 || event.keyCode == 76 { + return false + } + if shouldSubmitPalette { if paletteFieldEditorHasMarkedText { return false } @@ -9667,25 +9797,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Scope the omnibar check to the shortcut's routed window context so a // focused omnibar in another window does not suppress Cmd+P here. let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil - let isCommandShiftP = matchShortcut( - event: event, - shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) - ) - if isCommandShiftP { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP") - return true - } - let isCommandP = !hasFocusedAddressBarInShortcutContext - && matchShortcut( - event: event, - shortcut: StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) - ) - if isCommandP { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.cmdP") - return true + if commandPaletteEffectiveInTargetWindow { + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true + } + + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + !hasFocusedAddressBarInShortcutContext, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { + return true + } } if shouldConsumeShortcutWhileCommandPaletteVisible( @@ -9697,32 +9833,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) - ) { - return handleQuitShortcutWarning() - } - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) - ) { - GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma") - return true - } - - if shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: event.modifierFlags, - chars: chars, - keyCode: event.keyCode - ) { - guard let targetWindow = mainWindowForShortcutEvent(event) else { - return false - } - targetWindow.toggleFullScreen(nil) - return true - } - // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese // input), don't intercept non-Cmd key events — let them flow through to the // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still @@ -9793,7 +9903,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Fast path for normal typing and terminal navigation keys (for example Up-arrow // history): after command-palette/notification handling and browser omnibar // arrow navigation above, plain key events have no app-level shortcut behavior. - if normalizedFlags.isEmpty { + if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { return false } @@ -9803,13 +9913,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event) { + return true + } + + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true + } + + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } + + if matchConfiguredShortcut(event: event, action: .quit) { + return handleQuitShortcutWarning() + } + if matchConfiguredShortcut(event: event, action: .openSettings) { + openPreferencesWindow(debugSource: "shortcut.openSettings") + return true + } + if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") + return true + } + + if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { + guard let targetWindow = mainWindowForShortcutEvent(event) else { + return false + } + targetWindow.toggleFullScreen(nil) + return true + } + // Primary UI shortcuts - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) { + if matchConfiguredShortcut(event: event, action: .toggleSidebar) { _ = toggleSidebarInActiveMainWindow() return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) { + if matchConfiguredShortcut(event: event, action: .newTab) { #if DEBUG dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif @@ -9846,7 +9994,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Handled here instead of relying on SwiftUI's CommandGroup menu item because // after a browser panel has been shown, SwiftUI's menu dispatch can silently // consume the key equivalent without firing the action closure. - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newWindow)) { + if matchConfiguredShortcut(event: event, action: .newWindow) { openNewMainWindow(nil) return true } @@ -9854,18 +10002,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Open Folder: Cmd+O // Handled here to prevent AppKit's default NSDocumentController from opening // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openFolder)) { + if matchConfiguredShortcut(event: event, action: .openFolder) { showOpenFolderPanel() return true } // Check Show Notifications shortcut - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showNotifications)) { + if matchConfiguredShortcut(event: event, action: .showNotifications) { toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .sendFeedback)) { + if matchConfiguredShortcut(event: event, action: .sendFeedback) { guard let targetContext = preferredMainWindowContextForShortcuts(event: event), let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { return false @@ -9877,7 +10025,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Check Jump to Unread shortcut - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .jumpToUnread)) { + if matchConfiguredShortcut(event: event, action: .jumpToUnread) { #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"]) @@ -9888,22 +10036,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Flash the currently focused panel so the user can visually confirm focus. - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .triggerFlash)) { + if matchConfiguredShortcut(event: event, action: .triggerFlash) { tabManager?.triggerFocusFlash() return true } // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSurface)) { + if matchConfiguredShortcut(event: event, action: .nextSurface) { tabManager?.selectNextSurface() return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSurface)) { + if matchConfiguredShortcut(event: event, action: .prevSurface) { tabManager?.selectPreviousSurface() return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleTerminalCopyMode)) { + if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false #if DEBUG dlog( @@ -9917,7 +10065,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) { + if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { #if DEBUG let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( @@ -9928,7 +10076,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) { + if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { #if DEBUG let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( @@ -9939,16 +10087,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameWorkspace)) { + if matchConfiguredShortcut(event: event, action: .renameWorkspace) { return requestRenameWorkspaceViaCommandPalette( preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow ) } - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "t", command: true, shift: false, option: true, control: false) - ) { + if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { +#if DEBUG + dlog( + "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + return requestEditWorkspaceDescriptionViaCommandPalette( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + } + + if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) @@ -9965,14 +10122,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Cmd+W must close the focused panel even if first-responder momentarily lags on a // browser NSTextView during split focus transitions. - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) - ) { + if matchConfiguredShortcut(event: event, action: .closeTab) { + let targetWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. // This AppDelegate path is a fallback for cases where AppKit routes the // event through the global shortcut handler first. - if let targetWindow = [NSApp.keyWindow, event.window] + if let targetWindow = [targetWindow, NSApp.keyWindow] .compactMap({ $0 }) .first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) { #if DEBUG @@ -9980,38 +10136,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif targetWindow.performClose(nil) return true - } else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + } else if let targetWindow, cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { - let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + if let routedManager { #if DEBUG + let selectedWorkspace = routedManager.selectedWorkspace dlog( - "shortcut.cmdW route=ghostty workspace=\(terminalContext.workspaceId.uuidString.prefix(5)) " + - "panel=\(terminalContext.panelId.uuidString.prefix(5)) selected=\(terminalContext.tabManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + "shortcut.cmdW route=workspaceModel workspace=\(selectedWorkspace?.id.uuidString.prefix(5) ?? "nil") " + + "panel=\(selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "selected=\(routedManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" ) #endif - terminalContext.tabManager.closePanelWithConfirmation( - tabId: terminalContext.workspaceId, - surfaceId: terminalContext.panelId - ) + routedManager.closeCurrentPanelWithConfirmation() } else { #if DEBUG - dlog("shortcut.cmdW route=focusedPanelFallback") + dlog("shortcut.cmdW route=noManager") #endif - tabManager?.closeCurrentPanelWithConfirmation() + return false } } return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) { + if matchConfiguredShortcut(event: event, action: .closeWorkspace) { tabManager?.closeCurrentWorkspaceWithConfirmation() return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWindow)) { + if matchConfiguredShortcut(event: event, action: .closeWindow) { guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { NSSound.beep() return true @@ -10020,7 +10174,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameTab)) { + if matchConfiguredShortcut(event: event, action: .renameTab) { // Keep Cmd+R browser reload behavior when a browser panel is focused. if tabManager?.focusedBrowserPanel != nil { return false @@ -10033,10 +10187,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Numeric shortcuts for specific workspaces (9 = last workspace) // Always consume the event when the digit matches to prevent Ghostty's // goto_tab fallback from creating a new window when the index is out of bounds. - if let digit = numberedShortcutDigit( - event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) - ) { + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectWorkspaceByNumber) { if let manager = tabManager, let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { #if DEBUG @@ -10050,10 +10201,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Numeric shortcuts for surfaces within the focused pane (9 = last) - if let digit = numberedShortcutDigit( - event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .selectSurfaceByNumber) - ) { + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectSurfaceByNumber) { if digit == 9 { tabManager?.selectLastSurface() } else { @@ -10063,9 +10211,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). - if matchDirectionalShortcut( + if matchConfiguredDirectionalShortcut( event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .focusLeft), + action: .focusLeft, arrowGlyph: "←", arrowKeyCode: 123 ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { @@ -10075,9 +10223,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return true } - if matchDirectionalShortcut( + if matchConfiguredDirectionalShortcut( event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .focusRight), + action: .focusRight, arrowGlyph: "→", arrowKeyCode: 124 ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { @@ -10087,9 +10235,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return true } - if matchDirectionalShortcut( + if matchConfiguredDirectionalShortcut( event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .focusUp), + action: .focusUp, arrowGlyph: "↑", arrowKeyCode: 126 ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { @@ -10099,9 +10247,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return true } - if matchDirectionalShortcut( + if matchConfiguredDirectionalShortcut( event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .focusDown), + action: .focusDown, arrowGlyph: "↓", arrowKeyCode: 125 ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { @@ -10112,7 +10260,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) { + if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { _ = tabManager?.toggleFocusedSplitZoom() #if DEBUG recordGotoSplitZoomIfNeeded() @@ -10121,7 +10269,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Split actions: Cmd+D / Cmd+Shift+D - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) { + if matchConfiguredShortcut(event: event, action: .splitRight) { #if DEBUG dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") #endif @@ -10135,7 +10283,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) { + if matchConfiguredShortcut(event: event, action: .splitDown) { #if DEBUG dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") #endif @@ -10149,7 +10297,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) { + if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { #if DEBUG dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") #endif @@ -10157,7 +10305,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) { + if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { #if DEBUG dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") #endif @@ -10176,21 +10324,61 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // New surface: Cmd+T - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newSurface)) { + if matchConfiguredShortcut(event: event, action: .newSurface) { tabManager?.newSurface() return true } // Open browser: Cmd+Shift+L - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openBrowser)) { + if matchConfiguredShortcut(event: event, action: .openBrowser) { _ = openBrowserAndFocusAddressBar(insertAtEnd: true) return true } + if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { + if let focusedPanel = tabManager?.focusedBrowserPanel { + focusBrowserAddressBar(in: focusedPanel) + return true + } + + if let browserAddressBarFocusedPanelId, + focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { + return true + } + + if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { + return true + } + } + + if matchConfiguredShortcut(event: event, action: .browserBack) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.goBack() + return true + } + + if matchConfiguredShortcut(event: event, action: .browserForward) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.goForward() + return true + } + + if matchConfiguredShortcut(event: event, action: .browserReload) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.reload() + return true + } + // Safari defaults: // - Option+Command+I => Show/Toggle Web Inspector // - Option+Command+C => Show JavaScript Console - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + if matchConfiguredShortcut(event: event, action: .toggleBrowserDeveloperTools) { #if DEBUG logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) #endif @@ -10205,7 +10393,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + if matchConfiguredShortcut(event: event, action: .showBrowserJavaScriptConsole) { #if DEBUG logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) #endif @@ -10220,72 +10408,68 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Focus browser address bar: Cmd+L - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "l", command: true, shift: false, option: false, control: false) - ) { - if let focusedPanel = tabManager?.focusedBrowserPanel { - focusBrowserAddressBar(in: focusedPanel) + if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { + guard let tabManager, tabManager.focusedBrowserPanel != nil else { + NSSound.beep() return true } + tabManager.toggleReactGrabFocusedBrowser() + return true + } - if let browserAddressBarFocusedPanelId, - focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { - return true + if matchConfiguredShortcut(event: event, action: .browserZoomIn) { + return tabManager?.zoomInFocusedBrowser() ?? false + } + + if matchConfiguredShortcut(event: event, action: .browserZoomOut) { + return tabManager?.zoomOutFocusedBrowser() ?? false + } + + if matchConfiguredShortcut(event: event, action: .browserZoomReset) { + return tabManager?.resetZoomFocusedBrowser() ?? false + } + + if matchConfiguredShortcut(event: event, action: .find) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false } + tabManager?.startSearch() + return true + } - if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { - return true + if matchConfiguredShortcut(event: event, action: .findNext) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false } + tabManager?.findNext() + return true } - #if DEBUG - logBrowserZoomShortcutTrace(stage: "probe", event: event, flags: flags, chars: chars) - #endif - let zoomAction = browserZoomShortcutAction( - flags: flags, - chars: chars, - keyCode: event.keyCode, - literalChars: event.characters - ) - #if DEBUG - logBrowserZoomShortcutTrace(stage: "match", event: event, flags: flags, chars: chars, action: zoomAction) - #endif - if let action = zoomAction, let manager = tabManager { - let handled: Bool - switch action { - case .zoomIn: - handled = manager.zoomInFocusedBrowser() - case .zoomOut: - handled = manager.zoomOutFocusedBrowser() - case .reset: - handled = manager.resetZoomFocusedBrowser() + if matchConfiguredShortcut(event: event, action: .findPrevious) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false } - #if DEBUG - logBrowserZoomShortcutTrace( - stage: "dispatch", - event: event, - flags: flags, - chars: chars, - action: action, - handled: handled - ) - #endif - return handled + tabManager?.findPrevious() + return true } - #if DEBUG - if zoomAction != nil, tabManager == nil { - logBrowserZoomShortcutTrace( - stage: "dispatch.noManager", - event: event, - flags: flags, - chars: chars, - action: zoomAction, - handled: false - ) + + if matchConfiguredShortcut(event: event, action: .hideFind) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.hideFind() + return true + } + + if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { + tabManager?.searchSelection() + return true + } + + if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { + _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() + return true } - #endif return false } @@ -10918,6 +11102,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + @discardableResult + func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow +#if DEBUG + dlog( + "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + + "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + requestCommandPaletteEditWorkspaceDescription( + preferredWindow: targetWindow, + source: "shortcut.editWorkspaceDescription" + ) + return true + } + #if DEBUG // Debug/test hook: allow socket-driven shortcut simulation to reuse the same shortcut routing // logic as the local NSEvent monitor, without relying on AppKit event monitor behavior for @@ -11000,15 +11200,100 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - /// Match a shortcut against an event, handling normal keys. - private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false + } + return matchShortcutStroke(event: event, stroke: secondStroke) + } + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } + + private func numberedConfiguredShortcutDigit( + event: NSEvent, + action: KeyboardShortcutSettings.Action + ) -> Int? { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return nil + } + return numberedShortcutDigit(event: event, stroke: secondStroke) + } + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) + } + + private func matchConfiguredDirectionalShortcut( + event: NSEvent, + action: KeyboardShortcutSettings.Action, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false + } + return matchDirectionalShortcut( + event: event, + stroke: secondStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) + } + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) + } + + private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { + if let window = mainWindowForShortcutEvent(event) { + return window.windowNumber + } + if let window = event.window { + return window.windowNumber + } + return event.windowNumber > 0 ? event.windowNumber : nil + } + + private func armConfiguredShortcutChordIfNeeded( + event: NSEvent, + actions: [KeyboardShortcutSettings.Action]? = nil + ) -> Bool { + for action in actions ?? configuredShortcutChordActions { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard shortcut.hasChord else { continue } + if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { + pendingConfiguredShortcutChord = PendingConfiguredShortcutChord( + firstStroke: shortcut.firstStroke, + windowNumber: configuredShortcutChordWindowNumber(for: event) + ) + return true + } + } + return false + } + + /// Match a shortcut stroke against an event, handling normal keys. + private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { // Some keys can include extra flags (e.g. .function) depending on the responder chain. // Strip those for consistent matching across first responders (terminal, WebKit, etc). let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) - guard flags == shortcut.modifierFlags else { return false } + guard flags == stroke.modifierFlags else { return false } - let shortcutKey = shortcut.key.lowercased() + let shortcutKey = stroke.key.lowercased() if shortcutKey == "\r" { return event.keyCode == 36 || event.keyCode == 76 } @@ -11031,6 +11316,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // a Latin shortcut key — skip this guard and fall through to layout-based matching. let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true + let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true + if shortcutKeyIsDigit, + hasEventChars, + eventCharsAreASCII, + digitForNumberKeyCode(event.keyCode) == nil { + return false + } if hasEventChars, eventCharsAreASCII, flags.contains(.command), @@ -11076,10 +11368,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { + private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } + + private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) - guard flags == shortcut.modifierFlags else { return nil } + guard flags == stroke.modifierFlags else { return nil } + let numberKeyDigit = digitForNumberKeyCode(event.keyCode) if let digit = numberedShortcutDigit( eventCharacter: event.charactersIgnoringModifiers, @@ -11089,16 +11387,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return digit } - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if let digit = numberedShortcutDigit( - eventCharacter: layoutCharacter, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { - return digit + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + let hasUsableASCIIEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + && (eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true) + if !hasUsableASCIIEventChars || numberKeyDigit != nil { + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if let digit = numberedShortcutDigit( + eventCharacter: layoutCharacter, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return digit + } } - return digitForNumberKeyCode(event.keyCode) + return numberKeyDigit + } + + private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) } private func numberedShortcutDigit( @@ -11253,30 +11561,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Match arrow key shortcuts using keyCode /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. - private func matchArrowShortcut(event: NSEvent, shortcut: StoredShortcut, keyCode: UInt16) -> Bool { + private func matchArrowShortcut(event: NSEvent, stroke: ShortcutStroke, keyCode: UInt16) -> Bool { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) - return event.keyCode == keyCode && flags == shortcut.modifierFlags + return event.keyCode == keyCode && flags == stroke.modifierFlags } /// Match tab key shortcuts using keyCode 48 - private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + private func matchTabShortcut(event: NSEvent, stroke: ShortcutStroke) -> Bool { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return event.keyCode == 48 && flags == shortcut.modifierFlags + return event.keyCode == 48 && flags == stroke.modifierFlags + } + + private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchTabShortcut(event: event, stroke: shortcut.firstStroke) } /// Directional shortcuts default to arrow keys, but the shortcut recorder only supports letter/number keys. /// Support both so users can customize pane navigation (e.g. Cmd+Ctrl+H/J/K/L). private func matchDirectionalShortcut( event: NSEvent, - shortcut: StoredShortcut, + stroke: ShortcutStroke, arrowGlyph: String, arrowKeyCode: UInt16 ) -> Bool { - if shortcut.key == arrowGlyph { - return matchArrowShortcut(event: event, shortcut: shortcut, keyCode: arrowKeyCode) + if stroke.key == arrowGlyph { + return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) } - return matchShortcut(event: event, shortcut: shortcut) + return matchShortcutStroke(event: event, stroke: stroke) + } + + private func matchDirectionalShortcut( + event: NSEvent, + shortcut: StoredShortcut, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) } func validateMenuItem(_ item: NSMenuItem) -> Bool { @@ -11537,6 +11865,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserPanelOwning(webView)?.searchState != nil } + private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutResponder = shortcutWindow?.firstResponder + let owningWebView = tabManager?.focusedBrowserPanel?.webView as? CmuxWebView + guard let owningWebView else { return false } + return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + event, + responder: shortcutResponder, + owningWebView: owningWebView + ) + } + private func browserPanelOwning(_ webView: CmuxWebView) -> BrowserPanel? { var candidateManagers: [TabManager] = [] var seenManagers = Set<ObjectIdentifier>() diff --git a/Sources/CmuxDirectoryTrust.swift b/Sources/CmuxDirectoryTrust.swift index 1c906fbada9d..4c0190fb5130 100644 --- a/Sources/CmuxDirectoryTrust.swift +++ b/Sources/CmuxDirectoryTrust.swift @@ -6,6 +6,7 @@ import Foundation /// Global config (~/.config/cmux/cmux.json) is always trusted. final class CmuxDirectoryTrust { static let shared = CmuxDirectoryTrust() + static let didChangeNotification = Notification.Name("cmux.directoryTrustDidChange") private let storePath: String private var trustedPaths: Set<String> @@ -108,5 +109,6 @@ final class CmuxDirectoryTrust { let sorted = trustedPaths.sorted() guard let data = try? JSONEncoder().encode(sorted) else { return } FileManager.default.createFile(atPath: storePath, contents: data) + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c3b1de39fe43..9c3026fc67d9 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1060,6 +1060,71 @@ private final class PassthroughWindowOverlayContainerView: NSView { } } +#if DEBUG +private func debugCommandPaletteWindowSummary(_ window: NSWindow?) -> String { + guard let window else { return "nil" } + let ident = window.identifier?.rawValue ?? "nil" + return "num=\(window.windowNumber) ident=\(ident) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" +} + +private func debugCommandPaletteNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) +} + +private func debugCommandPaletteModifierFlagsSummary(_ flags: NSEvent.ModifierFlags) -> String { + let normalized = debugCommandPaletteNormalizedModifierFlags(flags) + var parts: [String] = [] + if normalized.contains(.command) { parts.append("cmd") } + if normalized.contains(.shift) { parts.append("shift") } + if normalized.contains(.option) { parts.append("opt") } + if normalized.contains(.control) { parts.append("ctrl") } + return parts.isEmpty ? "none" : parts.joined(separator: "+") +} + +private func debugCommandPaletteKeyEventSummary(_ event: NSEvent) -> String { + let chars = event.characters.map(String.init(reflecting:)) ?? "nil" + let charsIgnoring = event.charactersIgnoringModifiers.map(String.init(reflecting:)) ?? "nil" + return + "type=\(event.type) keyCode=\(event.keyCode) flags=\(debugCommandPaletteModifierFlagsSummary(event.modifierFlags)) " + + "chars=\(chars) charsIgnoring=\(charsIgnoring)" +} + +private func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + if escaped.count <= limit { + return escaped + } + let prefix = escaped.prefix(limit) + return "\(prefix)..." +} + +private func debugCommandPaletteResponderSummary(_ responder: NSResponder?) -> String { + guard let responder else { return "nil" } + + let typeName = String(describing: type(of: responder)) + if let textView = responder as? NSTextView { + let selection = textView.selectedRange() + return "\(typeName){fieldEditor=\(textView.isFieldEditor ? 1 : 0) editable=\(textView.isEditable ? 1 : 0) selectable=\(textView.isSelectable ? 1 : 0) hidden=\(textView.isHiddenOrHasHiddenAncestor ? 1 : 0) len=\((textView.string as NSString).length) sel=\(selection.location):\(selection.length)}" + } + + if let textField = responder as? NSTextField { + return "\(typeName){editable=\(textField.isEditable ? 1 : 0) enabled=\(textField.isEnabled ? 1 : 0) hidden=\(textField.isHiddenOrHasHiddenAncestor ? 1 : 0) len=\((textField.stringValue as NSString).length)}" + } + + if let view = responder as? NSView { + return "\(typeName){hidden=\(view.isHiddenOrHasHiddenAncestor ? 1 : 0)}" + } + + return typeName +} +#endif + @MainActor private final class WindowCommandPaletteOverlayController: NSObject { private weak var window: NSWindow? @@ -1162,11 +1227,20 @@ private final class WindowCommandPaletteOverlayController: NSObject { return false } + private func isPaletteMultilineTextView(_ textView: NSTextView) -> Bool { + guard !textView.isFieldEditor, + textView.isEditable, + textView.isSelectable, + !textView.isHiddenOrHasHiddenAncestor, + textView.isDescendant(of: containerView) else { return false } + return true + } + private func isPaletteTextInputFirstResponder(_ responder: NSResponder?) -> Bool { guard let responder else { return false } if let textView = responder as? NSTextView { - return isPaletteFieldEditor(textView) + return isPaletteFieldEditor(textView) || isPaletteMultilineTextView(textView) } if let textField = responder as? NSTextField { @@ -1176,6 +1250,30 @@ private final class WindowCommandPaletteOverlayController: NSObject { return false } + private func firstEditableTextInput(in view: NSView) -> NSResponder? { + if let textField = view as? NSTextField, + textField.isEditable, + textField.isEnabled, + !textField.isHiddenOrHasHiddenAncestor { + return textField + } + + if let textView = view as? NSTextView, + !textView.isFieldEditor, + textView.isEditable, + textView.isSelectable, + !textView.isHiddenOrHasHiddenAncestor { + return textView + } + + for subview in view.subviews { + if let match = firstEditableTextInput(in: subview) { + return match + } + } + return nil + } + private func firstEditableTextField(in view: NSView) -> NSTextField? { if let textField = view as? NSTextField, textField.isEditable, @@ -1192,7 +1290,63 @@ private final class WindowCommandPaletteOverlayController: NSObject { return nil } + private func focusPaletteTextInput(in window: NSWindow) -> Bool { + guard let input = firstEditableTextInput(in: hostingView) else { +#if DEBUG + dlog( + "palette.focus.direct missingInput window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif + return false + } +#if DEBUG + dlog( + "palette.focus.direct attempt window={\(debugCommandPaletteWindowSummary(window))} " + + "input=\(debugCommandPaletteResponderSummary(input)) " + + "frBefore=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif + guard window.makeFirstResponder(input) else { +#if DEBUG + dlog( + "palette.focus.direct failedMakeFirstResponder window={\(debugCommandPaletteWindowSummary(window))} " + + "input=\(debugCommandPaletteResponderSummary(input)) " + + "frAfter=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif + return false + } + + if let textView = input as? NSTextView, !textView.isFieldEditor { + let length = (textView.string as NSString).length + textView.setSelectedRange(NSRange(location: length, length: 0)) + } else { + normalizeSelectionAfterProgrammaticFocus() + } + + let didSettle = isPaletteTextInputFirstResponder(window.firstResponder) +#if DEBUG + dlog( + "palette.focus.direct settled window={\(debugCommandPaletteWindowSummary(window))} " + + "didSettle=\(didSettle ? 1 : 0) frAfter=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif + return didSettle + } + private func scheduleFocusIntoPalette(retries: Int = 4) { +#if DEBUG + if let window { + dlog( + "palette.focus.schedule retries=\(retries) " + + "window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) + } else { + dlog("palette.focus.schedule retries=\(retries) window=nil") + } +#endif scheduledFocusWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.scheduledFocusWorkItem = nil @@ -1204,27 +1358,69 @@ private final class WindowCommandPaletteOverlayController: NSObject { private func focusIntoPalette(retries: Int) { guard let window else { return } +#if DEBUG + dlog( + "palette.focus.retry start retries=\(retries) " + + "window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif if isPaletteTextInputFirstResponder(window.firstResponder) { +#if DEBUG + dlog( + "palette.focus.retry alreadyFocused window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif return } - if let textField = firstEditableTextField(in: hostingView), - window.makeFirstResponder(textField), - isPaletteTextInputFirstResponder(window.firstResponder) { - normalizeSelectionAfterProgrammaticFocus() + if focusPaletteTextInput(in: window) { +#if DEBUG + dlog( + "palette.focus.retry directSuccess retries=\(retries) " + + "window={\(debugCommandPaletteWindowSummary(window))}" + ) +#endif return } - if window.makeFirstResponder(containerView) { - if let textField = firstEditableTextField(in: hostingView), - window.makeFirstResponder(textField), - isPaletteTextInputFirstResponder(window.firstResponder) { - normalizeSelectionAfterProgrammaticFocus() + let containerFocused = window.makeFirstResponder(containerView) +#if DEBUG + dlog( + "palette.focus.retry containerResult retries=\(retries) " + + "window={\(debugCommandPaletteWindowSummary(window))} " + + "didFocusContainer=\(containerFocused ? 1 : 0) " + + "frAfterContainer=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif + if containerFocused { + if focusPaletteTextInput(in: window) { +#if DEBUG + dlog( + "palette.focus.retry containerAssistedSuccess retries=\(retries) " + + "window={\(debugCommandPaletteWindowSummary(window))}" + ) +#endif return } } - guard retries > 0 else { return } + guard retries > 0 else { +#if DEBUG + dlog( + "palette.focus.retry exhausted window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif + return + } +#if DEBUG + dlog( + "palette.focus.retry reschedule nextRetries=\(retries - 1) " + + "window={\(debugCommandPaletteWindowSummary(window))}" + ) +#endif DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in self?.focusIntoPalette(retries: retries - 1) } @@ -1258,11 +1454,22 @@ private final class WindowCommandPaletteOverlayController: NSObject { return } guard isPaletteVisible else { +#if DEBUG + dlog( + "palette.focus.lock inactive visible=0 window={\(debugCommandPaletteWindowSummary(window))}" + ) +#endif stopFocusLockTimer() return } guard window.isKeyWindow else { +#if DEBUG + dlog( + "palette.focus.lock keyWindowMissing window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif stopFocusLockTimer() if isPaletteResponder(window.firstResponder) { _ = window.makeFirstResponder(nil) @@ -1272,6 +1479,12 @@ private final class WindowCommandPaletteOverlayController: NSObject { startFocusLockTimer() if !isPaletteTextInputFirstResponder(window.firstResponder) { +#if DEBUG + dlog( + "palette.focus.lock requestRestore window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif scheduleFocusIntoPalette(retries: 8) } } @@ -1326,6 +1539,17 @@ private final class WindowCommandPaletteOverlayController: NSObject { previouslyVisible: isPaletteVisible, isVisible: isVisible ) +#if DEBUG + if let window { + dlog( + "palette.overlay.update visible=\(isVisible ? 1 : 0) promote=\(shouldPromote ? 1 : 0) " + + "window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) + } else { + dlog("palette.overlay.update visible=\(isVisible ? 1 : 0) promote=\(shouldPromote ? 1 : 0) window=nil") + } +#endif isPaletteVisible = isVisible if isVisible { hostingView.rootView = rootView @@ -1676,6 +1900,8 @@ struct ContentView: View { @State private var commandPaletteQuery: String = "" @State private var commandPaletteMode: CommandPaletteMode = .commands @State private var commandPaletteRenameDraft: String = "" + @State private var commandPaletteWorkspaceDescriptionDraft: String = "" + @State private var commandPaletteWorkspaceDescriptionHeight: CGFloat = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight @State private var commandPaletteSelectedResultIndex: Int = 0 @State private var commandPaletteSelectionAnchorCommandID: String? @State private var commandPaletteHoveredResultIndex: Int? @@ -1712,6 +1938,7 @@ struct ContentView: View { private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @State private var commandPaletteShouldFocusWorkspaceDescriptionEditor = false @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool @@ -1719,6 +1946,7 @@ struct ContentView: View { case commands case renameInput(CommandPaletteRenameTarget) case renameConfirm(CommandPaletteRenameTarget, proposedName: String) + case workspaceDescriptionInput(CommandPaletteWorkspaceDescriptionTarget) } private enum CommandPaletteListScope: String { @@ -1773,6 +2001,25 @@ struct ContentView: View { } } + private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + let workspaceId: UUID + let currentDescription: String + + var placeholder: String { + String( + localized: "commandPalette.description.workspacePlaceholder", + defaultValue: "Workspace description" + ) + } + + var inputHint: String { + String( + localized: "commandPalette.description.workspaceInputHint", + defaultValue: "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." + ) + } + } + private struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID @@ -1999,6 +2246,7 @@ struct ContentView: View { static let hasWorkspace = "workspace.hasSelection" static let workspaceName = "workspace.name" static let workspaceHasCustomName = "workspace.hasCustomName" + static let workspaceHasCustomDescription = "workspace.hasCustomDescription" static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" static let workspaceShouldPin = "workspace.shouldPin" static let workspaceHasPullRequests = "workspace.hasPullRequests" @@ -3070,6 +3318,26 @@ struct ContentView: View { openCommandPaletteRenameWorkspaceInput() }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteEditWorkspaceDescriptionRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + let shouldHandle = Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) +#if DEBUG + dlog( + "palette.wsDescription.request observed={\(debugCommandPaletteWindowSummary(observedWindow))} " + + "requested={\(debugCommandPaletteWindowSummary(requestedWindow))} " + + "shouldHandle=\(shouldHandle ? 1 : 0) presented=\(isCommandPalettePresented ? 1 : 0) " + + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode))" + ) +#endif + guard shouldHandle else { return } + openCommandPaletteWorkspaceDescriptionInput() + }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteMoveSelection)) { notification in guard isCommandPalettePresented else { return } guard case .commands = commandPaletteMode else { return } @@ -3663,6 +3931,10 @@ struct ContentView: View { GeometryReader { proxy in let maxAllowedWidth = max(340, proxy.size.width - 260) let targetWidth = min(560, maxAllowedWidth) + let workspaceDescriptionMaxEditorHeight = max( + CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight, + proxy.size.height - 120 + ) ZStack(alignment: .top) { Color.clear @@ -3689,6 +3961,11 @@ struct ContentView: View { commandPaletteRenameInputView(target: target) case let .renameConfirm(target, proposedName): commandPaletteRenameConfirmView(target: target, proposedName: proposedName) + case .workspaceDescriptionInput(let target): + commandPaletteWorkspaceDescriptionInputView( + target: target, + maxEditorHeight: workspaceDescriptionMaxEditorHeight + ) } } .frame(width: targetWidth) @@ -3880,25 +4157,79 @@ struct ContentView: View { } } - private func commandPaletteRenameInputView(target: CommandPaletteRenameTarget) -> some View { - VStack(spacing: 0) { - TextField(target.placeholder, text: $commandPaletteRenameDraft) + private enum CommandPaletteEditorFieldStyle { + case singleLine( + accessibilityIdentifier: String, + focus: FocusState<Bool>.Binding, + onDeleteBackward: ((EventModifiers) -> BackportKeyPressResult)? + ) + case multiline( + accessibilityIdentifier: String, + accessibilityLabel: String, + focus: Binding<Bool>, + measuredHeight: Binding<CGFloat>, + maxHeight: CGFloat + ) + } + + @ViewBuilder + private func commandPaletteEditorField( + style: CommandPaletteEditorFieldStyle, + placeholder: String, + text: Binding<String>, + onSubmit: @escaping (String) -> Void, + onEscape: @escaping () -> Void, + onInteraction: (() -> Void)? = nil + ) -> some View { + switch style { + case .singleLine(let accessibilityIdentifier, let focus, let onDeleteBackward): + TextField(placeholder, text: text) .textFieldStyle(.plain) .font(.system(size: 13, weight: .regular)) .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) - .focused($isCommandPaletteRenameFocused) - .accessibilityIdentifier("CommandPaletteRenameField") + .focused(focus) + .accessibilityIdentifier(accessibilityIdentifier) .backport.onKeyPress(.delete) { modifiers in - handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) + onDeleteBackward?(modifiers) ?? .ignored } .onSubmit { - continueRenameFlow(target: target) + onSubmit(text.wrappedValue) } .onTapGesture { - handleCommandPaletteRenameInputInteraction() + onInteraction?() } - .padding(.horizontal, 9) - .padding(.vertical, 7) + case .multiline(let accessibilityIdentifier, let accessibilityLabel, let focus, let measuredHeight, let maxHeight): + CommandPaletteMultilineTextEditorRepresentable( + placeholder: placeholder, + accessibilityLabel: accessibilityLabel, + accessibilityIdentifier: accessibilityIdentifier, + text: text, + isFocused: focus, + measuredHeight: measuredHeight, + maxHeight: maxHeight, + onSubmit: onSubmit, + onEscape: onEscape + ) + .frame(height: measuredHeight.wrappedValue) + } + } + + private func commandPaletteRenameInputView(target: CommandPaletteRenameTarget) -> some View { + VStack(spacing: 0) { + commandPaletteEditorField( + style: .singleLine( + accessibilityIdentifier: "CommandPaletteRenameField", + focus: $isCommandPaletteRenameFocused, + onDeleteBackward: handleCommandPaletteRenameDeleteBackward(modifiers:) + ), + placeholder: target.placeholder, + text: $commandPaletteRenameDraft, + onSubmit: { _ in continueRenameFlow(target: target) }, + onEscape: { dismissCommandPalette() }, + onInteraction: handleCommandPaletteRenameInputInteraction + ) + .padding(.horizontal, 9) + .padding(.vertical, 7) Divider() @@ -3964,6 +4295,65 @@ struct ContentView: View { } } + private func commandPaletteWorkspaceDescriptionInputView( + target: CommandPaletteWorkspaceDescriptionTarget, + maxEditorHeight: CGFloat + ) -> some View { + VStack(spacing: 0) { + commandPaletteEditorField( + style: .multiline( + accessibilityIdentifier: "CommandPaletteWorkspaceDescriptionEditor", + accessibilityLabel: String( + localized: "command.editWorkspaceDescription.title", + defaultValue: "Edit Workspace Description…" + ), + focus: $commandPaletteShouldFocusWorkspaceDescriptionEditor, + measuredHeight: $commandPaletteWorkspaceDescriptionHeight, + maxHeight: maxEditorHeight + ), + placeholder: target.placeholder, + text: $commandPaletteWorkspaceDescriptionDraft, + onSubmit: { proposedDescription in + applyWorkspaceDescriptionFlow(target: target, proposedDescription: proposedDescription) + }, + onEscape: { dismissCommandPalette() } + ) + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text(target.inputHint) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + } + .onAppear { +#if DEBUG + dlog( + "palette.wsDescription.view.appear workspace=\(target.workspaceId.uuidString.prefix(8)) " + + "draftLen=\((commandPaletteWorkspaceDescriptionDraft as NSString).length) " + + "height=\(String(format: "%.1f", commandPaletteWorkspaceDescriptionHeight)) " + + "focusFlag=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0)" + ) +#endif + resetCommandPaletteWorkspaceDescriptionFocus() + } + .onChange(of: commandPaletteShouldFocusWorkspaceDescriptionEditor) { _, newValue in +#if DEBUG + dlog( + "palette.wsDescription.focus.binding new=\(newValue ? 1 : 0) " + + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode)) " + + "window={\(debugCommandPaletteWindowSummary(observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + + "fr=\(debugCommandPaletteResponderSummary((observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow)?.firstResponder))" + ) +#endif + } + } + private final class CommandPaletteNativeTextField: NSTextField { var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? @@ -4085,7 +4475,8 @@ struct ContentView: View { if shouldSubmitCommandPaletteWithReturn( keyCode: event.keyCode, - flags: event.modifierFlags + flags: event.modifierFlags, + mode: "single_line" ) { parent.onSubmit() return true @@ -4145,59 +4536,621 @@ struct ContentView: View { field.onHandleKeyEvent = { [weak coordinator = context.coordinator] event, editor in coordinator?.handleKeyEvent(event, editor: editor) ?? false } - context.coordinator.parentField = field - return field + context.coordinator.parentField = field + return field + } + + func updateNSView(_ nsView: CommandPaletteNativeTextField, context: Context) { + context.coordinator.parent = self + context.coordinator.parentField = nsView + nsView.placeholderString = placeholder + + if let editor = nsView.currentEditor() as? NSTextView { + context.coordinator.attachEditorTextDidChangeObserverIfNeeded(editor) + if editor.string != text, !editor.hasMarkedText() { + context.coordinator.isProgrammaticMutation = true + editor.string = text + nsView.stringValue = text + context.coordinator.isProgrammaticMutation = false + } + } else if nsView.stringValue != text { + context.coordinator.detachEditorTextDidChangeObserver() + nsView.stringValue = text + } else { + context.coordinator.detachEditorTextDidChangeObserver() + } + + guard let window = nsView.window else { return } + let firstResponder = window.firstResponder + let isFirstResponder = + firstResponder === nsView || + nsView.currentEditor() != nil || + ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView + + if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true { + context.coordinator.pendingFocusRequest = true + DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in + coordinator?.pendingFocusRequest = nil + guard let coordinator, coordinator.parent.isFocused else { return } + guard let nsView, let window = nsView.window else { return } + let firstResponder = window.firstResponder + let alreadyFocused = + firstResponder === nsView || + nsView.currentEditor() != nil || + ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView + guard !alreadyFocused else { return } + window.makeFirstResponder(nsView) + } + } + } + + static func dismantleNSView(_ nsView: CommandPaletteNativeTextField, coordinator: Coordinator) { + nsView.delegate = nil + nsView.onHandleKeyEvent = nil + coordinator.detachEditorTextDidChangeObserver() + coordinator.parentField = nil + } + } + + private final class CommandPalettePassthroughLabel: NSTextField { + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } + + private final class CommandPaletteMultilineTextView: NSTextView { + var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? + var onDidBecomeFirstResponder: (() -> Void)? + + override func flagsChanged(with event: NSEvent) { +#if DEBUG + dlog( + "palette.wsDescription.editor.flagsChanged " + + "\(debugCommandPaletteKeyEventSummary(event))" + ) +#endif + super.flagsChanged(with: event) + } + + override func becomeFirstResponder() -> Bool { + let becameFirstResponder = super.becomeFirstResponder() +#if DEBUG + dlog( + "palette.wsDescription.editor.textView.becomeFirstResponder success=\(becameFirstResponder ? 1 : 0) " + + "window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window?.firstResponder))" + ) +#endif + if becameFirstResponder { + onDidBecomeFirstResponder?() + } + return becameFirstResponder + } + + override func keyDown(with event: NSEvent) { + if hasMarkedText() { +#if DEBUG + dlog( + "palette.wsDescription.editor.keyDown markedText=1 " + + "\(debugCommandPaletteKeyEventSummary(event))" + ) +#endif + super.keyDown(with: event) + return + } + let handled = onHandleKeyEvent?(event, self) == true +#if DEBUG + dlog( + "palette.wsDescription.editor.keyDown handled=\(handled ? 1 : 0) " + + "\(debugCommandPaletteKeyEventSummary(event))" + ) +#endif + if handled { + return + } + super.keyDown(with: event) + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if hasMarkedText() { +#if DEBUG + dlog( + "palette.wsDescription.editor.performKeyEquivalent markedText=1 " + + "\(debugCommandPaletteKeyEventSummary(event))" + ) +#endif + return super.performKeyEquivalent(with: event) + } + let handled = onHandleKeyEvent?(event, self) == true +#if DEBUG + dlog( + "palette.wsDescription.editor.performKeyEquivalent handled=\(handled ? 1 : 0) " + + "\(debugCommandPaletteKeyEventSummary(event))" + ) +#endif + if handled { + return true + } + let result = super.performKeyEquivalent(with: event) +#if DEBUG + dlog( + "palette.wsDescription.editor.performKeyEquivalent superResult=\(result ? 1 : 0) " + + "\(debugCommandPaletteKeyEventSummary(event))" + ) +#endif + return result + } + + override func doCommand(by commandSelector: Selector) { +#if DEBUG + dlog( + "palette.wsDescription.editor.doCommand selector=\(NSStringFromSelector(commandSelector)) " + + "len=\((string as NSString).length) " + + "sel=\(selectedRange().location):\(selectedRange().length)" + ) +#endif + super.doCommand(by: commandSelector) + } + + override func insertNewline(_ sender: Any?) { +#if DEBUG + dlog( + "palette.wsDescription.editor.insertNewline " + + "len=\((string as NSString).length) " + + "sel=\(selectedRange().location):\(selectedRange().length)" + ) +#endif + super.insertNewline(sender) + } + + override func insertLineBreak(_ sender: Any?) { +#if DEBUG + dlog( + "palette.wsDescription.editor.insertLineBreak " + + "len=\((string as NSString).length) " + + "sel=\(selectedRange().location):\(selectedRange().length)" + ) +#endif + super.insertLineBreak(sender) + } + + override func insertNewlineIgnoringFieldEditor(_ sender: Any?) { +#if DEBUG + dlog( + "palette.wsDescription.editor.insertNewlineIgnoringFieldEditor " + + "len=\((string as NSString).length) " + + "sel=\(selectedRange().location):\(selectedRange().length)" + ) +#endif + super.insertNewlineIgnoringFieldEditor(sender) + } + } + + private final class CommandPaletteMultilineTextEditorView: NSView { + private static let font = NSFont.systemFont(ofSize: 13) + private static let textInset = NSSize(width: 0, height: 2) + static let defaultMinimumHeight: CGFloat = { + let lineHeight = ceil(font.ascender - font.descender + font.leading) + return lineHeight * 5 + textInset.height * 2 + }() + + private let scrollView = NSScrollView(frame: .zero) + let textView = CommandPaletteMultilineTextView(frame: .zero) + private let placeholderField = CommandPalettePassthroughLabel(labelWithString: "") + var onMeasuredHeightChange: ((CGFloat) -> Void)? + private var lastReportedHeight: CGFloat? + var maximumHeight: CGFloat = .greatestFiniteMagnitude { + didSet { + refreshMetrics() + } + } + + var placeholder: String = "" { + didSet { + placeholderField.stringValue = placeholder + updatePlaceholderVisibility() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + addSubview(scrollView) + + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.font = Self.font + textView.textColor = .labelColor + textView.insertionPointColor = .labelColor + textView.textContainerInset = Self.textInset + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.minSize = NSSize(width: 0, height: Self.defaultMinimumHeight) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + scrollView.documentView = textView + + placeholderField.translatesAutoresizingMaskIntoConstraints = false + placeholderField.font = Self.font + placeholderField.textColor = .secondaryLabelColor + placeholderField.lineBreakMode = .byWordWrapping + placeholderField.maximumNumberOfLines = 0 + addSubview(placeholderField) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange(_:)), + name: NSText.didChangeNotification, + object: textView + ) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + + placeholderField.topAnchor.constraint(equalTo: topAnchor, constant: Self.textInset.height), + placeholderField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.textInset.width), + placeholderField.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -Self.textInset.width), + ]) + + updatePlaceholderVisibility() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func layout() { + super.layout() + updateTextViewLayout() + reportMeasuredHeightIfNeeded() + } + + func refreshMetrics() { + updatePlaceholderVisibility() + needsLayout = true + layoutSubtreeIfNeeded() + reportMeasuredHeightIfNeeded() + } + + func focusIfNeeded() { + guard let window else { +#if DEBUG + dlog("palette.wsDescription.editor.focusIfNeeded window=nil") +#endif + return + } + guard window.firstResponder !== textView else { +#if DEBUG + dlog( + "palette.wsDescription.editor.focusIfNeeded alreadyFocused window={\(debugCommandPaletteWindowSummary(window))}" + ) +#endif + return + } +#if DEBUG + dlog( + "palette.wsDescription.editor.focusIfNeeded attempt window={\(debugCommandPaletteWindowSummary(window))} " + + "frBefore=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif + let didFocus = window.makeFirstResponder(textView) + let length = (textView.string as NSString).length + textView.setSelectedRange(NSRange(location: length, length: 0)) +#if DEBUG + dlog( + "palette.wsDescription.editor.focusIfNeeded result didFocus=\(didFocus ? 1 : 0) " + + "window={\(debugCommandPaletteWindowSummary(window))} " + + "frAfter=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif + } + + private func cappedMaximumHeight() -> CGFloat { + max(Self.defaultMinimumHeight, maximumHeight) + } + + private func naturalHeight(for width: CGFloat) -> CGFloat { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return Self.defaultMinimumHeight + } + textContainer.containerSize = NSSize( + width: width, + height: CGFloat.greatestFiniteMagnitude + ) + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + let lineHeight = ceil(Self.font.ascender - Self.font.descender + Self.font.leading) + let contentHeight = max(lineHeight, ceil(usedRect.height)) + return max( + Self.defaultMinimumHeight, + ceil(contentHeight + Self.textInset.height * 2) + ) + } + + private func updateTextViewLayout() { + let availableWidth = max(scrollView.contentSize.width, bounds.width, 1) + let naturalHeight = naturalHeight(for: availableWidth) + let measuredHeight = min(cappedMaximumHeight(), naturalHeight) + let documentHeight = max(naturalHeight, measuredHeight) + textView.frame = NSRect(x: 0, y: 0, width: availableWidth, height: documentHeight) + } + + private func fittingHeight() -> CGFloat { + let availableWidth = max(scrollView.contentSize.width, bounds.width, 1) + return min(cappedMaximumHeight(), naturalHeight(for: availableWidth)) + } + + private func reportMeasuredHeightIfNeeded() { + let height = fittingHeight() + guard lastReportedHeight == nil || abs((lastReportedHeight ?? height) - height) > 0.5 else { return } + lastReportedHeight = height + onMeasuredHeightChange?(height) + } + + @objc + private func textDidChange(_ notification: Notification) { + updatePlaceholderVisibility() + reportMeasuredHeightIfNeeded() +#if DEBUG + let newlineCount = textView.string.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "palette.wsDescription.editor.textDidChange len=\((textView.string as NSString).length) " + + "newlines=\(newlineCount)" + ) +#endif + } + + private func updatePlaceholderVisibility() { + placeholderField.isHidden = textView.string.isEmpty == false + } + } + + private struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { + static let defaultMinimumHeight = CommandPaletteMultilineTextEditorView.defaultMinimumHeight + + let placeholder: String + let accessibilityLabel: String + let accessibilityIdentifier: String + @Binding var text: String + @Binding var isFocused: Bool + @Binding var measuredHeight: CGFloat + let maxHeight: CGFloat + let onSubmit: (String) -> Void + let onEscape: () -> Void + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: CommandPaletteMultilineTextEditorRepresentable + var isProgrammaticMutation = false + var pendingFocusRequest = false + + init(parent: CommandPaletteMultilineTextEditorRepresentable) { + self.parent = parent + } + + func textDidBeginEditing(_ notification: Notification) { +#if DEBUG + dlog( + "palette.wsDescription.editor.beginEditing focus=\(parent.isFocused ? 1 : 0) " + + "responder=\(debugCommandPaletteResponderSummary(notification.object as? NSResponder))" + ) +#endif + if !parent.isFocused { + DispatchQueue.main.async { + self.parent.isFocused = true + } + } + } + + func textDidChange(_ notification: Notification) { + guard !isProgrammaticMutation, + let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { +#if DEBUG + dlog( + "palette.wsDescription.editor.command selector=\(NSStringFromSelector(commandSelector)) " + + "len=\((textView.string as NSString).length) " + + "sel=\(textView.selectedRange().location):\(textView.selectedRange().length)" + ) +#endif + return false + } + + func handleDidBecomeFirstResponder() { +#if DEBUG + dlog( + "palette.wsDescription.editor.didBecomeFirstResponder focus=\(parent.isFocused ? 1 : 0)" + ) +#endif + if !parent.isFocused { + parent.isFocused = true + } + } + + func handleMeasuredHeight(_ height: CGFloat) { + guard abs(parent.measuredHeight - height) > 0.5 else { return } + DispatchQueue.main.async { + self.parent.measuredHeight = height + } + } + + func handleKeyEvent(_ event: NSEvent, editor: NSTextView?) -> Bool { + guard !(editor?.hasMarkedText() ?? false) else { return false } + + let normalizedFlags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + +#if DEBUG + dlog( + "palette.wsDescription.editor.handleKeyEvent " + + "\(debugCommandPaletteKeyEventSummary(event)) " + + "normalized=\(debugCommandPaletteModifierFlagsSummary(normalizedFlags))" + ) +#endif + + if event.keyCode == 36 || event.keyCode == 76 { + if normalizedFlags.isEmpty { + let currentText = editor?.string ?? parent.text +#if DEBUG + dlog("palette.wsDescription.editor.handleKeyEvent action=submit") + dlog( + "palette.wsDescription.editor.handleKeyEvent submitText " + + "len=\((currentText as NSString).length) " + + "text=\"\(debugCommandPaletteTextPreview(currentText))\"" + ) +#endif + if parent.text != currentText { + parent.text = currentText + } + parent.onSubmit(currentText) + return true + } + if normalizedFlags == [.shift] { +#if DEBUG + dlog("palette.wsDescription.editor.handleKeyEvent action=allowShiftReturn") +#endif + return false + } + } + + if event.keyCode == 53, normalizedFlags.isEmpty { +#if DEBUG + dlog("palette.wsDescription.editor.handleKeyEvent action=escape") +#endif + parent.onEscape() + return true + } + +#if DEBUG + dlog("palette.wsDescription.editor.handleKeyEvent action=passThrough") +#endif + return false + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> CommandPaletteMultilineTextEditorView { + let view = CommandPaletteMultilineTextEditorView(frame: .zero) + view.placeholder = placeholder + view.maximumHeight = maxHeight + view.textView.string = text + view.textView.delegate = context.coordinator + view.textView.setAccessibilityLabel(accessibilityLabel) + view.textView.setAccessibilityIdentifier(accessibilityIdentifier) + view.setAccessibilityIdentifier(accessibilityIdentifier) + view.textView.onHandleKeyEvent = { [weak coordinator = context.coordinator] event, editor in + coordinator?.handleKeyEvent(event, editor: editor) ?? false + } + view.textView.onDidBecomeFirstResponder = { [weak coordinator = context.coordinator] in + coordinator?.handleDidBecomeFirstResponder() + } + view.onMeasuredHeightChange = { [weak coordinator = context.coordinator] height in + coordinator?.handleMeasuredHeight(height) + } + view.refreshMetrics() +#if DEBUG + dlog( + "palette.wsDescription.editor.make focus=\(isFocused ? 1 : 0) " + + "textLen=\((text as NSString).length) " + + "height=\(String(format: "%.1f", measuredHeight))" + ) +#endif + return view } - func updateNSView(_ nsView: CommandPaletteNativeTextField, context: Context) { + func updateNSView(_ nsView: CommandPaletteMultilineTextEditorView, context: Context) { context.coordinator.parent = self - context.coordinator.parentField = nsView - nsView.placeholderString = placeholder + nsView.placeholder = placeholder + nsView.maximumHeight = maxHeight + nsView.textView.setAccessibilityLabel(accessibilityLabel) + nsView.textView.setAccessibilityIdentifier(accessibilityIdentifier) + nsView.setAccessibilityIdentifier(accessibilityIdentifier) - if let editor = nsView.currentEditor() as? NSTextView { - context.coordinator.attachEditorTextDidChangeObserverIfNeeded(editor) - if editor.string != text, !editor.hasMarkedText() { - context.coordinator.isProgrammaticMutation = true - editor.string = text - nsView.stringValue = text - context.coordinator.isProgrammaticMutation = false - } - } else if nsView.stringValue != text { - context.coordinator.detachEditorTextDidChangeObserver() - nsView.stringValue = text - } else { - context.coordinator.detachEditorTextDidChangeObserver() + if nsView.textView.string != text { + context.coordinator.isProgrammaticMutation = true + nsView.textView.string = text + context.coordinator.isProgrammaticMutation = false } + nsView.onMeasuredHeightChange = { [weak coordinator = context.coordinator] height in + coordinator?.handleMeasuredHeight(height) + } + nsView.refreshMetrics() - guard let window = nsView.window else { return } - let firstResponder = window.firstResponder - let isFirstResponder = - firstResponder === nsView || - nsView.currentEditor() != nil || - ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView - - if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true { + guard let window = nsView.window else { +#if DEBUG + if isFocused { + dlog( + "palette.wsDescription.editor.update waitingForWindow focus=1 " + + "pending=\(context.coordinator.pendingFocusRequest ? 1 : 0)" + ) + } +#endif + return + } + let isFirstResponder = window.firstResponder === nsView.textView +#if DEBUG + if isFocused || context.coordinator.pendingFocusRequest { + dlog( + "palette.wsDescription.editor.update focus=\(isFocused ? 1 : 0) " + + "isFirstResponder=\(isFirstResponder ? 1 : 0) " + + "pending=\(context.coordinator.pendingFocusRequest ? 1 : 0) " + + "window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) + } +#endif + if isFocused, !isFirstResponder, !context.coordinator.pendingFocusRequest { context.coordinator.pendingFocusRequest = true +#if DEBUG + dlog( + "palette.wsDescription.editor.update scheduleFocus window={\(debugCommandPaletteWindowSummary(window))} " + + "fr=\(debugCommandPaletteResponderSummary(window.firstResponder))" + ) +#endif DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in - coordinator?.pendingFocusRequest = nil - guard let coordinator, coordinator.parent.isFocused else { return } - guard let nsView, let window = nsView.window else { return } - let firstResponder = window.firstResponder - let alreadyFocused = - firstResponder === nsView || - nsView.currentEditor() != nil || - ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView - guard !alreadyFocused else { return } - window.makeFirstResponder(nsView) + guard let coordinator else { return } + coordinator.pendingFocusRequest = false + guard coordinator.parent.isFocused, let nsView else { return } + nsView.focusIfNeeded() } } } - static func dismantleNSView(_ nsView: CommandPaletteNativeTextField, coordinator: Coordinator) { - nsView.delegate = nil - nsView.onHandleKeyEvent = nil - coordinator.detachEditorTextDidChangeObserver() - coordinator.parentField = nil + static func dismantleNSView(_ nsView: CommandPaletteMultilineTextEditorView, coordinator: Coordinator) { + nsView.textView.delegate = nil + nsView.textView.onHandleKeyEvent = nil + nsView.textView.onDidBecomeFirstResponder = nil + nsView.onMeasuredHeightChange = nil } } @@ -5118,7 +6071,8 @@ struct ContentView: View { return CommandPaletteSwitcherSearchMetadata( directories: directories, branches: branches, - ports: ports + ports: ports, + description: workspace.customDescription ) } @@ -5264,6 +6218,8 @@ struct ContentView: View { return .renameTab case "palette.renameWorkspace": return .renameWorkspace + case "palette.editWorkspaceDescription": + return .editWorkspaceDescription case "palette.nextWorkspace": return .nextSidebarTab case "palette.previousWorkspace": @@ -5346,6 +6302,7 @@ struct ContentView: View { snapshot.setBool(CommandPaletteContextKeys.hasWorkspace, true) snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace)) snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil) + snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomDescription, workspace.hasCustomDescription) snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned) snapshot.setBool( CommandPaletteContextKeys.workspaceHasPullRequests, @@ -5659,6 +6616,16 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.editWorkspaceDescription", + title: constant(String(localized: "command.editWorkspaceDescription.title", defaultValue: "Edit Workspace Description…")), + subtitle: workspaceSubtitle, + keywords: ["edit", "workspace", "description", "notes", "markdown"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.clearWorkspaceName", @@ -5671,6 +6638,18 @@ struct ContentView: View { } ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearWorkspaceDescription", + title: constant(String(localized: "command.clearWorkspaceDescription.title", defaultValue: "Clear Workspace Description")), + subtitle: workspaceSubtitle, + keywords: ["clear", "workspace", "description", "notes"], + when: { + $0.bool(CommandPaletteContextKeys.hasWorkspace) + && $0.bool(CommandPaletteContextKeys.workspaceHasCustomDescription) + } + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.toggleWorkspacePin", @@ -6287,6 +7266,9 @@ struct ContentView: View { registry.register(commandId: "palette.renameWorkspace") { beginRenameWorkspaceFlow() } + registry.register(commandId: "palette.editWorkspaceDescription") { + beginWorkspaceDescriptionFlow() + } registry.register(commandId: "palette.clearWorkspaceName") { guard let workspace = tabManager.selectedWorkspace else { NSSound.beep() @@ -6294,6 +7276,13 @@ struct ContentView: View { } tabManager.clearCustomTitle(tabId: workspace.id) } + registry.register(commandId: "palette.clearWorkspaceDescription") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.clearCustomDescription(tabId: workspace.id) + } registry.register(commandId: "palette.toggleWorkspacePin") { guard let workspace = tabManager.selectedWorkspace else { NSSound.beep() @@ -6678,6 +7667,7 @@ struct ContentView: View { for port in metadata.ports { hasher.combine(port) } + hasher.combine(metadata.description ?? "") } static func commandPaletteScrollPositionAnchor( @@ -6869,6 +7859,21 @@ struct ContentView: View { continueRenameFlow(target: target) case .renameConfirm(let target, let proposedName): applyRenameFlow(target: target, proposedName: proposedName) + case .workspaceDescriptionInput(let target): +#if DEBUG + let newlineCount = commandPaletteWorkspaceDescriptionDraft.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "palette.wsDescription.submit.request workspace=\(target.workspaceId.uuidString.prefix(8)) " + + "draftLen=\((commandPaletteWorkspaceDescriptionDraft as NSString).length) " + + "newlines=\(newlineCount)" + ) +#endif + applyWorkspaceDescriptionFlow( + target: target, + proposedDescription: commandPaletteWorkspaceDescriptionDraft + ) } } @@ -6929,6 +7934,27 @@ struct ContentView: View { beginRenameWorkspaceFlow() } + private func openCommandPaletteWorkspaceDescriptionInput() { +#if DEBUG + dlog( + "palette.wsDescription.open begin presented=\(isCommandPalettePresented ? 1 : 0) " + + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode)) " + + "window={\(debugCommandPaletteWindowSummary(observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow))}" + ) +#endif + if !isCommandPalettePresented { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + beginWorkspaceDescriptionFlow() +#if DEBUG + dlog( + "palette.wsDescription.open end presented=\(isCommandPalettePresented ? 1 : 0) " + + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode)) " + + "focusFlag=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0)" + ) +#endif + } + private func presentFeedbackComposer() { DispatchQueue.main.async { isFeedbackComposerPresented = true @@ -6982,6 +8008,8 @@ struct ContentView: View { mode = "rename_input" case .renameConfirm: mode = "rename_confirm" + case .workspaceDescriptionInput: + mode = "workspace_description_input" } let rows = Array(commandPaletteVisibleResults.prefix(20)).map { result in @@ -7020,11 +8048,14 @@ struct ContentView: View { commandPaletteMode = .commands commandPaletteQuery = initialQuery commandPaletteRenameDraft = "" + commandPaletteWorkspaceDescriptionDraft = "" + commandPaletteWorkspaceDescriptionHeight = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight commandPaletteSelectedResultIndex = 0 commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil + commandPaletteShouldFocusWorkspaceDescriptionEditor = false scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() @@ -7039,17 +8070,34 @@ struct ContentView: View { preferredFocusTarget: CommandPaletteRestoreFocusTarget? ) { let focusTarget = preferredFocusTarget ?? commandPaletteRestoreFocusTarget +#if DEBUG + if case .workspaceDescriptionInput(let target) = commandPaletteMode { + let newlineCount = commandPaletteWorkspaceDescriptionDraft.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "palette.wsDescription.dismiss workspace=\(target.workspaceId.uuidString.prefix(8)) " + + "restoreFocus=\(restoreFocus ? 1 : 0) " + + "draftLen=\((commandPaletteWorkspaceDescriptionDraft as NSString).length) " + + "newlines=\(newlineCount) " + + "window={\(debugCommandPaletteWindowSummary(observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow))}" + ) + } +#endif cancelCommandPaletteSearch() commandPaletteSearchRequestID &+= 1 isCommandPalettePresented = false commandPaletteMode = .commands commandPaletteQuery = "" commandPaletteRenameDraft = "" + commandPaletteWorkspaceDescriptionDraft = "" + commandPaletteWorkspaceDescriptionHeight = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight commandPaletteSelectedResultIndex = 0 commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil + commandPaletteShouldFocusWorkspaceDescriptionEditor = false isCommandPaletteSearchFocused = false isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil @@ -7266,6 +8314,19 @@ struct ContentView: View { return "browser.findField" } } + + private func debugCommandPaletteModeLabel(_ mode: CommandPaletteMode) -> String { + switch mode { + case .commands: + return "commands" + case .renameInput: + return "renameInput" + case .renameConfirm: + return "renameConfirm" + case .workspaceDescriptionInput: + return "workspaceDescriptionInput" + } + } #endif private func resetCommandPaletteSearchFocus() { @@ -7276,6 +8337,39 @@ struct ContentView: View { applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) } + private func resetCommandPaletteWorkspaceDescriptionFocus() { +#if DEBUG + dlog( + "palette.wsDescription.focus.reset schedule presented=\(isCommandPalettePresented ? 1 : 0) " + + "mode=\(debugCommandPaletteModeLabel(commandPaletteMode)) " + + "focusFlag=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0)" + ) +#endif + DispatchQueue.main.async { +#if DEBUG + dlog( + "palette.wsDescription.focus.reset apply.before search=\(isCommandPaletteSearchFocused ? 1 : 0) " + + "rename=\(isCommandPaletteRenameFocused ? 1 : 0) " + + "editor=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0) " + + "window={\(debugCommandPaletteWindowSummary(observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + + "fr=\(debugCommandPaletteResponderSummary((observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow)?.firstResponder))" + ) +#endif + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = false + commandPaletteShouldFocusWorkspaceDescriptionEditor = true + commandPalettePendingTextSelectionBehavior = nil +#if DEBUG + dlog( + "palette.wsDescription.focus.reset apply.after search=\(isCommandPaletteSearchFocused ? 1 : 0) " + + "rename=\(isCommandPaletteRenameFocused ? 1 : 0) " + + "editor=\(commandPaletteShouldFocusWorkspaceDescriptionEditor ? 1 : 0) " + + "fr=\(debugCommandPaletteResponderSummary((observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow)?.firstResponder))" + ) +#endif + } + } + private func handleCommandPaletteRenameInputInteraction() { guard isCommandPalettePresented else { return } guard case .renameInput = commandPaletteMode else { return } @@ -7295,6 +8389,7 @@ struct ContentView: View { private func applyCommandPaletteInputFocusPolicy(_ policy: CommandPaletteInputFocusPolicy) { DispatchQueue.main.async { + commandPaletteShouldFocusWorkspaceDescriptionEditor = false switch policy.focusTarget { case .search: isCommandPaletteRenameFocused = false @@ -7327,6 +8422,8 @@ struct ContentView: View { break case .renameConfirm: return + case .workspaceDescriptionInput: + return } } guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } @@ -7480,6 +8577,18 @@ struct ContentView: View { startRenameFlow(target) } + private func beginWorkspaceDescriptionFlow() { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + let target = CommandPaletteWorkspaceDescriptionTarget( + workspaceId: workspace.id, + currentDescription: workspace.customDescription ?? "" + ) + startWorkspaceDescriptionFlow(target) + } + private func beginRenameTabFlow() { guard let panelContext = focusedPanelContext else { NSSound.beep() @@ -7499,11 +8608,36 @@ struct ContentView: View { private func startRenameFlow(_ target: CommandPaletteRenameTarget) { commandPaletteRenameDraft = target.currentName + commandPaletteShouldFocusWorkspaceDescriptionEditor = false commandPaletteMode = .renameInput(target) resetCommandPaletteRenameFocus() syncCommandPaletteDebugStateForObservedWindow() } + private func startWorkspaceDescriptionFlow(_ target: CommandPaletteWorkspaceDescriptionTarget) { +#if DEBUG + dlog( + "palette.wsDescription.flow.start workspace=\(target.workspaceId.uuidString.prefix(8)) " + + "descLen=\((target.currentDescription as NSString).length) " + + "presented=\(isCommandPalettePresented ? 1 : 0) " + + "modeBefore=\(debugCommandPaletteModeLabel(commandPaletteMode))" + ) +#endif + commandPaletteWorkspaceDescriptionDraft = target.currentDescription + commandPaletteWorkspaceDescriptionHeight = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight + commandPalettePendingTextSelectionBehavior = nil + commandPaletteMode = .workspaceDescriptionInput(target) + resetCommandPaletteWorkspaceDescriptionFocus() +#if DEBUG + dlog( + "palette.wsDescription.flow.armed workspace=\(target.workspaceId.uuidString.prefix(8)) " + + "height=\(String(format: "%.1f", commandPaletteWorkspaceDescriptionHeight)) " + + "modeAfter=\(debugCommandPaletteModeLabel(commandPaletteMode))" + ) +#endif + syncCommandPaletteDebugStateForObservedWindow() + } + private func continueRenameFlow(target: CommandPaletteRenameTarget) { guard case .renameInput(let activeTarget) = commandPaletteMode, activeTarget == target else { return } @@ -7528,6 +8662,43 @@ struct ContentView: View { dismissCommandPalette() } + private func applyWorkspaceDescriptionFlow( + target: CommandPaletteWorkspaceDescriptionTarget, + proposedDescription: String + ) { + guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { + NSSound.beep() + return + } +#if DEBUG + let newlineCount = proposedDescription.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "palette.wsDescription.apply.begin workspace=\(target.workspaceId.uuidString.prefix(8)) " + + "proposedLen=\((proposedDescription as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(proposedDescription))\"" + ) +#endif + tabManager.setCustomDescription(tabId: target.workspaceId, description: proposedDescription) +#if DEBUG + if let updatedWorkspace = tabManager.tabs.first(where: { $0.id == target.workspaceId }) { + let persisted = updatedWorkspace.customDescription ?? "" + let persistedNewlineCount = persisted.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "palette.wsDescription.apply.end workspace=\(target.workspaceId.uuidString.prefix(8)) " + + "persistedLen=\((persisted as NSString).length) " + + "persistedNewlines=\(persistedNewlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(persisted))\"" + ) + } +#endif + dismissCommandPalette() + } + private func focusFocusedBrowserAddressBar() -> Bool { guard let panel = tabManager.focusedBrowserPanel else { return false } _ = panel.requestAddressBarFocus() @@ -7647,15 +8818,18 @@ struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { let directories: [String] let branches: [String] let ports: [Int] + let description: String? init( directories: [String] = [], branches: [String] = [], - ports: [Int] = [] + ports: [Int] = [], + description: String? = nil ) { self.directories = directories self.branches = branches self.ports = ports + self.description = description } } @@ -7683,6 +8857,7 @@ enum CommandPaletteSwitcherSearchIndexer { let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } let portTokens = metadata.ports.flatMap(portTokensForSearch) + let descriptionTokens = descriptionTokensForSearch(metadata.description) var contextKeywords: [String] = [] if !directoryTokens.isEmpty { @@ -7694,8 +8869,11 @@ enum CommandPaletteSwitcherSearchIndexer { if !portTokens.isEmpty { contextKeywords.append(contentsOf: ["port", "ports"]) } + if !descriptionTokens.isEmpty { + contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) + } - return contextKeywords + directoryTokens + branchTokens + portTokens + return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens } private static func directoryTokensForSearch( @@ -7741,6 +8919,18 @@ enum CommandPaletteSwitcherSearchIndexer { return [portText, ":\(portText)"] } + private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { + let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return [] } + let normalizedWhitespace = trimmed.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) + } + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { var result: [String] = [] var seen: Set<String> = [] @@ -8762,12 +9952,11 @@ struct VerticalTabsSidebar: View { @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue - @AppStorage(KeyboardShortcutSettings.Action.selectWorkspaceByNumber.defaultsKey) - private var selectWorkspaceByNumberShortcutData = Data() /// Space at top of sidebar for traffic light buttons private let trafficLightPadding: CGFloat = 28 @@ -8783,18 +9972,8 @@ struct VerticalTabsSidebar: View { } private var workspaceNumberShortcut: StoredShortcut { - decodeShortcut( - from: selectWorkspaceByNumberShortcutData, - fallback: KeyboardShortcutSettings.Action.selectWorkspaceByNumber.defaultShortcut - ) - } - - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) } var body: some View { @@ -8823,7 +10002,9 @@ struct VerticalTabsSidebar: View { Spacer() .frame(height: trafficLightPadding) - LazyVStack(spacing: tabRowSpacing) { + // Workspaces are bounded, so prefer a non-lazy stack here. + // LazyVStack + drag-state invalidations can recurse through layout. + VStack(spacing: tabRowSpacing) { ForEach(tabs, id: \.id) { tab in let index = tabIndexById[tab.id] ?? 0 let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) @@ -8849,7 +10030,7 @@ struct VerticalTabsSidebar: View { at: index, workspaceCount: workspaceCount ), - workspaceShortcutModifierSymbol: workspaceNumberShortcut.modifierDisplayString, + workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, canCloseWorkspace: canCloseWorkspace, accessibilityWorkspaceCount: workspaceCount, unreadCount: notificationStore.unreadCount(forTabId: tab.id), @@ -8880,6 +10061,7 @@ struct VerticalTabsSidebar: View { } } .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) SidebarEmptyArea( rowSpacing: tabRowSpacing, @@ -8998,9 +10180,11 @@ enum ShortcutHintModifierPolicy { for modifierFlags: NSEvent.ModifierFlags, defaults: UserDefaults = .standard ) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) + guard !shortcut.hasChord else { return false } let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) - guard normalized == KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber).modifierFlags else { + guard normalized == [.command] else { return false } return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) @@ -10600,17 +11784,15 @@ private struct SidebarHelpMenuButton: View { private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") private let buttonSize: CGFloat = 22 private let iconSize: CGFloat = 11 - @AppStorage(KeyboardShortcutSettings.Action.sendFeedback.defaultsKey) private var sendFeedbackShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared let onSendFeedback: () -> Void @State private var isPopoverPresented = false private var sendFeedbackShortcutHint: String { - decodeShortcut( - from: sendFeedbackShortcutData, - fallback: KeyboardShortcutSettings.Action.sendFeedback.defaultShortcut - ).displayString + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString } var body: some View { @@ -10821,13 +12003,6 @@ private struct SidebarHelpMenuButton: View { } } - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut - } } private struct ArrowlessPopoverAnchor<PopoverContent: View>: NSViewRepresentable { @@ -11658,6 +12833,14 @@ private struct TabItemView: View, Equatable { .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) } + if let description = tab.customDescription { + SidebarWorkspaceDescriptionText( + markdown: description, + isActive: usesInvertedActiveForeground + ) + .id(description) + } + if let subtitle = effectiveSubtitle { Text(subtitle) .font(.system(size: 10)) @@ -11886,6 +13069,22 @@ private struct TabItemView: View, Equatable { .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) } } + .onReceive( + tab.sidebarImmediateObservationPublisher + .receive(on: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=immediate " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + workspaceObservationGeneration &+= 1 + } .onReceive( tab.sidebarObservationPublisher .receive(on: RunLoop.main) @@ -11894,6 +13093,16 @@ private struct TabItemView: View, Equatable { // row redraws once with the settled state instead of blinking. .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=debounced " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif workspaceObservationGeneration &+= 1 } .onDrag { @@ -11986,6 +13195,7 @@ private struct TabItemView: View, Equatable { single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), isMulti: isMulti) let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) + let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) Button(pinLabel) { for id in targetIds { @@ -12013,6 +13223,25 @@ private struct TabItemView: View, Equatable { } } + if !isMulti { + if let key = editWorkspaceDescriptionShortcut.keyEquivalent { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + } + + if tab.hasCustomDescription { + Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { + tabManager.clearCustomDescription(tabId: tab.id) + } + } + } + if !remoteContextMenuWorkspaceIds.isEmpty { Divider() @@ -12728,7 +13957,7 @@ private struct TabItemView: View, Equatable { alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") - let seed = tab.customColor ?? WorkspaceTabColorSettings.customColors().first ?? "" + let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" let input = NSTextField(string: seed) input.placeholderString = "#1565C0" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) @@ -12786,6 +14015,83 @@ private struct TabItemView: View, Equatable { guard response == .alertFirstButtonReturn else { return } tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) } + + private func beginWorkspaceDescriptionEditFromContextMenu() { + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + setSelectionToTabs() + _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + } +} + +private struct SidebarWorkspaceDescriptionText: View { + let markdown: String + let isActive: Bool + + var body: some View { + let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + } else { + Text(markdown) + } + } + .font(.system(size: 10.5)) + .foregroundColor(foregroundColor) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SidebarWorkspaceDescriptionText") + .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) + .onAppear { +#if DEBUG + let newlineCount = markdown.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=appear " + + "len=\((markdown as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(markdown))\"" + ) +#endif + } + .onChange(of: markdown) { newValue in +#if DEBUG + let newlineCount = newValue.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=change " + + "len=\((newValue as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(newValue))\"" + ) +#endif + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) + } + + private func accessibilityText(renderedMarkdown: AttributedString?) -> String { + if let renderedMarkdown { + return String(renderedMarkdown.characters) + } + return markdown + } +} + +enum SidebarMarkdownRenderer { + static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { + try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + } } private struct SidebarMetadataRows: View { @@ -13020,12 +14326,12 @@ private struct SidebarMetadataMarkdownBlockRow: View { } } -enum SidebarDropEdge { +enum SidebarDropEdge: Equatable { case top case bottom } -struct SidebarDropIndicator { +struct SidebarDropIndicator: Equatable { let tabId: UUID? let edge: SidebarDropEdge } @@ -13573,7 +14879,7 @@ private struct SidebarTabDropDelegate: DropDelegate { private func updateDropIndicator(for info: DropInfo) { let tabIds = tabManager.tabs.map(\.id) let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - dropIndicator = SidebarDropPlanner.indicator( + let nextIndicator = SidebarDropPlanner.indicator( draggedTabId: draggedTabId, targetTabId: targetTabId, tabIds: tabIds, @@ -13581,6 +14887,8 @@ private struct SidebarTabDropDelegate: DropDelegate { pointerY: targetTabId == nil ? nil : info.location.y, targetHeight: targetRowHeight ) + guard dropIndicator != nextIndicator else { return } + dropIndicator = nextIndicator } private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c085a051d408..dadb29cc6205 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6,11 +6,18 @@ import QuartzCore import Combine import CoreText import Darwin +import Carbon.HIToolbox import Sentry import Bonsplit import IOSurface import UniformTypeIdentifiers +@_silgen_name("ghostty_surface_clear_selection") +private func ghostty_surface_clear_selection_compat(_ surface: ghostty_surface_t) -> Bool + +@_silgen_name("ghostty_surface_select_cursor_cell") +private func ghostty_surface_select_cursor_cell_compat(_ surface: ghostty_surface_t) -> Bool + #if os(macOS) func cmuxShouldApplyWindowGlass( sidebarBlendMode: String, @@ -44,6 +51,14 @@ private func cmuxTransparentWindowBaseColor() -> NSColor { } #endif +private func cmuxRuntimeReadClipboardCallback( + _ userdata: UnsafeMutableRawPointer?, + _ location: ghostty_clipboard_e, + _ state: UnsafeMutableRawPointer? +) -> Bool { + GhosttyApp.runtimeReadClipboardCallback(userdata, location, state) +} + #if DEBUG private func cmuxChildExitProbePath() -> String? { let env = ProcessInfo.processInfo.environment @@ -969,6 +984,120 @@ class GhosttyApp { return URL(fileURLWithPath: "/tmp/cmux-bg.log") } + fileprivate static func runtimeReadClipboardCallback( + _ userdata: UnsafeMutableRawPointer?, + _ location: ghostty_clipboard_e, + _ state: UnsafeMutableRawPointer? + ) -> Bool { + guard let callbackContext = Self.callbackContext(from: userdata), + let requestSurface = callbackContext.runtimeSurface else { return false } + + DispatchQueue.main.async { + func completeClipboardRequest(with text: String) { + let finish = { + guard callbackContext.runtimeSurface == requestSurface else { return } + text.withCString { ptr in + ghostty_surface_complete_clipboard_request(requestSurface, ptr, state, false) + } + } + if Thread.isMainThread { + finish() + } else { + DispatchQueue.main.async(execute: finish) + } + } + + guard let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) else { + completeClipboardRequest(with: "") + return + } + + let preparedContent = TerminalImageTransferPlanner.prepare( + pasteboard: pasteboard, + mode: .paste + ) + + switch preparedContent { + case .reject: + completeClipboardRequest(with: "") + case .insertText(let text): + completeClipboardRequest(with: text) + case .fileURLs(let fileURLs): + let operation = TerminalImageTransferOperation() + MainActor.assumeIsolated { + callbackContext.terminalSurface?.hostedView.beginImageTransferIndicator( + for: operation, + onCancel: { + completeClipboardRequest(with: "") + } + ) + } + + let target = MainActor.assumeIsolated { + callbackContext.terminalSurface?.resolvedImageTransferTarget() ?? .local + } + let plan = TerminalImageTransferPlanner.plan( + fileURLs: fileURLs, + target: target + ) + + TerminalImageTransferPlanner.execute( + plan: plan, + operation: operation, + uploadWorkspaceRemote: { fileURLs, operation, finish in + guard let workspace = MainActor.assumeIsolated({ + callbackContext.terminalSurface?.owningWorkspace() + }) else { + finish(.failure(NSError(domain: "cmux.remote.paste", code: 3))) + GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) + return + } + workspace.uploadDroppedFilesForRemoteTerminal( + fileURLs, + operation: operation, + completion: { result in + finish(result) + GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) + } + ) + }, + uploadDetectedSSH: { session, fileURLs, operation, finish in + session.uploadDroppedFiles( + fileURLs, + operation: operation, + completion: { result in + finish(result) + GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) + } + ) + }, + insertText: { text in + MainActor.assumeIsolated { + callbackContext.terminalSurface?.hostedView.endImageTransferIndicator( + for: operation + ) + } + completeClipboardRequest(with: text) + }, + onFailure: { _ in + MainActor.assumeIsolated { + callbackContext.terminalSurface?.hostedView.endImageTransferIndicator( + for: operation + ) + } + NSSound.beep() +#if DEBUG + dlog("terminal.remotePasteUpload.failed surface=\(callbackContext.surfaceId.uuidString.prefix(5))") +#endif + completeClipboardRequest(with: "") + } + ) + } + } + + return true + } + let backgroundLogEnabled = { if ProcessInfo.processInfo.environment["CMUX_DEBUG_BG"] == "1" { return true @@ -1146,114 +1275,17 @@ class GhosttyApp { runtimeConfig.action_cb = { app, target, action in return GhosttyApp.shared.handleAction(target: target, action: action) } - runtimeConfig.read_clipboard_cb = { userdata, location, state in - guard let callbackContext = GhosttyApp.callbackContext(from: userdata), - let requestSurface = callbackContext.runtimeSurface else { return false } - - DispatchQueue.main.async { - func completeClipboardRequest(with text: String) { - let finish = { - guard callbackContext.runtimeSurface == requestSurface else { return } - text.withCString { ptr in - ghostty_surface_complete_clipboard_request(requestSurface, ptr, state, false) - } - } - if Thread.isMainThread { - finish() - } else { - DispatchQueue.main.async(execute: finish) - } - } - - guard let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) else { - completeClipboardRequest(with: "") - return - } - - let preparedContent = TerminalImageTransferPlanner.prepare( - pasteboard: pasteboard, - mode: .paste - ) - - switch preparedContent { - case .reject: - completeClipboardRequest(with: "") - case .insertText(let text): - completeClipboardRequest(with: text) - case .fileURLs(let fileURLs): - let operation = TerminalImageTransferOperation() - MainActor.assumeIsolated { - callbackContext.terminalSurface?.hostedView.beginImageTransferIndicator( - for: operation, - onCancel: { - completeClipboardRequest(with: "") - } - ) - } - - let target = MainActor.assumeIsolated { - callbackContext.terminalSurface?.resolvedImageTransferTarget() ?? .local - } - let plan = TerminalImageTransferPlanner.plan( - fileURLs: fileURLs, - target: target - ) - - TerminalImageTransferPlanner.execute( - plan: plan, - operation: operation, - uploadWorkspaceRemote: { fileURLs, operation, finish in - guard let workspace = MainActor.assumeIsolated({ - callbackContext.terminalSurface?.owningWorkspace() - }) else { - finish(.failure(NSError(domain: "cmux.remote.paste", code: 3))) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - return - } - workspace.uploadDroppedFilesForRemoteTerminal( - fileURLs, - operation: operation, - completion: { result in - finish(result) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - } - ) - }, - uploadDetectedSSH: { session, fileURLs, operation, finish in - session.uploadDroppedFiles( - fileURLs, - operation: operation, - completion: { result in - finish(result) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - } - ) - }, - insertText: { text in - MainActor.assumeIsolated { - callbackContext.terminalSurface?.hostedView.endImageTransferIndicator( - for: operation - ) - } - completeClipboardRequest(with: text) - }, - onFailure: { _ in - MainActor.assumeIsolated { - callbackContext.terminalSurface?.hostedView.endImageTransferIndicator( - for: operation - ) - } - NSSound.beep() -#if DEBUG - dlog("terminal.remotePasteUpload.failed surface=\(callbackContext.surfaceId.uuidString.prefix(5))") -#endif - completeClipboardRequest(with: "") - } - ) - } - } - return true - } + // Some GhosttyKit builds import this callback as returning `Void` in Swift even + // though the C ABI returns `bool`. Store the C-compatible shim explicitly so the + // project compiles against both importer variants. + runtimeConfig.read_clipboard_cb = unsafeBitCast( + cmuxRuntimeReadClipboardCallback as @convention(c) ( + UnsafeMutableRawPointer?, + ghostty_clipboard_e, + UnsafeMutableRawPointer? + ) -> Bool, + to: ghostty_runtime_read_clipboard_cb.self + ) runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in guard let content else { return } guard let callbackContext = GhosttyApp.callbackContext(from: userdata), @@ -2016,7 +2048,14 @@ class GhosttyApp { } } - func reloadConfiguration(soft: Bool = false, source: String = "unspecified") { + func reloadConfiguration( + soft: Bool = false, + source: String = "unspecified", + reloadSettingsFromFile: Bool = true + ) { + if reloadSettingsFromFile { + KeyboardShortcutSettings.settingsFileStore.reload() + } guard let app else { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") return @@ -2087,7 +2126,10 @@ class GhosttyApp { } guard shouldReload else { return } lastAppearanceColorScheme = currentColorScheme - reloadConfiguration(source: "appearanceSync:\(source)") + reloadConfiguration( + source: "appearanceSync:\(source)", + reloadSettingsFromFile: false + ) } func openConfigurationInTextEdit() { @@ -2190,10 +2232,10 @@ class GhosttyApp { private func bellAudioPath() -> String? { guard let config else { return nil } - var value = ghostty_config_path_s() + var value: UnsafePointer<Int8>? let key = "bell-audio-path" guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), - let rawPath = value.path else { + let rawPath = value else { return nil } let path = String(cString: rawPath) @@ -3016,6 +3058,26 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + private struct PendingKeyEvent { + let keycode: UInt32 + let mods: ghostty_input_mods_e + let label: String + } + + private enum PendingSocketInput { + case text(Data) + case key(PendingKeyEvent) + + var estimatedBytes: Int { + switch self { + case .text(let data): + return data.count + case .key(let event): + return max(event.label.utf8.count, 1) + } + } + } + private(set) var surface: ghostty_surface_t? private weak var attachedView: GhosttyNSView? @@ -3065,9 +3127,9 @@ final class TerminalSurface: Identifiable, ObservableObject { private var runtimeSurfaceCreatedAt: Date? private var teardownRequestedAt: Date? private var teardownRequestReason: String? - private var pendingTextQueue: [Data] = [] - private var pendingTextBytes: Int = 0 - private let maxPendingTextBytes = 1_048_576 + private var pendingSocketInputQueue: [PendingSocketInput] = [] + private var pendingSocketInputBytes: Int = 0 + private let maxPendingSocketInputBytes = 1_048_576 private var backgroundSurfaceStartQueued = false private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>? /// The desired focus state for the Ghostty C surface. May be set before the @@ -3972,6 +4034,14 @@ final class TerminalSurface: Identifiable, ObservableObject { // Ghostty's default. ghostty_surface_set_focus(createdSurface, desiredFocusState) + flushPendingSocketInputIfNeeded() + + // Kick an initial draw after creation/size setup. On some startup paths Ghostty can + // miss the first vsync callback and sit on a blank frame until another focus/visibility + // transition nudges the renderer. + view.forceRefreshSurface() + ghostty_surface_refresh(createdSurface) + NotificationCenter.default.post( name: .terminalSurfaceDidBecomeReady, object: self, @@ -3981,14 +4051,6 @@ final class TerminalSurface: Identifiable, ObservableObject { ] ) - flushPendingTextIfNeeded() - - // Kick an initial draw after creation/size setup. On some startup paths Ghostty can - // miss the first vsync callback and sit on a blank frame until another focus/visibility - // transition nudges the renderer. - view.forceRefreshSurface() - ghostty_surface_refresh(createdSurface) - #if DEBUG let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { String(format: "%.2f", $0) @@ -4144,12 +4206,25 @@ final class TerminalSurface: Identifiable, ObservableObject { func sendText(_ text: String) { guard let data = text.data(using: .utf8), !data.isEmpty else { return } guard let surface = surface else { - enqueuePendingText(data) + enqueuePendingSocketInput(.text(data)) + requestBackgroundSurfaceStartIfNeeded() return } writeTextData(data, to: surface) } + @discardableResult + func sendNamedKey(_ keyName: String) -> Bool { + guard let event = pendingKeyEvent(for: keyName) else { return false } + if let surface = surface { + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + } else { + enqueuePendingSocketInput(.key(event)) + requestBackgroundSurfaceStartIfNeeded() + } + return true + } + /// Send text with control characters (Return, Tab, etc.) delivered as key /// events so the shell processes them, while regular text is sent via the /// normal key-text path. Mirrors `TerminalController.sendSocketText`. @@ -4201,11 +4276,15 @@ final class TerminalSurface: Identifiable, ObservableObject { buffer.removeAll(keepingCapacity: true) } - private func sendKeyEvent(surface: ghostty_surface_t, keycode: UInt32) { + private func sendKeyEvent( + surface: ghostty_surface_t, + keycode: UInt32, + mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE + ) { var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = keycode - keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.mods = mods keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.unshifted_codepoint = 0 keyEvent.composing = false @@ -4251,37 +4330,188 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - private func enqueuePendingText(_ data: Data) { - let incomingBytes = data.count - while !pendingTextQueue.isEmpty && pendingTextBytes + incomingBytes > maxPendingTextBytes { - let dropped = pendingTextQueue.removeFirst() - pendingTextBytes -= dropped.count + private func keycodeForLetter(_ letter: Character) -> UInt32? { + switch String(letter).lowercased() { + case "a": return UInt32(kVK_ANSI_A) + case "b": return UInt32(kVK_ANSI_B) + case "c": return UInt32(kVK_ANSI_C) + case "d": return UInt32(kVK_ANSI_D) + case "e": return UInt32(kVK_ANSI_E) + case "f": return UInt32(kVK_ANSI_F) + case "g": return UInt32(kVK_ANSI_G) + case "h": return UInt32(kVK_ANSI_H) + case "i": return UInt32(kVK_ANSI_I) + case "j": return UInt32(kVK_ANSI_J) + case "k": return UInt32(kVK_ANSI_K) + case "l": return UInt32(kVK_ANSI_L) + case "m": return UInt32(kVK_ANSI_M) + case "n": return UInt32(kVK_ANSI_N) + case "o": return UInt32(kVK_ANSI_O) + case "p": return UInt32(kVK_ANSI_P) + case "q": return UInt32(kVK_ANSI_Q) + case "r": return UInt32(kVK_ANSI_R) + case "s": return UInt32(kVK_ANSI_S) + case "t": return UInt32(kVK_ANSI_T) + case "u": return UInt32(kVK_ANSI_U) + case "v": return UInt32(kVK_ANSI_V) + case "w": return UInt32(kVK_ANSI_W) + case "x": return UInt32(kVK_ANSI_X) + case "y": return UInt32(kVK_ANSI_Y) + case "z": return UInt32(kVK_ANSI_Z) + default: return nil + } + } + + private func keycodeForNamedKey(_ name: String) -> UInt32? { + switch name { + case "enter", "return": return UInt32(kVK_Return) + case "tab": return UInt32(kVK_Tab) + case "escape", "esc": return UInt32(kVK_Escape) + case "backspace": return UInt32(kVK_Delete) + case "delete": return UInt32(kVK_ForwardDelete) + case "space": return UInt32(kVK_Space) + case "up": return UInt32(kVK_UpArrow) + case "down": return UInt32(kVK_DownArrow) + case "left": return UInt32(kVK_LeftArrow) + case "right": return UInt32(kVK_RightArrow) + case "\\": return UInt32(kVK_ANSI_Backslash) + default: return nil } + } - pendingTextQueue.append(data) - pendingTextBytes += incomingBytes - #if DEBUG + private func pendingKeyEvent(for keyName: String) -> PendingKeyEvent? { + let normalized = keyName.lowercased() + switch normalized { + case "ctrl-c", "ctrl+c", "sigint": + return PendingKeyEvent(keycode: UInt32(kVK_ANSI_C), mods: GHOSTTY_MODS_CTRL, label: normalized) + case "ctrl-d", "ctrl+d", "eof": + return PendingKeyEvent(keycode: UInt32(kVK_ANSI_D), mods: GHOSTTY_MODS_CTRL, label: normalized) + case "ctrl-z", "ctrl+z", "sigtstp": + return PendingKeyEvent(keycode: UInt32(kVK_ANSI_Z), mods: GHOSTTY_MODS_CTRL, label: normalized) + case "ctrl-\\", "ctrl+\\", "sigquit": + return PendingKeyEvent(keycode: UInt32(kVK_ANSI_Backslash), mods: GHOSTTY_MODS_CTRL, label: normalized) + case "enter", "return": + return PendingKeyEvent(keycode: UInt32(kVK_Return), mods: GHOSTTY_MODS_NONE, label: normalized) + case "tab": + return PendingKeyEvent(keycode: UInt32(kVK_Tab), mods: GHOSTTY_MODS_NONE, label: normalized) + case "escape", "esc": + return PendingKeyEvent(keycode: UInt32(kVK_Escape), mods: GHOSTTY_MODS_NONE, label: normalized) + case "backspace": + return PendingKeyEvent(keycode: UInt32(kVK_Delete), mods: GHOSTTY_MODS_NONE, label: normalized) + case "up", "arrow_up", "arrowup": + return PendingKeyEvent(keycode: UInt32(kVK_UpArrow), mods: GHOSTTY_MODS_NONE, label: normalized) + case "down", "arrow_down", "arrowdown": + return PendingKeyEvent(keycode: UInt32(kVK_DownArrow), mods: GHOSTTY_MODS_NONE, label: normalized) + case "left", "arrow_left", "arrowleft": + return PendingKeyEvent(keycode: UInt32(kVK_LeftArrow), mods: GHOSTTY_MODS_NONE, label: normalized) + case "right", "arrow_right", "arrowright": + return PendingKeyEvent(keycode: UInt32(kVK_RightArrow), mods: GHOSTTY_MODS_NONE, label: normalized) + case "shift+tab", "shift-tab", "backtab": + return PendingKeyEvent(keycode: UInt32(kVK_Tab), mods: GHOSTTY_MODS_SHIFT, label: normalized) + case "home": + return PendingKeyEvent(keycode: UInt32(kVK_Home), mods: GHOSTTY_MODS_NONE, label: normalized) + case "end": + return PendingKeyEvent(keycode: UInt32(kVK_End), mods: GHOSTTY_MODS_NONE, label: normalized) + case "delete", "del", "forward_delete": + return PendingKeyEvent(keycode: UInt32(kVK_ForwardDelete), mods: GHOSTTY_MODS_NONE, label: normalized) + case "pageup", "page_up": + return PendingKeyEvent(keycode: UInt32(kVK_PageUp), mods: GHOSTTY_MODS_NONE, label: normalized) + case "pagedown", "page_down": + return PendingKeyEvent(keycode: UInt32(kVK_PageDown), mods: GHOSTTY_MODS_NONE, label: normalized) + default: + let parts = normalized + .split(separator: "+") + .flatMap { $0.split(separator: "-") } + .map(String.init) + .filter { !$0.isEmpty } + guard let baseKey = parts.last else { return nil } + + if parts.count == 1 { + if let keycode = keycodeForNamedKey(baseKey) { + return PendingKeyEvent(keycode: keycode, mods: GHOSTTY_MODS_NONE, label: normalized) + } + if baseKey.count == 1, + let char = baseKey.first, + let keycode = keycodeForLetter(char) { + return PendingKeyEvent(keycode: keycode, mods: GHOSTTY_MODS_NONE, label: normalized) + } + return nil + } + + var mods = GHOSTTY_MODS_NONE + for mod in parts.dropLast() { + switch mod { + case "ctrl", "control": + mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_CTRL.rawValue) + case "shift": + mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_SHIFT.rawValue) + case "alt", "opt", "option": + mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_ALT.rawValue) + case "cmd", "command", "super": + mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_SUPER.rawValue) + default: + return nil + } + } + + if let keycode = keycodeForNamedKey(baseKey) { + return PendingKeyEvent(keycode: keycode, mods: mods, label: normalized) + } + if baseKey.count == 1, + let char = baseKey.first, + let keycode = keycodeForLetter(char) { + return PendingKeyEvent(keycode: keycode, mods: mods, label: normalized) + } + return nil + } + } + + private func enqueuePendingSocketInput(_ input: PendingSocketInput) { + let incomingBytes = input.estimatedBytes + while !pendingSocketInputQueue.isEmpty, + pendingSocketInputBytes + incomingBytes > maxPendingSocketInputBytes { + let dropped = pendingSocketInputQueue.removeFirst() + pendingSocketInputBytes -= dropped.estimatedBytes + } + + pendingSocketInputQueue.append(input) + pendingSocketInputBytes += incomingBytes +#if DEBUG + let pendingKeys = pendingSocketInputQueue.reduce(into: 0) { count, item in + if case .key = item { + count += 1 + } + } dlog( - "surface.send_text.queue surface=\(id.uuidString.prefix(8)) chunks=\(pendingTextQueue.count) bytes=\(pendingTextBytes)" + "surface.socket_input.queue surface=\(id.uuidString.prefix(8)) items=\(pendingSocketInputQueue.count) " + + "keys=\(pendingKeys) bytes=\(pendingSocketInputBytes)" ) - #endif +#endif } - private func flushPendingTextIfNeeded() { - guard let surface = surface, !pendingTextQueue.isEmpty else { return } - let queued = pendingTextQueue - let queuedBytes = pendingTextBytes - pendingTextQueue.removeAll(keepingCapacity: false) - pendingTextBytes = 0 + private func flushPendingSocketInputIfNeeded() { + guard let surface = surface, !pendingSocketInputQueue.isEmpty else { return } + let queued = pendingSocketInputQueue + let queuedBytes = pendingSocketInputBytes + pendingSocketInputQueue.removeAll(keepingCapacity: false) + pendingSocketInputBytes = 0 - for chunk in queued { - writeTextData(chunk, to: surface) + var queuedKeys = 0 + for item in queued { + switch item { + case .text(let chunk): + writeTextData(chunk, to: surface) + case .key(let event): + queuedKeys += 1 + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + } } - #if DEBUG +#if DEBUG dlog( - "surface.send_text.flush surface=\(id.uuidString.prefix(8)) chunks=\(queued.count) bytes=\(queuedBytes)" + "surface.socket_input.flush surface=\(id.uuidString.prefix(8)) items=\(queued.count) " + + "keys=\(queuedKeys) bytes=\(queuedBytes)" ) - #endif +#endif } func performBindingAction(_ action: String) -> Bool { @@ -5149,7 +5379,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard surface != nil else { return false } setKeyboardCopyModeActive(!keyboardCopyModeActive) if !keyboardCopyModeActive, let surface { - _ = ghostty_surface_clear_selection(surface) + _ = ghostty_surface_clear_selection_compat(surface) } return true } @@ -5160,13 +5390,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyboardCopyModeActive = active if active, let surface { keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row - _ = ghostty_surface_clear_selection(surface) + _ = ghostty_surface_clear_selection_compat(surface) if keyboardCopyModeViewportRow == nil { keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface) } // Create a 1-cell selection at the terminal cursor to serve as a // visible cursor indicator in copy mode. - _ = ghostty_surface_select_cursor_cell(surface) + _ = ghostty_surface_select_cursor_cell_compat(surface) } else { keyboardCopyModeViewportRow = nil } @@ -5203,7 +5433,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func keyboardCopyModeSelectionAnchor(surface: ghostty_surface_t) -> (row: Int, y: Double)? { let size = ghostty_surface_size(surface) guard size.rows > 0, size.columns > 0 else { return nil } - guard ghostty_surface_select_cursor_cell(surface) else { return nil } + guard ghostty_surface_select_cursor_cell_compat(surface) else { return nil } var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return nil } @@ -5224,7 +5454,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return } keyboardCopyModeViewportRow = anchor.row // Preserve the visible cursor indicator. - _ = ghostty_surface_select_cursor_cell(surface) + _ = ghostty_surface_select_cursor_cell_compat(surface) } private func copyCurrentViewportLinesToClipboard( @@ -5239,7 +5469,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return false } - _ = ghostty_surface_clear_selection(surface) + _ = ghostty_surface_clear_selection_compat(surface) var imeX: Double = 0 var imeY: Double = 0 @@ -5295,18 +5525,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { switch action { case .exit: - _ = ghostty_surface_clear_selection(surface) + _ = ghostty_surface_clear_selection_compat(surface) setKeyboardCopyModeActive(false) case .startSelection: keyboardCopyModeVisualActive = true case .clearSelection: keyboardCopyModeVisualActive = false - _ = ghostty_surface_clear_selection(surface) + _ = ghostty_surface_clear_selection_compat(surface) // Re-create 1-cell cursor at terminal cursor position. - _ = ghostty_surface_select_cursor_cell(surface) + _ = ghostty_surface_select_cursor_cell_compat(surface) case .copyAndExit: _ = performBindingAction("copy_to_clipboard") - _ = ghostty_surface_clear_selection(surface) + _ = ghostty_surface_clear_selection_compat(surface) setKeyboardCopyModeActive(false) case .copyLineAndExit: let startRow = currentKeyboardCopyModeViewportRow(surface: surface) @@ -5315,7 +5545,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { startRow: startRow, lineCount: count ) - _ = ghostty_surface_clear_selection(surface) + _ = ghostty_surface_clear_selection_compat(surface) setKeyboardCopyModeActive(false) case let .scrollLines(delta): _ = performBindingAction("scroll_page_lines:\(delta * count)") @@ -7782,6 +8012,24 @@ final class GhosttySurfaceScrollView: NSView { self?.handleScrollbarUpdate(notification) }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let readySurfaceId = notification.userInfo?["surfaceId"] as? UUID, + readySurfaceId == self.surfaceView.terminalSurface?.id else { + return + } + // Session restore can request focus before the runtime surface exists. + // Re-run the normal first-responder/focus path once the surface is live. + guard self.isActive || self.surfaceView.desiredFocus || self.isSurfaceViewFirstResponder() else { + return + } + self.scheduleAutomaticFirstResponderApply(reason: "surfaceDidBecomeReady") + }) + observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidReceiveWheelScroll, object: surfaceView, @@ -9125,11 +9373,11 @@ final class GhosttySurfaceScrollView: NSView { }() let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let surfaceOwnsFirstResponder = isSurfaceViewFirstResponder() - guard surfaceView.desiredFocus else { return } - guard isSurfaceViewFirstResponder() else { return } - guard isActive else { return } + guard surfaceView.desiredFocus || surfaceOwnsFirstResponder else { return } guard surfaceView.isVisibleInUI else { return } + surfaceView.terminalSurface?.recordExternalFocusState(true) guard let window, window.isKeyWindow else { return } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG @@ -9142,6 +9390,15 @@ final class GhosttySurfaceScrollView: NSView { scheduleAutomaticFirstResponderApply(reason: "clearSuppressReparentFocus.hiddenOrTiny") return } + if !surfaceOwnsFirstResponder && !isSurfaceViewFirstResponder() { +#if DEBUG + dlog( + "focus.reparent.resume.restoreFirstResponder surface=\(surfaceShort) " + + "firstResponder=\(String(describing: window.firstResponder))" + ) +#endif + guard window.makeFirstResponder(surfaceView), isSurfaceViewFirstResponder() else { return } + } #if DEBUG dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))") #endif @@ -9225,6 +9482,12 @@ final class GhosttySurfaceScrollView: NSView { matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else { #if DEBUG dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target") +#endif + return + } + if AppDelegate.shared?.isCommandPaletteEffectivelyVisible(for: window) == true { +#if DEBUG + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=commandPaletteVisible") #endif return } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 601ed71cb28c..e468ca6344c4 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -5,14 +5,28 @@ import SwiftUI enum KeyboardShortcutSettings { static let didChangeNotification = Notification.Name("cmux.keyboardShortcutSettingsDidChange") static let actionUserInfoKey = "action" + static let settingsFileDisplayPath = "~/.config/cmux/settings.json" + static var settingsFileStore: KeyboardShortcutSettingsFileStore = .shared { + didSet { + notifySettingsFileDidChange() + } + } enum Action: String, CaseIterable, Identifiable { + // App / window + case openSettings + case reloadConfiguration + case newWindow + case closeWindow + case toggleFullScreen + case quit + // Titlebar / primary UI case toggleSidebar case newTab - case newWindow - case closeWindow case openFolder + case goToWorkspace + case commandPalette case sendFeedback case showNotifications case jumpToUnread @@ -27,7 +41,11 @@ enum KeyboardShortcutSettings { case selectWorkspaceByNumber case renameTab case renameWorkspace + case editWorkspaceDescription + case closeTab + case closeOtherTabsInPane case closeWorkspace + case reopenClosedBrowserPanel case newSurface case toggleTerminalCopyMode @@ -44,6 +62,18 @@ enum KeyboardShortcutSettings { // Panels case openBrowser + case focusBrowserAddressBar + case browserBack + case browserForward + case browserReload + case browserZoomIn + case browserZoomOut + case browserZoomReset + case find + case findNext + case findPrevious + case hideFind + case useSelectionForFind case toggleBrowserDeveloperTools case showBrowserJavaScriptConsole case toggleReactGrab @@ -52,11 +82,17 @@ enum KeyboardShortcutSettings { var label: String { switch self { - case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar") - case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace") + case .openSettings: return String(localized: "menu.app.settings", defaultValue: "Settings…") + case .reloadConfiguration: return String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration") case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window") case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window") + case .toggleFullScreen: return String(localized: "command.toggleFullScreen.title", defaultValue: "Toggle Full Screen") + case .quit: return String(localized: "menu.quitCmux", defaultValue: "Quit cmux") + case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar") + case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace") case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder") + case .goToWorkspace: return String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…") + case .commandPalette: return String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…") case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback") case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications") case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread") @@ -69,7 +105,11 @@ enum KeyboardShortcutSettings { case .selectWorkspaceByNumber: return String(localized: "shortcut.selectWorkspaceByNumber.label", defaultValue: "Select Workspace 1…9") case .renameTab: return String(localized: "shortcut.renameTab.label", defaultValue: "Rename Tab") case .renameWorkspace: return String(localized: "shortcut.renameWorkspace.label", defaultValue: "Rename Workspace") + case .editWorkspaceDescription: return String(localized: "shortcut.editWorkspaceDescription.label", defaultValue: "Edit Workspace Description") + case .closeTab: return String(localized: "menu.file.closeTab", defaultValue: "Close Tab") + case .closeOtherTabsInPane: return String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane") case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace") + case .reopenClosedBrowserPanel: return String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel") case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface") case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode") case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left") @@ -82,62 +122,50 @@ enum KeyboardShortcutSettings { case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right") case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down") case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") + case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar") + case .browserBack: return String(localized: "menu.view.back", defaultValue: "Back") + case .browserForward: return String(localized: "menu.view.forward", defaultValue: "Forward") + case .browserReload: return String(localized: "menu.view.reloadPage", defaultValue: "Reload Page") + case .browserZoomIn: return String(localized: "menu.view.zoomIn", defaultValue: "Zoom In") + case .browserZoomOut: return String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out") + case .browserZoomReset: return String(localized: "menu.view.actualSize", defaultValue: "Actual Size") + case .find: return String(localized: "menu.find.find", defaultValue: "Find…") + case .findNext: return String(localized: "menu.find.findNext", defaultValue: "Find Next") + case .findPrevious: return String(localized: "menu.find.findPrevious", defaultValue: "Find Previous") + case .hideFind: return String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar") + case .useSelectionForFind: return String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find") case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools") case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console") case .toggleReactGrab: return String(localized: "shortcut.toggleReactGrab.label", defaultValue: "Toggle React Grab") } } - var defaultsKey: String { - switch self { - case .toggleSidebar: return "shortcut.toggleSidebar" - case .newTab: return "shortcut.newTab" - case .newWindow: return "shortcut.newWindow" - case .closeWindow: return "shortcut.closeWindow" - case .openFolder: return "shortcut.openFolder" - case .sendFeedback: return "shortcut.sendFeedback" - case .showNotifications: return "shortcut.showNotifications" - case .jumpToUnread: return "shortcut.jumpToUnread" - case .triggerFlash: return "shortcut.triggerFlash" - case .selectWorkspaceByNumber: return "shortcut.selectWorkspaceByNumber" - case .nextSidebarTab: return "shortcut.nextSidebarTab" - case .prevSidebarTab: return "shortcut.prevSidebarTab" - case .renameTab: return "shortcut.renameTab" - case .renameWorkspace: return "shortcut.renameWorkspace" - case .closeWorkspace: return "shortcut.closeWorkspace" - case .focusLeft: return "shortcut.focusLeft" - case .focusRight: return "shortcut.focusRight" - case .focusUp: return "shortcut.focusUp" - case .focusDown: return "shortcut.focusDown" - case .splitRight: return "shortcut.splitRight" - case .splitDown: return "shortcut.splitDown" - case .toggleSplitZoom: return "shortcut.toggleSplitZoom" - case .splitBrowserRight: return "shortcut.splitBrowserRight" - case .splitBrowserDown: return "shortcut.splitBrowserDown" - case .nextSurface: return "shortcut.nextSurface" - case .prevSurface: return "shortcut.prevSurface" - case .selectSurfaceByNumber: return "shortcut.selectSurfaceByNumber" - case .newSurface: return "shortcut.newSurface" - case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode" - case .openBrowser: return "shortcut.openBrowser" - case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" - case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" - case .toggleReactGrab: return "shortcut.toggleReactGrab" - } - } + var defaultsKey: String { "shortcut.\(rawValue)" } var defaultShortcut: StoredShortcut { switch self { - case .toggleSidebar: - return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false) - case .newTab: - return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) + case .openSettings: + return StoredShortcut(key: ",", command: true, shift: false, option: false, control: false) + case .reloadConfiguration: + return StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) case .newWindow: return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false) case .closeWindow: return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true) + case .toggleFullScreen: + return StoredShortcut(key: "f", command: true, shift: false, option: false, control: true) + case .quit: + return StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) + case .toggleSidebar: + return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false) + case .newTab: + return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .openFolder: return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false) + case .goToWorkspace: + return StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) + case .commandPalette: + return StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) case .sendFeedback: return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false) case .showNotifications: @@ -154,8 +182,16 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false) case .renameWorkspace: return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) + case .editWorkspaceDescription: + return StoredShortcut(key: "e", command: true, shift: true, option: false, control: false) + case .closeTab: + return StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) + case .closeOtherTabsInPane: + return StoredShortcut(key: "t", command: true, shift: false, option: true, control: false) case .closeWorkspace: return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false) + case .reopenClosedBrowserPanel: + return StoredShortcut(key: "t", command: true, shift: true, option: false, control: false) case .focusLeft: return StoredShortcut(key: "←", command: true, shift: false, option: true, control: false) case .focusRight: @@ -188,6 +224,30 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + case .focusBrowserAddressBar: + return StoredShortcut(key: "l", command: true, shift: false, option: false, control: false) + case .browserBack: + return StoredShortcut(key: "[", command: true, shift: false, option: false, control: false) + case .browserForward: + return StoredShortcut(key: "]", command: true, shift: false, option: false, control: false) + case .browserReload: + return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false) + case .browserZoomIn: + return StoredShortcut(key: "=", command: true, shift: false, option: false, control: false) + case .browserZoomOut: + return StoredShortcut(key: "-", command: true, shift: false, option: false, control: false) + case .browserZoomReset: + return StoredShortcut(key: "0", command: true, shift: false, option: false, control: false) + case .find: + return StoredShortcut(key: "f", command: true, shift: false, option: false, control: false) + case .findNext: + return StoredShortcut(key: "g", command: true, shift: false, option: false, control: false) + case .findPrevious: + return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false) + case .hideFind: + return StoredShortcut(key: "f", command: true, shift: true, option: false, control: false) + case .useSelectionForFind: + return StoredShortcut(key: "e", command: true, shift: false, option: false, control: false) case .toggleBrowserDeveloperTools: // Safari default: Show Web Inspector. return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false) @@ -195,7 +255,7 @@ enum KeyboardShortcutSettings { // Safari default: Show JavaScript Console. return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) case .toggleReactGrab: - return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false) + return StoredShortcut(key: "g", command: true, shift: false, option: true, control: false) } } @@ -214,27 +274,31 @@ enum KeyboardShortcutSettings { func displayedShortcutString(for shortcut: StoredShortcut) -> String { if usesNumberedDigitMatching { - return shortcut.modifierDisplayString + "1…9" + return shortcut.numberedDisplayString } return shortcut.displayString } func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { guard usesNumberedDigitMatching else { return shortcut } - guard let digit = Int(shortcut.key), (1...9).contains(digit) else { + let digitSource = shortcut.secondStroke ?? shortcut.firstStroke + guard let digit = Int(digitSource.key), (1...9).contains(digit) else { return nil } - return StoredShortcut( - key: "1", - command: shortcut.command, - shift: shortcut.shift, - option: shortcut.option, - control: shortcut.control - ) + var normalized = shortcut + if shortcut.hasChord { + normalized.chordKey = "1" + } else { + normalized.key = "1" + } + return normalized } } static func shortcut(for action: Action) -> StoredShortcut { + if let managedShortcut = settingsFileStore.override(for: action) { + return managedShortcut + } guard let data = UserDefaults.standard.data(forKey: action.defaultsKey), let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { return action.defaultShortcut @@ -242,7 +306,18 @@ enum KeyboardShortcutSettings { return shortcut } + static func isManagedBySettingsFile(_ action: Action) -> Bool { + settingsFileStore.isManagedByFile(action) + } + + static func settingsFileManagedSubtitle(for action: Action) -> String? { + guard isManagedBySettingsFile(action) else { return nil } + return String(localized: "settings.shortcuts.managedByFile", defaultValue: "Managed in settings.json") + } + static func setShortcut(_ shortcut: StoredShortcut, for action: Action) { + guard !isManagedBySettingsFile(action) else { return } + let storedShortcut: StoredShortcut if let normalizedShortcut = action.normalizedRecordedShortcut(shortcut) { storedShortcut = normalizedShortcut @@ -258,6 +333,10 @@ enum KeyboardShortcutSettings { postDidChangeNotification(action: action) } + static func notifySettingsFileDidChange() { + postDidChangeNotification() + } + static func resetShortcut(for action: Action) { UserDefaults.standard.removeObject(forKey: action.defaultsKey) postDidChangeNotification(action: action) @@ -330,8 +409,7 @@ enum KeyboardShortcutSettings { static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } } -/// A keyboard shortcut that can be stored in UserDefaults -struct StoredShortcut: Codable, Equatable { +struct ShortcutStroke: Equatable { var key: String var command: Bool var shift: Bool @@ -354,7 +432,7 @@ struct StoredShortcut: Codable, Equatable { var keyDisplayString: String { switch key { case "\t": - return "TAB" + return String(localized: "shortcut.key.tab", defaultValue: "Tab") case "\r": return "↩" default: @@ -434,14 +512,36 @@ struct StoredShortcut: Codable, Equatable { } } - static func from(event: NSEvent) -> StoredShortcut? { - guard let key = storedKey(from: event) else { return nil } + static func isEscapeCancelEvent(_ event: NSEvent) -> Bool { + if event.keyCode == 53 { + return true + } + + let escapeScalar = UnicodeScalar(0x1B)! + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.capsLock, .function, .numericPad]) + let shouldTreatEscapeCharacterAsCancel = normalizedFlags.isEmpty || event.keyCode == 36 || event.keyCode == 76 + + if shouldTreatEscapeCharacterAsCancel, + event.characters?.unicodeScalars.contains(escapeScalar) == true { + return true + } + if shouldTreatEscapeCharacterAsCancel, + event.charactersIgnoringModifiers?.unicodeScalars.contains(escapeScalar) == true { + return true + } + return false + } + + static func from(event: NSEvent, requireModifier: Bool = true) -> ShortcutStroke? { + guard !isEscapeCancelEvent(event), + let key = storedKey(from: event) else { return nil } // Some keys include extra flags depending on the responder chain. let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) - let shortcut = StoredShortcut( + let stroke = ShortcutStroke( key: key, command: flags.contains(.command), shift: flags.contains(.shift), @@ -449,11 +549,11 @@ struct StoredShortcut: Codable, Equatable { control: flags.contains(.control) ) - // Avoid recording plain typing; require at least one modifier. - if !shortcut.command && !shortcut.shift && !shortcut.option && !shortcut.control { + if requireModifier, + !stroke.command && !stroke.shift && !stroke.option && !stroke.control { return nil } - return shortcut + return stroke } private static func storedKey(from event: NSEvent) -> String? { @@ -485,7 +585,6 @@ struct StoredShortcut: Codable, Equatable { return nil } - // Allow letters/numbers; everything else should be handled by keyCode mapping above. if char.isLetter || char.isNumber { return String(char) } @@ -493,17 +592,185 @@ struct StoredShortcut: Codable, Equatable { } } +/// A keyboard shortcut that can be stored in UserDefaults +struct StoredShortcut: Codable, Equatable { + var key: String + var command: Bool + var shift: Bool + var option: Bool + var control: Bool + var chordKey: String? + var chordCommand: Bool + var chordShift: Bool + var chordOption: Bool + var chordControl: Bool + + init( + key: String, + command: Bool, + shift: Bool, + option: Bool, + control: Bool, + chordKey: String? = nil, + chordCommand: Bool = false, + chordShift: Bool = false, + chordOption: Bool = false, + chordControl: Bool = false + ) { + self.key = key + self.command = command + self.shift = shift + self.option = option + self.control = control + self.chordKey = chordKey?.isEmpty == true ? nil : chordKey + self.chordCommand = chordCommand + self.chordShift = chordShift + self.chordOption = chordOption + self.chordControl = chordControl + } + + init(first: ShortcutStroke, second: ShortcutStroke? = nil) { + self.init( + key: first.key, + command: first.command, + shift: first.shift, + option: first.option, + control: first.control, + chordKey: second?.key, + chordCommand: second?.command ?? false, + chordShift: second?.shift ?? false, + chordOption: second?.option ?? false, + chordControl: second?.control ?? false + ) + } + + private enum CodingKeys: String, CodingKey { + case key + case command + case shift + case option + case control + case chordKey + case chordCommand + case chordShift + case chordOption + case chordControl + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + key: try container.decode(String.self, forKey: .key), + command: try container.decode(Bool.self, forKey: .command), + shift: try container.decode(Bool.self, forKey: .shift), + option: try container.decode(Bool.self, forKey: .option), + control: try container.decode(Bool.self, forKey: .control), + chordKey: try container.decodeIfPresent(String.self, forKey: .chordKey), + chordCommand: try container.decodeIfPresent(Bool.self, forKey: .chordCommand) ?? false, + chordShift: try container.decodeIfPresent(Bool.self, forKey: .chordShift) ?? false, + chordOption: try container.decodeIfPresent(Bool.self, forKey: .chordOption) ?? false, + chordControl: try container.decodeIfPresent(Bool.self, forKey: .chordControl) ?? false + ) + } + + var firstStroke: ShortcutStroke { + ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + } + + var secondStroke: ShortcutStroke? { + guard let chordKey else { return nil } + return ShortcutStroke( + key: chordKey, + command: chordCommand, + shift: chordShift, + option: chordOption, + control: chordControl + ) + } + + var hasChord: Bool { + secondStroke != nil + } + + var displayString: String { + if let secondStroke { + return "\(firstStroke.displayString) \(secondStroke.displayString)" + } + return firstStroke.displayString + } + + var numberedDisplayString: String { + if hasChord { + return numberedDigitHintPrefix + "1…9" + } + return firstStroke.modifierDisplayString + "1…9" + } + + var numberedDigitHintPrefix: String { + if let secondStroke { + return "\(firstStroke.displayString) \(secondStroke.modifierDisplayString)" + } + return firstStroke.modifierDisplayString + } + + var modifierDisplayString: String { + firstStroke.modifierDisplayString + } + + var keyDisplayString: String { + firstStroke.keyDisplayString + } + + var modifierFlags: NSEvent.ModifierFlags { + firstStroke.modifierFlags + } + + var keyEquivalent: KeyEquivalent? { + guard !hasChord else { return nil } + return firstStroke.keyEquivalent + } + + var eventModifiers: EventModifiers { + firstStroke.eventModifiers + } + + var menuItemKeyEquivalent: String? { + guard !hasChord else { return nil } + return firstStroke.menuItemKeyEquivalent + } + + static func from(event: NSEvent) -> StoredShortcut? { + guard let stroke = ShortcutStroke.from(event: event) else { return nil } + return StoredShortcut(first: stroke) + } +} + /// View for recording a keyboard shortcut struct KeyboardShortcutRecorder: View { let label: String + var subtitle: String? = nil @Binding var shortcut: StoredShortcut var displayString: (StoredShortcut) -> String = { $0.displayString } var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 } + var isDisabled: Bool = false @State private var isRecording = false var body: some View { - HStack { - Text(label) + HStack(alignment: subtitle == nil ? .center : .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(label) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } Spacer() @@ -513,7 +780,8 @@ struct KeyboardShortcutRecorder: View { displayString: displayString, transformRecordedShortcut: transformRecordedShortcut ) - .frame(width: 120) + .frame(width: 160) + .disabled(isDisabled) } } } @@ -547,7 +815,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable { } } -private class ShortcutRecorderNSButton: NSButton { +final class ShortcutRecorderNSButton: NSButton { var shortcut: StoredShortcut = KeyboardShortcutSettings.showNotificationsDefault var displayString: (StoredShortcut) -> String = { $0.displayString } var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 } @@ -555,6 +823,7 @@ private class ShortcutRecorderNSButton: NSButton { var onRecordingChanged: ((Bool) -> Void)? private var isRecording = false private var eventMonitor: Any? + private var pendingChordStart: ShortcutStroke? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -576,7 +845,12 @@ private class ShortcutRecorderNSButton: NSButton { func updateTitle() { if isRecording { - title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") + if let pendingChordStart { + let format = String(localized: "shortcut.recorder.pendingChord", defaultValue: "%@ …") + title = String.localizedStringWithFormat(format, pendingChordStart.displayString) + } else { + title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") + } } else { title = displayString(shortcut) } @@ -584,6 +858,16 @@ private class ShortcutRecorderNSButton: NSButton { @objc private func buttonClicked() { if isRecording { + if let pendingChordStart { + let storedShortcut = StoredShortcut(first: pendingChordStart) + guard let transformedShortcut = transformRecordedShortcut(storedShortcut) else { + NSSound.beep() + stopRecording() + return + } + shortcut = transformedShortcut + onShortcutRecorded?(transformedShortcut) + } stopRecording() } else { startRecording() @@ -592,18 +876,33 @@ private class ShortcutRecorderNSButton: NSButton { private func startRecording() { isRecording = true + pendingChordStart = nil onRecordingChanged?(true) updateTitle() eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self = self else { return event } - if event.keyCode == 53 { // Escape + if ShortcutStroke.isEscapeCancelEvent(event) { self.stopRecording() return nil } - if let newShortcut = StoredShortcut.from(event: event) { + if self.pendingChordStart == nil { + guard let firstStroke = ShortcutStroke.from(event: event, requireModifier: true) else { + return nil + } + self.pendingChordStart = firstStroke + self.updateTitle() + return nil + } + + guard let pendingChordStart = self.pendingChordStart else { + return nil + } + + if let secondStroke = ShortcutStroke.from(event: event, requireModifier: false) { + let newShortcut = StoredShortcut(first: pendingChordStart, second: secondStroke) guard let transformedShortcut = self.transformRecordedShortcut(newShortcut) else { NSSound.beep() return nil @@ -629,6 +928,7 @@ private class ShortcutRecorderNSButton: NSButton { private func stopRecording() { isRecording = false + pendingChordStart = nil onRecordingChanged?(false) updateTitle() @@ -644,6 +944,18 @@ private class ShortcutRecorderNSButton: NSButton { stopRecording() } +#if DEBUG + var debugIsRecording: Bool { + isRecording + } + + func debugSetPendingChordStart(_ stroke: ShortcutStroke?) { + isRecording = true + pendingChordStart = stroke + updateTitle() + } +#endif + deinit { stopRecording() } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift new file mode 100644 index 000000000000..f6adcf638335 --- /dev/null +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -0,0 +1,1719 @@ +import Combine +import Foundation + +@MainActor +final class KeyboardShortcutSettingsObserver: ObservableObject { + static let shared = KeyboardShortcutSettingsObserver() + + @Published private(set) var revision: UInt64 = 0 + + private var cancellable: AnyCancellable? + + private init(notificationCenter: NotificationCenter = .default) { + cancellable = notificationCenter.publisher(for: KeyboardShortcutSettings.didChangeNotification) + .sink { [weak self] _ in + self?.revision &+= 1 + } + } +} + +final class CmuxSettingsFileStore { + static let shared = CmuxSettingsFileStore() + + static let currentSchemaVersion = 1 + static let schemaURLString = "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json" + + private static let releaseBundleIdentifier = "com.cmuxterm.app" + private static let backupsDefaultsKey = "cmux.settingsFile.backups.v1" + fileprivate static let trustedDirectoriesBackupIdentifier = "customCommands.trustedDirectories" + fileprivate static let socketPassphraseBackupIdentifier = "automation.socket" + "Password" + + static var defaultPrimaryPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return (home as NSString).appendingPathComponent(".config/cmux/settings.json") + } + + static var defaultFallbackPath: String? { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + return nil + } + return appSupport + .appendingPathComponent(releaseBundleIdentifier, isDirectory: true) + .appendingPathComponent("settings.json", isDirectory: false) + .path + } + + private let primaryPath: String + private let fallbackPath: String? + private let fileManager: FileManager + private let notificationCenter: NotificationCenter + private let stateLock = NSLock() + + private var primaryWatcher: ShortcutSettingsFileWatcher? + private var fallbackWatcher: ShortcutSettingsFileWatcher? + private var defaultsCancellable: AnyCancellable? + private var trustObserver: NSObjectProtocol? + private var socketPassphraseObserver: NSObjectProtocol? + + private var shortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private var activeManagedUserDefaults: [String: ManagedSettingsValue] = [:] + private var activeManagedCustomSettings = ManagedCustomSettings() + private var isApplyingManagedSettings = false + private(set) var activeSourcePath: String? + + init( + primaryPath: String = CmuxSettingsFileStore.defaultPrimaryPath, + fallbackPath: String? = CmuxSettingsFileStore.defaultFallbackPath, + fileManager: FileManager = .default, + notificationCenter: NotificationCenter = .default, + startWatching: Bool = true + ) { + self.primaryPath = primaryPath + self.fallbackPath = fallbackPath + self.fileManager = fileManager + self.notificationCenter = notificationCenter + + bootstrapPrimaryTemplateIfNeeded() + reload() + guard startWatching else { return } + + primaryWatcher = ShortcutSettingsFileWatcher(path: primaryPath, fileManager: fileManager) { [weak self] in + DispatchQueue.main.async { + self?.reload() + } + } + if let fallbackPath { + fallbackWatcher = ShortcutSettingsFileWatcher(path: fallbackPath, fileManager: fileManager) { [weak self] in + DispatchQueue.main.async { + self?.reload() + } + } + } + + defaultsCancellable = notificationCenter.publisher(for: UserDefaults.didChangeNotification) + .sink { [weak self] _ in + self?.reapplyManagedSettingsIfNeeded() + } + trustObserver = notificationCenter.addObserver( + forName: CmuxDirectoryTrust.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.reapplyManagedSettingsIfNeeded() + } + socketPassphraseObserver = notificationCenter.addObserver( + forName: SocketControlPasswordStore.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.reapplyManagedSettingsIfNeeded() + } + } + + deinit { + primaryWatcher?.stop() + fallbackWatcher?.stop() + defaultsCancellable?.cancel() + if let trustObserver { + notificationCenter.removeObserver(trustObserver) + } + if let socketPassphraseObserver { + notificationCenter.removeObserver(socketPassphraseObserver) + } + } + + func reload() { + let previousShortcuts = synchronized { shortcutsByAction } + let previousActiveSourcePath = synchronized { activeSourcePath } + let resolved = resolveSettings() + applyManagedSettings(snapshot: resolved) + synchronized { + shortcutsByAction = resolved.shortcuts + activeManagedUserDefaults = resolved.managedUserDefaults + activeManagedCustomSettings = resolved.managedCustomSettings + activeSourcePath = resolved.path + } + + if previousShortcuts != resolved.shortcuts || previousActiveSourcePath != resolved.path { + KeyboardShortcutSettings.notifySettingsFileDidChange() + } + } + + func override(for action: KeyboardShortcutSettings.Action) -> StoredShortcut? { + synchronized { shortcutsByAction[action] } + } + + func isManagedByFile(_ action: KeyboardShortcutSettings.Action) -> Bool { + synchronized { shortcutsByAction[action] != nil } + } + + func settingsFileURLForEditing() -> URL { + if let activeSourcePath = synchronized({ activeSourcePath }) { + return URL(fileURLWithPath: activeSourcePath) + } + + bootstrapPrimaryTemplateIfNeeded() + reload() + + if let activeSourcePath = synchronized({ activeSourcePath }) { + return URL(fileURLWithPath: activeSourcePath) + } + + return URL(fileURLWithPath: primaryPath) + } + + func settingsFileDisplayPath() -> String { + let path = synchronized { activeSourcePath } ?? primaryPath + return (path as NSString).abbreviatingWithTildeInPath + } + + private func bootstrapPrimaryTemplateIfNeeded() { + guard !fileManager.fileExists(atPath: primaryPath) else { return } + if let fallbackPath, fileManager.fileExists(atPath: fallbackPath) { + return + } + + let fileURL = URL(fileURLWithPath: primaryPath) + let directoryURL = fileURL.deletingLastPathComponent() + + do { + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + let template = Self.defaultTemplate() + try template.write(to: fileURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } catch { + NSLog("[CmuxSettingsFileStore] failed to bootstrap %@: %@", primaryPath, String(describing: error)) + } + } + + private func reapplyManagedSettingsIfNeeded() { + let snapshot: ResolvedSettingsSnapshot? = synchronized { + guard !isApplyingManagedSettings else { return nil } + if activeManagedUserDefaults.isEmpty && activeManagedCustomSettings.isEmpty { + return nil + } + return ResolvedSettingsSnapshot( + path: activeSourcePath, + shortcuts: shortcutsByAction, + managedUserDefaults: activeManagedUserDefaults, + managedCustomSettings: activeManagedCustomSettings + ) + } + guard let snapshot else { return } + applyManagedSettings(snapshot: snapshot, updateBackups: false) + } + + private func synchronized<T>(_ body: () -> T) -> T { + stateLock.lock() + defer { stateLock.unlock() } + return body() + } + + private func resolveSettings() -> ResolvedSettingsSnapshot { + switch loadSettings(at: primaryPath) { + case .parsed(let snapshot): + return snapshot + case .invalid: + return ResolvedSettingsSnapshot(path: primaryPath) + case .missing: + break + } + + guard let fallbackPath else { + return ResolvedSettingsSnapshot(path: nil) + } + + switch loadSettings(at: fallbackPath) { + case .parsed(let snapshot): + return snapshot + case .invalid: + return ResolvedSettingsSnapshot(path: fallbackPath) + case .missing: + return ResolvedSettingsSnapshot(path: nil) + } + } + + private enum LoadResult { + case missing + case invalid + case parsed(ResolvedSettingsSnapshot) + } + + private func loadSettings(at path: String) -> LoadResult { + guard fileManager.fileExists(atPath: path) else { + return .missing + } + guard let data = fileManager.contents(atPath: path), !data.isEmpty else { + return .invalid + } + + do { + let sanitized = try JSONCParser.preprocess(data: data) + let object = try JSONSerialization.jsonObject(with: sanitized, options: []) + guard let root = object as? [String: Any] else { + return .invalid + } + return .parsed(parseSettingsFile(root: root, sourcePath: path)) + } catch { + NSLog("[CmuxSettingsFileStore] parse error at %@: %@", path, String(describing: error)) + return .invalid + } + } + + private func parseSettingsFile(root: [String: Any], sourcePath: String) -> ResolvedSettingsSnapshot { + let schemaVersion = jsonInt(root["schemaVersion"]) ?? 1 + if schemaVersion > Self.currentSchemaVersion { + NSLog( + "[CmuxSettingsFileStore] %@ uses future schemaVersion %d; parsing known fields only", + sourcePath, + schemaVersion + ) + } + + var snapshot = ResolvedSettingsSnapshot(path: sourcePath) + + if let appSection = root["app"] as? [String: Any] { + parseAppSection(appSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let notificationsSection = root["notifications"] as? [String: Any] { + parseNotificationsSection(notificationsSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let sidebarSection = root["sidebar"] as? [String: Any] { + parseSidebarSection(sidebarSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let workspaceColorsSection = root["workspaceColors"] as? [String: Any] { + parseWorkspaceColorsSection(workspaceColorsSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let sidebarAppearanceSection = root["sidebarAppearance"] as? [String: Any] { + parseSidebarAppearanceSection(sidebarAppearanceSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let automationSection = root["automation"] as? [String: Any] { + parseAutomationSection(automationSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let customCommandsSection = root["customCommands"] as? [String: Any] { + parseCustomCommandsSection(customCommandsSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let browserSection = root["browser"] as? [String: Any] { + parseBrowserSection(browserSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let shortcutsSection = root["shortcuts"] { + parseShortcutsSection(shortcutsSection, sourcePath: sourcePath, snapshot: &snapshot) + } + + return snapshot + } + + private func parseAppSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["language"]) { + guard let language = AppLanguage(rawValue: raw) else { + logInvalid("app.language", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[LanguageSettings.languageKey] = .string(language.rawValue) + } + if let raw = jsonString(section["appearance"]) { + let normalized = AppearanceSettings.mode(for: raw).rawValue + let accepted = Set(AppearanceMode.allCases.map(\.rawValue)) + guard accepted.contains(raw) else { + logInvalid("app.appearance", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[AppearanceSettings.appearanceModeKey] = .string(normalized) + } + if let raw = jsonString(section["appIcon"]) { + guard let mode = AppIconMode(rawValue: raw) else { + logInvalid("app.appIcon", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[AppIconSettings.modeKey] = .string(mode.rawValue) + } + if let raw = jsonString(section["newWorkspacePlacement"]) { + guard let placement = NewWorkspacePlacement(rawValue: raw) else { + logInvalid("app.newWorkspacePlacement", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[WorkspacePlacementSettings.placementKey] = .string(placement.rawValue) + } + if let value = jsonBool(section["minimalMode"]) { + let mode = value ? WorkspacePresentationModeSettings.Mode.minimal : .standard + snapshot.managedUserDefaults[WorkspacePresentationModeSettings.modeKey] = .string(mode.rawValue) + } + if let value = jsonBool(section["keepWorkspaceOpenWhenClosingLastSurface"]) { + snapshot.managedUserDefaults[LastSurfaceCloseShortcutSettings.key] = .bool(!value) + } + if let value = jsonBool(section["focusPaneOnFirstClick"]) { + snapshot.managedUserDefaults[PaneFirstClickFocusSettings.enabledKey] = .bool(value) + } + if let value = jsonString(section["preferredEditor"]) { + snapshot.managedUserDefaults[PreferredEditorSettings.key] = .string(value) + } + if let value = jsonBool(section["reorderOnNotification"]) { + snapshot.managedUserDefaults[WorkspaceAutoReorderSettings.key] = .bool(value) + } + if let value = jsonBool(section["sendAnonymousTelemetry"]) { + snapshot.managedUserDefaults[TelemetrySettings.sendAnonymousTelemetryKey] = .bool(value) + } + if let value = jsonBool(section["warnBeforeQuit"]) { + snapshot.managedUserDefaults[QuitWarningSettings.warnBeforeQuitKey] = .bool(value) + } + if let value = jsonBool(section["renameSelectsExistingName"]) { + snapshot.managedUserDefaults[CommandPaletteRenameSelectionSettings.selectAllOnFocusKey] = .bool(value) + } + if let value = jsonBool(section["commandPaletteSearchesAllSurfaces"]) { + snapshot.managedUserDefaults[CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey] = .bool(value) + } + } + + private func parseNotificationsSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let value = jsonBool(section["dockBadge"]) { + snapshot.managedUserDefaults[NotificationBadgeSettings.dockBadgeEnabledKey] = .bool(value) + } + if let value = jsonBool(section["showInMenuBar"]) { + snapshot.managedUserDefaults[MenuBarExtraSettings.showInMenuBarKey] = .bool(value) + } + if let value = jsonBool(section["unreadPaneRing"]) { + snapshot.managedUserDefaults[NotificationPaneRingSettings.enabledKey] = .bool(value) + } + if let value = jsonBool(section["paneFlash"]) { + snapshot.managedUserDefaults[NotificationPaneFlashSettings.enabledKey] = .bool(value) + } + if let raw = jsonString(section["sound"]) { + let allowed = Set(NotificationSoundSettings.systemSounds.map(\.value)) + guard allowed.contains(raw) else { + logInvalid("notifications.sound", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[NotificationSoundSettings.key] = .string(raw) + } + if let raw = jsonString(section["customSoundFilePath"]) { + snapshot.managedUserDefaults[NotificationSoundSettings.customFilePathKey] = .string(raw) + } + if let raw = jsonString(section["command"]) { + snapshot.managedUserDefaults[NotificationSoundSettings.customCommandKey] = .string(raw) + } + } + + private func parseSidebarSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let value = jsonBool(section["hideAllDetails"]) { + snapshot.managedUserDefaults[SidebarWorkspaceDetailSettings.hideAllDetailsKey] = .bool(value) + } + if let raw = jsonString(section["branchLayout"]) { + switch raw { + case "vertical": + snapshot.managedUserDefaults[SidebarBranchLayoutSettings.key] = .bool(true) + case "inline": + snapshot.managedUserDefaults[SidebarBranchLayoutSettings.key] = .bool(false) + default: + logInvalid("sidebar.branchLayout", sourcePath: sourcePath) + } + } + if let value = jsonBool(section["showNotificationMessage"]) { + snapshot.managedUserDefaults[SidebarWorkspaceDetailSettings.showNotificationMessageKey] = .bool(value) + } + if let value = jsonBool(section["showBranchDirectory"]) { + snapshot.managedUserDefaults["sidebarShowBranchDirectory"] = .bool(value) + } + if let value = jsonBool(section["showPullRequests"]) { + snapshot.managedUserDefaults["sidebarShowPullRequest"] = .bool(value) + } + if let value = jsonBool(section["openPullRequestLinksInCmuxBrowser"]) { + snapshot.managedUserDefaults[BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey] = .bool(value) + } + if let value = jsonBool(section["openPortLinksInCmuxBrowser"]) { + snapshot.managedUserDefaults[BrowserLinkOpenSettings.openSidebarPortLinksInCmuxBrowserKey] = .bool(value) + } + if let value = jsonBool(section["showSSH"]) { + snapshot.managedUserDefaults["sidebarShowSSH"] = .bool(value) + } + if let value = jsonBool(section["showPorts"]) { + snapshot.managedUserDefaults["sidebarShowPorts"] = .bool(value) + } + if let value = jsonBool(section["showLog"]) { + snapshot.managedUserDefaults["sidebarShowLog"] = .bool(value) + } + if let value = jsonBool(section["showProgress"]) { + snapshot.managedUserDefaults["sidebarShowProgress"] = .bool(value) + } + if let value = jsonBool(section["showCustomMetadata"]) { + snapshot.managedUserDefaults["sidebarShowStatusPills"] = .bool(value) + } + } + + private func parseWorkspaceColorsSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["indicatorStyle"]) { + let normalized = SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: raw).rawValue + let accepted = Set(SidebarActiveTabIndicatorStyle.allCases.map(\.rawValue)).union([ + "rail", "border", "wash", "lift", "typography", "washRail", "blueWashColorRail", + ]) + guard accepted.contains(raw) else { + logInvalid("workspaceColors.indicatorStyle", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[SidebarActiveTabIndicatorSettings.styleKey] = .string(normalized) + } + if section.keys.contains("selectionColor") { + guard let value = parseNullableHex( + section["selectionColor"], + path: "workspaceColors.selectionColor", + sourcePath: sourcePath + ) else { return } + snapshot.managedUserDefaults["sidebarSelectionColorHex"] = .nullableString(value) + } + if section.keys.contains("notificationBadgeColor") { + guard let value = parseNullableHex( + section["notificationBadgeColor"], + path: "workspaceColors.notificationBadgeColor", + sourcePath: sourcePath + ) else { return } + snapshot.managedUserDefaults["sidebarNotificationBadgeColorHex"] = .nullableString(value) + } + if section.keys.contains("colors") { + guard let rawColors = section["colors"] as? [String: Any] else { + logInvalid("workspaceColors.colors", sourcePath: sourcePath) + return + } + + var normalizedPalette: [String: String] = [:] + for (rawName, rawValue) in rawColors { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + NSLog("[CmuxSettingsFileStore] ignoring empty workspace color name in %@", sourcePath) + continue + } + guard let hex = jsonString(rawValue), + let normalizedHex = WorkspaceTabColorSettings.normalizedHex(hex) else { + NSLog("[CmuxSettingsFileStore] ignoring invalid workspace color '%@' in %@", name, sourcePath) + continue + } + normalizedPalette[name] = normalizedHex + } + snapshot.managedUserDefaults[WorkspaceTabColorSettings.paletteKey] = .stringDictionary(normalizedPalette) + return + } + + let validNames = Set(WorkspaceTabColorSettings.defaultPalette.map(\.name)) + var normalizedLegacyPalette: [String: String]? = nil + if let rawOverrides = section["paletteOverrides"] as? [String: Any] { + var palette = Dictionary( + uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) } + ) + for (name, rawValue) in rawOverrides { + guard validNames.contains(name) else { + NSLog("[CmuxSettingsFileStore] ignoring unknown workspace color '%@' in %@", name, sourcePath) + continue + } + guard let hex = jsonString(rawValue), + let normalizedHex = WorkspaceTabColorSettings.normalizedHex(hex) else { + NSLog("[CmuxSettingsFileStore] ignoring invalid workspace color override '%@' in %@", name, sourcePath) + continue + } + palette[name] = normalizedHex + } + normalizedLegacyPalette = palette + } + if let rawCustomColors = jsonStringArray(section["customColors"]) { + var palette = normalizedLegacyPalette ?? Dictionary( + uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) } + ) + var existingNames = Set(palette.keys) + var seenCustomHexes: Set<String> = [] + for rawHex in rawCustomColors { + guard let normalizedHex = WorkspaceTabColorSettings.normalizedHex(rawHex), + seenCustomHexes.insert(normalizedHex).inserted else { continue } + var index = 1 + while existingNames.contains("Custom \(index)") { + index += 1 + } + let name = "Custom \(index)" + palette[name] = normalizedHex + existingNames.insert(name) + } + normalizedLegacyPalette = palette + } + if let normalizedLegacyPalette { + snapshot.managedUserDefaults[WorkspaceTabColorSettings.paletteKey] = .stringDictionary(normalizedLegacyPalette) + } + } + + private func parseSidebarAppearanceSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let value = jsonBool(section["matchTerminalBackground"]) { + snapshot.managedUserDefaults["sidebarMatchTerminalBackground"] = .bool(value) + } + if let raw = jsonString(section["tintColor"]) { + guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + logInvalid("sidebarAppearance.tintColor", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults["sidebarTintHex"] = .string(normalized) + } + if section.keys.contains("lightModeTintColor") { + guard let value = parseNullableHex( + section["lightModeTintColor"], + path: "sidebarAppearance.lightModeTintColor", + sourcePath: sourcePath + ) else { return } + snapshot.managedUserDefaults["sidebarTintHexLight"] = .nullableString(value) + } + if section.keys.contains("darkModeTintColor") { + guard let value = parseNullableHex( + section["darkModeTintColor"], + path: "sidebarAppearance.darkModeTintColor", + sourcePath: sourcePath + ) else { return } + snapshot.managedUserDefaults["sidebarTintHexDark"] = .nullableString(value) + } + if let value = jsonDouble(section["tintOpacity"]) { + let clamped = min(max(value, 0), 1) + snapshot.managedUserDefaults["sidebarTintOpacity"] = .double(clamped) + } + } + + private func parseAutomationSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["socketControlMode"]) { + let knownModes = Set([ + "off", "cmuxonly", "automation", "password", "allowall", "openaccess", "fullopenaccess", + "notifications", "full", + ]) + let normalizedRaw = raw.replacingOccurrences(of: "-", with: "").lowercased() + guard knownModes.contains(normalizedRaw) else { + logInvalid("automation.socketControlMode", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[SocketControlSettings.appStorageKey] = .string( + SocketControlSettings.migrateMode(raw).rawValue + ) + } + if section.keys.contains("socket" + "Password") { + if section["socket" + "Password"] is NSNull { + snapshot.managedCustomSettings.socketPassphrase = .clear + } else if let raw = jsonString(section["socket" + "Password"]) { + snapshot.managedCustomSettings.socketPassphrase = raw.isEmpty ? .clear : .set(raw) + } else { + logInvalid("automation.socketPassphrase", sourcePath: sourcePath) + return + } + } + if let value = jsonBool(section["claudeCodeIntegration"]) { + snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.hooksEnabledKey] = .bool(value) + } + if let raw = jsonString(section["claudeBinaryPath"]) { + snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.customClaudePathKey] = .string(raw) + } + if let value = jsonInt(section["portBase"]) { + guard value > 0 else { + logInvalid("automation.portBase", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults["cmuxPortBase"] = .int(value) + } + if let value = jsonInt(section["portRange"]) { + guard value > 0 else { + logInvalid("automation.portRange", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults["cmuxPortRange"] = .int(value) + } + } + + private func parseCustomCommandsSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let values = jsonStringArray(section["trustedDirectories"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedCustomSettings.trustedDirectories = normalized + } else if section.keys.contains("trustedDirectories") { + logInvalid("customCommands.trustedDirectories", sourcePath: sourcePath) + } + } + + private func parseBrowserSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["defaultSearchEngine"]) { + guard let engine = BrowserSearchEngine(rawValue: raw) else { + logInvalid("browser.defaultSearchEngine", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[BrowserSearchSettings.searchEngineKey] = .string(engine.rawValue) + } + if let value = jsonBool(section["showSearchSuggestions"]) { + snapshot.managedUserDefaults[BrowserSearchSettings.searchSuggestionsEnabledKey] = .bool(value) + } + if let raw = jsonString(section["theme"]) { + guard let mode = BrowserThemeMode(rawValue: raw) else { + logInvalid("browser.theme", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[BrowserThemeSettings.modeKey] = .string(mode.rawValue) + } + if let value = jsonBool(section["openTerminalLinksInCmuxBrowser"]) { + snapshot.managedUserDefaults[BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey] = .bool(value) + } + if let value = jsonBool(section["interceptTerminalOpenCommandInCmuxBrowser"]) { + snapshot.managedUserDefaults[BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey] = .bool(value) + } + if let values = jsonStringArray(section["hostsToOpenInEmbeddedBrowser"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedUserDefaults[BrowserLinkOpenSettings.browserHostWhitelistKey] = .string(normalized.joined(separator: "\n")) + } else if section.keys.contains("hostsToOpenInEmbeddedBrowser") { + logInvalid("browser.hostsToOpenInEmbeddedBrowser", sourcePath: sourcePath) + } + if let values = jsonStringArray(section["urlsToAlwaysOpenExternally"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedUserDefaults[BrowserLinkOpenSettings.browserExternalOpenPatternsKey] = .string( + normalized.joined(separator: "\n") + ) + } else if section.keys.contains("urlsToAlwaysOpenExternally") { + logInvalid("browser.urlsToAlwaysOpenExternally", sourcePath: sourcePath) + } + if let values = jsonStringArray(section["insecureHttpHostsAllowedInEmbeddedBrowser"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedUserDefaults[BrowserInsecureHTTPSettings.allowlistKey] = .string( + normalized.joined(separator: "\n") + ) + } else if section.keys.contains("insecureHttpHostsAllowedInEmbeddedBrowser") { + logInvalid("browser.insecureHttpHostsAllowedInEmbeddedBrowser", sourcePath: sourcePath) + } + if let value = jsonBool(section["showImportHintOnBlankTabs"]) { + snapshot.managedUserDefaults[BrowserImportHintSettings.showOnBlankTabsKey] = .bool(value) + } + if let raw = jsonString(section["reactGrabVersion"]) { + snapshot.managedUserDefaults[ReactGrabSettings.versionKey] = .string(raw) + } + } + + private func parseShortcutsSection( + _ value: Any, + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + guard let section = value as? [String: Any] else { + logInvalid("shortcuts", sourcePath: sourcePath) + return + } + + if let value = jsonBool(section["showModifierHoldHints"]) { + snapshot.managedUserDefaults[ShortcutHintDebugSettings.showHintsOnCommandHoldKey] = .bool(value) + } + + var bindings = section["bindings"] as? [String: Any] ?? [:] + for (key, rawValue) in section where key != "bindings" && key != "showModifierHoldHints" { + bindings[key] = rawValue + } + + for (rawAction, rawBinding) in bindings { + guard let action = KeyboardShortcutSettings.Action(rawValue: rawAction) else { + NSLog("[CmuxSettingsFileStore] ignoring unknown shortcut action '%@' in %@", rawAction, sourcePath) + continue + } + guard let shortcut = parseShortcutBindingValue(rawBinding, action: action) else { + NSLog( + "[CmuxSettingsFileStore] ignoring invalid shortcut binding for '%@' in %@", + rawAction, + sourcePath + ) + continue + } + snapshot.shortcuts[action] = shortcut + } + } + + private func parseShortcutBindingValue( + _ rawValue: Any, + action: KeyboardShortcutSettings.Action + ) -> StoredShortcut? { + let shortcut: StoredShortcut? + if let stroke = jsonString(rawValue) { + shortcut = parseStoredShortcut(strokes: [stroke]) + } else if let strokes = jsonStringArray(rawValue) { + shortcut = parseStoredShortcut(strokes: strokes) + } else { + shortcut = nil + } + + guard let shortcut else { return nil } + if let normalized = action.normalizedRecordedShortcut(shortcut) { + return normalized + } + return action.usesNumberedDigitMatching ? nil : shortcut + } + + private func parseStoredShortcut(strokes: [String]) -> StoredShortcut? { + guard !strokes.isEmpty, strokes.count <= 2 else { return nil } + let parsedStrokes = strokes.compactMap(parseStroke(_:)) + guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { + return nil + } + guard !firstStroke.modifierFlags.isEmpty else { return nil } + let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil + return StoredShortcut(first: firstStroke, second: secondStroke) + } + + private func parseStroke(_ rawValue: String) -> ShortcutStroke? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(separator: "+", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !parts.isEmpty, let lastPart = parts.last, !lastPart.isEmpty else { + return nil + } + + var command = false + var shift = false + var option = false + var control = false + + for modifier in parts.dropLast() { + switch modifier.lowercased() { + case "cmd", "command", "⌘": + command = true + case "shift", "⇧": + shift = true + case "opt", "option", "alt", "⌥": + option = true + case "ctrl", "control", "ctl", "⌃": + control = true + default: + return nil + } + } + + guard let key = parseKeyToken(lastPart) else { return nil } + return ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + } + + private func parseKeyToken(_ rawValue: String) -> String? { + let lowered = rawValue.lowercased() + switch lowered { + case "left", "arrowleft", "leftarrow", "←": + return "←" + case "right", "arrowright", "rightarrow", "→": + return "→" + case "up", "arrowup", "uparrow", "↑": + return "↑" + case "down", "arrowdown", "downarrow", "↓": + return "↓" + case "tab": + return "\t" + case "return", "enter", "↩": + return "\r" + case "space": + return " " + case "comma": + return "," + case "period", "dot": + return "." + case "slash": + return "/" + case "backslash": + return "\\" + case "semicolon": + return ";" + case "quote", "apostrophe": + return "'" + case "backtick", "grave": + return "`" + case "minus", "hyphen": + return "-" + case "plus", "equals": + return "=" + case "leftbracket", "openbracket": + return "[" + case "rightbracket", "closebracket": + return "]" + default: + guard lowered.count == 1 else { return nil } + return lowered + } + } + + private func parseNullableHex( + _ rawValue: Any?, + path: String, + sourcePath: String + ) -> String?? { + if rawValue is NSNull { + return .some(nil) + } + guard let raw = jsonString(rawValue), + let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + logInvalid(path, sourcePath: sourcePath) + return nil + } + return .some(normalized) + } + + private func applyManagedSettings( + snapshot: ResolvedSettingsSnapshot, + updateBackups: Bool = true + ) { + var backups = loadBackups() + let currentManagedIdentifiers = Set(backups.keys) + let nextManagedIdentifiers = Set(snapshot.managedUserDefaults.keys) + .union(snapshot.managedCustomSettings.managedIdentifiers) + + synchronized { + isApplyingManagedSettings = true + } + defer { + synchronized { + isApplyingManagedSettings = false + } + } + + if updateBackups { + for (defaultsKey, value) in snapshot.managedUserDefaults where backups[defaultsKey] == nil { + backups[defaultsKey] = backupValueForUserDefaultsKey(defaultsKey, managedValue: value) + } + if snapshot.managedCustomSettings.trustedDirectories != nil, + backups[Self.trustedDirectoriesBackupIdentifier] == nil { + backups[Self.trustedDirectoriesBackupIdentifier] = .stringArray(CmuxDirectoryTrust.shared.allTrustedPaths) + } + if snapshot.managedCustomSettings.socketPassphrase != nil, + backups[Self.socketPassphraseBackupIdentifier] == nil { + backups[Self.socketPassphraseBackupIdentifier] = currentSocketPasswordBackupValue() + } + } + + for identifier in currentManagedIdentifiers.subtracting(nextManagedIdentifiers) { + guard let backup = backups[identifier] else { continue } + restoreBackup(backup, for: identifier) + backups.removeValue(forKey: identifier) + } + + for (defaultsKey, value) in snapshot.managedUserDefaults { + applyManagedUserDefaultsValue(value, for: defaultsKey) + } + applyManagedCustomSettings(snapshot.managedCustomSettings) + + if updateBackups { + saveBackups(backups) + } + } + + private func applyManagedCustomSettings(_ settings: ManagedCustomSettings) { + if let trustedDirectories = settings.trustedDirectories, + CmuxDirectoryTrust.shared.allTrustedPaths != trustedDirectories { + CmuxDirectoryTrust.shared.replaceAll(with: trustedDirectories) + } + + if let socketPassphrase = settings.socketPassphrase { + switch socketPassphrase { + case .set(let value): + let current = (try? SocketControlPasswordStore.loadPassword()) ?? nil + if current != value { + try? SocketControlPasswordStore.savePassword(value) + } + case .clear: + let current = (try? SocketControlPasswordStore.loadPassword()) ?? nil + if current != nil { + try? SocketControlPasswordStore.clearPassword() + } + } + } + } + + private func restoreBackup(_ backup: BackupValue, for identifier: String) { + switch identifier { + case Self.trustedDirectoriesBackupIdentifier: + if case .stringArray(let values) = backup { + CmuxDirectoryTrust.shared.replaceAll(with: values) + } else { + CmuxDirectoryTrust.shared.replaceAll(with: []) + } + case Self.socketPassphraseBackupIdentifier: + switch backup { + case .string(let value): + try? SocketControlPasswordStore.savePassword(value) + case .absent: + try? SocketControlPasswordStore.clearPassword() + default: + break + } + default: + restoreUserDefaultsBackup(backup, for: identifier) + } + } + + private func backupValueForUserDefaultsKey(_ defaultsKey: String, managedValue: ManagedSettingsValue) -> BackupValue { + let defaults = UserDefaults.standard + switch managedValue { + case .bool: + guard defaults.object(forKey: defaultsKey) != nil else { return .absent } + return .bool(defaults.bool(forKey: defaultsKey)) + case .int: + guard defaults.object(forKey: defaultsKey) != nil else { return .absent } + return .int(defaults.integer(forKey: defaultsKey)) + case .double: + guard defaults.object(forKey: defaultsKey) != nil else { return .absent } + return .double(defaults.double(forKey: defaultsKey)) + case .string, .nullableString: + guard let value = defaults.string(forKey: defaultsKey) else { return .absent } + return .string(value) + case .stringArray: + guard let value = defaults.array(forKey: defaultsKey) as? [String] else { return .absent } + return .stringArray(value) + case .stringDictionary: + if defaultsKey == WorkspaceTabColorSettings.paletteKey { + guard let value = WorkspaceTabColorSettings.backupPaletteMap(defaults: defaults) else { + return .absent + } + return .stringDictionary(value) + } + guard let value = defaults.dictionary(forKey: defaultsKey) as? [String: String] else { + return .absent + } + return .stringDictionary(value) + } + } + + private func currentSocketPasswordBackupValue() -> BackupValue { + guard let current = try? SocketControlPasswordStore.loadPassword() else { + return .absent + } + return .string(current) + } + + private func applyManagedUserDefaultsValue(_ value: ManagedSettingsValue, for defaultsKey: String) { + let defaults = UserDefaults.standard + if defaultsKey == WorkspaceTabColorSettings.paletteKey, + case .stringDictionary(let next) = value { + let current = WorkspaceTabColorSettings.resolvedPaletteMap(defaults: defaults) + if current != next { + WorkspaceTabColorSettings.persistPaletteMap(next, defaults: defaults) + } + return + } + + switch value { + case .bool(let next): + let current = defaults.object(forKey: defaultsKey) as? Bool + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .int(let next): + let current = defaults.object(forKey: defaultsKey) as? Int + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .double(let next): + let current = defaults.object(forKey: defaultsKey) as? Double + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .string(let next): + let current = defaults.string(forKey: defaultsKey) + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .nullableString(let next): + let current = defaults.string(forKey: defaultsKey) + if current != next { + if let next { + defaults.set(next, forKey: defaultsKey) + } else { + defaults.removeObject(forKey: defaultsKey) + } + } + case .stringArray(let next): + let current = defaults.array(forKey: defaultsKey) as? [String] + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .stringDictionary(let next): + let current = defaults.dictionary(forKey: defaultsKey) as? [String: String] + if current != next { + defaults.set(next, forKey: defaultsKey) + } + } + + switch defaultsKey { + case LanguageSettings.languageKey: + let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system + LanguageSettings.apply(language) + case AppIconSettings.modeKey: + AppIconSettings.applyIcon(AppIconSettings.resolvedMode()) + default: + break + } + } + + private func restoreUserDefaultsBackup(_ backup: BackupValue, for defaultsKey: String) { + let defaults = UserDefaults.standard + if defaultsKey == WorkspaceTabColorSettings.paletteKey { + switch backup { + case .absent: + WorkspaceTabColorSettings.reset(defaults: defaults) + case .stringDictionary(let value): + WorkspaceTabColorSettings.persistPaletteMap(value, defaults: defaults) + default: + break + } + return + } + + switch backup { + case .absent: + defaults.removeObject(forKey: defaultsKey) + case .bool(let value): + defaults.set(value, forKey: defaultsKey) + case .int(let value): + defaults.set(value, forKey: defaultsKey) + case .double(let value): + defaults.set(value, forKey: defaultsKey) + case .string(let value): + defaults.set(value, forKey: defaultsKey) + case .stringArray(let value): + defaults.set(value, forKey: defaultsKey) + case .stringDictionary(let value): + defaults.set(value, forKey: defaultsKey) + } + + switch defaultsKey { + case LanguageSettings.languageKey: + let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system + LanguageSettings.apply(language) + case AppIconSettings.modeKey: + AppIconSettings.applyIcon(AppIconSettings.resolvedMode()) + default: + break + } + } + + private func loadBackups() -> [String: BackupValue] { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: Self.backupsDefaultsKey), + let backups = try? JSONDecoder().decode([String: BackupValue].self, from: data) else { + return [:] + } + return backups + } + + private func saveBackups(_ backups: [String: BackupValue]) { + let defaults = UserDefaults.standard + if backups.isEmpty { + defaults.removeObject(forKey: Self.backupsDefaultsKey) + return + } + guard let data = try? JSONEncoder().encode(backups) else { return } + defaults.set(data, forKey: Self.backupsDefaultsKey) + } + + private func logInvalid(_ path: String, sourcePath: String) { + NSLog("[CmuxSettingsFileStore] ignoring invalid setting '%@' in %@", path, sourcePath) + } + + private func jsonString(_ rawValue: Any?) -> String? { + rawValue as? String + } + + private func jsonBool(_ rawValue: Any?) -> Bool? { + guard let number = rawValue as? NSNumber else { return nil } + guard CFGetTypeID(number) == CFBooleanGetTypeID() else { return nil } + return number.boolValue + } + + private func jsonInt(_ rawValue: Any?) -> Int? { + guard let number = rawValue as? NSNumber else { return nil } + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + let doubleValue = number.doubleValue + guard doubleValue.rounded() == doubleValue else { return nil } + return number.intValue + } + + private func jsonDouble(_ rawValue: Any?) -> Double? { + guard let number = rawValue as? NSNumber else { return nil } + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + return number.doubleValue + } + + private func jsonStringArray(_ rawValue: Any?) -> [String]? { + guard let values = rawValue as? [Any] else { return nil } + var strings: [String] = [] + strings.reserveCapacity(values.count) + for value in values { + guard let string = value as? String else { return nil } + strings.append(string) + } + return strings + } + + static func defaultTemplate() -> String { + var lines: [String] = [ + "{", + " \"$schema\": \"\(schemaURLString)\",", + " \"schemaVersion\": \(currentSchemaVersion),", + "", + " // This file uses JSON with comments (JSONC).", + " // Uncomment and edit any setting to make it file-managed.", + " // Remove a setting to fall back to the value saved in Settings.", + " // cmux creates this template on launch when both settings file locations are missing.", + " // ~/.config/cmux/settings.json takes precedence over the Application Support fallback.", + "", + ] + + let sections = defaultTemplateSections() + for (index, section) in sections.enumerated() { + lines.append(contentsOf: commentedTemplateLines(for: section)) + if index < sections.count - 1 { + lines.append("") + } + } + + lines.append("}") + return lines.joined(separator: "\n") + "\n" + } + + private static func commentedTemplateLines(for section: [String: Any]) -> [String] { + let json = prettyJSONString(section) + let sectionLines = json + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + guard sectionLines.count >= 2 else { return [] } + + return sectionLines + .dropFirst() + .dropLast() + .enumerated() + .map { index, line in + if index == sectionLines.count - 3 { + return " // \(line)," + } + return " // \(line)" + } + } + + private static func defaultTemplateSections() -> [[String: Any]] { + let shortcutsBindings = Dictionary( + uniqueKeysWithValues: KeyboardShortcutSettings.Action.allCases.map { action in + (action.rawValue, shortcutTemplateValue(action.defaultShortcut, usesNumberedDigits: action.usesNumberedDigitMatching)) + } + ) + + return [ + [ + "app": [ + "language": LanguageSettings.defaultLanguage.rawValue, + "appearance": AppearanceSettings.defaultMode.rawValue, + "appIcon": AppIconSettings.defaultMode.rawValue, + "newWorkspacePlacement": WorkspacePlacementSettings.defaultPlacement.rawValue, + "minimalMode": false, + "keepWorkspaceOpenWhenClosingLastSurface": !LastSurfaceCloseShortcutSettings.defaultValue, + "focusPaneOnFirstClick": PaneFirstClickFocusSettings.defaultEnabled, + "preferredEditor": "", + "reorderOnNotification": WorkspaceAutoReorderSettings.defaultValue, + "sendAnonymousTelemetry": TelemetrySettings.defaultSendAnonymousTelemetry, + "warnBeforeQuit": QuitWarningSettings.defaultWarnBeforeQuit, + "renameSelectsExistingName": CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus, + "commandPaletteSearchesAllSurfaces": CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces, + ], + ], + [ + "notifications": [ + "dockBadge": NotificationBadgeSettings.defaultDockBadgeEnabled, + "showInMenuBar": MenuBarExtraSettings.defaultShowInMenuBar, + "unreadPaneRing": NotificationPaneRingSettings.defaultEnabled, + "paneFlash": NotificationPaneFlashSettings.defaultEnabled, + "sound": NotificationSoundSettings.defaultValue, + "customSoundFilePath": NotificationSoundSettings.defaultCustomFilePath, + "command": NotificationSoundSettings.defaultCustomCommand, + ], + ], + [ + "sidebar": [ + "hideAllDetails": SidebarWorkspaceDetailSettings.defaultHideAllDetails, + "branchLayout": SidebarBranchLayoutSettings.defaultVerticalLayout ? "vertical" : "inline", + "showNotificationMessage": SidebarWorkspaceDetailSettings.defaultShowNotificationMessage, + "showBranchDirectory": true, + "showPullRequests": true, + "openPullRequestLinksInCmuxBrowser": BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser, + "openPortLinksInCmuxBrowser": BrowserLinkOpenSettings.defaultOpenSidebarPortLinksInCmuxBrowser, + "showSSH": true, + "showPorts": true, + "showLog": true, + "showProgress": true, + "showCustomMetadata": true, + ], + ], + [ + "workspaceColors": [ + "indicatorStyle": SidebarActiveTabIndicatorSettings.defaultStyle.rawValue, + "selectionColor": NSNull(), + "notificationBadgeColor": NSNull(), + "colors": Dictionary( + uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) } + ), + ], + ], + [ + "sidebarAppearance": [ + "matchTerminalBackground": false, + "tintColor": SidebarTintDefaults.hex, + "lightModeTintColor": NSNull(), + "darkModeTintColor": NSNull(), + "tintOpacity": SidebarTintDefaults.opacity, + ], + ], + [ + "automation": [ + "socketControlMode": SocketControlSettings.defaultMode.rawValue, + "socket" + "Password": "", + "claudeCodeIntegration": ClaudeCodeIntegrationSettings.defaultHooksEnabled, + "claudeBinaryPath": "", + "portBase": 9100, + "portRange": 10, + ], + ], + [ + "customCommands": [ + "trustedDirectories": [String](), + ], + ], + [ + "browser": [ + "defaultSearchEngine": BrowserSearchSettings.defaultSearchEngine.rawValue, + "showSearchSuggestions": BrowserSearchSettings.defaultSearchSuggestionsEnabled, + "theme": BrowserThemeSettings.defaultMode.rawValue, + "openTerminalLinksInCmuxBrowser": BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser, + "interceptTerminalOpenCommandInCmuxBrowser": BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser, + "hostsToOpenInEmbeddedBrowser": [String](), + "urlsToAlwaysOpenExternally": [String](), + "insecureHttpHostsAllowedInEmbeddedBrowser": BrowserInsecureHTTPSettings.defaultAllowlistPatterns, + "showImportHintOnBlankTabs": BrowserImportHintSettings.defaultShowOnBlankTabs, + "reactGrabVersion": ReactGrabSettings.defaultVersion, + ], + ], + [ + "shortcuts": [ + "showModifierHoldHints": ShortcutHintDebugSettings.defaultShowHintsOnCommandHold, + "bindings": shortcutsBindings, + ], + ], + ] + } + + private static func shortcutTemplateValue( + _ shortcut: StoredShortcut, + usesNumberedDigits: Bool + ) -> Any { + let defaultShortcut = usesNumberedDigits ? (shortcut.secondStroke ?? shortcut.firstStroke) : nil + let rendered = renderShortcutStroke(shortcut.firstStroke, preserveDigit: !usesNumberedDigits) + if let secondStroke = shortcut.secondStroke { + return [rendered, renderShortcutStroke(secondStroke, preserveDigit: true)] + } + if let defaultShortcut { + return renderShortcutStroke(defaultShortcut, preserveDigit: true) + } + return rendered + } + + private static func renderShortcutStroke(_ stroke: ShortcutStroke, preserveDigit: Bool) -> String { + var parts: [String] = [] + if stroke.command { parts.append("cmd") } + if stroke.shift { parts.append("shift") } + if stroke.option { parts.append("opt") } + if stroke.control { parts.append("ctrl") } + parts.append(renderShortcutKey(stroke.key, preserveDigit: preserveDigit)) + return parts.joined(separator: "+") + } + + private static func renderShortcutKey(_ key: String, preserveDigit: Bool) -> String { + if preserveDigit { + return key + } + return key + } + + private static func prettyJSONString(_ value: Any) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), + let string = String(data: data, encoding: .utf8) else { + return "{}" + } + return string + } +} + +typealias KeyboardShortcutSettingsFileStore = CmuxSettingsFileStore + +private struct ResolvedSettingsSnapshot { + var path: String? + var shortcuts: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + var managedUserDefaults: [String: ManagedSettingsValue] = [:] + var managedCustomSettings = ManagedCustomSettings() +} + +private enum ManagedStringOverride: Equatable { + case set(String) + case clear +} + +private struct ManagedCustomSettings: Equatable { + var trustedDirectories: [String]? + var socketPassphrase: ManagedStringOverride? + + var isEmpty: Bool { + trustedDirectories == nil && socketPassphrase == nil + } + + var managedIdentifiers: Set<String> { + var identifiers: Set<String> = [] + if trustedDirectories != nil { + identifiers.insert(CmuxSettingsFileStore.trustedDirectoriesBackupIdentifier) + } + if socketPassphrase != nil { + identifiers.insert(CmuxSettingsFileStore.socketPassphraseBackupIdentifier) + } + return identifiers + } +} + +private enum ManagedSettingsValue: Equatable { + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case nullableString(String?) + case stringArray([String]) + case stringDictionary([String: String]) +} + +private enum BackupValue: Codable, Equatable { + case absent + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case stringArray([String]) + case stringDictionary([String: String]) + + private enum Kind: String, Codable { + case absent + case bool + case int + case double + case string + case stringArray + case stringDictionary + } + + private enum CodingKeys: String, CodingKey { + case kind + case boolValue + case intValue + case doubleValue + case stringValue + case stringArrayValue + case stringDictionaryValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .absent: + self = .absent + case .bool: + self = .bool(try container.decode(Bool.self, forKey: .boolValue)) + case .int: + self = .int(try container.decode(Int.self, forKey: .intValue)) + case .double: + self = .double(try container.decode(Double.self, forKey: .doubleValue)) + case .string: + self = .string(try container.decode(String.self, forKey: .stringValue)) + case .stringArray: + self = .stringArray(try container.decode([String].self, forKey: .stringArrayValue)) + case .stringDictionary: + self = .stringDictionary(try container.decode([String: String].self, forKey: .stringDictionaryValue)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .absent: + try container.encode(Kind.absent, forKey: .kind) + case .bool(let value): + try container.encode(Kind.bool, forKey: .kind) + try container.encode(value, forKey: .boolValue) + case .int(let value): + try container.encode(Kind.int, forKey: .kind) + try container.encode(value, forKey: .intValue) + case .double(let value): + try container.encode(Kind.double, forKey: .kind) + try container.encode(value, forKey: .doubleValue) + case .string(let value): + try container.encode(Kind.string, forKey: .kind) + try container.encode(value, forKey: .stringValue) + case .stringArray(let value): + try container.encode(Kind.stringArray, forKey: .kind) + try container.encode(value, forKey: .stringArrayValue) + case .stringDictionary(let value): + try container.encode(Kind.stringDictionary, forKey: .kind) + try container.encode(value, forKey: .stringDictionaryValue) + } + } +} + +private enum JSONCParser { + static func preprocess(data: Data) throws -> Data { + guard let source = String(data: data, encoding: .utf8) else { + throw JSONCError.invalidUTF8 + } + let withoutBOM = source.hasPrefix("\u{feff}") ? String(source.dropFirst()) : source + let stripped = stripComments(from: withoutBOM) + let normalized = stripTrailingCommas(from: stripped) + return Data(normalized.utf8) + } + + private static func stripComments(from source: String) -> String { + var result = "" + var index = source.startIndex + var inString = false + var isEscaped = false + + while index < source.endIndex { + let character = source[index] + + if inString { + result.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + index = source.index(after: index) + continue + } + + if character == "\"" { + inString = true + result.append(character) + index = source.index(after: index) + continue + } + + if character == "/" { + let nextIndex = source.index(after: index) + if nextIndex < source.endIndex { + let next = source[nextIndex] + if next == "/" { + index = source.index(after: nextIndex) + while index < source.endIndex && source[index] != "\n" { + index = source.index(after: index) + } + continue + } + if next == "*" { + index = source.index(after: nextIndex) + while index < source.endIndex { + let current = source[index] + let followingIndex = source.index(after: index) + if current == "*" && followingIndex < source.endIndex && source[followingIndex] == "/" { + index = source.index(after: followingIndex) + break + } + index = followingIndex + } + continue + } + } + } + + result.append(character) + index = source.index(after: index) + } + + return result + } + + private static func stripTrailingCommas(from source: String) -> String { + var result = "" + var index = source.startIndex + var inString = false + var isEscaped = false + + while index < source.endIndex { + let character = source[index] + + if inString { + result.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + index = source.index(after: index) + continue + } + + if character == "\"" { + inString = true + result.append(character) + index = source.index(after: index) + continue + } + + if character == "," { + var lookahead = source.index(after: index) + while lookahead < source.endIndex && source[lookahead].isWhitespace { + lookahead = source.index(after: lookahead) + } + if lookahead < source.endIndex && (source[lookahead] == "}" || source[lookahead] == "]") { + index = source.index(after: index) + continue + } + } + + result.append(character) + index = source.index(after: index) + } + + return result + } + + private enum JSONCError: Error { + case invalidUTF8 + } +} + +private final class ShortcutSettingsFileWatcher { + private let path: String + private let fileManager: FileManager + private let onChange: () -> Void + private let watchQueue = DispatchQueue(label: "com.cmux.shortcut-settings-file-watch") + + private var source: DispatchSourceFileSystemObject? + + init(path: String, fileManager: FileManager = .default, onChange: @escaping () -> Void) { + self.path = path + self.fileManager = fileManager + self.onChange = onChange + start() + } + + func stop() { + source?.cancel() + source = nil + } + + private func start() { + stop() + + if fileManager.fileExists(atPath: path) { + startFileWatcher() + } else { + startDirectoryWatcher() + } + } + + private func startFileWatcher() { + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { + startDirectoryWatcher() + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename, .extend], + queue: watchQueue + ) + + source.setEventHandler { [weak self] in + guard let self else { return } + let flags = source.data + if flags.contains(.delete) || flags.contains(.rename) { + self.start() + } + self.onChange() + } + + source.setCancelHandler { + close(fd) + } + + self.source = source + source.resume() + } + + private func startDirectoryWatcher() { + let directoryPath = (path as NSString).deletingLastPathComponent + let fd = open(directoryPath, O_EVTONLY) + guard fd >= 0 else { return } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .rename, .delete], + queue: watchQueue + ) + + source.setEventHandler { [weak self] in + guard let self else { return } + if self.fileManager.fileExists(atPath: self.path) { + self.start() + } else { + self.onChange() + } + } + + source.setCancelHandler { + close(fd) + } + + self.source = source + source.resume() + } +} diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 688b90e989c2..eac4a62a683a 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -6,7 +6,7 @@ struct NotificationsPage: View { @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection @FocusState private var focusedNotificationId: UUID? - @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared var body: some View { VStack(spacing: 0) { @@ -139,27 +139,17 @@ struct NotificationsPage: View { } private var jumpToUnreadShortcut: StoredShortcut { - decodeShortcut( - from: jumpToUnreadShortcutData, - fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut - ) - } - - private var hasUnreadNotifications: Bool { - notificationStore.notifications.contains(where: { !$0.isRead }) - } - - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .jumpToUnread) } private func tabTitle(for tabId: UUID) -> String? { AppDelegate.shared?.tabTitle(for: tabId) ?? tabManager.tabs.first(where: { $0.id == tabId })?.title } + + private var hasUnreadNotifications: Bool { + notificationStore.notifications.contains(where: { !$0.isRead }) + } } struct ShortcutAnnotation: View { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f34b067e94f7..1d31fddea099 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -297,8 +297,7 @@ struct BrowserPanelView: View { @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed - @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) - private var toggleBrowserDeveloperToolsShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var suggestionTask: Task<Void, Never>? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @@ -323,7 +322,6 @@ struct BrowserPanelView: View { for: .light, themeBackgroundColor: GhosttyBackgroundTheme.currentColor() ) - @State private var toggleBrowserDeveloperToolsShortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut // Keep this below half of the compact omnibar height so it reads as a squircle, // not a capsule. private let omnibarPillCornerRadius: CGFloat = 10 @@ -414,7 +412,8 @@ struct BrowserPanelView: View { private var developerToolsButtonHelp: String { let base = String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools") - return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))" + let _ = keyboardShortcutSettingsObserver.revision + return "\(base) (\(KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools).displayString))" } private var browserImportHintSummary: String { @@ -533,7 +532,6 @@ struct BrowserPanelView: View { BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, ]) refreshBrowserChromeStyle() - refreshToggleBrowserDeveloperToolsShortcut() let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) if browserThemeModeRaw != resolvedThemeMode.rawValue { browserThemeModeRaw = resolvedThemeMode.rawValue @@ -597,9 +595,6 @@ struct BrowserPanelView: View { refreshBrowserChromeStyle() panel.refreshAppearanceDrivenColors() } - .onChange(of: toggleBrowserDeveloperToolsShortcutData) { _ in - refreshToggleBrowserDeveloperToolsShortcut() - } .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in applyPendingAddressBarFocusRequestIfNeeded() } @@ -1219,21 +1214,6 @@ struct BrowserPanelView: View { ) } - private func refreshToggleBrowserDeveloperToolsShortcut() { - toggleBrowserDeveloperToolsShortcut = decodeShortcut( - from: toggleBrowserDeveloperToolsShortcutData, - fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut - ) - } - - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut - } - private func syncWebViewResponderPolicyWithViewState( reason: String, isPanelFocusedOverride: Bool? = nil diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index 5de6e896d626..44611f3e6c92 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -17,6 +17,10 @@ final class PortScanner: @unchecked Sendable { /// Callback delivers `(workspaceId, panelId, ports)` on the main actor. var onPortsUpdated: (@MainActor (_ workspaceId: UUID, _ panelId: UUID, _ ports: [Int]) -> Void)? + /// Callback delivers workspace-scoped ports owned by tracked agents. + var onAgentPortsUpdated: (@MainActor (_ workspaceId: UUID, _ ports: [Int]) -> Void)? + /// Provider returns tracked agent root PIDs for the given workspaces. + var agentPIDsProvider: (@MainActor (_ workspaceIds: Set<UUID>) -> [UUID: Set<Int>])? // MARK: - State (all guarded by `queue`) @@ -25,6 +29,12 @@ final class PortScanner: @unchecked Sendable { /// TTY name per (workspace, panel). private var ttyNames: [PanelKey: String] = [:] + /// Monotonic revision per workspace for tracked agent PID changes. + private var agentRevisionByWorkspace: [UUID: UInt64] = [:] + + /// Workspaces with active agent PID tracking that need background rescans. + private var trackedAgentWorkspaces: Set<UUID> = [] + /// Panels that requested a scan since the last coalesce snapshot. private var pendingKicks: Set<PanelKey> = [] @@ -34,14 +44,18 @@ final class PortScanner: @unchecked Sendable { /// Coalesce timer (200ms after first kick). private var coalesceTimer: DispatchSourceTimer? + /// Periodic timer for agent-owned process trees that aren't attached to a TTY. + private var agentScanTimer: DispatchSourceTimer? + /// Burst scan offsets in seconds from the start of the burst. /// Each scan fires at this absolute offset; the recursive scheduler /// converts to relative delays between consecutive scans. private static let burstOffsets: [Double] = [0.5, 1.5, 3, 5, 7.5, 10] + private static let agentRescanInterval: TimeInterval = 2 // MARK: - Public API - struct PanelKey: Hashable { + struct PanelKey: Hashable, Sendable { let workspaceId: UUID let panelId: UUID } @@ -75,6 +89,12 @@ final class PortScanner: @unchecked Sendable { } } + func refreshAgentPorts(workspaceId: UUID, agentPIDs: Set<Int>) { + queue.async { [self] in + refreshAgentPortsLocked(workspaceId: workspaceId, agentPIDs: agentPIDs) + } + } + // MARK: - Coalesce + Burst private func startCoalesce() { @@ -125,9 +145,9 @@ final class PortScanner: @unchecked Sendable { // Already on `queue`. Snapshot which panels to scan and their TTYs. // We scan all registered panels, not just pending ones, since ports can // appear/disappear on any panel. - let snapshot = ttyNames + let panelSnapshot = ttyNames - guard !snapshot.isEmpty else { + guard !panelSnapshot.isEmpty else { pendingKicks.removeAll() return } @@ -135,22 +155,63 @@ final class PortScanner: @unchecked Sendable { // Clear pending kicks — they're accounted for in this scan. pendingKicks.removeAll() + let workspaceIds = Set(panelSnapshot.keys.map(\.workspaceId)) + let agentRevisions = agentRevisionSnapshot(for: workspaceIds) + guard let agentPIDsProvider, !workspaceIds.isEmpty else { + finishScan( + panelSnapshot: panelSnapshot, + agentPIDsByWorkspace: [:], + agentRevisions: agentRevisions + ) + return + } + + Task { [weak self] in + guard let self else { return } + let agentPIDsByWorkspace = await MainActor.run { + agentPIDsProvider(workspaceIds) + } + self.queue.async { [weak self] in + self?.finishScan( + panelSnapshot: panelSnapshot, + agentPIDsByWorkspace: agentPIDsByWorkspace, + agentRevisions: agentRevisions + ) + } + } + } + + private func finishScan( + panelSnapshot: [PanelKey: String], + agentPIDsByWorkspace: [UUID: Set<Int>], + agentRevisions: [UUID: UInt64] + ) { + // Already on `queue`. + let workspaceIds = Set(panelSnapshot.keys.map(\.workspaceId)) + // Build TTY set (deduplicated). - let uniqueTTYs = Set(snapshot.values) + let uniqueTTYs = Set(panelSnapshot.values) let ttyList = uniqueTTYs.joined(separator: ",") // 1. ps -t tty1,tty2,... -o pid=,tty= - let pidToTTY = runPS(ttyList: ttyList) - guard !pidToTTY.isEmpty else { - // No processes on any TTY — clear ports for all panels. - let results = snapshot.map { ($0.key, [Int]()) } - deliverResults(results) + let pidToTTY = ttyList.isEmpty ? [:] : runPS(ttyList: ttyList) + let agentPidToWorkspaces = expandAgentProcessTree(agentPIDsByWorkspace: agentPIDsByWorkspace) + + let allPids = Set(pidToTTY.keys).union(agentPidToWorkspaces.keys) + guard !allPids.isEmpty else { + let panelResults = panelSnapshot.map { ($0.key, [Int]()) } + deliverResults( + panelResults, + workspaceIds: workspaceIds, + agentPortsByWorkspace: [:], + agentRevisions: agentRevisions + ) return } // 2. lsof -nP -a -p <all_pids> -iTCP -sTCP:LISTEN -F pn - let allPids = pidToTTY.keys.sorted().map(String.init).joined(separator: ",") - let pidToPorts = runLsof(pidsCsv: allPids) + let pidsCsv = allPids.sorted().map(String.init).joined(separator: ",") + let pidToPorts = runLsof(pidsCsv: pidsCsv) // 3. Join: PID→TTY + PID→ports → TTY→ports var portsByTTY: [String: Set<Int>] = [:] @@ -159,27 +220,275 @@ final class PortScanner: @unchecked Sendable { portsByTTY[tty, default: []].formUnion(ports) } + var agentPortsByWorkspace: [UUID: Set<Int>] = [:] + for (pid, ports) in pidToPorts { + guard let workspaceIdsForPid = agentPidToWorkspaces[pid] else { continue } + for workspaceId in workspaceIdsForPid { + agentPortsByWorkspace[workspaceId, default: []].formUnion(ports) + } + } + // 4. Map to per-panel port lists. var results: [(PanelKey, [Int])] = [] - for (key, tty) in snapshot { + for (key, tty) in panelSnapshot { let ports = portsByTTY[tty].map { Array($0).sorted() } ?? [] results.append((key, ports)) } - deliverResults(results) + deliverResults( + results, + workspaceIds: workspaceIds, + agentPortsByWorkspace: agentPortsByWorkspace, + agentRevisions: agentRevisions + ) } - private func deliverResults(_ results: [(PanelKey, [Int])]) { - guard let callback = onPortsUpdated else { return } - Task { @MainActor in - for (key, ports) in results { - callback(key.workspaceId, key.panelId, ports) + private func refreshAgentPortsLocked(workspaceId: UUID, agentPIDs: Set<Int>) { + let agentRevision = nextAgentRevision(for: workspaceId) + let normalizedPIDs = Set(agentPIDs.filter { $0 > 0 }) + if normalizedPIDs.isEmpty { + trackedAgentWorkspaces.remove(workspaceId) + } else { + trackedAgentWorkspaces.insert(workspaceId) + } + updateAgentScanTimerLocked() + + scanAgentPorts( + workspaceIds: [workspaceId], + agentPIDsByWorkspace: normalizedPIDs.isEmpty ? [:] : [workspaceId: normalizedPIDs], + agentRevisions: [workspaceId: agentRevision] + ) + } + + private func updateAgentScanTimerLocked() { + guard !trackedAgentWorkspaces.isEmpty else { + agentScanTimer?.cancel() + agentScanTimer = nil + return + } + guard agentScanTimer == nil else { return } + + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule( + deadline: .now() + Self.agentRescanInterval, + repeating: Self.agentRescanInterval + ) + timer.setEventHandler { [weak self] in + self?.runTrackedAgentScan() + } + agentScanTimer = timer + timer.resume() + } + + private func runTrackedAgentScan() { + let workspaceIds = trackedAgentWorkspaces + guard !workspaceIds.isEmpty else { + updateAgentScanTimerLocked() + return + } + + let agentRevisions = agentRevisionSnapshot(for: workspaceIds) + guard let agentPIDsProvider else { + trackedAgentWorkspaces.removeAll() + updateAgentScanTimerLocked() + deliverAgentResults( + workspaceIds: workspaceIds, + agentPortsByWorkspace: [:], + agentRevisions: agentRevisions + ) + return + } + + Task { [weak self] in + guard let self else { return } + let agentPIDsByWorkspace = await MainActor.run { + agentPIDsProvider(workspaceIds) + } + self.queue.async { [weak self] in + self?.finishTrackedAgentScan( + workspaceIds: workspaceIds, + agentPIDsByWorkspace: agentPIDsByWorkspace, + agentRevisions: agentRevisions + ) } } } + private func finishTrackedAgentScan( + workspaceIds: Set<UUID>, + agentPIDsByWorkspace: [UUID: Set<Int>], + agentRevisions: [UUID: UInt64] + ) { + let normalizedPIDsByWorkspace = agentPIDsByWorkspace.reduce(into: [UUID: Set<Int>]()) { partial, item in + let valid = Set(item.value.filter { $0 > 0 }) + guard !valid.isEmpty else { return } + partial[item.key] = valid + } + let inactiveWorkspaceIds = workspaceIds.subtracting(normalizedPIDsByWorkspace.keys) + if !inactiveWorkspaceIds.isEmpty { + trackedAgentWorkspaces.subtract(inactiveWorkspaceIds) + updateAgentScanTimerLocked() + } + + scanAgentPorts( + workspaceIds: workspaceIds, + agentPIDsByWorkspace: normalizedPIDsByWorkspace, + agentRevisions: agentRevisions + ) + } + + private func scanAgentPorts( + workspaceIds: Set<UUID>, + agentPIDsByWorkspace: [UUID: Set<Int>], + agentRevisions: [UUID: UInt64] + ) { + guard !workspaceIds.isEmpty else { return } + + let agentPidToWorkspaces = expandAgentProcessTree(agentPIDsByWorkspace: agentPIDsByWorkspace) + guard !agentPidToWorkspaces.isEmpty else { + deliverAgentResults( + workspaceIds: workspaceIds, + agentPortsByWorkspace: [:], + agentRevisions: agentRevisions + ) + return + } + + let pidsCsv = agentPidToWorkspaces.keys.sorted().map(String.init).joined(separator: ",") + let pidToPorts = runLsof(pidsCsv: pidsCsv) + var agentPortsByWorkspace: [UUID: Set<Int>] = [:] + for (pid, ports) in pidToPorts { + guard let workspaceIdsForPid = agentPidToWorkspaces[pid] else { continue } + for targetWorkspaceId in workspaceIdsForPid { + agentPortsByWorkspace[targetWorkspaceId, default: []].formUnion(ports) + } + } + + deliverAgentResults( + workspaceIds: workspaceIds, + agentPortsByWorkspace: agentPortsByWorkspace, + agentRevisions: agentRevisions + ) + } + + private func deliverResults( + _ panelResults: [(PanelKey, [Int])], + workspaceIds: Set<UUID>, + agentPortsByWorkspace: [UUID: Set<Int>], + agentRevisions: [UUID: UInt64] + ) { + let panelCallback = onPortsUpdated + if let panelCallback { + Task { @MainActor in + for (key, ports) in panelResults { + panelCallback(key.workspaceId, key.panelId, ports) + } + } + } + deliverAgentResults( + workspaceIds: workspaceIds, + agentPortsByWorkspace: agentPortsByWorkspace, + agentRevisions: agentRevisions + ) + } + + private func deliverAgentResults( + workspaceIds: Set<UUID>, + agentPortsByWorkspace: [UUID: Set<Int>], + agentRevisions: [UUID: UInt64] + ) { + guard let agentCallback = onAgentPortsUpdated else { return } + Task { [weak self] in + guard let self else { return } + let validatedResults = await self.validatedAgentResults( + workspaceIds: workspaceIds, + agentPortsByWorkspace: agentPortsByWorkspace, + agentRevisions: agentRevisions + ) + guard !validatedResults.isEmpty else { return } + await MainActor.run { + for (workspaceId, ports) in validatedResults { + agentCallback(workspaceId, ports) + } + } + } + } + + private func validatedAgentResults( + workspaceIds: Set<UUID>, + agentPortsByWorkspace: [UUID: Set<Int>], + agentRevisions: [UUID: UInt64] + ) async -> [(UUID, [Int])] { + await withCheckedContinuation { continuation in + queue.async { [self] in + var results: [(UUID, [Int])] = [] + for workspaceId in workspaceIds.sorted(by: { $0.uuidString < $1.uuidString }) { + let currentRevision = agentRevisionByWorkspace[workspaceId, default: 0] + let expectedRevision = agentRevisions[workspaceId, default: 0] + guard currentRevision == expectedRevision else { continue } + let ports = Array(agentPortsByWorkspace[workspaceId] ?? []).sorted() + results.append((workspaceId, ports)) + } + continuation.resume(returning: results) + } + } + } + + private func agentRevisionSnapshot(for workspaceIds: Set<UUID>) -> [UUID: UInt64] { + workspaceIds.reduce(into: [UUID: UInt64]()) { partial, workspaceId in + partial[workspaceId] = agentRevisionByWorkspace[workspaceId, default: 0] + } + } + + private func nextAgentRevision(for workspaceId: UUID) -> UInt64 { + let nextRevision = agentRevisionByWorkspace[workspaceId, default: 0] &+ 1 + agentRevisionByWorkspace[workspaceId] = nextRevision + return nextRevision + } + // MARK: - Process helpers + private func expandAgentProcessTree(agentPIDsByWorkspace: [UUID: Set<Int>]) -> [Int: Set<UUID>] { + let normalizedRoots = agentPIDsByWorkspace.reduce(into: [UUID: Set<Int>]()) { partial, item in + let valid = Set(item.value.filter { $0 > 0 }) + guard !valid.isEmpty else { return } + partial[item.key] = valid + } + guard !normalizedRoots.isEmpty else { return [:] } + + var pidToWorkspaces: [Int: Set<UUID>] = [:] + var queue: [(pid: Int, workspaceId: UUID)] = [] + for (workspaceId, roots) in normalizedRoots { + for pid in roots { + if pidToWorkspaces[pid, default: []].insert(workspaceId).inserted { + queue.append((pid, workspaceId)) + } + } + } + + let parentByPid = runAllProcesses() + guard !parentByPid.isEmpty else { return pidToWorkspaces } + + var childrenByParent: [Int: [Int]] = [:] + for (pid, parentPid) in parentByPid { + childrenByParent[parentPid, default: []].append(pid) + } + + var index = 0 + while index < queue.count { + let (pid, workspaceId) = queue[index] + index += 1 + + for childPid in childrenByParent[pid] ?? [] { + if pidToWorkspaces[childPid, default: []].insert(workspaceId).inserted { + queue.append((childPid, workspaceId)) + } + } + } + + return pidToWorkspaces + } + private func runPS(ttyList: String) -> [Int: String] { // `ps -t tty1,tty2,... -o pid=,tty=` — targeted scan, much cheaper than -ax. let process = Process() @@ -210,6 +519,36 @@ final class PortScanner: @unchecked Sendable { return mapping } + private func runAllProcesses() -> [Int: Int] { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-ax", "-o", "pid=,ppid="] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return [:] + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard let output = String(data: data, encoding: .utf8) else { return [:] } + + var mapping: [Int: Int] = [:] + for line in output.split(separator: "\n") { + let parts = line.split(whereSeparator: \.isWhitespace) + guard parts.count >= 2, + let pid = Int(parts[0]), + let parentPid = Int(parts[1]) else { continue } + mapping[pid] = parentPid + } + return mapping + } + private func runLsof(pidsCsv: String) -> [Int: Set<Int>] { // `lsof -nP -a -p <pids> -iTCP -sTCP:LISTEN -F pn` let process = Process() diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 188833d40298..a6498b8bec1c 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -330,6 +330,7 @@ indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { struct SessionWorkspaceSnapshot: Codable, Sendable { var processTitle: String var customTitle: String? + var customDescription: String? var customColor: String? var isPinned: Bool var currentDirectory: String diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 7290807ca6e2..62a3292a320f 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -61,6 +61,7 @@ enum SocketControlMode: String, CaseIterable, Identifiable { enum SocketControlPasswordStore { static let directoryName = "cmux" static let fileName = "socket-control-password" + static let didChangeNotification = Notification.Name("cmux.socketControlPasswordDidChange") private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion" private static let keychainMigrationVersion = 1 private static let legacyKeychainService = "com.cmuxterm.app.socket-control" @@ -194,6 +195,7 @@ enum SocketControlPasswordStore { let data = Data(normalized.utf8) try data.write(to: fileURL, options: .atomic) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + NotificationCenter.default.post(name: didChangeNotification, object: nil) } static func clearPassword(fileURL: URL? = nil) throws { @@ -204,6 +206,7 @@ enum SocketControlPasswordStore { return } try FileManager.default.removeItem(at: fileURL) + NotificationCenter.default.post(name: didChangeNotification, object: nil) } static func resetLazyKeychainFallbackCacheForTests() { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f88c0d6bf2e8..2adab4308fc9 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -803,13 +803,14 @@ struct WorkspaceTabColorEntry: Equatable, Identifiable { let name: String let hex: String - var id: String { "\(name)-\(hex)" } + var id: String { name } } enum WorkspaceTabColorSettings { - static let defaultOverridesKey = "workspaceTabColor.defaultOverrides" - static let customColorsKey = "workspaceTabColor.customColors" - static let maxCustomColors = 24 + static let paletteKey = "workspaceTabColor.colors" + + private static let legacyDefaultOverridesKey = "workspaceTabColor.defaultOverrides" + private static let legacyCustomColorsKey = "workspaceTabColor.customColors" private static let originalPRPalette: [WorkspaceTabColorEntry] = [ WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"), @@ -835,93 +836,88 @@ enum WorkspaceTabColorSettings { } static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { - defaultPaletteWithOverrides(defaults: defaults) + customColorEntries(defaults: defaults) + let paletteMap = effectivePaletteMap(defaults: defaults) + let builtInOrder = defaultPalette.compactMap { entry -> WorkspaceTabColorEntry? in + guard let hex = paletteMap[entry.name] else { return nil } + return WorkspaceTabColorEntry(name: entry.name, hex: hex) + } + let builtInNames = Set(defaultPalette.map(\.name)) + let customEntries = paletteMap + .filter { !builtInNames.contains($0.key) } + .sorted { lhs, rhs in + lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending + } + .map { WorkspaceTabColorEntry(name: $0.key, hex: $0.value) } + return builtInOrder + customEntries } - static func defaultPaletteWithOverrides(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { - let palette = defaultPalette - let overrides = defaultOverrideMap(defaults: defaults) - return palette.map { entry in - WorkspaceTabColorEntry(name: entry.name, hex: overrides[entry.name] ?? entry.hex) - } + static func customPaletteEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + let builtInNames = Set(defaultPalette.map(\.name)) + return palette(defaults: defaults).filter { !builtInNames.contains($0.name) } } - static func defaultColorHex(named name: String, defaults: UserDefaults = .standard) -> String { - let palette = defaultPalette - guard let entry = palette.first(where: { $0.name == name }) else { - return palette.first?.hex ?? "#1565C0" - } - return defaultOverrideMap(defaults: defaults)[name] ?? entry.hex + static func defaultColorHex(named name: String) -> String? { + defaultPalette.first(where: { $0.name == name })?.hex } - static func setDefaultColor(named name: String, hex: String, defaults: UserDefaults = .standard) { - let palette = defaultPalette - guard let entry = palette.first(where: { $0.name == name }), - let normalized = normalizedHex(hex) else { return } + static func currentColorHex(named name: String, defaults: UserDefaults = .standard) -> String? { + effectivePaletteMap(defaults: defaults)[name] + } - var overrides = defaultOverrideMap(defaults: defaults) - if normalized == entry.hex { - overrides.removeValue(forKey: name) - } else { - overrides[name] = normalized - } - saveDefaultOverrideMap(overrides, defaults: defaults) + static func setColor(named name: String, hex: String, defaults: UserDefaults = .standard) { + guard let normalizedName = normalizedColorName(name), + let normalizedHex = normalizedHex(hex) else { return } + + var palette = editablePaletteMap(defaults: defaults) + palette[normalizedName] = normalizedHex + persistPaletteMap(palette, defaults: defaults) } - static func customColors(defaults: UserDefaults = .standard) -> [String] { - guard let raw = defaults.array(forKey: customColorsKey) as? [String] else { return [] } - var result: [String] = [] - var seen: Set<String> = [] - for value in raw { - guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } - result.append(normalized) - if result.count >= maxCustomColors { break } - } - return result + static func removeColor(named name: String, defaults: UserDefaults = .standard) { + guard let normalizedName = normalizedColorName(name) else { return } + var palette = editablePaletteMap(defaults: defaults) + palette.removeValue(forKey: normalizedName) + persistPaletteMap(palette, defaults: defaults) } - static func customColorEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { - customColors(defaults: defaults).enumerated().map { index, hex in - WorkspaceTabColorEntry(name: "Custom \(index + 1)", hex: hex) + static func persistPaletteMap(_ rawPalette: [String: String], defaults: UserDefaults = .standard) { + let normalizedPalette = normalizedPaletteMap(rawPalette) + if normalizedPalette == defaultPaletteMap { + defaults.removeObject(forKey: paletteKey) + } else { + defaults.set(normalizedPalette, forKey: paletteKey) } + defaults.removeObject(forKey: legacyDefaultOverridesKey) + defaults.removeObject(forKey: legacyCustomColorsKey) } - @discardableResult - static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? { - guard let normalized = normalizedHex(hex) else { return nil } - var colors = customColors(defaults: defaults) - colors.removeAll { $0 == normalized } - colors.insert(normalized, at: 0) - setCustomColors(colors, defaults: defaults) - return normalized + static func backupPaletteMap(defaults: UserDefaults = .standard) -> [String: String]? { + if let stored = storedPaletteMap(defaults: defaults) { + return stored + } + return legacyPaletteMap(defaults: defaults) } - static func removeCustomColor(_ hex: String, defaults: UserDefaults = .standard) { - guard let normalized = normalizedHex(hex) else { return } - var colors = customColors(defaults: defaults) - colors.removeAll { $0 == normalized } - setCustomColors(colors, defaults: defaults) + static func resolvedPaletteMap(defaults: UserDefaults = .standard) -> [String: String] { + effectivePaletteMap(defaults: defaults) } - static func setCustomColors(_ hexes: [String], defaults: UserDefaults = .standard) { - var normalizedColors: [String] = [] - var seen: Set<String> = [] - for value in hexes { - guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } - normalizedColors.append(normalized) - if normalizedColors.count >= maxCustomColors { break } + static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? { + guard let normalized = normalizedHex(hex) else { return nil } + var palette = editablePaletteMap(defaults: defaults) + if palette.contains(where: { $0.value == normalized }) { + return normalized } - if normalizedColors.isEmpty { - defaults.removeObject(forKey: customColorsKey) - } else { - defaults.set(normalizedColors, forKey: customColorsKey) - } + palette[nextCustomColorName(existingNames: Set(palette.keys))] = normalized + persistPaletteMap(palette, defaults: defaults) + return normalized } static func reset(defaults: UserDefaults = .standard) { - defaults.removeObject(forKey: defaultOverridesKey) - defaults.removeObject(forKey: customColorsKey) + defaults.removeObject(forKey: paletteKey) + defaults.removeObject(forKey: legacyDefaultOverridesKey) + defaults.removeObject(forKey: legacyCustomColorsKey) } static func normalizedHex(_ raw: String) -> String? { @@ -960,23 +956,95 @@ enum WorkspaceTabColorSettings { return baseColor } - private static func defaultOverrideMap(defaults: UserDefaults) -> [String: String] { - guard let raw = defaults.dictionary(forKey: defaultOverridesKey) as? [String: String] else { return [:] } - let validNames = Set(defaultPalette.map(\.name)) + private static func effectivePaletteMap(defaults: UserDefaults) -> [String: String] { + if let stored = storedPaletteMap(defaults: defaults) { + return stored + } + if let legacy = legacyPaletteMap(defaults: defaults) { + return legacy + } + return defaultPaletteMap + } + + private static func editablePaletteMap(defaults: UserDefaults) -> [String: String] { + if let stored = storedPaletteMap(defaults: defaults) { + return stored + } + if let legacy = legacyPaletteMap(defaults: defaults) { + return legacy + } + return defaultPaletteMap + } + + private static func storedPaletteMap(defaults: UserDefaults) -> [String: String]? { + guard let raw = defaults.dictionary(forKey: paletteKey) as? [String: String] else { return nil } + return normalizedPaletteMap(raw) + } + + private static func legacyPaletteMap(defaults: UserDefaults) -> [String: String]? { + let hasLegacyOverrides = defaults.object(forKey: legacyDefaultOverridesKey) != nil + let hasLegacyCustomColors = defaults.object(forKey: legacyCustomColorsKey) != nil + guard hasLegacyOverrides || hasLegacyCustomColors else { return nil } + + var palette = defaultPaletteMap + + if let rawOverrides = defaults.dictionary(forKey: legacyDefaultOverridesKey) as? [String: String] { + let validNames = Set(defaultPalette.map(\.name)) + for (name, hex) in rawOverrides { + guard validNames.contains(name), + let normalized = normalizedHex(hex) else { continue } + palette[name] = normalized + } + } + + if let rawCustomColors = defaults.array(forKey: legacyCustomColorsKey) as? [String] { + var index = 1 + var seenCustomHexes: Set<String> = [] + for rawHex in rawCustomColors { + guard let normalized = normalizedHex(rawHex), + seenCustomHexes.insert(normalized).inserted else { continue } + let name = nextCustomColorName( + existingNames: Set(palette.keys), + startingAt: index + ) + palette[name] = normalized + index += 1 + } + } + + return palette + } + + private static func normalizedPaletteMap(_ rawPalette: [String: String]) -> [String: String] { var normalized: [String: String] = [:] - for (name, hex) in raw { - guard validNames.contains(name), - let normalizedHex = normalizedHex(hex) else { continue } - normalized[name] = normalizedHex + for (rawName, rawHex) in rawPalette { + guard let name = normalizedColorName(rawName), + let hex = normalizedHex(rawHex) else { continue } + normalized[name] = hex } return normalized } - private static func saveDefaultOverrideMap(_ map: [String: String], defaults: UserDefaults) { - if map.isEmpty { - defaults.removeObject(forKey: defaultOverridesKey) - } else { - defaults.set(map, forKey: defaultOverridesKey) + private static var defaultPaletteMap: [String: String] { + Dictionary(uniqueKeysWithValues: defaultPalette.map { ($0.name, $0.hex) }) + } + + private static func normalizedColorName(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func nextCustomColorName( + existingNames: Set<String>, + startingAt initialIndex: Int = 1 + ) -> String { + var index = max(1, initialIndex) + while true { + let candidate = "Custom \(index)" + if !existingNames.contains(where: { $0.caseInsensitiveCompare(candidate) == .orderedSame }) { + return candidate + } + index += 1 } } @@ -1841,16 +1909,38 @@ class TabManager: ObservableObject { return workspaceGitProbeTimersByKey[probeKey]?.count ?? 0 } + func activeWorkspaceGitProbePanelIdsForTesting(workspaceId: UUID) -> Set<UUID> { + let probeKeys = Set(workspaceGitProbeGenerationByKey.keys.filter { $0.workspaceId == workspaceId }) + .union(workspaceGitProbeTimersByKey.keys.filter { $0.workspaceId == workspaceId }) + return Set(probeKeys.map(\.panelId)) + } + private func trackedWorkspaceGitMetadataPollCandidatePanelIds( in workspace: Workspace, activeProbeKeys: Set<WorkspaceGitProbeKey> ) -> Set<UUID> { var candidatePanelIds = Set(workspace.panelGitBranches.keys) candidatePanelIds.formUnion(workspace.panelPullRequests.keys) + // Only keep background polling panels whose current directory has already + // proven to yield sidebar git metadata. Initial multi-attempt probes handle + // startup races; this avoids polling non-repo directories forever. + candidatePanelIds.formUnion( + workspace.panels.keys.compactMap { panelId in + guard let currentDirectory = gitProbeDirectory(for: workspace, panelId: panelId) else { + return nil + } + let probeKey = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId) + guard workspaceGitTrackedDirectoryByKey[probeKey] == currentDirectory else { + return nil + } + return panelId + } + ) if candidatePanelIds.isEmpty, let focusedPanelId = workspace.focusedPanelId, - workspace.gitBranch != nil || workspace.pullRequest != nil { + (workspace.gitBranch != nil || workspace.pullRequest != nil), + gitProbeDirectory(for: workspace, panelId: focusedPanelId) != nil { candidatePanelIds.insert(focusedPanelId) } @@ -1881,6 +1971,8 @@ class TabManager: ObservableObject { tab.statusEntries.removeValue(forKey: key) tab.agentPIDs.removeValue(forKey: key) } + let remainingAgentPIDs = Set(tab.agentPIDs.values.compactMap { $0 > 0 ? Int($0) : nil }) + PortScanner.shared.refreshAgentPorts(workspaceId: tab.id, agentPIDs: remainingAgentPIDs) // Also clear stale notifications (e.g. "Doing well, thanks!") // left behind when Claude was killed without SessionEnd firing. AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id) @@ -1889,11 +1981,31 @@ class TabManager: ObservableObject { } private func gitProbeDirectory(for workspace: Workspace, panelId: UUID) -> String? { + // Match the sidebar directory fallback chain so hidden/background panels can + // still probe git metadata before OSC 7 has reported a live cwd. let rawDirectory = workspace.panelDirectories[panelId] + ?? workspace.terminalPanel(for: panelId)?.requestedWorkingDirectory ?? (workspace.focusedPanelId == panelId ? workspace.currentDirectory : nil) return rawDirectory.flatMap(normalizedWorkingDirectory) } + func scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: UUID, + panelId: UUID, + reason: String = "initial" + ) { + guard let workspace = tabs.first(where: { $0.id == workspaceId }), + !workspace.isRemoteWorkspace else { + return + } + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: reason, + delays: Self.initialWorkspaceGitProbeDelays + ) + } + private func scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: UUID, panelId: UUID, @@ -2141,12 +2253,10 @@ class TabManager: ObservableObject { updatedTabs.append(newWorkspace) } tabs = updatedTabs - if let explicitWorkingDirectory, - let terminalPanel = newWorkspace.focusedTerminalPanel { - scheduleInitialWorkspaceGitMetadataRefresh( + if let terminalPanel = newWorkspace.focusedTerminalPanel { + scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: newWorkspace.id, - panelId: terminalPanel.id, - directory: explicitWorkingDirectory + panelId: terminalPanel.id ) } if eagerLoadTerminal { @@ -2243,20 +2353,6 @@ class TabManager: ObservableObject { } } - private func scheduleInitialWorkspaceGitMetadataRefresh( - workspaceId: UUID, - panelId: UUID, - directory: String - ) { - scheduleWorkspaceGitMetadataRefresh( - workspaceId: workspaceId, - panelId: panelId, - directory: directory, - delays: Self.initialWorkspaceGitProbeDelays, - reason: "initial" - ) - } - private func scheduleWorkspaceGitMetadataRefresh( workspaceId: UUID, panelId: UUID, @@ -2369,6 +2465,17 @@ class TabManager: ObservableObject { workspace.updatePanelDirectory(panelId: probeKey.panelId, directory: expectedDirectory) + let resolvedPullRequest: SidebarPullRequestState? = { + guard case .resolved(let pullRequest) = snapshot.pullRequest else { return nil } + return pullRequest + }() + let resolvedSidebarMetadata = snapshot.branch != nil || resolvedPullRequest != nil + if resolvedSidebarMetadata { + workspaceGitTrackedDirectoryByKey[probeKey] = expectedDirectory + } else if workspaceGitTrackedDirectoryByKey[probeKey] != expectedDirectory { + workspaceGitTrackedDirectoryByKey.removeValue(forKey: probeKey) + } + let nextBranch = snapshot.branch if let nextBranch { workspace.updatePanelGitBranch( @@ -3433,6 +3540,15 @@ class TabManager: ObservableObject { setCustomTitle(tabId: tabId, title: nil) } + func setCustomDescription(tabId: UUID, description: String?) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + tabs[index].setCustomDescription(description) + } + + func clearCustomDescription(tabId: UUID) { + setCustomDescription(tabId: tabId, description: nil) + } + func setTabColor(tabId: UUID, color: String?) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } tab.setCustomColor(color) @@ -3736,8 +3852,9 @@ class TabManager: ObservableObject { UITestRecorder.incrementInt("closePanelInvocations") #endif guard let selectedId = selectedTabId, - let tab = tabs.first(where: { $0.id == selectedId }), - let focusedPanelId = tab.focusedPanelId else { return } + let tab = tabs.first(where: { $0.id == selectedId }) else { return } + reconcileFocusedPanelFromFirstResponderForKeyboard() + guard let focusedPanelId = shortcutCloseTargetPanelId(in: tab) else { return } closePanelWithConfirmation(tab: tab, panelId: focusedPanelId) } @@ -4066,6 +4183,28 @@ class TabManager: ObservableObject { #endif } + private func shortcutCloseTargetPanelId(in workspace: Workspace) -> UUID? { + if let focusedPanelId = workspace.focusedPanelId, + workspace.panels[focusedPanelId] != nil { + return focusedPanelId + } + + if workspace.panels.count == 1 { + return workspace.panels.keys.first + } + + let candidatePane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first + if let candidatePane, + let selectedTabId = workspace.bonsplitController.selectedTab(inPane: candidatePane)?.id + ?? workspace.bonsplitController.tabs(inPane: candidatePane).first?.id, + let panelId = workspace.panelIdFromSurfaceId(selectedTabId), + workspace.panels[panelId] != nil { + return panelId + } + + return nil + } + func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } closePanelWithConfirmation(tab: tab, panelId: surfaceId) @@ -6550,6 +6689,7 @@ extension TabManager { hasher.combine(workspace.focusedPanelId) hasher.combine(workspace.currentDirectory) hasher.combine(workspace.customTitle ?? "") + hasher.combine(workspace.customDescription ?? "") hasher.combine(workspace.customColor ?? "") hasher.combine(workspace.isPinned) hasher.combine(workspace.panels.count) @@ -6616,6 +6756,7 @@ extension TabManager { for key in existingProbeKeys { clearWorkspaceGitProbe(key) } + workspaceGitTrackedDirectoryByKey.removeAll() // Clear non-@Published state without touching tabs/selectedTabId yet. lastFocusedPanelByTab.removeAll() @@ -6681,13 +6822,9 @@ extension TabManager { for workspace in newTabs { let terminalPanels = workspace.panels.values.compactMap { $0 as? TerminalPanel } for terminalPanel in terminalPanels { - guard let directory = gitProbeDirectory(for: workspace, panelId: terminalPanel.id) else { - continue - } - scheduleInitialWorkspaceGitMetadataRefresh( + scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspace.id, - panelId: terminalPanel.id, - directory: directory + panelId: terminalPanel.id ) } } @@ -6761,6 +6898,7 @@ extension Notification.Name { static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested") static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") static let commandPaletteRenameWorkspaceRequested = Notification.Name("cmux.commandPaletteRenameWorkspaceRequested") + static let commandPaletteEditWorkspaceDescriptionRequested = Notification.Name("cmux.commandPaletteEditWorkspaceDescriptionRequested") static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection") static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested") static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8c352c4a3157..519350ba711a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -545,6 +545,19 @@ class TerminalController { } } + nonisolated static func parseRemotePortScanKickReason( + _ rawReason: String + ) -> WorkspaceRemoteSessionController.PortScanKickReason? { + switch rawReason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "command", "running", "foreground", "start": + return .command + case "refresh", "prompt", "idle": + return .refresh + default: + return nil + } + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -1027,6 +1040,26 @@ class TerminalController { workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports workspace.recomputeListeningPorts() } + PortScanner.shared.onAgentPortsUpdated = { [weak self] workspaceId, ports in + guard let self, let tabManager = self.tabManager else { return } + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + if workspace.agentListeningPorts != ports { + workspace.agentListeningPorts = ports + workspace.recomputeListeningPorts() + } + } + PortScanner.shared.agentPIDsProvider = { [weak self] workspaceIds in + guard let self, let tabManager = self.tabManager else { return [:] } + var pidsByWorkspace: [UUID: Set<Int>] = [:] + for workspaceId in workspaceIds { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue } + let pids = Set(workspace.agentPIDs.values.compactMap { $0 > 0 ? Int($0) : nil }) + if !pids.isEmpty { + pidsByWorkspace[workspaceId] = pids + } + } + return pidsByWorkspace + } // Accept connections in background thread Thread.detachNewThread { [weak self] in @@ -2078,6 +2111,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceEqualizeSplits(params: params)) case "workspace.remote.configure": return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params)) + case "workspace.remote.foreground_auth_ready": + return v2Result(id: id, self.v2WorkspaceRemoteForegroundAuthReady(params: params)) case "workspace.remote.reconnect": return v2Result(id: id, self.v2WorkspaceRemoteReconnect(params: params)) case "workspace.remote.disconnect": @@ -2131,6 +2166,10 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceSendText(params: params)) case "surface.send_key": return v2Result(id: id, self.v2SurfaceSendKey(params: params)) + case "surface.report_tty": + return v2Result(id: id, self.v2SurfaceReportTTY(params: params)) + case "surface.ports_kick": + return v2Result(id: id, self.v2SurfacePortsKick(params: params)) case "surface.clear_history": return v2Result(id: id, self.v2SurfaceClearHistory(params: params)) case "surface.trigger_flash": @@ -2450,6 +2489,7 @@ class TerminalController { "workspace.last", "workspace.equalize_splits", "workspace.remote.configure", + "workspace.remote.foreground_auth_ready", "workspace.remote.reconnect", "workspace.remote.disconnect", "workspace.remote.status", @@ -2473,6 +2513,8 @@ class TerminalController { "debug.terminals", "surface.send_text", "surface.send_key", + "surface.report_tty", + "surface.ports_kick", "surface.read_text", "surface.clear_history", "surface.trigger_flash", @@ -2901,6 +2943,7 @@ class TerminalController { "ref": v2Ref(kind: .workspace, uuid: workspace.id), "index": index, "title": workspace.title, + "description": v2OrNull(workspace.customDescription), "selected": selected, "pinned": workspace.isPinned, "panes": panes @@ -3283,6 +3326,29 @@ class TerminalController { // MARK: - V2 Workspace Methods + private func v2WorkspaceSummaryPayload( + workspace: Workspace, + index: Int?, + selected: Bool + ) -> [String: Any] { + var payload: [String: Any] = [ + "id": workspace.id.uuidString, + "ref": v2Ref(kind: .workspace, uuid: workspace.id), + "title": workspace.title, + "description": v2OrNull(workspace.customDescription), + "selected": selected, + "pinned": workspace.isPinned, + "listening_ports": workspace.listeningPorts, + "remote": workspace.remoteStatusPayload(), + "current_directory": v2OrNull(workspace.currentDirectory), + "custom_color": v2OrNull(workspace.customColor) + ] + if let index { + payload["index"] = index + } + return payload + } + private func v2WorkspaceList(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -3291,18 +3357,11 @@ class TerminalController { var workspaces: [[String: Any]] = [] v2MainSync { workspaces = tabManager.tabs.enumerated().map { index, ws in - return [ - "id": ws.id.uuidString, - "ref": v2Ref(kind: .workspace, uuid: ws.id), - "index": index, - "title": ws.title, - "selected": ws.id == tabManager.selectedTabId, - "pinned": ws.isPinned, - "listening_ports": ws.listeningPorts, - "remote": ws.remoteStatusPayload(), - "current_directory": v2OrNull(ws.currentDirectory), - "custom_color": v2OrNull(ws.customColor) - ] + v2WorkspaceSummaryPayload( + workspace: ws, + index: index, + selected: ws.id == tabManager.selectedTabId + ) } } @@ -3344,6 +3403,7 @@ class TerminalController { let requestedTitle = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines) let title = (requestedTitle?.isEmpty == false) ? requestedTitle : nil + let description = v2RawString(params, "description") var newId: UUID? let shouldFocus = v2FocusAllowed() @@ -3356,6 +3416,7 @@ class TerminalController { select: shouldFocus, eagerLoadTerminal: !shouldFocus ) + ws.setCustomDescription(description) newId = ws.id } @@ -3413,15 +3474,12 @@ class TerminalController { v2MainSync { wsId = tabManager.selectedTabId if let wsId, let workspace = tabManager.tabs.first(where: { $0.id == wsId }) { - wsPayload = [ - "id": workspace.id.uuidString, - "ref": v2Ref(kind: .workspace, uuid: workspace.id), - "title": workspace.title, - "selected": true, - "pinned": workspace.isPinned, - "listening_ports": workspace.listeningPorts, - "remote": workspace.remoteStatusPayload(), - ] + let index = tabManager.tabs.firstIndex(where: { $0.id == wsId }) + wsPayload = v2WorkspaceSummaryPayload( + workspace: workspace, + index: index, + selected: true + ) } } guard let wsId else { @@ -3791,6 +3849,8 @@ class TerminalController { } let relayID = v2RawString(params, "relay_id")?.trimmingCharacters(in: .whitespacesAndNewlines) let relayToken = v2RawString(params, "relay_token")?.trimmingCharacters(in: .whitespacesAndNewlines) + let foregroundAuthToken = v2RawString(params, "foreground_auth_token")? + .trimmingCharacters(in: .whitespacesAndNewlines) let localSocketPath = v2RawString(params, "local_socket_path") let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -3835,7 +3895,8 @@ class TerminalController { relayID: relayID?.isEmpty == true ? nil : relayID, relayToken: relayToken?.isEmpty == true ? nil : relayToken, localSocketPath: localSocketPath, - terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand + terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand, + foregroundAuthToken: foregroundAuthToken?.isEmpty == true ? nil : foregroundAuthToken ) workspace.configureRemoteConnection(config, autoConnect: autoConnect) @@ -3935,6 +3996,45 @@ class TerminalController { return result } + private func v2WorkspaceRemoteForegroundAuthReady(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + let foregroundAuthToken = v2RawString(params, "foreground_auth_token")? + .trimmingCharacters(in: .whitespacesAndNewlines) + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because this may arm a pending connect or start reconnecting immediately. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + workspace.notifyRemoteForegroundAuthenticationReady(token: foregroundAuthToken) + 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), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + private func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { @@ -4013,6 +4113,211 @@ class TerminalController { return result } + private func v2SurfaceReportTTY(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) + } + let requestedSurfaceId = v2UUID(params, "surface_id") + if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let ttyName = v2RawString(params, "tty_name")?.trimmingCharacters(in: .whitespacesAndNewlines), + !ttyName.isEmpty else { + return .err(code: "invalid_params", message: "Missing tty_name", 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": v2OrNull(requestedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), + ] + ) + + v2MainSync { + guard let tab = self.tabForSidebarMutation(id: workspaceId) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let surfaceId = self.resolveReportedSurfaceId( + in: tab, + requestedSurfaceId: requestedSurfaceId, + validSurfaceIds: validSurfaceIds + ) + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { + if tab.isRemoteWorkspace, validSurfaceIds.isEmpty { + tab.rememberPendingRemoteSurfaceTTY(ttyName, requestedSurfaceId: requestedSurfaceId) + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": v2OrNull(requestedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), + "tty_name": ttyName, + "pending": true, + ]) + return + } + result = .err( + code: "not_found", + message: "Surface not found", + data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": v2OrNull(requestedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), + ] + ) + return + } + + tab.surfaceTTYNames[surfaceId] = ttyName + if tab.isRemoteWorkspace { + tab.syncRemotePortScanTTYs() + _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: surfaceId) + } else { + PortScanner.shared.registerTTY(workspaceId: workspaceId, panelId: surfaceId, ttyName: ttyName) + } + + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "tty_name": ttyName, + ]) + } + + return result + } + + private func v2SurfacePortsKick(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) + } + let requestedSurfaceId = v2UUID(params, "surface_id") + if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + let reason: WorkspaceRemoteSessionController.PortScanKickReason + if let rawReason = v2RawString(params, "reason") { + guard let parsedReason = Self.parseRemotePortScanKickReason(rawReason) else { + return .err( + code: "invalid_params", + message: "reason must be command or refresh", + data: nil + ) + } + reason = parsedReason + } else { + reason = .command + } + + 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": v2OrNull(requestedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), + ] + ) + + v2MainSync { + guard let tab = self.tabForSidebarMutation(id: workspaceId) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let surfaceId = self.resolveReportedSurfaceId( + in: tab, + requestedSurfaceId: requestedSurfaceId, + validSurfaceIds: validSurfaceIds + ) + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { + if tab.isRemoteWorkspace, validSurfaceIds.isEmpty { + tab.rememberPendingRemoteSurfacePortKick( + reason: reason, + requestedSurfaceId: requestedSurfaceId + ) + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": v2OrNull(requestedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), + "reason": reason.rawValue, + "pending": true, + ]) + return + } + result = .err( + code: "not_found", + message: "Surface not found", + data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": v2OrNull(requestedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), + ] + ) + return + } + + if tab.isRemoteWorkspace { + tab.kickRemotePortScan(panelId: surfaceId, reason: reason) + } else { + PortScanner.shared.kick(workspaceId: workspaceId, panelId: surfaceId) + } + + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "reason": reason.rawValue, + ]) + } + + return result + } + + @MainActor + private func resolveReportedSurfaceId( + in workspace: Workspace, + requestedSurfaceId: UUID?, + validSurfaceIds: Set<UUID> + ) -> UUID? { + if let requestedSurfaceId { + guard validSurfaceIds.contains(requestedSurfaceId) else { return nil } + return requestedSurfaceId + } + + if let focusedSurfaceId = workspace.focusedPanelId, + validSurfaceIds.contains(focusedSurfaceId), + (!workspace.isRemoteWorkspace || workspace.isRemoteTerminalSurface(focusedSurfaceId)) { + return focusedSurfaceId + } + + guard workspace.isRemoteWorkspace else { return nil } + + let remoteTerminalSurfaceIds = validSurfaceIds.filter { workspace.isRemoteTerminalSurface($0) } + if remoteTerminalSurfaceIds.count == 1 { + return remoteTerminalSurfaceIds.first + } + + if validSurfaceIds.count == 1 { + return validSurfaceIds.first + } + + return nil + } + private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -4023,6 +4328,7 @@ class TerminalController { let supportedActions = [ "pin", "unpin", "rename", "clear_name", + "set_description", "clear_description", "move_up", "move_down", "move_top", "close_others", "close_above", "close_below", "mark_read", "mark_unread", @@ -4096,6 +4402,19 @@ class TerminalController { tabManager.clearCustomTitle(tabId: workspace.id) finish(["title": workspace.title]) + case "set_description": + guard let descriptionRaw = v2String(params, "description"), + !descriptionRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + result = .err(code: "invalid_params", message: "Missing or invalid description", data: nil) + return + } + tabManager.setCustomDescription(tabId: workspace.id, description: descriptionRaw) + finish(["description": v2OrNull(workspace.customDescription)]) + + case "clear_description": + tabManager.clearCustomDescription(tabId: workspace.id) + finish(["description": NSNull()]) + case "move_up": guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) @@ -4159,8 +4478,8 @@ class TerminalController { return } let colorInput = colorRaw.trimmingCharacters(in: .whitespacesAndNewlines) - // Resolve named colors from effective palette (includes user overrides, excludes custom entries) - let effectivePalette = WorkspaceTabColorSettings.defaultPaletteWithOverrides() + // Resolve named colors from the effective palette, including file-defined additions. + let effectivePalette = WorkspaceTabColorSettings.palette() let hex: String if let entry = effectivePalette.first(where: { $0.name.caseInsensitiveCompare(colorInput) == .orderedSame @@ -4622,15 +4941,13 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId - guard let targetSurfaceId else { + let requestedSurfaceId: UUID? = v2UUID(params, "surface_id") + // Fall back to focused surface if the requested surface no longer exists (e.g. closed teammate pane) + let targetSurfaceId: UUID? = requestedSurfaceId.flatMap({ ws.panels[$0] != nil ? $0 : nil }) ?? ws.focusedPanelId + guard let targetSurfaceId, ws.panels[targetSurfaceId] != nil else { result = .err(code: "not_found", message: "No focused surface", data: nil) return } - guard ws.panels[targetSurfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": targetSurfaceId.uuidString]) - return - } v2MaybeFocusWindow(for: tabManager) v2MaybeSelectWorkspace(tabManager, workspace: ws) @@ -5427,15 +5744,14 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { - result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) - return - } - guard sendNamedKey(surface, keyName: key) else { + let surfaceWasReady = terminalPanel.surface.surface != nil + guard terminalPanel.surface.sendNamedKey(key) else { result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) return } - terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") + if surfaceWasReady { + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") + } result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result @@ -6600,7 +6916,7 @@ class TerminalController { } private func v2NotificationClear() -> V2CallResult { - DispatchQueue.main.sync { + DispatchQueue.main.async { TerminalNotificationStore.shared.clearAll() } return .ok([:]) @@ -11080,7 +11396,7 @@ class TerminalController { focus_pane <pane-id|index> - Focus a pane focus_surface_by_panel <panel_id> - Focus surface by panel ID close_surface [id|idx] - Close surface (collapse split) - reload_config [soft] - Reload Ghostty config and refresh terminals + reload_config - Reload Ghostty config, cmux settings, and refresh terminals refresh_surfaces - Force refresh all terminals surface_health [workspace] - Check view health of all surfaces @@ -11120,7 +11436,7 @@ class TerminalController { clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning - ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel + ports_kick [--tab=X] [--panel=Y] [--reason=command|refresh] - Request batched port scan for panel report_shell_state <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command report_pr_action <merge|close|reopen|create|checkout|ready|edit|view> [--target=X] [--tab=X] [--panel=Y] - Hint that a PR-affecting command completed in the panel report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory @@ -12360,6 +12676,30 @@ class TerminalController { let tabArg = parts[0] let panelArg = parts[1] let payload = parts.count > 2 ? parts[2] : "" + let (title, subtitle, body) = parseNotificationPayload(payload) + + if let workspaceId = UUID(uuidString: tabArg), + let panelId = UUID(uuidString: panelArg) { + var result = "OK" + DispatchQueue.main.sync { + guard let tab = self.tabForSidebarMutation(id: workspaceId) else { + result = "ERROR: Tab not found" + return + } + guard tab.panels[panelId] != nil else { + result = "ERROR: Panel not found" + return + } + TerminalNotificationStore.shared.addNotification( + tabId: workspaceId, + surfaceId: panelId, + title: title, + subtitle: subtitle, + body: body + ) + } + return result + } var result = "OK" DispatchQueue.main.sync { @@ -12378,7 +12718,6 @@ class TerminalController { result = "ERROR: Panel not found" return } - let (title, subtitle, body) = parseNotificationPayload(payload) TerminalNotificationStore.shared.addNotification( tabId: tab.id, surfaceId: panelId, @@ -12406,7 +12745,7 @@ class TerminalController { private func clearNotifications(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - DispatchQueue.main.sync { + DispatchQueue.main.async { TerminalNotificationStore.shared.clearAll() } return "OK" @@ -12416,17 +12755,12 @@ class TerminalController { !tabOption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "ERROR: Usage: clear_notifications [--tab=X]" } - var tabId: UUID? - DispatchQueue.main.sync { - if let tab = resolveTabForReport(trimmed) { - tabId = tab.id - } + let targetResolution = parseSidebarMutationTabTarget(options: parsed.options) + guard let target = targetResolution.target else { + return targetResolution.error ?? "ERROR: Tab not found" } - guard let tabId else { - return "ERROR: Tab not found" - } - DispatchQueue.main.sync { - TerminalNotificationStore.shared.clearNotifications(forTabId: tabId) + scheduleSidebarMutation(target: target) { _, tab in + TerminalNotificationStore.shared.clearNotifications(forTabId: tab.id) } return "OK" } @@ -13295,154 +13629,6 @@ class TerminalController { } } - private func keycodeForLetter(_ letter: Character) -> UInt32? { - switch String(letter).lowercased() { - case "a": return UInt32(kVK_ANSI_A) - case "b": return UInt32(kVK_ANSI_B) - case "c": return UInt32(kVK_ANSI_C) - case "d": return UInt32(kVK_ANSI_D) - case "e": return UInt32(kVK_ANSI_E) - case "f": return UInt32(kVK_ANSI_F) - case "g": return UInt32(kVK_ANSI_G) - case "h": return UInt32(kVK_ANSI_H) - case "i": return UInt32(kVK_ANSI_I) - case "j": return UInt32(kVK_ANSI_J) - case "k": return UInt32(kVK_ANSI_K) - case "l": return UInt32(kVK_ANSI_L) - case "m": return UInt32(kVK_ANSI_M) - case "n": return UInt32(kVK_ANSI_N) - case "o": return UInt32(kVK_ANSI_O) - case "p": return UInt32(kVK_ANSI_P) - case "q": return UInt32(kVK_ANSI_Q) - case "r": return UInt32(kVK_ANSI_R) - case "s": return UInt32(kVK_ANSI_S) - case "t": return UInt32(kVK_ANSI_T) - case "u": return UInt32(kVK_ANSI_U) - case "v": return UInt32(kVK_ANSI_V) - case "w": return UInt32(kVK_ANSI_W) - case "x": return UInt32(kVK_ANSI_X) - case "y": return UInt32(kVK_ANSI_Y) - case "z": return UInt32(kVK_ANSI_Z) - default: return nil - } - } - - private func keycodeForNamedKey(_ name: String) -> UInt32? { - switch name { - case "enter", "return": return UInt32(kVK_Return) - case "tab": return UInt32(kVK_Tab) - case "escape", "esc": return UInt32(kVK_Escape) - case "backspace": return UInt32(kVK_Delete) - case "delete": return UInt32(kVK_ForwardDelete) - case "space": return UInt32(kVK_Space) - case "up": return UInt32(kVK_UpArrow) - case "down": return UInt32(kVK_DownArrow) - case "left": return UInt32(kVK_LeftArrow) - case "right": return UInt32(kVK_RightArrow) - case "\\": return UInt32(kVK_ANSI_Backslash) - default: return nil - } - } - - private func sendNamedKey(_ surface: ghostty_surface_t, keyName: String) -> Bool { - switch keyName.lowercased() { - case "ctrl-c", "ctrl+c", "sigint": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_C), mods: GHOSTTY_MODS_CTRL) - return true - case "ctrl-d", "ctrl+d", "eof": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_D), mods: GHOSTTY_MODS_CTRL) - return true - case "ctrl-z", "ctrl+z", "sigtstp": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Z), mods: GHOSTTY_MODS_CTRL) - return true - case "ctrl-\\", "ctrl+\\", "sigquit": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Backslash), mods: GHOSTTY_MODS_CTRL) - return true - case "enter", "return": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return)) - return true - case "tab": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab)) - return true - case "escape", "esc": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape)) - return true - case "backspace": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete)) - return true - case "up", "arrow_up", "arrowup": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_UpArrow)) - return true - case "down", "arrow_down", "arrowdown": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_DownArrow)) - return true - case "left", "arrow_left", "arrowleft": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_LeftArrow)) - return true - case "right", "arrow_right", "arrowright": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_RightArrow)) - return true - case "shift+tab", "shift-tab", "backtab": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab), mods: GHOSTTY_MODS_SHIFT) - return true - case "home": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Home)) - return true - case "end": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_End)) - return true - case "delete", "del", "forward_delete": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_ForwardDelete)) - return true - case "pageup", "page_up": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_PageUp)) - return true - case "pagedown", "page_down": - sendKeyEvent(surface: surface, keycode: UInt32(kVK_PageDown)) - return true - default: - // Parse modifier+key combinations (e.g. "ctrl+enter", "shift+tab", - // "ctrl+shift+a") or standalone named keys (e.g. "space", "up"). - // Separators: '+' or '-'. - let parts = keyName.lowercased().split(separator: "+").flatMap { $0.split(separator: "-") }.map(String.init).filter { !$0.isEmpty } - guard let baseKey = parts.last else { return false } - - // Single named key without modifiers (e.g. "space", "up", "delete") - if parts.count == 1 { - if let keycode = keycodeForNamedKey(baseKey) { - sendKeyEvent(surface: surface, keycode: keycode) - return true - } - if baseKey.count == 1, let char = baseKey.first, let keycode = keycodeForLetter(char) { - sendKeyEvent(surface: surface, keycode: keycode) - return true - } - return false - } - - var mods = GHOSTTY_MODS_NONE - for mod in parts.dropLast() { - switch mod { - case "ctrl", "control": mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_CTRL.rawValue) - case "shift": mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_SHIFT.rawValue) - case "alt", "opt", "option": mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_ALT.rawValue) - case "cmd", "command", "super": mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_SUPER.rawValue) - default: return false - } - } - - if let keycode = keycodeForNamedKey(baseKey) { - sendKeyEvent(surface: surface, keycode: keycode, mods: mods) - return true - } - if baseKey.count == 1, let char = baseKey.first, let keycode = keycodeForLetter(char) { - sendKeyEvent(surface: surface, keycode: keycode, mods: mods) - return true - } - return false - } - } - private func sendInput(_ text: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } @@ -13644,16 +13830,7 @@ class TerminalController { return } - guard let surface = resolveTerminalSurface( - from: terminalPanel.id.uuidString, - tabManager: tabManager, - waitUpTo: 2.0 - ) else { - error = "ERROR: Surface not ready" - return - } - - success = sendNamedKey(surface, keyName: keyName) + success = terminalPanel.surface.sendNamedKey(keyName) } if let error { return error } return success ? "OK" : "ERROR: Unknown key '\(keyName)'" @@ -13670,15 +13847,11 @@ class TerminalController { var success = false var error: String? DispatchQueue.main.sync { - guard resolveTerminalPanel(from: target, tabManager: tabManager) != nil else { + guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { error = "ERROR: Surface not found" return } - guard let surface = resolveTerminalSurface(from: target, tabManager: tabManager, waitUpTo: 2.0) else { - error = "ERROR: Surface not ready" - return - } - success = sendNamedKey(surface, keyName: keyName) + success = terminalPanel.surface.sendNamedKey(keyName) } if let error { return error } @@ -14313,21 +14486,48 @@ class TerminalController { return tabManager.tabs.first(where: { $0.id == selectedId }) } - private func resolveTabIdForSidebarMutation( - reportArgs: String, + private enum SidebarMutationTabTarget { + case selected + case workspace(UUID) + case index(Int) + } + + private func parseSidebarMutationTabTarget( options: [String: String] - ) -> (tabId: UUID?, error: String?) { - var tabId: UUID? - DispatchQueue.main.sync { - if let tab = resolveTabForReport(reportArgs) { - tabId = tab.id + ) -> (target: SidebarMutationTabTarget?, error: String?) { + if let rawTabArg = options["tab"] { + let tabArg = rawTabArg.trimmingCharacters(in: .whitespacesAndNewlines) + guard !tabArg.isEmpty else { + return (nil, "ERROR: Tab not found") } + if let tabId = UUID(uuidString: tabArg) { + return (.workspace(tabId), nil) + } + if let index = Int(tabArg), index >= 0 { + return (.index(index), nil) + } + return (nil, "ERROR: Tab not found") } - if let tabId { - return (tabId, nil) + return (.selected, nil) + } + + private func resolveSidebarMutationTab(_ target: SidebarMutationTabTarget) -> Tab? { + switch target { + case .selected: + guard let tabManager = self.tabManager, + let selectedId = tabManager.selectedTabId else { + return nil + } + return tabManager.tabs.first(where: { $0.id == selectedId }) + case .workspace(let tabId): + return tabForSidebarMutation(id: tabId) + case .index(let index): + guard let tabManager = self.tabManager, + index < tabManager.tabs.count else { + return nil + } + return tabManager.tabs[index] } - let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" - return (nil, error) } private func tabForSidebarMutation(id: UUID) -> Tab? { @@ -14357,6 +14557,16 @@ class TerminalController { return trimmed.isEmpty ? nil : trimmed } + private func scheduleSidebarMutation( + target: SidebarMutationTabTarget, + mutation: @escaping (TerminalController, Tab) -> Void + ) { + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.resolveSidebarMutationTab(target) else { return } + mutation(self, tab) + } + } + private func schedulePanelMetadataMutation( args: String, options: [String: String], @@ -14448,9 +14658,9 @@ class TerminalController { parsedURL = nil } - let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) - guard let targetTabId = tabResolution.tabId else { - return tabResolution.error ?? "ERROR: No tab selected" + let targetResolution = parseSidebarMutationTabTarget(options: parsed.options) + guard let target = targetResolution.target else { + return targetResolution.error ?? "ERROR: No tab selected" } let pidValue: pid_t? = { @@ -14461,8 +14671,7 @@ class TerminalController { return nil }() - DispatchQueue.main.async { [weak self] in - guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + scheduleSidebarMutation(target: target) { controller, tab in guard Self.shouldReplaceStatusEntry( current: tab.statusEntries[key], key: key, @@ -14476,6 +14685,7 @@ class TerminalController { // Still update PID tracking even if the status display hasn't changed. if let pidValue { tab.agentPIDs[key] = pidValue + controller.refreshTrackedAgentPorts(for: tab) } return } @@ -14491,6 +14701,7 @@ class TerminalController { ) if let pidValue { tab.agentPIDs[key] = pidValue + controller.refreshTrackedAgentPorts(for: tab) } } return "OK" @@ -14502,18 +14713,18 @@ class TerminalController { return "ERROR: Missing metadata key — usage: \(usage)" } - var result = "OK" - DispatchQueue.main.sync { - guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" - return - } - if tab.statusEntries.removeValue(forKey: key) == nil { - result = "OK (key not found)" + let targetResolution = parseSidebarMutationTabTarget(options: parsed.options) + guard let target = targetResolution.target else { + return targetResolution.error ?? "ERROR: No tab selected" + } + + scheduleSidebarMutation(target: target) { controller, tab in + _ = tab.statusEntries.removeValue(forKey: key) + if tab.agentPIDs.removeValue(forKey: key) != nil { + controller.refreshTrackedAgentPorts(for: tab) } - tab.agentPIDs.removeValue(forKey: key) } - return result + return "OK" } /// Register an agent PID for stale-session detection without setting a visible status entry. @@ -14525,13 +14736,13 @@ class TerminalController { return "ERROR: Usage: set_agent_pid <key> <pid> [--tab=<id>]" } let key = parsed.positional[0] - let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) - guard let targetTabId = tabResolution.tabId else { - return tabResolution.error ?? "ERROR: No tab selected" + let targetResolution = parseSidebarMutationTabTarget(options: parsed.options) + guard let target = targetResolution.target else { + return targetResolution.error ?? "ERROR: No tab selected" } - DispatchQueue.main.async { [weak self] in - guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + scheduleSidebarMutation(target: target) { controller, tab in tab.agentPIDs[key] = pid + controller.refreshTrackedAgentPorts(for: tab) } return "OK" } @@ -14542,19 +14753,22 @@ class TerminalController { guard let key = parsed.positional.first else { return "ERROR: Usage: clear_agent_pid <key> [--tab=<id>]" } - // Resolve tab ID synchronously before dispatching to avoid - // racing against selection changes on the main queue. - let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) - guard let targetTabId = tabResolution.tabId else { - return tabResolution.error ?? "ERROR: No tab selected" + let targetResolution = parseSidebarMutationTabTarget(options: parsed.options) + guard let target = targetResolution.target else { + return targetResolution.error ?? "ERROR: No tab selected" } - DispatchQueue.main.async { [weak self] in - guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + scheduleSidebarMutation(target: target) { controller, tab in tab.agentPIDs.removeValue(forKey: key) + controller.refreshTrackedAgentPorts(for: tab) } return "OK" } + private func refreshTrackedAgentPorts(for tab: Workspace) { + let agentPIDs = Set(tab.agentPIDs.values.compactMap { $0 > 0 ? Int($0) : nil }) + PortScanner.shared.refreshAgentPorts(workspaceId: tab.id, agentPIDs: agentPIDs) + } + private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String { var line = "\(entry.key)=\(entry.value)" if let icon = entry.icon { line += " icon=\(icon)" } @@ -14665,13 +14879,12 @@ class TerminalController { priority = 0 } - let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, options: parsed.options) - guard let targetTabId = tabResolution.tabId else { - return tabResolution.error ?? "ERROR: No tab selected" + let targetResolution = parseSidebarMutationTabTarget(options: parsed.options) + guard let target = targetResolution.target else { + return targetResolution.error ?? "ERROR: No tab selected" } - DispatchQueue.main.async { [weak self] in - guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + scheduleSidebarMutation(target: target) { _, tab in guard Self.shouldReplaceMetadataBlock( current: tab.metadataBlocks[key], key: key, @@ -15253,7 +15466,12 @@ class TerminalController { tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } tab.surfaceTTYNames[scope.panelId] = ttyName - PortScanner.shared.registerTTY(workspaceId: scope.workspaceId, panelId: scope.panelId, ttyName: ttyName) + if tab.isRemoteWorkspace { + tab.syncRemotePortScanTTYs() + _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: scope.panelId) + } else { + PortScanner.shared.registerTTY(workspaceId: scope.workspaceId, panelId: scope.panelId, ttyName: ttyName) + } } return "OK" } @@ -15292,13 +15510,28 @@ class TerminalController { } tab.surfaceTTYNames[surfaceId] = ttyName - PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) + if tab.isRemoteWorkspace { + tab.syncRemotePortScanTTYs() + _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: surfaceId) + } else { + PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) + } } return result } private func portsKick(_ args: String) -> String { let parsed = parseOptions(args) + let reason: WorkspaceRemoteSessionController.PortScanKickReason + if let rawReason = parsed.options["reason"], !rawReason.isEmpty { + guard let parsedReason = Self.parseRemotePortScanKickReason(rawReason) else { + return "ERROR: Invalid ports_kick reason '\(rawReason)' — expected command or refresh" + } + reason = parsedReason + } else { + reason = .command + } + if let scope = Self.explicitSocketScope(options: parsed.options) { DispatchQueue.main.async { guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), @@ -15308,7 +15541,11 @@ class TerminalController { let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } - PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) + if tab.isRemoteWorkspace { + tab.kickRemotePortScan(panelId: scope.panelId, reason: reason) + } else { + PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) + } } return "OK" } @@ -15340,7 +15577,11 @@ class TerminalController { surfaceId = focused } - PortScanner.shared.kick(workspaceId: tab.id, panelId: surfaceId) + if tab.isRemoteWorkspace { + tab.kickRemotePortScan(panelId: surfaceId, reason: reason) + } else { + PortScanner.shared.kick(workspaceId: tab.id, panelId: surfaceId) + } } return result } @@ -15432,20 +15673,14 @@ class TerminalController { private func reloadConfig(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let soft: Bool - switch trimmed { - case "", "full": - soft = false - case "soft": - soft = true - default: - return "ERROR: Usage: reload_config [soft]" + guard trimmed.isEmpty else { + return "ERROR: Usage: reload_config" } v2MainSync { - GhosttyApp.shared.reloadConfiguration(soft: soft, source: "socket.reload_config") + GhosttyApp.shared.reloadConfiguration(source: "socket.reload_config") } - return soft ? "OK Reloaded config (soft)" : "OK Reloaded config" + return "OK Reloaded config" } private func refreshSurfaces() -> String { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index fd424bdbc57a..bca164006791 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1024,7 +1024,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private struct NotificationsPopoverView: View { @ObservedObject var notificationStore: TerminalNotificationStore - @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared let onDismiss: () -> Void var body: some View { @@ -1098,24 +1098,14 @@ private struct NotificationsPopoverView: View { } private var jumpToUnreadShortcut: StoredShortcut { - decodeShortcut( - from: jumpToUnreadShortcutData, - fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut - ) + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .jumpToUnread) } private var hasUnreadNotifications: Bool { notificationStore.notifications.contains(where: { !$0.isRead }) } - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut - } - private func jumpToLatestUnread() { DispatchQueue.main.async { AppDelegate.shared?.jumpToLatestUnread() diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 44117ffe6218..8e1d32913746 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8,6 +8,21 @@ import Darwin import Network import CoreText +#if DEBUG +private func debugWorkspaceDescriptionPreview(_ text: String?, limit: Int = 120) -> String { + guard let text else { return "nil" } + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + if escaped.count <= limit { + return escaped + } + return "\(escaped.prefix(limit))..." +} +#endif + struct CmuxSurfaceConfigTemplate { var fontSize: Float32 = 0 var workingDirectory: String? @@ -280,6 +295,7 @@ extension Workspace { return SessionWorkspaceSnapshot( processTitle: processTitle, customTitle: customTitle, + customDescription: customDescription, customColor: customColor, isPinned: isPinned, currentDirectory: currentDirectory, @@ -319,6 +335,7 @@ extension Workspace { applyProcessTitle(snapshot.processTitle) setCustomTitle(snapshot.customTitle) + setCustomDescription(snapshot.customDescription) setCustomColor(snapshot.customColor) isPinned = snapshot.isPinned @@ -327,6 +344,7 @@ extension Workspace { // restarts because the processes that set them are gone. statusEntries.removeAll() agentPIDs.removeAll() + agentListeningPorts.removeAll() logEntries = snapshot.logEntries.map { entry in SidebarLogEntry( message: entry.message, @@ -420,7 +438,12 @@ extension Workspace { let branchSnapshot = panelGitBranches[panelId].map { SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) } - let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + let listeningPorts: [Int] + if remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId) { + listeningPorts = [] + } else { + listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + } let ttyName = surfaceTTYNames[panelId] let terminalSnapshot: SessionTerminalPanelSnapshot? @@ -692,6 +715,7 @@ extension Workspace { } else { surfaceTTYNames.removeValue(forKey: panelId) } + syncRemotePortScanTTYs() if let browserSnapshot = snapshot.browser, let browserPanel = browserPanel(for: panelId) { @@ -1045,6 +1069,125 @@ final class WorkspaceRemoteDaemonPendingCallRegistry { } } +enum WorkspaceRemoteSSHBatchCommandBuilder { + private static let batchSSHControlOptionKeys: Set<String> = [ + "controlmaster", + "controlpersist", + ] + + static func daemonTransportArguments( + configuration: WorkspaceRemoteConfiguration, + remotePath: String + ) -> [String] { + let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(shellSingleQuoted(script))" + return ["-T"] + + batchArguments(configuration: configuration) + + ["-o", "RequestTTY=no", configuration.destination, command] + } + + static func reverseRelayControlMasterArguments( + configuration: WorkspaceRemoteConfiguration, + controlCommand: String, + forwardSpec: String + ) -> [String]? { + guard let controlPath = sshOptionValue(named: "ControlPath", in: configuration.sshOptions)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !controlPath.isEmpty, + controlPath.lowercased() != "none" else { + return nil + } + + var args = batchArguments(configuration: configuration) + args += ["-O", controlCommand, "-R", forwardSpec, configuration.destination] + return args + } + + private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { + let effectiveSSHOptions = backgroundSSHOptions(configuration.sshOptions) + var args: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + args += ["-o", "StrictHostKeyChecking=accept-new"] + } + args += ["-o", "BatchMode=yes"] + // Batch helpers may reuse an existing ControlPath, but must not negotiate a new master. + args += ["-o", "ControlMaster=no"] + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + if sshOptionKey(option) == loweredKey { + return true + } + } + return false + } + + private static func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private static func backgroundSSHOptions(_ options: [String]) -> [String] { + normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !batchSSHControlOptionKeys.contains(key) + } + } + + private static func sshOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in normalizedSSHOptions(options) { + let parts = option.split( + maxSplits: 1, + omittingEmptySubsequences: true, + whereSeparator: { $0 == "=" || $0.isWhitespace } + ) + guard parts.count == 2, parts[0].lowercased() == loweredKey else { + continue + } + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return value + } + } + return nil + } + + private static func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} + private final class WorkspaceRemoteDaemonRPCClient { private static let maxStdoutBufferBytes = 256 * 1024 static let requiredProxyStreamCapability = "proxy.stream.push" @@ -1068,6 +1211,9 @@ private final class WorkspaceRemoteDaemonRPCClient { private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() private var process: Process? + private var stdinPipe: Pipe? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? private var stdinHandle: FileHandle? private var stdoutHandle: FileHandle? private var stderrHandle: FileHandle? @@ -1094,6 +1240,12 @@ private final class WorkspaceRemoteDaemonRPCClient { let stdoutPipe = Pipe() let stderrPipe = Pipe() + stateQueue.sync { + self.stdinPipe = stdinPipe + self.stdoutPipe = stdoutPipe + self.stderrPipe = stderrPipe + } + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) process.standardInput = stdinPipe @@ -1402,6 +1554,9 @@ private final class WorkspaceRemoteDaemonRPCClient { isClosed = true self.process = nil + stdinPipe = nil + stdoutPipe = nil + stderrPipe = nil stdinHandle = nil stdoutHandle?.readabilityHandler = nil stdoutHandle = nil @@ -1431,6 +1586,9 @@ private final class WorkspaceRemoteDaemonRPCClient { let capturedStderr = stderrHandle process = nil + stdinPipe = nil + stdoutPipe = nil + stderrPipe = nil stdinHandle = nil stdoutHandle = nil stderrHandle = nil @@ -1475,87 +1633,10 @@ private final class WorkspaceRemoteDaemonRPCClient { } private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { - let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" - // Use non-login sh so remote ~/.profile noise does not interfere with daemon transport startup. - let command = "sh -c \(shellSingleQuoted(script))" - return ["-T", "-S", "none"] - + sshCommonArguments(configuration: configuration, batchMode: true) - + ["-o", "RequestTTY=no", configuration.destination, command] - } - - private static let batchSSHControlOptionKeys: Set<String> = [ - "controlmaster", - "controlpersist", - ] - - private static func sshCommonArguments(configuration: WorkspaceRemoteConfiguration, batchMode: Bool) -> [String] { - let effectiveSSHOptions: [String] = { - if batchMode { - return backgroundSSHOptions(configuration.sshOptions) - } - return normalizedSSHOptions(configuration.sshOptions) - }() - var args: [String] = [ - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - ] - if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { - args += ["-o", "StrictHostKeyChecking=accept-new"] - } - if batchMode { - args += ["-o", "BatchMode=yes"] - // Batch helpers should reuse an existing ControlPath if one was configured, - // but must never try to negotiate a new master connection. - args += ["-o", "ControlMaster=no"] - } - if let port = configuration.port { - args += ["-p", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - for option in effectiveSSHOptions { - args += ["-o", option] - } - return args - } - - private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - for option in options { - let token = sshOptionKey(option) - if token == loweredKey { - return true - } - } - return false - } - - private static func normalizedSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - } - - private static func backgroundSSHOptions(_ options: [String]) -> [String] { - normalizedSSHOptions(options).filter { option in - guard let key = sshOptionKey(option) else { return false } - return !batchSSHControlOptionKeys.contains(key) - } - } - - private static func sshOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() + WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( + configuration: configuration, + remotePath: remotePath + ) } private static func shellSingleQuoted(_ value: String) -> String { @@ -3193,6 +3274,29 @@ private final class WorkspaceRemoteCLIRelayServer { } final class WorkspaceRemoteSessionController { + enum PortScanKickReason: String { + case command + case refresh + + var burstOffsets: [Double] { + switch self { + case .command: + return [0.5, 1.5, 3.0, 5.0, 7.5, 10.0] + case .refresh: + return [0.0] + } + } + + func merged(with other: Self) -> Self { + switch (self, other) { + case (.command, _), (_, .command): + return .command + case (.refresh, .refresh): + return .refresh + } + } + } + private struct RetrySchedule { let retry: Int let delay: TimeInterval @@ -3227,6 +3331,34 @@ final class WorkspaceRemoteSessionController { private let configuration: WorkspaceRemoteConfiguration private let controllerID: UUID + private enum RemotePortPollingMode { + case hostWide + case hostWideDelta + case ttyScoped + + var initialDelay: TimeInterval { + switch self { + case .hostWide: + return 0.5 + case .hostWideDelta: + return 0.5 + case .ttyScoped: + return 1.0 + } + } + + var repeatInterval: TimeInterval { + switch self { + case .hostWide: + return 2.0 + case .hostWideDelta: + return 5.0 + case .ttyScoped: + return 5.0 + } + } + } + private var isStopping = false private var proxyLease: WorkspaceRemoteProxyBroker.Lease? private var proxyEndpoint: BrowserProxyEndpoint? @@ -3234,7 +3366,24 @@ final class WorkspaceRemoteSessionController { private var daemonBootstrapVersion: String? private var daemonRemotePath: String? private var reverseRelayProcess: Process? + private var reverseRelayControlMasterForwardSpec: String? private var cliRelayServer: WorkspaceRemoteCLIRelayServer? + private var remotePortScanTTYNames: [UUID: String] = [:] + private var remoteScannedPortsByPanel: [UUID: [Int]] = [:] + private var remotePortScanBurstActive = false + private var remotePortScanActiveReason: PortScanKickReason? + private var remotePortScanPendingReason: PortScanKickReason? + private var remotePortScanGeneration: UInt64 = 0 + private var remotePortScanCoalesceWorkItem: DispatchWorkItem? + private var remotePortPollTimer: DispatchSourceTimer? + private var remotePortPollMode: RemotePortPollingMode? + private var polledRemotePorts: [Int] = [] + private var remotePortPollBaselinePorts: Set<Int>? + private var keepPolledRemotePortsUntilTTYScan = false + private var bootstrapRemoteTTYResolved = false + private var bootstrapRemoteTTYRetryWorkItem: DispatchWorkItem? + private var bootstrapRemoteTTYFetchInFlight = false + private var bootstrapRemoteTTYRetryCount = 0 private var reverseRelayStderrPipe: Pipe? private var reverseRelayRestartWorkItem: DispatchWorkItem? private var reverseRelayStderrBuffer = "" @@ -3331,7 +3480,24 @@ final class WorkspaceRemoteSessionController { reconnectRetryCount = 0 reverseRelayRestartWorkItem?.cancel() reverseRelayRestartWorkItem = nil + remotePortScanCoalesceWorkItem?.cancel() + remotePortScanCoalesceWorkItem = nil stopReverseRelayLocked() + remotePortScanGeneration &+= 1 + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + remotePortScanPendingReason = nil + remotePortScanTTYNames.removeAll() + remoteScannedPortsByPanel.removeAll() + stopRemotePortPollingLocked() + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + keepPolledRemotePortsUntilTTYScan = false + bootstrapRemoteTTYResolved = false + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYFetchInFlight = false + bootstrapRemoteTTYRetryCount = 0 proxyLease?.release() proxyLease = nil @@ -3346,9 +3512,20 @@ final class WorkspaceRemoteSessionController { private func beginConnectionAttemptLocked() { guard !isStopping else { return } + Self.killOrphanedRemoteSSHProcesses( + destination: configuration.destination, + relayPort: configuration.relayPort + ) connectionAttemptStartedAt = Date() debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") reconnectWorkItem = nil + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYFetchInFlight = false + if remotePortScanTTYNames.isEmpty { + bootstrapRemoteTTYResolved = false + bootstrapRemoteTTYRetryCount = 0 + } let connectDetail: String let bootstrapDetail: String if reconnectRetryCount > 0 { @@ -3380,6 +3557,7 @@ final class WorkspaceRemoteSessionController { ) recordHeartbeatActivityLocked() startReverseRelayLocked(remotePath: hello.remotePath) + requestBootstrapRemoteTTYIfNeededLocked() startProxyLocked() } catch { daemonReady = false @@ -3432,6 +3610,7 @@ final class WorkspaceRemoteSessionController { return } guard reverseRelayProcess == nil else { return } + guard reverseRelayControlMasterForwardSpec == nil else { return } reverseRelayRestartWorkItem?.cancel() reverseRelayRestartWorkItem = nil @@ -3444,7 +3623,35 @@ final class WorkspaceRemoteSessionController { ) relayServer = server let localRelayPort = try server.start() - Self.killOrphanedRelayProcesses(relayPort: relayPort, destination: configuration.destination) + Self.killOrphanedRemoteSSHProcesses( + destination: configuration.destination, + relayPort: relayPort + ) + let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" + + if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec) { + cliRelayServer = relayServer + reverseRelayStderrBuffer = "" + do { + try installRemoteRelayMetadataLocked( + remotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + } catch { + debugLog("remote.relay.metadata.error \(error.localizedDescription)") + stopReverseRelayLocked() + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + recordHeartbeatActivityLocked() + debugLog( + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + + "target=\(configuration.displayTarget) controlMaster=1" + ) + return + } let process = Process() let stderrPipe = Pipe() @@ -3500,7 +3707,7 @@ final class WorkspaceRemoteSessionController { recordHeartbeatActivityLocked() debugLog( "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + - "target=\(configuration.displayTarget)" + "target=\(configuration.displayTarget) controlMaster=0" ) } catch { debugLog( @@ -3570,6 +3777,7 @@ final class WorkspaceRemoteSessionController { reverseRelayProcess.terminate() } reverseRelayProcess = nil + stopReverseRelayViaControlMasterLocked() reverseRelayStderrPipe = nil reverseRelayStderrBuffer = "" cliRelayServer?.stop() @@ -3596,14 +3804,26 @@ final class WorkspaceRemoteSessionController { } proxyEndpoint = endpoint publishProxyEndpoint(endpoint) + updateRemotePortPollingStateLocked() publishPortsSnapshotLocked() publishState( .connected, detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" ) + requestBootstrapRemoteTTYIfNeededLocked() recordHeartbeatActivityLocked() case .error(let detail): debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") + remotePortScanGeneration &+= 1 + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + remotePortScanPendingReason = nil + remotePortScanCoalesceWorkItem?.cancel() + remotePortScanCoalesceWorkItem = nil + remoteScannedPortsByPanel.removeAll() + stopRemotePortPollingLocked() + polledRemotePorts = [] + keepPolledRemotePortsUntilTTYScan = false proxyEndpoint = nil publishProxyEndpoint(nil) publishPortsSnapshotLocked() @@ -3695,11 +3915,19 @@ final class WorkspaceRemoteSessionController { private func publishPortsSnapshotLocked() { let controllerID = self.controllerID + let detectedByPanel = remotePortScanTTYNames.keys.reduce(into: [UUID: [Int]]()) { result, panelId in + result[panelId] = remoteScannedPortsByPanel[panelId] ?? [] + } + let detected = Array( + Set(polledRemotePorts) + .union(detectedByPanel.values.flatMap { $0 }) + ).sorted() DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemotePortsSnapshot( - detected: [], + workspace.applyRemoteDetectedSurfacePortsSnapshot( + detectedByPanel: detectedByPanel, + detected: detected, conflicts: [], target: workspace.remoteDisplayTarget ?? "remote host" ) @@ -3720,10 +3948,76 @@ final class WorkspaceRemoteSessionController { } } + private func requestBootstrapRemoteTTYIfNeededLocked() { + guard !bootstrapRemoteTTYResolved else { return } + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + if !remotePortScanTTYNames.isEmpty { + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + return + } + guard !bootstrapRemoteTTYFetchInFlight else { return } + bootstrapRemoteTTYFetchInFlight = true + defer { bootstrapRemoteTTYFetchInFlight = false } + + let command = "sh -c \(Self.shellSingleQuoted("tty_path=\"$HOME/.cmux/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" + do { + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 2 + ) + guard result.status == 0 else { + scheduleBootstrapRemoteTTYRetryLocked() + return + } + guard let ttyName = Self.normalizedRemotePortScanTTYName(result.stdout) else { + scheduleBootstrapRemoteTTYRetryLocked() + return + } + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + debugLog("remote.tty.bootstrap.ready tty=\(ttyName) \(debugConfigSummary())") + publishBootstrapRemoteTTY(ttyName) + } catch { + debugLog("remote.tty.bootstrap.failed error=\(error.localizedDescription) \(debugConfigSummary())") + scheduleBootstrapRemoteTTYRetryLocked() + } + } + + private func scheduleBootstrapRemoteTTYRetryLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard !bootstrapRemoteTTYResolved else { return } + guard remotePortScanTTYNames.isEmpty else { return } + guard bootstrapRemoteTTYRetryCount < Self.bootstrapRemoteTTYRetryLimit else { return } + guard bootstrapRemoteTTYRetryWorkItem == nil else { return } + + bootstrapRemoteTTYRetryCount += 1 + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.bootstrapRemoteTTYRetryWorkItem = nil + self.requestBootstrapRemoteTTYIfNeededLocked() + } + bootstrapRemoteTTYRetryWorkItem = workItem + queue.asyncAfter(deadline: .now() + Self.bootstrapRemoteTTYRetryDelay, execute: workItem) + } + + private func publishBootstrapRemoteTTY(_ ttyName: String) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyBootstrapRemoteTTY(ttyName) + } + } + private func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [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. + // Fallback standalone transport when dynamic forwarding through an existing + // control master is unavailable. var args: [String] = ["-N", "-T", "-S", "none"] args += sshCommonArguments(batchMode: true) args += [ @@ -3735,9 +4029,49 @@ final class WorkspaceRemoteSessionController { return args } + private func startReverseRelayViaControlMasterLocked(forwardSpec: String) -> Bool { + guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "forward", + forwardSpec: forwardSpec + ) else { + return false + } + + do { + let result = try sshExec(arguments: arguments, timeout: 6) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.controlmaster.forwardFailed \(detail) \(debugConfigSummary())") + return false + } + reverseRelayControlMasterForwardSpec = forwardSpec + return true + } catch { + debugLog("remote.relay.controlmaster.forwardFailed \(error.localizedDescription) \(debugConfigSummary())") + return false + } + } + + private func stopReverseRelayViaControlMasterLocked() { + guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } + reverseRelayControlMasterForwardSpec = nil + guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "cancel", + forwardSpec: forwardSpec + ) else { + return + } + _ = try? sshExec(arguments: arguments, timeout: 4) + } + private static let remotePlatformProbeOSMarker = "__CMUX_REMOTE_OS__=" private static let remotePlatformProbeArchMarker = "__CMUX_REMOTE_ARCH__=" private static let remotePlatformProbeExistsMarker = "__CMUX_REMOTE_EXISTS__=" + private static let bootstrapRemoteTTYRetryDelay: TimeInterval = 0.5 + private static let bootstrapRemoteTTYRetryLimit = 8 private func sshCommonArguments(batchMode: Bool) -> [String] { let effectiveSSHOptions: [String] = { @@ -4074,7 +4408,7 @@ final class WorkspaceRemoteSessionController { if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then rm -f "$socket_addr_file" fi - rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" "$HOME/.cmux/relay/\(relayPort).tty" """ } @@ -4614,8 +4948,8 @@ final class WorkspaceRemoteSessionController { static func remoteCLIWrapperScript() -> String { """ - #!/usr/bin/env bash - set -euo pipefail + #!/bin/sh + set -eu daemon="$HOME/.cmux/bin/cmuxd-remote-current" socket_path="${CMUX_SOCKET_PATH:-}" @@ -4770,24 +5104,218 @@ final class WorkspaceRemoteSessionController { ".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote" } - private static func killOrphanedRelayProcesses(relayPort: Int, destination: String) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") - process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):127\\.0\\.0\\.1:[0-9]+.*\(destination)"] - process.standardOutput = FileHandle.nullDevice + static func orphanedCMUXRemoteSSHPIDs( + psOutput: String, + destination: String, + relayPort: Int? = nil + ) -> [Int] { + let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedDestination.isEmpty else { return [] } + + return psOutput + .split(separator: "\n", omittingEmptySubsequences: false) + .compactMap { line -> Int? in + guard let parsed = parsePSLine(line) else { return nil } + guard parsed.ppid == 1 else { return nil } + guard isOrphanedCMUXRemoteSSHCommand( + parsed.command, + destination: trimmedDestination, + relayPort: relayPort + ) else { + return nil + } + return parsed.pid + } + .sorted() + } + + private static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { + guard let output = captureCommandStandardOutput( + executablePath: "/bin/ps", + arguments: ["-axo", "pid=,ppid=,command="] + ) else { + return + } + + for pid in orphanedCMUXRemoteSSHPIDs( + psOutput: output, + destination: destination, + relayPort: relayPort + ) { + _ = Darwin.kill(pid_t(pid), SIGTERM) + } + } + + private static func captureCommandStandardOutput( + executablePath: String, + arguments: [String] + ) -> String? { + let process = Process() + let stdoutPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.standardOutput = stdoutPipe process.standardError = FileHandle.nullDevice + do { try process.run() + let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() + guard process.terminationStatus == 0, + let output = String(data: outputData, encoding: .utf8), + !output.isEmpty else { + return nil + } + return output } catch { // Best effort cleanup only. + return nil + } + } + + private static func parsePSLine(_ line: Substring) -> (pid: Int, ppid: Int, command: String)? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let scanner = Scanner(string: trimmed) + var pidValue: Int = 0 + var ppidValue: Int = 0 + guard scanner.scanInt(&pidValue), scanner.scanInt(&ppidValue) else { + return nil + } + + let commandStart = scanner.currentIndex + let command = String(trimmed[commandStart...]).trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { return nil } + return (pidValue, ppidValue, command) + } + + private static func isOrphanedCMUXRemoteSSHCommand( + _ command: String, + destination: String, + relayPort: Int? + ) -> Bool { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + guard trimmed.hasPrefix("/usr/bin/ssh ") || trimmed.hasPrefix("ssh ") else { return false } + guard commandContainsDestination(trimmed, destination: destination) else { return false } + + if let relayPort { + return trimmed.contains(" -N ") + && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") + } + + if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { + return true + } + if trimmed.contains("cmuxd-remote") && trimmed.contains(" serve --stdio") { + return true } + return false + } + + private static func commandContainsDestination(_ command: String, destination: String) -> Bool { + guard !destination.isEmpty else { return false } + let escaped = NSRegularExpression.escapedPattern(for: destination) + guard let regex = try? NSRegularExpression( + pattern: "(^|[\\s'\\\"])\(escaped)($|[\\s'\\\"])", + options: [] + ) else { + return command.contains(destination) + } + let range = NSRange(command.startIndex..<command.endIndex, in: command) + return regex.firstMatch(in: command, options: [], range: range) != nil + } + + static func executableSearchPaths( + environment: [String: String] = ProcessInfo.processInfo.environment, + pathHelperOutput: String? = nil + ) -> [String] { + var ordered: [String] = [] + var seen: Set<String> = [] + + func appendSearchPath(_ rawPath: String?) { + guard let rawPath else { return } + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard seen.insert(trimmed).inserted else { return } + ordered.append(trimmed) + } + + if let path = environment["PATH"] { + for component in path.split(separator: ":") { + appendSearchPath(String(component)) + } + } + + if let home = environment["HOME"], !home.isEmpty { + appendSearchPath((home as NSString).appendingPathComponent(".local/bin")) + appendSearchPath((home as NSString).appendingPathComponent("go/bin")) + appendSearchPath((home as NSString).appendingPathComponent("bin")) + } + + let helperOutput = pathHelperOutput ?? pathHelperShellOutput() + for component in parsePathHelperPaths(helperOutput) { + appendSearchPath(component) + } + + for component in [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] { + appendSearchPath(component) + } + + return ordered + } + + static func parsePathHelperPaths(_ output: String) -> [String] { + for fragment in output.split(whereSeparator: { $0 == "\n" || $0 == ";" }) { + let trimmed = fragment.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("PATH=\"") else { continue } + let suffix = trimmed.dropFirst("PATH=\"".count) + guard let closingQuote = suffix.firstIndex(of: "\"") else { return [] } + return suffix[..<closingQuote] + .split(separator: ":") + .map(String.init) + } + return [] + } + + private static func pathHelperShellOutput() -> String { + let executable = "/usr/libexec/path_helper" + guard FileManager.default.isExecutableFile(atPath: executable) else { return "" } + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = ["-s"] + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + return "" + } + + process.waitUntilExit() + guard process.terminationStatus == 0 else { return "" } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" } private static func which(_ executable: String) -> String? { - let path = ProcessInfo.processInfo.environment["PATH"] ?? "" - for component in path.split(separator: ":") { - let candidate = String(component) + "/" + executable + for component in executableSearchPaths() { + let candidate = (component as NSString).appendingPathComponent(executable) if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } @@ -4910,6 +5438,481 @@ final class WorkspaceRemoteSessionController { || lowered.contains("daemon transport stopped") } + func updateRemotePortScanTTYs(_ ttyNames: [UUID: String]) { + queue.async { [weak self] in + self?.updateRemotePortScanTTYsLocked(ttyNames) + } + } + + func kickRemotePortScan(panelId: UUID, reason: PortScanKickReason = .command) { + queue.async { [weak self] in + self?.kickRemotePortScanLocked(panelId: panelId, reason: reason) + } + } + + private func updateRemotePortScanTTYsLocked(_ ttyNames: [UUID: String]) { + let previousTTYNames = remotePortScanTTYNames + let nextTTYNames = ttyNames.reduce(into: [UUID: String]()) { result, entry in + guard let ttyName = Self.normalizedRemotePortScanTTYName(entry.value) else { return } + result[entry.key] = ttyName + } + guard previousTTYNames != nextTTYNames else { return } + if !nextTTYNames.isEmpty { + bootstrapRemoteTTYResolved = true + bootstrapRemoteTTYRetryWorkItem?.cancel() + bootstrapRemoteTTYRetryWorkItem = nil + bootstrapRemoteTTYRetryCount = 0 + } + keepPolledRemotePortsUntilTTYScan = + !previousTTYNames.isEmpty + ? keepPolledRemotePortsUntilTTYScan + : shouldUseFallbackRemotePortPollingLocked() && !polledRemotePorts.isEmpty && !nextTTYNames.isEmpty + remoteScannedPortsByPanel = remoteScannedPortsByPanel.filter { panelId, _ in + guard let oldTTY = previousTTYNames[panelId], + let newTTY = nextTTYNames[panelId] else { + return false + } + return oldTTY == newTTY + } + remotePortScanTTYNames = nextTTYNames + if nextTTYNames.isEmpty { + keepPolledRemotePortsUntilTTYScan = false + } + updateRemotePortPollingStateLocked() + publishPortsSnapshotLocked() + } + + private func kickRemotePortScanLocked(panelId: UUID, reason: PortScanKickReason) { + guard !isStopping else { return } + guard daemonReady else { return } + guard remotePortScanTTYNames[panelId] != nil else { return } + if remotePortScanBurstActive, remotePortScanActiveReason == .command, reason == .refresh { + return + } + remotePortScanPendingReason = remotePortScanPendingReason?.merged(with: reason) ?? reason + scheduleRemotePortScanCoalesceLocked() + } + + private func scheduleRemotePortScanCoalesceLocked() { + guard !remotePortScanBurstActive else { return } + guard remotePortScanCoalesceWorkItem == nil else { return } + + let generation = remotePortScanGeneration + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.remotePortScanGeneration == generation else { return } + self.remotePortScanCoalesceWorkItem = nil + guard let reason = self.remotePortScanPendingReason else { return } + self.remotePortScanPendingReason = nil + self.remotePortScanBurstActive = true + self.remotePortScanActiveReason = reason + self.runRemotePortScanBurstLocked(index: 0, generation: generation, reason: reason) + } + remotePortScanCoalesceWorkItem = workItem + queue.asyncAfter(deadline: .now() + 0.2, execute: workItem) + } + + private func runRemotePortScanBurstLocked( + index: Int, + generation: UInt64, + reason: PortScanKickReason, + burstStart: DispatchTime? = nil + ) { + guard remotePortScanGeneration == generation else { return } + + let burstOffsets = reason.burstOffsets + guard index < burstOffsets.count else { + remotePortScanBurstActive = false + remotePortScanActiveReason = nil + if remotePortScanPendingReason != nil && remotePortScanCoalesceWorkItem == nil { + scheduleRemotePortScanCoalesceLocked() + } + return + } + + let start = burstStart ?? .now() + let deadline = start + burstOffsets[index] + queue.asyncAfter(deadline: deadline) { [weak self] in + guard let self else { return } + guard self.remotePortScanGeneration == generation else { return } + self.performRemotePortScanLocked() + self.runRemotePortScanBurstLocked( + index: index + 1, + generation: generation, + reason: reason, + burstStart: start + ) + } + } + + private func performRemotePortScanLocked() { + let ttyNamesByPanel = remotePortScanTTYNames + guard !ttyNamesByPanel.isEmpty else { + remoteScannedPortsByPanel.removeAll() + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + return + } + + do { + remoteScannedPortsByPanel = try scanRemotePortsByPanelLocked(ttyNamesByPanel: ttyNamesByPanel) + keepPolledRemotePortsUntilTTYScan = false + polledRemotePorts = [] + publishPortsSnapshotLocked() + } catch { + debugLog("remote.ports.scan.failed error=\(error.localizedDescription) \(debugConfigSummary())") + } + } + + private func scanRemotePortsByPanelLocked(ttyNamesByPanel: [UUID: String]) throws -> [UUID: [Int]] { + let ttyNames = Array(Set(ttyNamesByPanel.values)).sorted() + guard !ttyNames.isEmpty else { return [:] } + + let command = "sh -c \(Self.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.ports", code: 90, userInfo: [ + NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", + ]) + } + + let portsByTTY = Self.parseRemoteTTYPortPairs( + output: result.stdout, + trackedTTYNames: Set(ttyNames) + ) + + return ttyNamesByPanel.reduce(into: [UUID: [Int]]()) { result, entry in + result[entry.key] = portsByTTY[entry.value] ?? [] + } + } + + private func startRemotePortPollingLocked(mode: RemotePortPollingMode) { + if remotePortPollTimer != nil, remotePortPollMode == mode { + return + } + stopRemotePortPollingLocked() + + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + mode.initialDelay, repeating: mode.repeatInterval) + timer.setEventHandler { [weak self] in + self?.pollRemotePortsLocked() + } + remotePortPollTimer = timer + remotePortPollMode = mode + timer.resume() + pollRemotePortsLocked() + } + + private func stopRemotePortPollingLocked() { + remotePortPollTimer?.setEventHandler {} + remotePortPollTimer?.cancel() + remotePortPollTimer = nil + remotePortPollMode = nil + } + + private func updateRemotePortPollingStateLocked() { + guard daemonReady, !isStopping, let pollingMode = remotePortPollingModeLocked() else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + remotePortPollBaselinePorts = nil + return + } + startRemotePortPollingLocked(mode: pollingMode) + } + + private func pollRemotePortsLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + if !remotePortScanTTYNames.isEmpty { + guard shouldUseTTYFallbackRemotePortPollingLocked() else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + publishPortsSnapshotLocked() + return + } + if remotePortScanBurstActive || remotePortScanCoalesceWorkItem != nil || remotePortScanPendingReason != nil { + return + } + performRemotePortScanLocked() + return + } + guard let pollingMode = remotePortPollingModeLocked() else { + stopRemotePortPollingLocked() + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + return + } + guard remotePortScanTTYNames.isEmpty else { + stopRemotePortPollingLocked() + if !keepPolledRemotePortsUntilTTYScan { + polledRemotePorts = [] + } + remotePortPollBaselinePorts = nil + publishPortsSnapshotLocked() + return + } + + let command = "sh -c \(Self.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" + do { + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.ports", code: 90, userInfo: [ + NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", + ]) + } + let currentPorts = Set(Self.parseRemotePorts(output: result.stdout)) + switch pollingMode { + case .hostWide: + polledRemotePorts = currentPorts.sorted() + remotePortPollBaselinePorts = nil + case .hostWideDelta: + if let baselinePorts = remotePortPollBaselinePorts { + polledRemotePorts = currentPorts.subtracting(baselinePorts).sorted() + } else { + remotePortPollBaselinePorts = currentPorts + polledRemotePorts = [] + } + case .ttyScoped: + polledRemotePorts = [] + remotePortPollBaselinePorts = nil + } + keepPolledRemotePortsUntilTTYScan = false + publishPortsSnapshotLocked() + } catch { + debugLog("remote.ports.poll.failed error=\(error.localizedDescription) \(debugConfigSummary())") + } + } + + private func excludedRemoteScanPorts() -> Set<Int> { + var excluded: Set<Int> = [] + if let relayPort = configuration.relayPort, relayPort > 0 { + excluded.insert(relayPort) + } + if let configuredPort = configuration.port, configuredPort > 0 { + excluded.insert(configuredPort) + } + return excluded + } + + private func shouldUseFallbackRemotePortPollingLocked() -> Bool { + // `cmux ssh` owns the remote shell bootstrap and can report the remote + // TTY precisely. Falling back to host-wide port scans in that path leaks + // unrelated listeners from the remote machine into the workspace card. + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + return startupCommand?.isEmpty != false + } + + private func shouldUseTTYFallbackRemotePortPollingLocked() -> Bool { + // `cmux ssh` can still land in shells without our command hooks, such as + // `/bin/sh` in the Docker fixture. Once the workspace knows the TTY, + // keep a low-frequency TTY-scoped poll so unsupported shells still + // surface ports without bringing back noisy host-wide scans. + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + return startupCommand?.isEmpty == false + } + + private func remotePortPollingModeLocked() -> RemotePortPollingMode? { + if !remotePortScanTTYNames.isEmpty { + return shouldUseTTYFallbackRemotePortPollingLocked() ? .ttyScoped : nil + } + let startupCommand = configuration.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + if startupCommand?.isEmpty == false { + return .hostWideDelta + } + return shouldUseFallbackRemotePortPollingLocked() ? .hostWide : nil + } + + private static func parseRemoteTTYPortPairs(output: String, trackedTTYNames: Set<String>) -> [String: [Int]] { + var portsByTTY = Dictionary(uniqueKeysWithValues: trackedTTYNames.map { ($0, Set<Int>()) }) + + for line in output.split(separator: "\n") { + let parts = line.split(separator: "\t", omittingEmptySubsequences: false) + guard parts.count == 2 else { continue } + let ttyName = String(parts[0]).trimmingCharacters(in: .whitespacesAndNewlines) + guard trackedTTYNames.contains(ttyName), + let port = Int(parts[1]), + port >= 1024, + port <= 65535 else { + continue + } + portsByTTY[ttyName, default: []].insert(port) + } + + return portsByTTY.reduce(into: [String: [Int]]()) { result, entry in + result[entry.key] = entry.value.sorted() + } + } + + private static func parseRemotePorts(output: String) -> [Int] { + let values = output + .split(whereSeparator: \.isWhitespace) + .compactMap { Int($0) } + .filter { $0 >= 1024 && $0 <= 65535 } + return Array(Set(values)).sorted() + } + + private static func normalizedRemotePortScanTTYName(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let candidate = trimmed.split(separator: "/").last.map(String.init) ?? trimmed + guard !candidate.isEmpty else { return nil } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + guard candidate.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + return candidate + } + + private static func remotePortScanScript(ttyNames: [String], excluding ports: Set<Int>) -> String { + let ttySet = ttyNames.joined(separator: " ") + let ttyCSV = ttyNames.joined(separator: ",") + let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") + + return """ + set -eu + cmux_tracked_ttys=" \(ttySet) " + cmux_tty_csv='\(ttyCSV)' + cmux_excluded_ports=" \(excludedPorts) " + + cmux_emit_port() { + cmux_tty="$1" + cmux_port="$2" + case "$cmux_tracked_ttys" in + *" $cmux_tty "*) ;; + *) return 0 ;; + esac + case "$cmux_excluded_ports" in + *" $cmux_port "*) return 0 ;; + esac + [ "$cmux_port" -ge 1024 ] && [ "$cmux_port" -le 65535 ] || return 0 + printf '%s\\t%s\\n' "$cmux_tty" "$cmux_port" + } + + cmux_used_ss=0 + if [ -d /proc ] && command -v ss >/dev/null 2>&1; then + cmux_ss_output="$(ss -ltnpH 2>/dev/null || true)" + case "$cmux_ss_output" in + *pid=*) + cmux_used_ss=1 + printf '%s\\n' "$cmux_ss_output" | while IFS= read -r cmux_line; do + [ -n "$cmux_line" ] || continue + cmux_port="$(printf '%s\\n' "$cmux_line" | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ { print $1; exit }')" + [ -n "$cmux_port" ] || continue + printf '%s\\n' "$cmux_line" | awk ' + { + line = $0 + while (match(line, /pid=[0-9]+/)) { + print substr(line, RSTART + 4, RLENGTH - 4) + line = substr(line, RSTART + RLENGTH) + } + } + ' | while IFS= read -r cmux_pid; do + [ -n "$cmux_pid" ] || continue + cmux_tty_path="$(readlink "/proc/$cmux_pid/fd/0" 2>/dev/null || true)" + [ -n "$cmux_tty_path" ] || continue + cmux_tty="${cmux_tty_path##*/}" + [ -n "$cmux_tty" ] || continue + cmux_emit_port "$cmux_tty" "$cmux_port" + done + done + ;; + esac + fi + + if [ "$cmux_used_ss" -eq 0 ] && command -v lsof >/dev/null 2>&1 && [ -n "$cmux_tty_csv" ]; then + cmux_tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t cmux-ports)" + trap 'rm -rf "$cmux_tmpdir"' EXIT INT TERM + cmux_pid_tty_map="$cmux_tmpdir/pid_tty" + ps -t "$cmux_tty_csv" -o pid=,tty= 2>/dev/null | awk ' + NF >= 2 { + tty = $2 + sub(/^.*\\//, "", tty) + print $1 "\\t" tty + } + ' > "$cmux_pid_tty_map" + [ -s "$cmux_pid_tty_map" ] || exit 0 + cmux_pid_csv="$(awk '{print $1}' "$cmux_pid_tty_map" | paste -sd, -)" + [ -n "$cmux_pid_csv" ] || exit 0 + lsof -nP -a -p "$cmux_pid_csv" -iTCP -sTCP:LISTEN -Fpn 2>/dev/null | awk -v map="$cmux_pid_tty_map" ' + BEGIN { + while ((getline < map) > 0) { + pid_to_tty[$1] = $2 + } + close(map) + } + $0 ~ /^p/ { + pid = substr($0, 2) + tty = pid_to_tty[pid] + next + } + $0 ~ /^n/ && tty != "" { + name = substr($0, 2) + sub(/->.*/, "", name) + sub(/^.*:/, "", name) + sub(/[^0-9].*/, "", name) + if (name != "") { + print tty "\\t" name + } + } + ' | while IFS=$'\\t' read -r cmux_tty cmux_port; do + [ -n "$cmux_tty" ] || continue + [ -n "$cmux_port" ] || continue + cmux_emit_port "$cmux_tty" "$cmux_port" + done + fi + """ + } + + private static func remoteAllPortsScanScript(excluding ports: Set<Int>) -> String { + let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") + + return """ + set -eu + cmux_excluded_ports=" \(excludedPorts) " + + cmux_emit_port() { + cmux_port="$1" + case "$cmux_excluded_ports" in + *" $cmux_port "*) return 0 ;; + esac + [ "$cmux_port" -ge 1024 ] && [ "$cmux_port" -le 65535 ] || return 0 + printf '%s\\n' "$cmux_port" + } + + if command -v ss >/dev/null 2>&1; then + ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r cmux_port; do + [ -n "$cmux_port" ] || continue + cmux_emit_port "$cmux_port" + done + elif command -v netstat >/dev/null 2>&1; then + netstat -lnt 2>/dev/null | awk 'NR > 2 {print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r cmux_port; do + [ -n "$cmux_port" ] || continue + cmux_emit_port "$cmux_port" + done + elif command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR > 1 {print $9}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r cmux_port; do + [ -n "$cmux_port" ] || continue + cmux_emit_port "$cmux_port" + done + fi + """ + } + } enum SidebarLogLevel: String { @@ -4990,6 +5993,33 @@ struct WorkspaceRemoteConfiguration: Equatable { let relayToken: String? let localSocketPath: String? let terminalStartupCommand: String? + let foregroundAuthToken: String? + + init( + destination: String, + port: Int?, + identityFile: String?, + sshOptions: [String], + localProxyPort: Int?, + relayPort: Int?, + relayID: String?, + relayToken: String?, + localSocketPath: String?, + terminalStartupCommand: String?, + foregroundAuthToken: String? = nil + ) { + self.destination = destination + self.port = port + self.identityFile = identityFile + self.sshOptions = sshOptions + self.localProxyPort = localProxyPort + self.relayPort = relayPort + self.relayID = relayID + self.relayToken = relayToken + self.localSocketPath = localSocketPath + self.terminalStartupCommand = terminalStartupCommand + self.foregroundAuthToken = foregroundAuthToken + } var displayTarget: String { guard let port else { return destination } @@ -5488,6 +6518,7 @@ final class Workspace: Identifiable, ObservableObject { let id: UUID @Published var title: String @Published var customTitle: String? + @Published var customDescription: String? @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" @Published var currentDirectory: String @@ -5575,6 +6606,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var pullRequest: SidebarPullRequestState? @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] + var agentListeningPorts: [Int] = [] @Published var remoteConfiguration: WorkspaceRemoteConfiguration? @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected @Published var remoteConnectionDetail: String? @@ -5588,10 +6620,12 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 var surfaceTTYNames: [UUID: String] = [:] private var remoteSessionController: WorkspaceRemoteSessionController? + private var pendingRemoteForegroundAuthToken: String? fileprivate var activeRemoteSessionControllerID: UUID? private var remoteLastErrorFingerprint: String? private var remoteLastDaemonErrorFingerprint: String? private var remoteLastPortConflictFingerprint: String? + private var remoteDetectedSurfaceIds: Set<UUID> = [] private var activeRemoteTerminalSurfaceIds: Set<UUID> = [] private var pendingRemoteTerminalChildExitSurfaceIds: Set<UUID> = [] @@ -5624,11 +6658,19 @@ final class Workspace: Identifiable, ObservableObject { .eraseToAnyPublisher() } - lazy var sidebarObservationPublisher: AnyPublisher<Void, Never> = { + lazy var sidebarImmediateObservationPublisher: AnyPublisher<Void, Never> = { let publishers: [AnyPublisher<Void, Never>] = [ sidebarObservationSignal($title), + sidebarObservationSignal($customDescription), sidebarObservationSignal($isPinned), sidebarObservationSignal($customColor), + ] + + return Publishers.MergeMany(publishers).eraseToAnyPublisher() + }() + + lazy var sidebarObservationPublisher: AnyPublisher<Void, Never> = { + let publishers: [AnyPublisher<Void, Never>] = [ sidebarObservationSignal($currentDirectory), $panels .map(SidebarPanelObservationState.init) @@ -5818,6 +6860,7 @@ final class Workspace: Identifiable, ObservableObject { self.processTitle = title self.title = title self.customTitle = nil + self.customDescription = nil let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty @@ -6036,6 +7079,10 @@ final class Workspace: Identifiable, ObservableObject { private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] private var activeDetachCloseTransactions: Int = 0 private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } + private var pendingRemoteSurfaceTTYName: String? + private var pendingRemoteSurfaceTTYSurfaceId: UUID? + private var pendingRemoteSurfacePortKickReason: WorkspaceRemoteSessionController.PortScanKickReason? + private var pendingRemoteSurfacePortKickSurfaceId: UUID? // When the last live remote terminal is detached out, the source workspace may be // closed immediately after the move succeeds. That teardown must not shut down the // shared SSH control master that is still serving the moved terminal. @@ -6454,6 +7501,10 @@ final class Workspace: Identifiable, ObservableObject { return !trimmed.isEmpty } + var hasCustomDescription: Bool { + Self.normalizedCustomDescription(customDescription) != nil + } + func applyProcessTitle(_ title: String) { processTitle = title guard customTitle == nil else { return } @@ -6468,6 +7519,15 @@ final class Workspace: Identifiable, ObservableObject { } } + private static func normalizedCustomDescription(_ description: String?) -> String? { + let normalizedLineEndings = description? + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let trimmed = normalizedLineEndings?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return normalizedLineEndings + } + func setCustomTitle(_ title: String?) { let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { @@ -6479,6 +7539,28 @@ final class Workspace: Identifiable, ObservableObject { } } + func setCustomDescription(_ description: String?) { + let normalizedDescription = Self.normalizedCustomDescription(description) +#if DEBUG + let inputNewlines = description?.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } ?? 0 + let normalizedNewlines = normalizedDescription?.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } ?? 0 + dlog( + "workspace.customDescription.update workspace=\(id.uuidString.prefix(8)) " + + "inputLen=\((description as NSString?)?.length ?? 0) " + + "inputNewlines=\(inputNewlines) " + + "normalizedLen=\((normalizedDescription as NSString?)?.length ?? 0) " + + "normalizedNewlines=\(normalizedNewlines) " + + "input=\"\(debugWorkspaceDescriptionPreview(description))\" " + + "normalized=\"\(debugWorkspaceDescriptionPreview(normalizedDescription))\"" + ) +#endif + customDescription = normalizedDescription + } + // MARK: - Directory Updates func updatePanelDirectory(panelId: UUID, directory: String) { @@ -6619,6 +7701,7 @@ final class Workspace: Identifiable, ObservableObject { func resetSidebarContext(reason: String = "unspecified") { statusEntries.removeAll() agentPIDs.removeAll() + agentListeningPorts.removeAll() logEntries.removeAll() progress = nil gitBranch = nil @@ -6716,13 +7799,17 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } + remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } + syncRemotePortScanTTYs() recomputeListeningPorts() } func recomputeListeningPorts() { let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) + .union(agentListeningPorts) + .union(remoteDetectedPorts) let next = unique.sorted() if listeningPorts != next { listeningPorts = next @@ -6934,6 +8021,17 @@ final class Workspace: Identifiable, ObservableObject { ) } + func syncRemotePortScanTTYs() { + guard isRemoteWorkspace else { return } + remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames) + } + + func kickRemotePortScan(panelId: UUID, reason: WorkspaceRemoteSessionController.PortScanKickReason = .command) { + guard isRemoteWorkspace else { return } + syncRemotePortScanTTYs() + remoteSessionController?.kickRemotePortScan(panelId: panelId, reason: reason) + } + func remoteStatusPayload() -> [String: Any] { let heartbeatAgeSeconds: Any = { guard let last = remoteLastHeartbeatAt else { return NSNull() } @@ -7009,6 +8107,7 @@ final class Workspace: Identifiable, ObservableObject { skipControlMasterCleanupAfterDetachedRemoteTransfer = false remoteConfiguration = configuration seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) + clearRemoteDetectedSurfacePorts() remoteDetectedPorts = [] remotePortConflicts = [] remoteProxyEndpoint = nil @@ -7030,7 +8129,12 @@ final class Workspace: Identifiable, ObservableObject { applyRemoteProxyEndpointUpdate(nil) applyBrowserRemoteWorkspaceStatusToPanels() - guard autoConnect else { + let foregroundAuthToken = Self.normalizedForegroundAuthToken(configuration.foregroundAuthToken) + let shouldAutoConnect = + autoConnect + || (foregroundAuthToken != nil && foregroundAuthToken == pendingRemoteForegroundAuthToken) + pendingRemoteForegroundAuthToken = nil + guard shouldAutoConnect else { remoteConnectionState = .disconnected applyBrowserRemoteWorkspaceStatusToPanels() return @@ -7046,6 +8150,7 @@ final class Workspace: Identifiable, ObservableObject { ) activeRemoteSessionControllerID = controllerID remoteSessionController = controller + syncRemotePortScanTTYs() controller.start() } @@ -7054,6 +8159,31 @@ final class Workspace: Identifiable, ObservableObject { configureRemoteConnection(configuration, autoConnect: true) } + private static func normalizedForegroundAuthToken(_ token: String?) -> String? { + guard let token else { return nil } + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func notifyRemoteForegroundAuthenticationReady(token: String? = nil) { + guard let foregroundAuthToken = Self.normalizedForegroundAuthToken(token) else { + return + } + + guard let remoteConfiguration else { + pendingRemoteForegroundAuthToken = foregroundAuthToken + return + } + + guard Self.normalizedForegroundAuthToken(remoteConfiguration.foregroundAuthToken) == foregroundAuthToken else { + return + } + + pendingRemoteForegroundAuthToken = nil + guard remoteConnectionState == .disconnected else { return } + reconnectRemoteConnection() + } + func disconnectRemoteConnection(clearConfiguration: Bool = false) { let shouldCleanupControlMaster = clearConfiguration @@ -7065,8 +8195,14 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteSessionControllerID = nil remoteSessionController = nil previousController?.stop() + pendingRemoteForegroundAuthToken = nil activeRemoteTerminalSurfaceIds.removeAll() activeRemoteTerminalSessionCount = 0 + pendingRemoteSurfaceTTYName = nil + pendingRemoteSurfaceTTYSurfaceId = nil + pendingRemoteSurfacePortKickReason = nil + pendingRemoteSurfacePortKickSurfaceId = nil + clearRemoteDetectedSurfacePorts() remoteDetectedPorts = [] remotePortConflicts = [] remoteProxyEndpoint = nil @@ -7115,6 +8251,8 @@ final class Workspace: Identifiable, ObservableObject { transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) + _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) } private func untrackRemoteTerminalSurface(_ panelId: UUID) { @@ -7135,6 +8273,89 @@ final class Workspace: Identifiable, ObservableObject { } } + @MainActor + func rememberPendingRemoteSurfaceTTY(_ ttyName: String, requestedSurfaceId: UUID?) { + let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTTY.isEmpty else { return } + pendingRemoteSurfaceTTYName = trimmedTTY + pendingRemoteSurfaceTTYSurfaceId = requestedSurfaceId + } + + @MainActor + func rememberPendingRemoteSurfacePortKick( + reason: WorkspaceRemoteSessionController.PortScanKickReason, + requestedSurfaceId: UUID? + ) { + pendingRemoteSurfacePortKickReason = reason + pendingRemoteSurfacePortKickSurfaceId = requestedSurfaceId + } + + @MainActor + private func applyPendingRemoteSurfaceTTYIfNeeded(to panelId: UUID) { + guard let ttyName = pendingRemoteSurfaceTTYName?.trimmingCharacters(in: .whitespacesAndNewlines), + !ttyName.isEmpty else { + return + } + if let requestedSurfaceId = pendingRemoteSurfaceTTYSurfaceId, requestedSurfaceId != panelId { + return + } + surfaceTTYNames[panelId] = ttyName + pendingRemoteSurfaceTTYName = nil + pendingRemoteSurfaceTTYSurfaceId = nil + syncRemotePortScanTTYs() + if !applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) { + kickRemotePortScan(panelId: panelId, reason: .command) + } + } + + @MainActor + @discardableResult + func applyPendingRemoteSurfacePortKickIfNeeded(to panelId: UUID) -> Bool { + guard let reason = pendingRemoteSurfacePortKickReason else { + return false + } + if let requestedSurfaceId = pendingRemoteSurfacePortKickSurfaceId, + requestedSurfaceId != panelId { + return false + } + guard let ttyName = surfaceTTYNames[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + !ttyName.isEmpty else { + return false + } + _ = ttyName + pendingRemoteSurfacePortKickReason = nil + pendingRemoteSurfacePortKickSurfaceId = nil + kickRemotePortScan(panelId: panelId, reason: reason) + return true + } + + @MainActor + fileprivate func applyBootstrapRemoteTTY(_ ttyName: String) { + let trimmedTTY = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTTY.isEmpty else { return } + + let candidateSurfaceId: UUID? = { + if let focusedPanelId, activeRemoteTerminalSurfaceIds.contains(focusedPanelId) { + return focusedPanelId + } + if activeRemoteTerminalSurfaceIds.count == 1 { + return activeRemoteTerminalSurfaceIds.first + } + return nil + }() + + guard let candidateSurfaceId else { + rememberPendingRemoteSurfaceTTY(trimmedTTY, requestedSurfaceId: nil) + return + } + + surfaceTTYNames[candidateSurfaceId] = trimmedTTY + syncRemotePortScanTTYs() + if !applyPendingRemoteSurfacePortKickIfNeeded(to: candidateSurfaceId) { + kickRemotePortScan(panelId: candidateSurfaceId, reason: .command) + } + } + private func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool { guard let relayPort, relayPort > 0, @@ -7332,7 +8553,26 @@ final class Workspace: Identifiable, ObservableObject { applyBrowserRemoteWorkspaceStatusToPanels() } - fileprivate func applyRemotePortsSnapshot(detected: [Int], conflicts: [Int], target: String) { + fileprivate func applyRemoteDetectedSurfacePortsSnapshot( + detectedByPanel: [UUID: [Int]], + detected: [Int], + conflicts: [Int], + target: String + ) { + let trackedSurfaceIds = Set(detectedByPanel.keys) + for panelId in remoteDetectedSurfaceIds.subtracting(trackedSurfaceIds) { + surfaceListeningPorts.removeValue(forKey: panelId) + } + remoteDetectedSurfaceIds = trackedSurfaceIds + + for (panelId, ports) in detectedByPanel { + if ports.isEmpty { + surfaceListeningPorts.removeValue(forKey: panelId) + } else { + surfaceListeningPorts[panelId] = ports + } + } + remoteDetectedPorts = detected remotePortConflicts = conflicts recomputeListeningPorts() @@ -7362,6 +8602,13 @@ final class Workspace: Identifiable, ObservableObject { ) } + private func clearRemoteDetectedSurfacePorts() { + for panelId in remoteDetectedSurfaceIds { + surfaceListeningPorts.removeValue(forKey: panelId) + } + remoteDetectedSurfaceIds.removeAll() + } + private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } @@ -7660,6 +8907,12 @@ final class Workspace: Identifiable, ObservableObject { ) } + owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: id, + panelId: newPanel.id, + reason: "splitCreate" + ) + return newPanel } @@ -7734,6 +8987,12 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView: previousHostedView ) } + + owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: id, + panelId: newPanel.id, + reason: "surfaceCreate" + ) return newPanel } @@ -8513,6 +9772,7 @@ final class Workspace: Identifiable, ObservableObject { } else { surfaceTTYNames.removeValue(forKey: detached.panelId) } + syncRemotePortScanTTYs() if let cachedTitle = detached.cachedTitle { panelTitles[detached.panelId] = cachedTitle } @@ -8546,6 +9806,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: detached.panelId) panelDirectories.removeValue(forKey: detached.panelId) surfaceTTYNames.removeValue(forKey: detached.panelId) + syncRemotePortScanTTYs() panelTitles.removeValue(forKey: detached.panelId) panelCustomTitles.removeValue(forKey: detached.panelId) pinnedPanelIds.remove(detached.panelId) @@ -10478,6 +11739,7 @@ extension Workspace: BonsplitDelegate { panelSubscriptions.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) + syncRemotePortScanTTYs() restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) @@ -10635,6 +11897,7 @@ extension Workspace: BonsplitDelegate { PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) } + syncRemotePortScanTTYs() let closedSet = Set(closedPanelIds) surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index b0380bb60027..2534f5228ab6 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -780,8 +780,7 @@ extension WorkspaceContentView { struct EmptyPanelView: View { @ObservedObject var workspace: Workspace let paneId: PaneID - @AppStorage(KeyboardShortcutSettings.Action.newSurface.defaultsKey) private var newSurfaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.openBrowser.defaultsKey) private var openBrowserShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private struct ShortcutHint: View { let text: String @@ -817,19 +816,13 @@ struct EmptyPanelView: View { } private var newSurfaceShortcut: StoredShortcut { - decodeShortcut(from: newSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.newSurface.defaultShortcut) + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .newSurface) } private var openBrowserShortcut: StoredShortcut { - decodeShortcut(from: openBrowserShortcutData, fallback: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut) - } - - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .openBrowser) } @ViewBuilder diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 220fb101ae4b..32d9111808f7 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -140,6 +140,7 @@ struct cmuxApp: App { @StateObject private var sidebarState = SidebarState() @StateObject private var sidebarSelectionState = SidebarSelectionState() @StateObject private var cmuxConfigStore = CmuxConfigStore() + @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let primaryWindowId = UUID() @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @@ -147,30 +148,7 @@ struct cmuxApp: App { @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue - @AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.newWindow.defaultsKey) private var newWindowShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.showNotifications.defaultsKey) private var showNotificationsShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.nextSurface.defaultsKey) private var nextSurfaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.prevSurface.defaultsKey) private var prevSurfaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey) private var nextWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.selectWorkspaceByNumber.defaultsKey) private var selectWorkspaceByNumberShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() @AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing - @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) - private var toggleBrowserDeveloperToolsShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey) - private var showBrowserJavaScriptConsoleShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.toggleReactGrab.defaultsKey) - private var toggleReactGrabShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.openFolder.defaultsKey) private var openFolderShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate private var browserToolbarAccessorySpacing: Int { @@ -185,6 +163,7 @@ struct cmuxApp: App { } Self.configureGhosttyEnvironment() + _ = KeyboardShortcutSettings.settingsFileStore // Apply saved language preference before any UI loads LanguageSettings.apply(LanguageSettings.languageAtLaunch) @@ -387,10 +366,18 @@ struct cmuxApp: App { .windowStyle(.hiddenTitleBar) .commands { CommandGroup(replacing: .appSettings) { - Button(String(localized: "menu.app.settings", defaultValue: "Settings…")) { + splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } - .keyboardShortcut(",", modifiers: .command) + Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open settings.json")) { + openCmuxSettingsFileInEditor() + } + Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { + GhosttyApp.shared.openConfigurationInTextEdit() + } + splitCommandButton(title: String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration"), shortcut: menuShortcut(for: .reloadConfiguration)) { + GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") + } } CommandGroup(replacing: .appInfo) { @@ -415,6 +402,12 @@ struct cmuxApp: App { InstallUpdateMenuItem(model: appDelegate.updateViewModel) } + CommandGroup(replacing: .appTermination) { + splitCommandButton(title: String(localized: "menu.quitCmux", defaultValue: "Quit cmux"), shortcut: menuShortcut(for: .quit)) { + NSApp.terminate(nil) + } + } + #if DEBUG CommandMenu("Update Pill") { Button("Show Update Pill") { @@ -453,11 +446,11 @@ struct cmuxApp: App { Divider() } - splitCommandButton(title: String(localized: "menu.notifications.show", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) { + splitCommandButton(title: String(localized: "menu.notifications.show", defaultValue: "Show Notifications"), shortcut: menuShortcut(for: .showNotifications)) { showNotificationsPopover() } - splitCommandButton(title: String(localized: "menu.notifications.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) { + splitCommandButton(title: String(localized: "menu.notifications.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: menuShortcut(for: .jumpToUnread)) { appDelegate.jumpToLatestUnread() } .disabled(!snapshot.hasUnreadNotifications) @@ -588,11 +581,11 @@ struct cmuxApp: App { // New tab commands CommandGroup(replacing: .newItem) { - splitCommandButton(title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), shortcut: newWindowMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), shortcut: menuShortcut(for: .newWindow)) { appDelegate.openNewMainWindow(nil) } - splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: newWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: menuShortcut(for: .newTab)) { if let appDelegate = AppDelegate.shared { if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { #if DEBUG @@ -607,7 +600,7 @@ struct cmuxApp: App { } } - splitCommandButton(title: String(localized: "menu.file.openFolder", defaultValue: "Open Folder…"), shortcut: openFolderMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.openFolder", defaultValue: "Open Folder…"), shortcut: menuShortcut(for: .openFolder)) { AppDelegate.shared?.showOpenFolderPanel() } @@ -631,17 +624,15 @@ struct cmuxApp: App { Divider() - Button(String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…")) { + splitCommandButton(title: String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…"), shortcut: menuShortcut(for: .goToWorkspace)) { let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) } - .keyboardShortcut("p", modifiers: [.command]) - Button(String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…")) { + splitCommandButton(title: String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…"), shortcut: menuShortcut(for: .commandPalette)) { let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) } - .keyboardShortcut("p", modifiers: [.command, .shift]) Divider() @@ -650,20 +641,18 @@ struct cmuxApp: App { // default, closing the last surface also closes the workspace and the window // if it was also the last workspace. Users can opt into keeping the workspace // open instead. - Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) { + splitCommandButton(title: String(localized: "menu.file.closeTab", defaultValue: "Close Tab"), shortcut: menuShortcut(for: .closeTab)) { closePanelOrWindow() } - .keyboardShortcut("w", modifiers: .command) - Button(String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane")) { + splitCommandButton(title: String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane"), shortcut: menuShortcut(for: .closeOtherTabsInPane)) { closeOtherTabsInFocusedPane() } - .keyboardShortcut("t", modifiers: [.command, .option]) .disabled(!activeTabManager.canCloseOtherTabsInFocusedPane()) // Cmd+Shift+W closes the current workspace (with confirmation if needed). If this // is the last workspace, it closes the window. - splitCommandButton(title: String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace"), shortcut: closeWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace"), shortcut: menuShortcut(for: .closeWorkspace)) { closeTabOrWindow() } @@ -671,54 +660,48 @@ struct cmuxApp: App { workspaceCommandMenuContent(manager: activeTabManager) } - Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) { + splitCommandButton(title: String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel"), shortcut: menuShortcut(for: .reopenClosedBrowserPanel)) { _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } - .keyboardShortcut("t", modifiers: [.command, .shift]) } // Find CommandGroup(after: .textEditing) { Menu(String(localized: "menu.find.title", defaultValue: "Find")) { - Button(String(localized: "menu.find.find", defaultValue: "Find…")) { + splitCommandButton(title: String(localized: "menu.find.find", defaultValue: "Find…"), shortcut: menuShortcut(for: .find)) { #if DEBUG dlog("find.menu Cmd+F fired") #endif activeTabManager.startSearch() } - .keyboardShortcut("f", modifiers: .command) - Button(String(localized: "menu.find.findNext", defaultValue: "Find Next")) { + splitCommandButton(title: String(localized: "menu.find.findNext", defaultValue: "Find Next"), shortcut: menuShortcut(for: .findNext)) { activeTabManager.findNext() } - .keyboardShortcut("g", modifiers: .command) - Button(String(localized: "menu.find.findPrevious", defaultValue: "Find Previous")) { + splitCommandButton(title: String(localized: "menu.find.findPrevious", defaultValue: "Find Previous"), shortcut: menuShortcut(for: .findPrevious)) { activeTabManager.findPrevious() } - .keyboardShortcut("g", modifiers: [.command, .shift]) Divider() - Button(String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar")) { + splitCommandButton(title: String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar"), shortcut: menuShortcut(for: .hideFind)) { activeTabManager.hideFind() } - .keyboardShortcut("f", modifiers: [.command, .shift]) .disabled(!(activeTabManager.isFindVisible)) Divider() - Button(String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find")) { + splitCommandButton(title: String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find"), shortcut: menuShortcut(for: .useSelectionForFind)) { activeTabManager.searchSelection() } - .keyboardShortcut("e", modifiers: .command) .disabled(!(activeTabManager.canUseSelectionForFind)) } } // Tab navigation CommandGroup(after: .toolbar) { - splitCommandButton(title: String(localized: "menu.view.toggleSidebar", defaultValue: "Toggle Sidebar"), shortcut: toggleSidebarMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.toggleSidebar", defaultValue: "Toggle Sidebar"), shortcut: menuShortcut(for: .toggleSidebar)) { if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true { sidebarState.toggle() } @@ -726,61 +709,55 @@ struct cmuxApp: App { Divider() - splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: nextSurfaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: menuShortcut(for: .nextSurface)) { activeTabManager.selectNextSurface() } - splitCommandButton(title: String(localized: "menu.view.previousSurface", defaultValue: "Previous Surface"), shortcut: prevSurfaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.previousSurface", defaultValue: "Previous Surface"), shortcut: menuShortcut(for: .prevSurface)) { activeTabManager.selectPreviousSurface() } - Button(String(localized: "menu.view.back", defaultValue: "Back")) { + splitCommandButton(title: String(localized: "menu.view.back", defaultValue: "Back"), shortcut: menuShortcut(for: .browserBack)) { activeTabManager.focusedBrowserPanel?.goBack() } - .keyboardShortcut("[", modifiers: .command) - Button(String(localized: "menu.view.forward", defaultValue: "Forward")) { + splitCommandButton(title: String(localized: "menu.view.forward", defaultValue: "Forward"), shortcut: menuShortcut(for: .browserForward)) { activeTabManager.focusedBrowserPanel?.goForward() } - .keyboardShortcut("]", modifiers: .command) - Button(String(localized: "menu.view.reloadPage", defaultValue: "Reload Page")) { + splitCommandButton(title: String(localized: "menu.view.reloadPage", defaultValue: "Reload Page"), shortcut: menuShortcut(for: .browserReload)) { activeTabManager.focusedBrowserPanel?.reload() } - .keyboardShortcut("r", modifiers: .command) - splitCommandButton(title: String(localized: "menu.view.toggleDevTools", defaultValue: "Toggle Developer Tools"), shortcut: toggleBrowserDeveloperToolsMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.toggleDevTools", defaultValue: "Toggle Developer Tools"), shortcut: menuShortcut(for: .toggleBrowserDeveloperTools)) { let manager = activeTabManager if !manager.toggleDeveloperToolsFocusedBrowser() { NSSound.beep() } } - splitCommandButton(title: String(localized: "menu.view.showJSConsole", defaultValue: "Show JavaScript Console"), shortcut: showBrowserJavaScriptConsoleMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.showJSConsole", defaultValue: "Show JavaScript Console"), shortcut: menuShortcut(for: .showBrowserJavaScriptConsole)) { let manager = activeTabManager if !manager.showJavaScriptConsoleFocusedBrowser() { NSSound.beep() } } - splitCommandButton(title: String(localized: "menu.view.toggleReactGrab", defaultValue: "Toggle React Grab"), shortcut: toggleReactGrabMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.toggleReactGrab", defaultValue: "Toggle React Grab"), shortcut: menuShortcut(for: .toggleReactGrab)) { activeTabManager.toggleReactGrabFocusedBrowser() } - Button(String(localized: "menu.view.zoomIn", defaultValue: "Zoom In")) { + splitCommandButton(title: String(localized: "menu.view.zoomIn", defaultValue: "Zoom In"), shortcut: menuShortcut(for: .browserZoomIn)) { _ = activeTabManager.zoomInFocusedBrowser() } - .keyboardShortcut("=", modifiers: .command) - Button(String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out")) { + splitCommandButton(title: String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out"), shortcut: menuShortcut(for: .browserZoomOut)) { _ = activeTabManager.zoomOutFocusedBrowser() } - .keyboardShortcut("-", modifiers: .command) - Button(String(localized: "menu.view.actualSize", defaultValue: "Actual Size")) { + splitCommandButton(title: String(localized: "menu.view.actualSize", defaultValue: "Actual Size"), shortcut: menuShortcut(for: .browserZoomReset)) { _ = activeTabManager.resetZoomFocusedBrowser() } - .keyboardShortcut("0", modifiers: .command) Button(String(localized: "menu.view.clearBrowserHistory", defaultValue: "Clear Browser History")) { BrowserHistoryStore.shared.clearHistory() @@ -793,33 +770,42 @@ struct cmuxApp: App { } } - splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: menuShortcut(for: .nextSidebarTab)) { activeTabManager.selectNextTab() } - splitCommandButton(title: String(localized: "menu.view.previousWorkspace", defaultValue: "Previous Workspace"), shortcut: prevWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.previousWorkspace", defaultValue: "Previous Workspace"), shortcut: menuShortcut(for: .prevSidebarTab)) { activeTabManager.selectPreviousTab() } - splitCommandButton(title: String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…"), shortcut: renameWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…"), shortcut: menuShortcut(for: .renameWorkspace)) { _ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette() } + splitCommandButton(title: String(localized: "menu.view.editWorkspaceDescription", defaultValue: "Edit Workspace Description…"), shortcut: menuShortcut(for: .editWorkspaceDescription)) { + _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + } + + splitCommandButton(title: String(localized: "command.toggleFullScreen.title", defaultValue: "Toggle Full Screen"), shortcut: menuShortcut(for: .toggleFullScreen)) { + guard let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow else { return } + targetWindow.toggleFullScreen(nil) + } + Divider() - splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: splitRightMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: menuShortcut(for: .splitRight)) { performSplitFromMenu(direction: .right) } - splitCommandButton(title: String(localized: "menu.view.splitDown", defaultValue: "Split Down"), shortcut: splitDownMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitDown", defaultValue: "Split Down"), shortcut: menuShortcut(for: .splitDown)) { performSplitFromMenu(direction: .down) } - splitCommandButton(title: String(localized: "menu.view.splitBrowserRight", defaultValue: "Split Browser Right"), shortcut: splitBrowserRightMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitBrowserRight", defaultValue: "Split Browser Right"), shortcut: menuShortcut(for: .splitBrowserRight)) { performBrowserSplitFromMenu(direction: .right) } - splitCommandButton(title: String(localized: "menu.view.splitBrowserDown", defaultValue: "Split Browser Down"), shortcut: splitBrowserDownMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitBrowserDown", defaultValue: "Split Browser Down"), shortcut: menuShortcut(for: .splitBrowserDown)) { performBrowserSplitFromMenu(direction: .down) } @@ -827,25 +813,35 @@ struct cmuxApp: App { // Numbered workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in - Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) { - let manager = activeTabManager - if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: number, workspaceCount: manager.tabs.count) { - manager.selectTab(at: targetIndex) + let selectWorkspaceByNumberShortcut = menuShortcut(for: .selectWorkspaceByNumber) + if selectWorkspaceByNumberShortcut.hasChord { + Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) { + let manager = activeTabManager + if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: number, workspaceCount: manager.tabs.count) { + manager.selectTab(at: targetIndex) + } } + } else { + Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) { + let manager = activeTabManager + if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: number, workspaceCount: manager.tabs.count) { + manager.selectTab(at: targetIndex) + } + } + .keyboardShortcut( + KeyEquivalent(Character("\(number)")), + modifiers: selectWorkspaceByNumberShortcut.eventModifiers + ) } - .keyboardShortcut( - KeyEquivalent(Character("\(number)")), - modifiers: selectWorkspaceByNumberMenuShortcut.eventModifiers - ) } Divider() - splitCommandButton(title: String(localized: "menu.view.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: menuShortcut(for: .jumpToUnread)) { AppDelegate.shared?.jumpToLatestUnread() } - splitCommandButton(title: String(localized: "menu.view.showNotifications", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.showNotifications", defaultValue: "Show Notifications"), shortcut: menuShortcut(for: .showNotifications)) { showNotificationsPopover() } } @@ -894,120 +890,9 @@ struct cmuxApp: App { SocketControlSettings.migrateMode(socketControlMode) } - private var splitRightMenuShortcut: StoredShortcut { - decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut) - } - - private var toggleSidebarMenuShortcut: StoredShortcut { - decodeShortcut(from: toggleSidebarShortcutData, fallback: KeyboardShortcutSettings.Action.toggleSidebar.defaultShortcut) - } - - private var newWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut(from: newWorkspaceShortcutData, fallback: KeyboardShortcutSettings.Action.newTab.defaultShortcut) - } - - private var newWindowMenuShortcut: StoredShortcut { - decodeShortcut(from: newWindowShortcutData, fallback: KeyboardShortcutSettings.Action.newWindow.defaultShortcut) - } - - private var openFolderMenuShortcut: StoredShortcut { - decodeShortcut(from: openFolderShortcutData, fallback: KeyboardShortcutSettings.Action.openFolder.defaultShortcut) - } - - private var showNotificationsMenuShortcut: StoredShortcut { - decodeShortcut( - from: showNotificationsShortcutData, - fallback: KeyboardShortcutSettings.Action.showNotifications.defaultShortcut - ) - } - - private var jumpToUnreadMenuShortcut: StoredShortcut { - decodeShortcut( - from: jumpToUnreadShortcutData, - fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut - ) - } - - private var nextSurfaceMenuShortcut: StoredShortcut { - decodeShortcut(from: nextSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.nextSurface.defaultShortcut) - } - - private var prevSurfaceMenuShortcut: StoredShortcut { - decodeShortcut(from: prevSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.prevSurface.defaultShortcut) - } - - private var nextWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut( - from: nextWorkspaceShortcutData, - fallback: KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut - ) - } - - private var prevWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut( - from: prevWorkspaceShortcutData, - fallback: KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut - ) - } - - private var selectWorkspaceByNumberMenuShortcut: StoredShortcut { - decodeShortcut( - from: selectWorkspaceByNumberShortcutData, - fallback: KeyboardShortcutSettings.Action.selectWorkspaceByNumber.defaultShortcut - ) - } - - private var splitDownMenuShortcut: StoredShortcut { - decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) - } - - private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut { - decodeShortcut( - from: toggleBrowserDeveloperToolsShortcutData, - fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut - ) - } - - private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut { - decodeShortcut( - from: showBrowserJavaScriptConsoleShortcutData, - fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut - ) - } - - private var toggleReactGrabMenuShortcut: StoredShortcut { - decodeShortcut( - from: toggleReactGrabShortcutData, - fallback: KeyboardShortcutSettings.Action.toggleReactGrab.defaultShortcut - ) - } - - private var splitBrowserRightMenuShortcut: StoredShortcut { - decodeShortcut( - from: splitBrowserRightShortcutData, - fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut - ) - } - - private var splitBrowserDownMenuShortcut: StoredShortcut { - decodeShortcut( - from: splitBrowserDownShortcutData, - fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut - ) - } - - private var renameWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut( - from: renameWorkspaceShortcutData, - fallback: KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut - ) - } - - private var closeWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut( - from: closeWorkspaceShortcutData, - fallback: KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut - ) + private func menuShortcut(for action: KeyboardShortcutSettings.Action) -> StoredShortcut { + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: action) } private var notificationMenuSnapshot: NotificationMenuSnapshot { @@ -1020,14 +905,6 @@ struct cmuxApp: App { ) ?? tabManager } - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut - } - private func notificationMenuItemTitle(for notification: TerminalNotification) -> String { let tabTitle = appDelegate.tabTitle(for: notification.tabId) return MenuBarNotificationLineFormatter.menuTitle(notification: notification, tabTitle: tabTitle) @@ -1167,6 +1044,11 @@ struct cmuxApp: App { } .disabled(workspace == nil) + Button(String(localized: "menu.view.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + } + .disabled(workspace == nil) + if workspace?.hasCustomTitle == true { Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { clearSelectedWorkspaceCustomName(in: manager) @@ -4279,10 +4161,25 @@ enum PreferredEditorSettings { } } +private func openCmuxSettingsFileInEditor() { + let url = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() + PreferredEditorSettings.open(url) +} + +private func openCmuxSettingsFileInTextEdit() { + #if os(macOS) + let fileURL = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() + let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app") + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) + #endif +} + struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 private let notificationSoundControlWidth: CGFloat = 280 + private let shortcutChordsDocsURL = URL(string: "https://cmux.com/docs/keyboard-shortcuts#shortcut-chords")! @AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @@ -4369,6 +4266,7 @@ struct SettingsView: View { @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false @ObservedObject private var notificationStore = TerminalNotificationStore.shared + @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -4389,8 +4287,7 @@ struct SettingsView: View { @State private var telemetryValueAtLaunch = TelemetrySettings.enabledForCurrentLaunch @State private var showLanguageRestartAlert = false @State private var isResettingSettings = false - @State private var workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() - @State private var workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() + @State private var workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() @State private var trustedDirectoriesDraft: String = CmuxDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") @State private var settingsSearchText = "" @State private var activeSection: SettingsSection? @@ -4869,6 +4766,7 @@ struct SettingsView: View { } var body: some View { + let _ = keyboardShortcutSettingsObserver.revision ScrollViewReader { proxy in HStack(spacing: 0) { SettingsTOCSidebar( @@ -5507,71 +5405,68 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardNote(String(localized: "settings.workspaceColors.paletteNote", defaultValue: "Customize the workspace color palette used by Sidebar > Workspace Color. \"Choose Custom Color...\" entries are persisted below.")) - - ForEach(Array(workspaceTabDefaultEntries.enumerated()), id: \.element.name) { index, entry in - if index > 0 { - SettingsCardDivider() - } - SettingsCardRow( - entry.name, - subtitle: String(localized: "settings.workspaceColors.base", defaultValue: "Base: \(baseTabColorHex(for: entry.name))") - ) { - HStack(spacing: 8) { - ColorPicker( - "", - selection: defaultTabColorBinding(for: entry.name), - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) - - Text(entry.hex) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - } - - SettingsCardDivider() + SettingsCardNote( + String( + localized: "settings.workspaceColors.dictionaryNote", + defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." + ) + ) - if workspaceTabCustomColors.isEmpty { - SettingsCardNote(String(localized: "settings.workspaceColors.noCustomColors", defaultValue: "Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu.")) + if workspaceTabPaletteEntries.isEmpty { + SettingsCardNote( + String( + localized: "settings.workspaceColors.emptyPalette", + defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." + ) + ) } else { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.workspaceColors.customColors", defaultValue: "Custom Colors")) - .font(.system(size: 13, weight: .semibold)) - - ForEach(workspaceTabCustomColors, id: \.self) { hex in + ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { + SettingsCardDivider() + } + SettingsCardRow( + entry.name, + subtitle: baseTabColorHex(for: entry.name).map { + String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") + } ?? String( + localized: "settings.workspaceColors.customEntry", + defaultValue: "Named palette entry." + ) + ) { HStack(spacing: 8) { - Circle() - .fill(Color(nsColor: NSColor(hex: hex) ?? .gray)) - .frame(width: 11, height: 11) - - Text(hex) + ColorPicker( + "", + selection: tabColorBinding(for: entry.name), + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(entry.hex) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) - - Spacer(minLength: 8) - - Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { - removeWorkspaceCustomColor(hex) + .frame(width: 76, alignment: .trailing) + + if baseTabColorHex(for: entry.name) == nil { + Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { + removeWorkspaceColor(named: entry.name) + } + .buttonStyle(.bordered) + .controlSize(.small) } - .buttonStyle(.bordered) - .controlSize(.small) } } } - .padding(.horizontal, 14) - .padding(.vertical, 10) } SettingsCardDivider() SettingsCardRow( String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), - subtitle: String(localized: "settings.workspaceColors.resetPalette.subtitle", defaultValue: "Restore built-in defaults and clear all custom colors.") + subtitle: String( + localized: "settings.workspaceColors.resetPalette.subtitleV2", + defaultValue: "Restore the built-in palette and remove extra named colors." + ) ) { Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { resetWorkspaceTabColors() @@ -6119,6 +6014,26 @@ struct SettingsView: View { .id(SettingsSection.keyboardShortcuts) .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { + SettingsCardRow( + String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), + subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") + ) { + HStack(spacing: 8) { + Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) + .font(.caption) + .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") + + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { + openCmuxSettingsFileInTextEdit() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsKeyboardShortcutsOpenSettingsFileButton") + } + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), subtitle: showShortcutHintsOnCommandHold @@ -6235,6 +6150,14 @@ struct SettingsView: View { .font(.system(size: 16, weight: .semibold)) .foregroundColor(.primary.opacity(0.92)) Spacer(minLength: 0) + HStack(spacing: 6) { + SettingsHeaderActionButton( + title: String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json"), + helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), + accessibilityIdentifier: "SettingsFileOpenButton", + action: openCmuxSettingsFileInTextEdit + ) + } } .padding(.leading, settingsTitleLeadingInset) .padding(.trailing, 20) @@ -6454,28 +6377,28 @@ struct SettingsView: View { DispatchQueue.main.async { isResettingSettings = false } } - private func defaultTabColorBinding(for name: String) -> Binding<Color> { + private func tabColorBinding(for name: String) -> Binding<Color> { Binding( get: { - let hex = WorkspaceTabColorSettings.defaultColorHex(named: name) + let hex = WorkspaceTabColorSettings.currentColorHex(named: name) + ?? WorkspaceTabColorSettings.defaultColorHex(named: name) + ?? "#1565C0" return Color(nsColor: NSColor(hex: hex) ?? .systemBlue) }, set: { newValue in let hex = NSColor(newValue).hexString() - WorkspaceTabColorSettings.setDefaultColor(named: name, hex: hex) + WorkspaceTabColorSettings.setColor(named: name, hex: hex) reloadWorkspaceTabColorSettings() } ) } - private func baseTabColorHex(for name: String) -> String { - WorkspaceTabColorSettings.defaultPalette - .first(where: { $0.name == name })? - .hex ?? "#1565C0" + private func baseTabColorHex(for name: String) -> String? { + WorkspaceTabColorSettings.defaultColorHex(named: name) } - private func removeWorkspaceCustomColor(_ hex: String) { - WorkspaceTabColorSettings.removeCustomColor(hex) + private func removeWorkspaceColor(named name: String) { + WorkspaceTabColorSettings.removeColor(named: name) reloadWorkspaceTabColorSettings() } @@ -6485,8 +6408,7 @@ struct SettingsView: View { } private func reloadWorkspaceTabColorSettings() { - workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() - workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() + workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() } private func saveBrowserInsecureHTTPAllowlist() { @@ -6584,6 +6506,35 @@ private struct SettingsCard<Content: View>: View { } } +private struct SettingsHeaderActionButton: View { + let title: String + let helpText: String + let accessibilityIdentifier: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.34)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(Color(nsColor: NSColor.separatorColor).opacity(0.22), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .controlSize(.small) + .help(helpText) + .accessibilityIdentifier(accessibilityIdentifier) + } +} + private struct SettingsCardRow<Trailing: View>: View { let title: String let subtitle: String? @@ -7006,9 +6957,11 @@ private struct ShortcutSettingRow: View { var body: some View { KeyboardShortcutRecorder( label: action.label, + subtitle: KeyboardShortcutSettings.settingsFileManagedSubtitle(for: action), shortcut: $shortcut, displayString: { action.displayedShortcutString(for: $0) }, - transformRecordedShortcut: { action.normalizedRecordedShortcut($0) } + transformRecordedShortcut: { action.normalizedRecordedShortcut($0) }, + isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(action) ) .onChange(of: shortcut) { newValue in KeyboardShortcutSettings.setShortcut(newValue, for: action) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index db922a601ea7..f098eccfff0d 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -13,6 +13,7 @@ private final class FakeWKInspectorContainerView: NSView {} final class AppDelegateShortcutRoutingTests: XCTestCase { private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] private var actionsWithPersistedShortcut: Set<KeyboardShortcutSettings.Action> = [] + private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! private func makeKeyEvent( modifierFlags: NSEvent.ModifierFlags, @@ -51,10 +52,12 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { (action, KeyboardShortcutSettings.shortcut(for: action)) } ) + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore KeyboardShortcutSettings.resetAll() } override func tearDown() { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore AppDelegate.shared?.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) AppDelegate.shared?.debugCloseMainWindowConfirmationHandler = nil AppDelegate.shared?.debugCreateMainWindowSourceIsNativeFullScreenOverride = nil @@ -123,6 +126,538 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should add workspace to the event's window") } + func testChordedNewWorkspaceShortcutConsumesPrefixAndTriggersOnSecondKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and manager") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(manager.tabs.count, initialCount, "Chord prefix must not fire the action early") + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount + 1, "Chord second key should dispatch the configured shortcut") + } + + func testSettingsFileChordDispatchesNewWorkspaceShortcut() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and tab manager") + return + } + + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try """ + { + "shortcuts": { + "newTab": ["ctrl+b", "n"] + } + } + """.write(to: settingsFileURL, atomically: true, encoding: .utf8) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertEqual(manager.tabs.count, initialCount, "Chord prefix must not fire the action early") + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount + 1, "settings.json chord should dispatch the configured shortcut") + } + + func testConfiguredChordPrefixIsClearedWhenAppResignsActive() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and manager") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + appDelegate.applicationWillResignActive(Notification(name: NSApplication.willResignActiveNotification)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount, "Chord suffix should not fire after the app resigns active") + } + + func testConfiguredChordPrefixBeatsConflictingSingleStrokeShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + SettingsWindowController.shared.close() + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and manager") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + let shortcut = StoredShortcut( + key: ",", + command: true, + shift: false, + option: false, + control: false, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: ",", + modifiers: [.command], + keyCode: 43, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+, prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount + 1, "Chord prefix should arm instead of firing Settings") + } + + func testConfiguredChordPrefixBlocksUnrelatedSingleStrokeShortcutOnSecondKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialWorkspaceCount = manager.tabs.count + let initialPanelCount = workspace.panels.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "d" + ) + + withTemporaryShortcut(action: .splitRight, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let conflictingSingleStrokeEvent = makeKeyDownEvent( + key: "n", + modifiers: [.command], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: conflictingSingleStrokeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialWorkspaceCount, "Pending chord should block unrelated single-stroke actions") + XCTAssertEqual(workspace.panels.count, initialPanelCount, "Mismatched second key should not split the workspace") + } + + func testConfiguredChordDoesNotCrossWindowBoundary() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstWindow = window(withId: firstWindowId), + let secondWindow = window(withId: secondWindowId), + let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId) else { + XCTFail("Expected both test windows and managers") + return + } + + firstWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialFirstCount = firstManager.tabs.count + let initialSecondCount = secondManager.tabs.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: firstWindow.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(firstManager.tabs.count, initialFirstCount, "Prefix window should not change without a matching suffix") + XCTAssertEqual(secondManager.tabs.count, initialSecondCount, "Chord suffix in another window must not trigger the action") + } + + func testShortcutChangeClearsPendingConfiguredChord() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialPanelCount = workspace.panels.count + let chordShortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "d" + ) + + withTemporaryShortcut(action: .splitRight, shortcut: chordShortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let suffixEvent = makeKeyDownEvent( + key: "d", + modifiers: [], + keyCode: 2, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct D suffix event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "d", command: true, shift: false, option: false, control: false), + for: .splitRight + ) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: suffixEvent)) +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(workspace.panels.count, initialPanelCount, "Changing shortcuts should discard any pending chord prefix") + } + + func testChordedShortcutMismatchDoesNotConsumeSecondKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialPanelCount = workspace.panels.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "d" + ) + + withTemporaryShortcut(action: .splitRight, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let mismatchEvent = makeKeyDownEvent( + key: "x", + modifiers: [], + keyCode: 7, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct mismatch event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: mismatchEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(workspace.panels.count, initialPanelCount, "Unmatched chord suffix must not trigger the action") + } + func testCreateMainWindowDoesNotDisallowFullScreenTilingByDefault() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -848,6 +1383,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + let defaults = UserDefaults.standard let originalSetting = defaults.object(forKey: appDelegateLastSurfaceCloseShortcutDefaultsKey) defaults.set(false, forKey: appDelegateLastSurfaceCloseShortcutDefaultsKey) @@ -870,6 +1407,10 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + // This test exercises keep-workspace-open semantics, not close-confirm heuristics. + // Mark the shell idle so Cmd+W routes through the immediate close path deterministically. + workspace.updatePanelShellActivityState(panelId: initialPanelId, state: .promptIdle) + guard let event = makeKeyDownEvent( key: "w", modifiers: [.command], @@ -1617,6 +2158,146 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedPaletteWindow?.windowNumber, window.windowNumber) } + func testCmdPStillRequestsCommandPaletteSwitcherWhilePaletteIsVisible() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { appDelegate.setCommandPaletteVisible(false, for: window) } + + let switcherExpectation = expectation(description: "Expected switcher request while command palette is visible") + var observedSwitcherWindow: NSWindow? + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { notification in + observedSwitcherWindow = notification.object as? NSWindow + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + guard let event = makeKeyDownEvent( + key: "p", + modifiers: [.command], + keyCode: 35, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+P event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation], timeout: 1.0) + XCTAssertEqual(observedSwitcherWindow?.windowNumber, window.windowNumber) + } + + func testCmdShiftPStillRequestsCommandPaletteCommandsWhilePaletteIsVisible() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { appDelegate.setCommandPaletteVisible(false, for: window) } + + let paletteExpectation = expectation(description: "Expected commands request while command palette is visible") + var observedPaletteWindow: NSWindow? + let paletteToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRequested, + object: nil, + queue: nil + ) { notification in + observedPaletteWindow = notification.object as? NSWindow + paletteExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(paletteToken) } + + guard let event = makeKeyDownEvent( + key: "P", + modifiers: [.command, .shift], + keyCode: 35, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Shift+P event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [paletteExpectation], timeout: 1.0) + XCTAssertEqual(observedPaletteWindow?.windowNumber, window.windowNumber) + } + + func testCmdFFocusedBrowserKeepsWebContentFirstRouting() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + manager.openBrowser(inWorkspace: workspace.id) != nil else { + XCTFail("Expected focused browser panel") + return + } + + XCTAssertNotNil(manager.focusedBrowserPanel) + XCTAssertNil(manager.focusedBrowserPanel?.searchState) + + guard let event = makeKeyDownEvent( + key: "f", + modifiers: [.command], + keyCode: 3, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+F event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: event), + "Cmd+F should fall through so browser web content gets first chance" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertNil(manager.focusedBrowserPanel?.searchState) + } + func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -2199,6 +2880,68 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedWorkspaceWindow?.windowNumber, window.windowNumber) } + func testCmdShiftERequestsEditWorkspaceDescriptionInCommandPalette() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let descriptionExpectation = expectation(description: "Expected command palette edit workspace description notification") + var observedWorkspaceWindow: NSWindow? + var didObserveDescriptionNotification = false + let descriptionToken = NotificationCenter.default.addObserver( + forName: .commandPaletteEditWorkspaceDescriptionRequested, + object: nil, + queue: nil + ) { notification in + guard !didObserveDescriptionNotification else { return } + didObserveDescriptionNotification = true + observedWorkspaceWindow = notification.object as? NSWindow + descriptionExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(descriptionToken) } + + let renameWorkspaceExpectation = expectation(description: "Rename workspace notification should not fire for Cmd+Shift+E") + renameWorkspaceExpectation.isInverted = true + let renameWorkspaceToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameWorkspaceRequested, + object: nil, + queue: nil + ) { _ in + renameWorkspaceExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(renameWorkspaceToken) } + + guard let event = makeKeyDownEvent( + key: "e", + modifiers: [.command, .shift], + keyCode: 14, // kVK_ANSI_E + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Shift+E event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [descriptionExpectation, renameWorkspaceExpectation], timeout: 1.0) + XCTAssertEqual(observedWorkspaceWindow?.windowNumber, window.windowNumber) + } + func testEscapeDismissesVisibleCommandPaletteAndIsConsumed() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 11785cc1b6ec..03e79250e368 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1213,6 +1213,15 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { XCTAssertFalse(shortcut.shift) XCTAssertFalse(shortcut.control) } + + func testDefaultShortcutForToggleReactGrabAvoidsFindPreviousCollision() { + let shortcut = KeyboardShortcutSettings.Action.toggleReactGrab.defaultShortcut + XCTAssertEqual(shortcut.key, "g") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index d6e64df02a24..25707f471786 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1285,6 +1285,44 @@ final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase { } } +final class WorkspaceRemoteSSHCleanupTests: XCTestCase { + func testOrphanedCMUXRemoteSSHPIDsMatchesOnlyParentOneRelayAndDaemonTransports() { + let psOutput = """ + 101 1 /usr/bin/ssh -N -T -S none -o ControlPath=/tmp/cmux-ssh-501-56080-%C -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini + 102 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + 103 999 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini + 104 1 /usr/bin/ssh -tt cmux-macmini + 105 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56082:127.0.0.1:64050 other-host + 106 1 /usr/bin/ssh -T -S none cmux-macmini /bin/sh + """ + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini" + ), + [101, 102] + ) + } + + func testOrphanedCMUXRemoteSSHPIDsCanRestrictCleanupToSpecificRelayPort() { + let psOutput = """ + 201 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini + 202 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini + 203 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + """ + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + relayPort: 56081 + ), + [202] + ) + } +} + final class TitlebarDoubleClickPreferenceTests: XCTestCase { func testResolvesZoomForFillPreference() { XCTAssertEqual( @@ -2872,6 +2910,286 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertEqual(output, "report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111") } + func testShellIntegrationRelayReportTTYUsesWorkspaceIDInZsh() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-relay-report-tty-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("cmux", isDirectory: false), + contents: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + : > "\(logPath.path)" + _CMUX_TTY_NAME=ttys777 + _cmux_report_tty_via_relay + cat "\(logPath.path)" + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + XCTAssertTrue( + output.contains(#"rpc surface.report_tty {"workspace_id":"11111111-1111-1111-1111-111111111111","tty_name":"ttys777","surface_id":"22222222-2222-2222-2222-222222222222"}"#), + output + ) + } + + func testShellIntegrationRelayPortsKickOmitsSurfaceIDUntilAvailableInZsh() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-relay-kick-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("cmux", isDirectory: false), + contents: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + : > "\(logPath.path)" + _cmux_ports_kick_via_relay refresh + repeat 20; do + [[ -s "\(logPath.path)" ]] && break + sleep 0.05 + done + cat "\(logPath.path)" + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "", + ] + ) + + XCTAssertTrue( + output.contains(#"rpc surface.ports_kick {"workspace_id":"11111111-1111-1111-1111-111111111111","reason":"refresh"}"#), + output + ) + XCTAssertFalse(output.contains("surface_id"), output) + } + + func testShellIntegrationRelayPromptRefreshUsesRefreshReasonInZsh() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-relay-precmd-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("cmux", isDirectory: false), + contents: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + : > "\(logPath.path)" + _CMUX_TTY_REPORTED=1 + _CMUX_PORTS_LAST_RUN=-999 + _cmux_precmd + repeat 20; do + [[ -s "\(logPath.path)" ]] && break + sleep 0.05 + done + cat "\(logPath.path)" + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + XCTAssertTrue( + output.contains(#"rpc surface.ports_kick {"workspace_id":"11111111-1111-1111-1111-111111111111","reason":"refresh","surface_id":"22222222-2222-2222-2222-222222222222"}"#), + output + ) + } + + func testShellIntegrationRelayReportTTYUsesWorkspaceIDInBash() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-bash-relay-report-tty-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("cmux", isDirectory: false), + contents: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + let result = try runInteractiveBash( + cmuxLoadShellIntegration: true, + command: """ + : > "\(logPath.path)" + _CMUX_TTY_NAME=ttys888 + _cmux_report_tty_via_relay + cat "\(logPath.path)" + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + XCTAssertTrue( + result.stdout.contains(#"rpc surface.report_tty {"workspace_id":"11111111-1111-1111-1111-111111111111","tty_name":"ttys888","surface_id":"22222222-2222-2222-2222-222222222222"}"#), + result.stdout + ) + } + + func testShellIntegrationRelayPreexecWorksBeforeSurfaceIDExistsInBash() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-bash-relay-preexec-no-surface-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("cmux", isDirectory: false), + contents: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + let result = try runInteractiveBash( + cmuxLoadShellIntegration: true, + command: """ + : > "\(logPath.path)" + _CMUX_TTY_NAME=ttys889 + _CMUX_TTY_REPORTED=0 + _cmux_preexec_command "python3 -m http.server 8899" + for _cmux_i in $(seq 1 20); do + [ -s "\(logPath.path)" ] && break + sleep 0.05 + done + cat "\(logPath.path)" + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "", + ] + ) + + XCTAssertTrue( + result.stdout.contains(#"rpc surface.report_tty {"workspace_id":"11111111-1111-1111-1111-111111111111","tty_name":"ttys889"}"#), + result.stdout + ) + XCTAssertTrue( + result.stdout.contains(#"rpc surface.ports_kick {"workspace_id":"11111111-1111-1111-1111-111111111111","reason":"command"}"#), + result.stdout + ) + XCTAssertFalse(result.stdout.contains(#""surface_id""#), result.stdout) + } + + func testShellIntegrationRelayPromptRefreshUsesRefreshReasonInBashWithoutPromptNoise() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-bash-relay-prompt-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("cmux", isDirectory: false), + contents: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + let result = try runInteractiveBash( + cmuxLoadShellIntegration: true, + command: """ + : > "\(logPath.path)" + _CMUX_TTY_REPORTED=1 + _CMUX_PORTS_LAST_RUN=-999 + _cmux_prompt_command + for _cmux_i in $(seq 1 20); do + [ -s "\(logPath.path)" ] && break + sleep 0.05 + done + cat "\(logPath.path)" + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + XCTAssertFalse(result.stderr.contains("_cmux_report_tmux_state"), result.stderr) + XCTAssertTrue( + result.stdout.contains(#"rpc surface.ports_kick {"workspace_id":"11111111-1111-1111-1111-111111111111","reason":"refresh","surface_id":"22222222-2222-2222-2222-222222222222"}"#), + result.stdout + ) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, @@ -2968,6 +3286,79 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { return output.trimmingCharacters(in: .whitespacesAndNewlines) } + private func runInteractiveBash( + cmuxLoadShellIntegration: Bool, + command: String, + extraEnvironment: [String: String] = [:] + ) throws -> (stdout: String, stderr: String) { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-bash-shell-integration-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let integrationPath = repoRoot.appendingPathComponent("Resources/shell-integration/cmux-bash-integration.bash") + let rcfilePath = root.appendingPathComponent(".bashrc") + let rcfileContents: String = { + guard cmuxLoadShellIntegration else { return ":\n" } + return """ + . "\(integrationPath.path)" + """ + }() + try rcfileContents.write(to: rcfilePath, atomically: true, encoding: .utf8) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [ + "--noprofile", + "--rcfile", rcfilePath.path, + "-i", + "-c", command + ] + process.environment = [ + "HOME": root.path, + "TERM": "xterm-256color", + "SHELL": "/bin/bash", + "USER": NSUserName(), + ] + if cmuxLoadShellIntegration { + process.environment?["CMUX_SOCKET_PATH"] = root.appendingPathComponent("cmux-test.sock").path + process.environment?["CMUX_TAB_ID"] = "tab-test" + process.environment?["CMUX_PANEL_ID"] = "panel-test" + } + for (key, value) in extraEnvironment { + process.environment?[key] = value + } + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + let deadline = Date().addingTimeInterval(5) + while process.isRunning && Date() < deadline { + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + if process.isRunning { + process.terminate() + process.waitUntilExit() + XCTFail("Timed out waiting for bash to exit") + } + + let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + XCTAssertEqual(process.terminationStatus, 0, error) + return ( + stdout: output.trimmingCharacters(in: .whitespacesAndNewlines), + stderr: error.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + private func writeExecutableScript(at url: URL, contents: String) throws { try contents.write(to: url, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 6a0ec8f5a843..a8b58f7d3af0 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -46,6 +46,32 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(restored.panelTitle(panelId: restoredPanelId), "Readme") } + @MainActor + func testSessionSnapshotSkipsTransientRemoteListeningPorts() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64001, + relayID: "relay-test", + relayToken: String(repeating: "c", count: 64), + localSocketPath: "/tmp/cmux-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.configureRemoteConnection(configuration, autoConnect: false) + workspace.surfaceListeningPorts[panelId] = [6969] + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + let panelSnapshot = try XCTUnwrap(snapshot.panels.first { $0.id == panelId }) + + XCTAssertTrue(panelSnapshot.listeningPorts.isEmpty) + } + func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 6fbdf8638aea..63fbee7d39c5 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -302,6 +302,37 @@ final class CommandPaletteKeyboardNavigationTests: XCTestCase { ) ) } + + func testInlineTextHandlingDisablesPaletteSelectionNavigationRouting() { + XCTAssertTrue( + shouldRouteCommandPaletteSelectionNavigation( + delta: -1, + isInteractive: true, + usesInlineTextHandling: false + ) + ) + XCTAssertFalse( + shouldRouteCommandPaletteSelectionNavigation( + delta: -1, + isInteractive: true, + usesInlineTextHandling: true + ) + ) + XCTAssertFalse( + shouldRouteCommandPaletteSelectionNavigation( + delta: nil, + isInteractive: true, + usesInlineTextHandling: false + ) + ) + XCTAssertFalse( + shouldRouteCommandPaletteSelectionNavigation( + delta: 1, + isInteractive: false, + usesInlineTextHandling: false + ) + ) + } } @@ -596,6 +627,26 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { } } + func testShortcutHintStaysCommandOnlyWhenWorkspaceShortcutIsCustomized() { + let action = KeyboardShortcutSettings.Action.selectWorkspaceByNumber + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "1", command: false, shift: false, option: false, control: true), + for: action + ) + + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + } + } + func testShortcutHintUsesIntentionalHoldDelay() { XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001) } diff --git a/cmuxTests/SidebarMarkdownRendererTests.swift b/cmuxTests/SidebarMarkdownRendererTests.swift new file mode 100644 index 000000000000..ef315c001602 --- /dev/null +++ b/cmuxTests/SidebarMarkdownRendererTests.swift @@ -0,0 +1,29 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SidebarMarkdownRendererTests: XCTestCase { + func testRenderWorkspaceDescriptionPreservesLineBreaks() throws { + let rendered = try XCTUnwrap( + SidebarMarkdownRenderer.renderWorkspaceDescription("First line\nSecond line") + ) + + XCTAssertEqual(String(rendered.characters), "First line\nSecond line") + } + + func testRenderWorkspaceDescriptionPreservesInlineMarkdownAttributes() throws { + let rendered = try XCTUnwrap( + SidebarMarkdownRenderer.renderWorkspaceDescription("**Bold**\n[Link](https://example.com)") + ) + + XCTAssertEqual(String(rendered.characters), "Bold\nLink") + XCTAssertTrue(rendered.runs.contains { $0.inlinePresentationIntent != nil }) + XCTAssertTrue( + rendered.runs.contains { $0.link == URL(string: "https://example.com") } + ) + } +} diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index fddbfc483275..83332f391778 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -460,6 +460,76 @@ final class TabManagerPullRequestProbeTests: XCTestCase { ) } + func testTrackedWorkspaceGitMetadataPollCandidatesExcludeDirectoriesWithoutResolvedGitMetadata() throws { + let fileManager = FileManager.default + let directoryURL = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-git-nonrepo-candidate-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directoryURL) } + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + manager.updateSurfaceDirectory(tabId: workspace.id, surfaceId: panelId, directory: directoryURL.path) + + XCTAssertTrue( + waitForCondition { + manager.activeWorkspaceGitProbePanelIdsForTesting(workspaceId: workspace.id).isEmpty && + manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id) + .isEmpty && + workspace.panelGitBranches[panelId] == nil + } + ) + } + + func testInheritedBackgroundWorkspaceFetchesGitBranchWithoutSelection() throws { + let fileManager = FileManager.default + let repoURL = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-git-inherited-background-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: repoURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: repoURL) } + + try runGit(["init", "-b", "main"], in: repoURL) + try runGit(["config", "user.name", "cmux tests"], in: repoURL) + try runGit(["config", "user.email", "cmux@example.invalid"], in: repoURL) + try "seed\n".write( + to: repoURL.appendingPathComponent("README.md"), + atomically: true, + encoding: .utf8 + ) + try runGit(["add", "README.md"], in: repoURL) + try runGit(["commit", "-m", "Initial commit"], in: repoURL) + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace else { + XCTFail("Expected selected workspace") + return + } + workspace.currentDirectory = repoURL.path + + let backgroundWorkspace = manager.addWorkspace(select: false) + guard let backgroundPanelId = backgroundWorkspace.focusedPanelId else { + XCTFail("Expected background workspace with focused panel") + return + } + + XCTAssertNotEqual(manager.selectedTabId, backgroundWorkspace.id) + XCTAssertTrue( + waitForCondition { + backgroundWorkspace.panelGitBranches[backgroundPanelId]?.branch == "main" + } + ) + XCTAssertEqual(backgroundWorkspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["main"]) + } + func testPeriodicWorkspaceGitMetadataRefreshUpdatesMainWorkspaceAfterCheckoutToFeatureBranch() throws { let fileManager = FileManager.default let repoURL = fileManager.temporaryDirectory.appendingPathComponent("cmux-git-main-refresh-\(UUID().uuidString)") @@ -484,8 +554,8 @@ final class TabManagerPullRequestProbeTests: XCTestCase { return } - workspace.updatePanelDirectory(panelId: panelId, directory: repoURL.path) - workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) + manager.updateSurfaceDirectory(tabId: workspace.id, surfaceId: panelId, directory: repoURL.path) + manager.updateSurfaceGitBranch(tabId: workspace.id, surfaceId: panelId, branch: "main", isDirty: false) XCTAssertEqual( manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id), @@ -581,6 +651,90 @@ final class TabManagerPullRequestProbeTests: XCTestCase { XCTAssertEqual(workspace.pullRequest?.number, 42) } + func testPeriodicWorkspaceGitMetadataRefreshRestoresClearedBranchForStaleTerminal() throws { + let fileManager = FileManager.default + let repoURL = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-git-stale-branch-refresh-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: repoURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: repoURL) } + + try runGit(["init", "-b", "main"], in: repoURL) + try runGit(["config", "user.name", "cmux tests"], in: repoURL) + try runGit(["config", "user.email", "cmux@example.invalid"], in: repoURL) + try "seed\n".write( + to: repoURL.appendingPathComponent("README.md"), + atomically: true, + encoding: .utf8 + ) + try runGit(["add", "README.md"], in: repoURL) + try runGit(["commit", "-m", "Initial commit"], in: repoURL) + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + manager.updateSurfaceDirectory(tabId: workspace.id, surfaceId: panelId, directory: repoURL.path) + manager.updateSurfaceGitBranch(tabId: workspace.id, surfaceId: panelId, branch: "main", isDirty: false) + manager.clearSurfaceGitBranch(tabId: workspace.id, surfaceId: panelId) + + XCTAssertNil(workspace.panelGitBranches[panelId]) + + manager.refreshTrackedWorkspaceGitMetadataForTesting() + + XCTAssertTrue( + waitForCondition { + workspace.panelGitBranches[panelId]?.branch == "main" + } + ) + XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["main"]) + } + + func testRemoteSplitSkipsInitialGitMetadataProbe() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + XCTAssertTrue( + waitForCondition(timeout: 12.0) { + manager.activeWorkspaceGitProbePanelIdsForTesting(workspaceId: workspace.id).isEmpty + } + ) + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64017, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ), + autoConnect: false + ) + + guard let splitPanel = workspace.newTerminalSplit(from: panelId, orientation: .horizontal, focus: false) else { + XCTFail("Expected remote split terminal panel to be created") + return + } + + drainMainQueue() + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteTerminalSurface(splitPanel.id)) + XCTAssertEqual(manager.activeWorkspaceGitProbePanelIdsForTesting(workspaceId: workspace.id), Set<UUID>()) + } + func testResolvedCommandPathFallsBackOutsideAppPATH() throws { let fileManager = FileManager.default let tempDir = fileManager.temporaryDirectory.appendingPathComponent( diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 4a2550b2dfd9..1ff6a3c9cafd 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -244,6 +244,107 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: focusedPanelId)) } + func testSurfaceRelayRPCsReturnResolvedFocusedSurfaceWhenSurfaceIDOmitted() async throws { + let socketPath = makeSocketPath("relay-fallback") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true) + + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + + guard let focusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a focused panel") + return + } + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let reportTTYResponse = try await sendV2RequestAsync( + method: "surface.report_tty", + params: [ + "workspace_id": workspace.id.uuidString, + "tty_name": "ttys999" + ], + to: socketPath + ) + + XCTAssertEqual(reportTTYResponse["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(reportTTYResponse)") + let reportTTYResult = try XCTUnwrap(reportTTYResponse["result"] as? [String: Any], "Unexpected JSON-RPC response: \(reportTTYResponse)") + XCTAssertEqual(reportTTYResult["surface_id"] as? String, focusedPanelId.uuidString) + XCTAssertEqual(workspace.surfaceTTYNames[focusedPanelId], "ttys999") + + let portsKickResponse = try await sendV2RequestAsync( + method: "surface.ports_kick", + params: ["workspace_id": workspace.id.uuidString], + to: socketPath + ) + + XCTAssertEqual(portsKickResponse["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(portsKickResponse)") + let portsKickResult = try XCTUnwrap(portsKickResponse["result"] as? [String: Any], "Unexpected JSON-RPC response: \(portsKickResponse)") + XCTAssertEqual(portsKickResult["surface_id"] as? String, focusedPanelId.uuidString) + } + + func testSurfaceRelayRPCsRejectExplicitUnknownSurfaceID() async throws { + let socketPath = makeSocketPath("relay-invalid") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true) + + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + + let unknownSurfaceId = UUID() + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let reportTTYResponse = try await sendV2RequestAsync( + method: "surface.report_tty", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": unknownSurfaceId.uuidString, + "tty_name": "ttys999" + ], + to: socketPath + ) + + XCTAssertEqual(reportTTYResponse["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(reportTTYResponse)") + let reportTTYError = try XCTUnwrap(reportTTYResponse["error"] as? [String: Any], "Unexpected JSON-RPC response: \(reportTTYResponse)") + XCTAssertEqual(reportTTYError["code"] as? String, "not_found") + let reportTTYData = try XCTUnwrap(reportTTYError["data"] as? [String: Any], "Expected error data payload") + XCTAssertEqual(reportTTYData["surface_id"] as? String, unknownSurfaceId.uuidString) + XCTAssertTrue(workspace.surfaceTTYNames.isEmpty) + + let portsKickResponse = try await sendV2RequestAsync( + method: "surface.ports_kick", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": unknownSurfaceId.uuidString + ], + to: socketPath + ) + + XCTAssertEqual(portsKickResponse["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(portsKickResponse)") + let portsKickError = try XCTUnwrap(portsKickResponse["error"] as? [String: Any], "Unexpected JSON-RPC response: \(portsKickResponse)") + XCTAssertEqual(portsKickError["code"] as? String, "not_found") + let portsKickData = try XCTUnwrap(portsKickError["data"] as? [String: Any], "Expected error data payload") + XCTAssertEqual(portsKickData["surface_id"] as? String, unknownSurfaceId.uuidString) + } + func testWorkspaceCloseRejectsPinnedWorkspace() async throws { let socketPath = makeSocketPath("close-pinned") let manager = TabManager() @@ -376,6 +477,27 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } + private func sendV2RequestAsync( + method: String, + params: [String: Any], + to socketPath: String + ) async throws -> [String: Any] { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + let response = try self.sendV2Request( + method: method, + params: params, + to: socketPath + ) + continuation.resume(returning: response) + } catch { + continuation.resume(throwing: error) + } + } + } + } + private nonisolated func connect(to socketPath: String) throws -> Int32 { let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 51ebaecd73bb..819c45bfa758 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -1,9 +1,9 @@ import XCTest -#if canImport(cmux) -@testable import cmux -#elseif canImport(cmux_DEV) +#if canImport(cmux_DEV) @testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux #endif final class WorkspaceRemoteConnectionTests: XCTestCase { @@ -119,11 +119,13 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") let authURL = relayDir.appendingPathComponent("64008.auth") let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path") + let ttyURL = relayDir.appendingPathComponent("64008.tty") XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "ttys001".write(to: ttyURL, atomically: true, encoding: .utf8)) defer { try? fileManager.removeItem(at: home) } let result = runProcess( @@ -142,6 +144,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } func testRemoteRelayMetadataCleanupScriptPreservesDifferentSocketAddr() { @@ -151,11 +154,13 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") let authURL = relayDir.appendingPathComponent("64009.auth") let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path") + let ttyURL = relayDir.appendingPathComponent("64009.tty") XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "ttys002".write(to: ttyURL, atomically: true, encoding: .utf8)) defer { try? fileManager.removeItem(at: home) } let result = runProcess( @@ -174,6 +179,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } func testRelayZshBootstrapUsesRealHomeHistoryByDefault() throws { @@ -222,6 +228,62 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009") } + func testExecutableSearchPathsIncludesHomebrewAndHomeFallbacks() { + let paths = WorkspaceRemoteSessionController.executableSearchPaths( + environment: [ + "HOME": "/Users/tester", + "PATH": "/usr/bin:/bin", + ], + pathHelperOutput: "PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin\"; export PATH;\n" + ) + + XCTAssertEqual( + paths, + [ + "/usr/bin", + "/bin", + "/Users/tester/.local/bin", + "/Users/tester/go/bin", + "/Users/tester/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + "/opt/homebrew/sbin", + "/usr/local/sbin", + "/usr/sbin", + "/sbin", + ] + ) + } + + func testParsePathHelperPathsExtractsPathEntries() { + XCTAssertEqual( + WorkspaceRemoteSessionController.parsePathHelperPaths( + "PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin\"; export PATH;\n" + ), + [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + ] + ) + } + + func testParsePathHelperPathsIgnoresMANPATHAssignments() { + XCTAssertEqual( + WorkspaceRemoteSessionController.parsePathHelperPaths( + """ + MANPATH="/opt/homebrew/share/man:/usr/share/man"; export MANPATH; + PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin"; export PATH; + """ + ), + [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + ] + ) + } + @MainActor func testRemoteTerminalSurfaceLookupTracksOnlyActiveSSHSurfaces() throws { let workspace = Workspace() @@ -247,6 +309,102 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) } + @MainActor + func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64029, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini", + foregroundAuthToken: "token-a" + ) + + workspace.notifyRemoteForegroundAuthenticationReady(token: "token-a") + workspace.configureRemoteConnection(config, autoConnect: false) + + XCTAssertEqual(workspace.remoteConnectionState, .connecting) + workspace.disconnectRemoteConnection(clearConfiguration: true) + } + + @MainActor + func testForegroundSSHAuthReadyReconnectsConfiguredDisconnectedRemoteWorkspace() { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64030, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini", + foregroundAuthToken: "token-a" + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + + workspace.notifyRemoteForegroundAuthenticationReady(token: "token-a") + + XCTAssertEqual(workspace.remoteConnectionState, .connecting) + workspace.disconnectRemoteConnection(clearConfiguration: true) + } + + @MainActor + func testForegroundSSHAuthReadyBufferedTokenDoesNotReconnectDifferentConfiguration() { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64031, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini", + foregroundAuthToken: "token-b" + ) + + workspace.notifyRemoteForegroundAuthenticationReady(token: "token-a") + workspace.configureRemoteConnection(config, autoConnect: false) + + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + } + + @MainActor + func testForegroundSSHAuthReadyIgnoresMismatchedConfiguredToken() { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64032, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini", + foregroundAuthToken: "token-a" + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + workspace.notifyRemoteForegroundAuthenticationReady(token: "token-b") + + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + } + @MainActor func testRemoteTerminalSessionEndRequestsControlMasterCleanupWhenWorkspaceDemotes() throws { let workspace = Workspace() @@ -909,6 +1067,137 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(scpArgs.contains("-S")) } + func testDaemonTransportArgumentsReuseConfiguredControlPath() { + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: nil, + terminalStartupCommand: "ssh cmux-macmini" + ) + + let arguments = WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( + configuration: configuration, + remotePath: "/remote/cmuxd-remote" + ) + + XCTAssertFalse(arguments.contains("-S")) + XCTAssertTrue(arguments.contains("ControlMaster=no")) + XCTAssertTrue(arguments.contains(where: { $0 == "ControlPath /tmp/cmux-ssh-%C" || $0 == "ControlPath=/tmp/cmux-ssh-%C" })) + XCTAssertTrue(arguments.contains("cmux-macmini")) + XCTAssertTrue(arguments.last?.contains("/remote/cmuxd-remote") ?? false) + } + + func testDaemonTransportArgumentsReuseWhitespaceConfiguredControlPath() { + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: [ + "ControlMaster auto", + "ControlPersist 600", + "ControlPath /tmp/cmux-ssh-%C", + "StrictHostKeyChecking accept-new", + ], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: nil, + terminalStartupCommand: "ssh cmux-macmini" + ) + + let arguments = WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( + configuration: configuration, + remotePath: "/remote/cmuxd-remote" + ) + + XCTAssertFalse(arguments.contains("-S")) + XCTAssertTrue(arguments.contains("ControlMaster=no")) + XCTAssertTrue(arguments.contains(where: { $0 == "ControlPath /tmp/cmux-ssh-%C" || $0 == "ControlPath=/tmp/cmux-ssh-%C" })) + } + + func testReverseRelayControlMasterArgumentsReuseConfiguredControlSocket() throws { + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64007, + relayID: nil, + relayToken: nil, + localSocketPath: nil, + terminalStartupCommand: "ssh cmux-macmini" + ) + + let arguments = try XCTUnwrap( + WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "forward", + forwardSpec: "127.0.0.1:64007:127.0.0.1:54321" + ) + ) + + XCTAssertFalse(arguments.contains("-S")) + XCTAssertTrue(arguments.contains("ControlMaster=no")) + XCTAssertTrue(arguments.contains("ControlPath=/tmp/cmux-ssh-%C")) + XCTAssertTrue(arguments.contains("-O")) + XCTAssertTrue(arguments.contains("forward")) + XCTAssertTrue(arguments.contains("-R")) + XCTAssertTrue(arguments.contains("127.0.0.1:64007:127.0.0.1:54321")) + XCTAssertTrue(arguments.contains("cmux-macmini")) + } + + func testReverseRelayControlMasterArgumentsReuseWhitespaceConfiguredControlSocket() throws { + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: [ + "ControlMaster auto", + "ControlPersist 600", + "ControlPath /tmp/cmux-ssh-%C", + "StrictHostKeyChecking accept-new", + ], + localProxyPort: nil, + relayPort: 64033, + relayID: nil, + relayToken: nil, + localSocketPath: nil, + terminalStartupCommand: "ssh cmux-macmini" + ) + + let arguments = try XCTUnwrap( + WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "forward", + forwardSpec: "127.0.0.1:64033:127.0.0.1:54321" + ) + ) + + XCTAssertFalse(arguments.contains("-S")) + XCTAssertTrue(arguments.contains("ControlMaster=no")) + XCTAssertTrue(arguments.contains(where: { $0 == "ControlPath /tmp/cmux-ssh-%C" || $0 == "ControlPath=/tmp/cmux-ssh-%C" })) + XCTAssertTrue(arguments.contains("-O")) + XCTAssertTrue(arguments.contains("forward")) + } + func testDetectedSSHSessionBracketsIPv6LiteralSCPDestination() { let session = DetectedSSHSession( destination: "lawrence@2001:db8::1", @@ -1505,6 +1794,8 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { case "workspace.rename": return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) case "workspace.remote.configure": + let params = payload["params"] as? [String: Any] ?? [:] + let autoConnect = (params["auto_connect"] as? Bool) ?? true return self.v2Response( id: id, ok: true, @@ -1513,7 +1804,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { "workspace_ref": workspaceRef, "remote": [ "enabled": true, - "state": "connecting", + "state": autoConnect ? "connecting" : "disconnected", ], ] ) @@ -1540,7 +1831,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { "--name", "SSH Workspace", "--port", "2222", "--identity", "/Users/test/.ssh/id_ed25519", - "--ssh-option", "ControlPath=/tmp/cmux-ssh-%C", + "--ssh-option", "ControlPath /tmp/cmux-ssh-%C", "--ssh-option", "StrictHostKeyChecking=accept-new", "cmux-macmini", ], @@ -1552,7 +1843,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertFalse(result.timedOut, result.stderr) XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=connecting\n") + XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=disconnected\n") XCTAssertTrue(result.stderr.isEmpty, result.stderr) let requests = try state.commands.map { line -> [String: Any] in @@ -1578,19 +1869,21 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(configureParams["port"] as? Int, 2222) XCTAssertEqual(configureParams["identity_file"] as? String, "/Users/test/.ssh/id_ed25519") XCTAssertEqual(configureParams["local_socket_path"] as? String, socketPath) - XCTAssertEqual(configureParams["auto_connect"] as? Bool, true) + XCTAssertEqual(configureParams["auto_connect"] as? Bool, false) let relayPort = try XCTUnwrap(configureParams["relay_port"] as? Int) XCTAssertGreaterThan(relayPort, 0) let relayID = try XCTUnwrap(configureParams["relay_id"] as? String) XCTAssertFalse(relayID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) let relayToken = try XCTUnwrap(configureParams["relay_token"] as? String) XCTAssertEqual(relayToken.count, 64) + let foregroundAuthToken = try XCTUnwrap(configureParams["foreground_auth_token"] as? String) + XCTAssertFalse(foregroundAuthToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) let terminalStartupCommand = try XCTUnwrap(configureParams["terminal_startup_command"] as? String) XCTAssertFalse(terminalStartupCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) XCTAssertTrue(sshOptions.contains("ControlMaster=auto")) XCTAssertTrue(sshOptions.contains("ControlPersist=600")) - XCTAssertTrue(sshOptions.contains("ControlPath=/tmp/cmux-ssh-%C")) + XCTAssertTrue(sshOptions.contains("ControlPath /tmp/cmux-ssh-%C")) XCTAssertTrue(sshOptions.contains("StrictHostKeyChecking=accept-new")) // `cmux ssh` should land the user in the new SSH workspace immediately. @@ -1599,6 +1892,353 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(selectParams["window_id"] as? String, windowID) } + @MainActor + func testSSHCommandDoesNotDeferReconnectWhenWhitespaceControlMasterDisablesMultiplexing() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("ssh-controlmaster-no") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let workspaceID = "11111111-1111-1111-1111-111111111111" + let workspaceRef = "workspace:9" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.v2Response( + id: "unknown", + ok: false, + error: ["code": "unexpected", "message": "Unexpected payload"] + ) + } + + switch method { + case "workspace.create": + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceID, + ] + ) + case "workspace.remote.configure": + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceID, + "workspace_ref": workspaceRef, + "remote": [ + "enabled": true, + "state": "connecting", + ], + ] + ) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "ssh", + "--no-focus", + "--port", "2222", + "--ssh-option", "ControlMaster no", + "--ssh-option", "ControlPath /tmp/cmux-ssh-%C", + "cmux-macmini", + ], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=connecting\n") + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + + let requests = try state.commands.map { line -> [String: Any] in + let data = try XCTUnwrap(line.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) + } + XCTAssertEqual( + requests.compactMap { $0["method"] as? String }, + ["workspace.create", "workspace.remote.configure"] + ) + + let configureParams = try XCTUnwrap(requests[1]["params"] as? [String: Any]) + XCTAssertEqual(configureParams["auto_connect"] as? Bool, true) + XCTAssertNil(configureParams["foreground_auth_token"]) + let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) + XCTAssertTrue(sshOptions.contains("ControlMaster no")) + XCTAssertTrue(sshOptions.contains("ControlPath /tmp/cmux-ssh-%C")) + } + + @MainActor + func testSSHBootstrapStartupCommandPassesRemoteInstallScriptAsSingleSSHCommand() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("sshboot") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let workspaceID = "11111111-1111-1111-1111-111111111111" + let workspaceRef = "workspace:8" + let windowID = "22222222-2222-2222-2222-222222222222" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.v2Response( + id: "unknown", + ok: false, + error: ["code": "unexpected", "message": "Unexpected payload"] + ) + } + + switch method { + case "workspace.create": + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceID, + "window_id": windowID, + ] + ) + case "workspace.rename": + return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) + case "workspace.remote.configure": + let params = payload["params"] as? [String: Any] ?? [:] + let autoConnect = (params["auto_connect"] as? Bool) ?? true + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceID, + "workspace_ref": workspaceRef, + "remote": [ + "enabled": true, + "state": autoConnect ? "connecting" : "disconnected", + ], + ] + ) + case "workspace.select": + return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "ssh", + "--name", "SSH Workspace", + "--port", "2222", + "--identity", "/Users/test/.ssh/id_ed25519", + "--ssh-option", "ControlPath=/tmp/cmux-ssh-%C", + "--ssh-option", "StrictHostKeyChecking=accept-new", + "cmux-macmini", + ], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let requests = try state.commands.map { line -> [String: Any] in + let data = try XCTUnwrap(line.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) + } + let createParams = try XCTUnwrap(requests.first?["params"] as? [String: Any]) + let initialCommand = try XCTUnwrap(createParams["initial_command"] as? String) + let configureParams = try XCTUnwrap(requests.dropFirst(2).first?["params"] as? [String: Any]) + let foregroundAuthToken = try XCTUnwrap(configureParams["foreground_auth_token"] as? String) + + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("cmux-ssh-bootstrap-\(UUID().uuidString)") + let fakeBin = tempRoot.appendingPathComponent("bin") + let fakeSSHLog = tempRoot.appendingPathComponent("fake-ssh.jsonl") + let fakeSSH = fakeBin.appendingPathComponent("ssh") + + try fileManager.createDirectory(at: fakeBin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempRoot) } + + let fakeSSHScript = """ + #!/bin/sh + python3 - "$@" <<'PY' + import json + import os + import subprocess + import sys + + args = sys.argv[1:] + with open(os.environ["CMUX_FAKE_SSH_LOG"], "a", encoding="utf-8") as handle: + handle.write(json.dumps(args) + "\\n") + + local_command = None + for index, arg in enumerate(args): + if arg == "-o" and index + 1 < len(args) and args[index + 1].startswith("LocalCommand="): + local_command = args[index + 1].split("=", 1)[1] + break + + if local_command: + subprocess.run(["/bin/sh", "-c", local_command], check=False, env=os.environ.copy()) + PY + cat >/dev/null + exit 0 + """ + try fakeSSHScript.write(to: fakeSSH, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeSSH.path) + + var startupEnvironment = ProcessInfo.processInfo.environment + startupEnvironment["HOME"] = tempRoot.path + startupEnvironment["PATH"] = "\(fakeBin.path):/usr/bin:/bin:/usr/sbin:/sbin" + startupEnvironment["CMUX_FAKE_SSH_LOG"] = fakeSSHLog.path + startupEnvironment["CMUX_SOCKET_PATH"] = socketPath + startupEnvironment["CMUX_WORKSPACE_ID"] = workspaceID + startupEnvironment["CMUX_CLI_SENTRY_DISABLED"] = "1" + startupEnvironment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let foregroundAuthState = MockSocketServerState() + let foregroundAuthHandled = startMockServer(listenerFD: listenerFD, state: foregroundAuthState) { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String, + method == "workspace.remote.foreground_auth_ready" else { + return self.v2Response( + id: "unknown", + ok: false, + error: ["code": "unexpected", "message": "Unexpected payload"] + ) + } + + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceID, + "workspace_ref": workspaceRef, + "remote": [ + "enabled": true, + "state": "connecting", + ], + ] + ) + } + + let startupResult = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", initialCommand], + environment: startupEnvironment, + timeout: 5 + ) + + wait(for: [foregroundAuthHandled], timeout: 5) + XCTAssertFalse(startupResult.timedOut, startupResult.stderr) + XCTAssertEqual(startupResult.status, 0, startupResult.stderr) + + let logLines = try String(contentsOf: fakeSSHLog, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + XCTAssertGreaterThanOrEqual(logLines.count, 2) + + let firstInvocationData = try XCTUnwrap(logLines.first?.data(using: .utf8)) + let firstInvocation = try XCTUnwrap( + JSONSerialization.jsonObject(with: firstInvocationData, options: []) as? [String] + ) + let localCommandArgument = try XCTUnwrap( + firstInvocation.first(where: { $0.hasPrefix("LocalCommand=") }) + ) + let localCommand = String(localCommandArgument.dropFirst("LocalCommand=".count)) + XCTAssertTrue( + firstInvocation.contains(where: { $0.contains("LocalCommand=") && $0.contains("workspace.remote.foreground_auth_ready") }), + "Expected the bootstrap install SSH hop to signal foreground auth readiness via LocalCommand, saw \(firstInvocation)" + ) + XCTAssertTrue( + localCommand.contains("%%s\\n"), + "Expected LocalCommand to percent-escape literal percent signs for OpenSSH, saw \(localCommand)" + ) + let localCommandSyntaxCheck = runProcess( + executablePath: "/bin/sh", + arguments: ["-n", "-c", localCommand], + environment: ProcessInfo.processInfo.environment, + timeout: 5 + ) + XCTAssertEqual( + localCommandSyntaxCheck.status, + 0, + "Expected LocalCommand shell snippet to parse cleanly, stderr: \(localCommandSyntaxCheck.stderr)" + ) + let destinationIndex = try XCTUnwrap(firstInvocation.lastIndex(of: "cmux-macmini")) + let remoteCommandArgs = Array(firstInvocation.suffix(from: firstInvocation.index(after: destinationIndex))) + + XCTAssertEqual( + remoteCommandArgs.count, + 1, + "Expected the staged bootstrap installer to be passed as one SSH remote command, saw \(firstInvocation)" + ) + XCTAssertTrue(remoteCommandArgs[0].contains("/bin/sh -lc"), "Expected a POSIX shell wrapper in \(remoteCommandArgs)") + XCTAssertTrue(remoteCommandArgs[0].contains("set -eu"), "Expected installer command body in \(remoteCommandArgs)") + XCTAssertFalse(remoteCommandArgs.contains("sh")) + XCTAssertFalse(remoteCommandArgs.contains("-c")) + + let secondInvocationData = try XCTUnwrap(logLines.dropFirst().first?.data(using: .utf8)) + let secondInvocation = try XCTUnwrap( + JSONSerialization.jsonObject(with: secondInvocationData, options: []) as? [String] + ) + XCTAssertFalse( + secondInvocation.contains(where: { $0.contains("LocalCommand=") }), + "Expected only the bootstrap install hop to trigger LocalCommand, saw \(secondInvocation)" + ) + + XCTAssertEqual(foregroundAuthState.commands.count, 1) + let foregroundAuthPayloadData = try XCTUnwrap(foregroundAuthState.commands.first?.data(using: .utf8)) + let foregroundAuthPayload = try XCTUnwrap( + JSONSerialization.jsonObject(with: foregroundAuthPayloadData, options: []) as? [String: Any] + ) + XCTAssertEqual(foregroundAuthPayload["method"] as? String, "workspace.remote.foreground_auth_ready") + let foregroundAuthParams = try XCTUnwrap(foregroundAuthPayload["params"] as? [String: Any]) + XCTAssertEqual(foregroundAuthParams["workspace_id"] as? String, workspaceID) + XCTAssertEqual(foregroundAuthParams["foreground_auth_token"] as? String, foregroundAuthToken) + } + @MainActor func testNotifyPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { let cliPath = try bundledCLIPath() diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 8a9687af6943..cb6146269b98 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -194,6 +194,889 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey) XCTAssertEqual(Set(keys).count, keys.count) } + + func testChordedShortcutDisplayDisablesMenuKeyEquivalent() { + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "d" + ) + + XCTAssertEqual(shortcut.displayString, "⌃B D") + XCTAssertNil(shortcut.keyEquivalent) + XCTAssertNil(shortcut.menuItemKeyEquivalent) + } + + func testNumberedChordDisplayUsesChordSuffix() { + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "7" + ) + + XCTAssertEqual( + KeyboardShortcutSettings.Action.selectWorkspaceByNumber.displayedShortcutString(for: shortcut), + "⌃B 1…9" + ) + } + + func testNumberedChordNormalizationTargetsSecondStroke() { + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "7" + ) + + let normalized = KeyboardShortcutSettings.Action.selectWorkspaceByNumber.normalizedRecordedShortcut(shortcut) + XCTAssertEqual(normalized?.key, "b") + XCTAssertEqual(normalized?.chordKey, "1") + } + + func testStoredShortcutDecodesLegacySingleStrokePayload() throws { + let data = """ + {"key":"d","command":true,"shift":false,"option":false,"control":false} + """.data(using: .utf8)! + + let shortcut = try JSONDecoder().decode(StoredShortcut.self, from: data) + + XCTAssertEqual(shortcut.key, "d") + XCTAssertFalse(shortcut.hasChord) + XCTAssertNil(shortcut.chordKey) + } + + func testEscapeCancelDetectionTreatsEscapeCharacterAsCancelEvenWithUnexpectedKeyCode() { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 36 + ) else { + XCTFail("Failed to construct escape-like event") + return + } + + XCTAssertTrue(ShortcutStroke.isEscapeCancelEvent(event)) + XCTAssertNil(ShortcutStroke.from(event: event, requireModifier: false)) + } + + func testEscapeCancelDetectionAllowsModifiedEscapeGeneratingShortcut() { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 33 + ) else { + XCTFail("Failed to construct modified escape-generating event") + return + } + + XCTAssertFalse(ShortcutStroke.isEscapeCancelEvent(event)) + XCTAssertEqual( + ShortcutStroke.from(event: event, requireModifier: false), + ShortcutStroke(key: "[", command: true, shift: false, option: false, control: false) + ) + } + + func testShortcutRecorderStopsRecordingWhenFirstStrokeConfirmationIsRejected() { +#if DEBUG + let button = ShortcutRecorderNSButton(frame: .zero) + button.transformRecordedShortcut = { _ in nil } + button.debugSetPendingChordStart( + ShortcutStroke( + key: "x", + command: true, + shift: false, + option: false, + control: false + ) + ) + + button.performClick(nil) + + XCTAssertFalse(button.debugIsRecording) +#else + XCTFail("Shortcut recorder debug hooks are only available in DEBUG") +#endif + } +} + +final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { + private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! + private let settingsFileBackupsDefaultsKey = "cmux.settingsFile.backups.v1" + + override func setUp() { + super.setUp() + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore + KeyboardShortcutSettings.resetAll() + } + + override func tearDown() { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + KeyboardShortcutSettings.resetAll() + super.tearDown() + } + + func testSettingsFileStoreParsesSingleStrokeChordAndNumberedChord() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "toggleSidebar": "cmd+b", + "newTab": ["ctrl+b", "c"], + "selectWorkspaceByNumber": ["ctrl+b", "7"] + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .toggleSidebar), + StoredShortcut(key: "b", command: true, shift: false, option: false, control: false) + ) + XCTAssertEqual( + store.override(for: .newTab), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + ) + XCTAssertEqual( + store.override(for: .selectWorkspaceByNumber), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "1") + ) + XCTAssertEqual(store.activeSourcePath, settingsFileURL.path) + } + + func testSettingsFileStoreRejectsModifierFreeFirstStroke() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "toggleSidebar": "b", + "newTab": ["b", "c"], + "splitRight": ["ctrl+b", "d"] + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertNil(store.override(for: .toggleSidebar)) + XCTAssertNil(store.override(for: .newTab)) + XCTAssertEqual( + store.override(for: .splitRight), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "d") + ) + } + + func testSettingsFileStoreUsesFallbackOnlyWhenPrimaryIsMissing() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("primary.json", isDirectory: false) + let fallbackURL = directoryURL.appendingPathComponent("fallback.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: fallbackURL + ) + + let fallbackStore = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + XCTAssertEqual( + fallbackStore.override(for: .showNotifications), + StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) + ) + XCTAssertEqual(fallbackStore.activeSourcePath, fallbackURL.path) + + try writeSettingsFile("{ not valid json", to: primaryURL) + + let invalidPrimaryStore = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + XCTAssertNil(invalidPrimaryStore.override(for: .showNotifications)) + XCTAssertEqual(invalidPrimaryStore.activeSourcePath, primaryURL.path) + } + + func testShortcutSettingsFileOverridesPersistedShortcutValues() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "newTab": ["ctrl+b", "c"] + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "n", command: true, shift: false, option: false, control: false), + for: .newTab + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + KeyboardShortcutSettings.shortcut(for: .newTab), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + ) + XCTAssertTrue(KeyboardShortcutSettings.isManagedBySettingsFile(.newTab)) + XCTAssertNotNil(KeyboardShortcutSettings.settingsFileManagedSubtitle(for: .newTab)) + } + + @MainActor + func testReloadConfigurationReloadsShortcutSettingsFile() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "newTab": "cmd+n" + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + KeyboardShortcutSettings.shortcut(for: .newTab), + StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) + ) + + try writeSettingsFile( + """ + { + "shortcuts": { + "newTab": ["ctrl+b", "c"] + } + } + """, + to: settingsFileURL + ) + + GhosttyApp.shared.reloadConfiguration(source: "test.reload_config") + + XCTAssertEqual( + KeyboardShortcutSettings.shortcut(for: .newTab), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + ) + } + + func testManagedShortcutWritesDoNotOverwritePersistedValue() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + let missingSettingsFileURL = directoryURL.appendingPathComponent("missing.json", isDirectory: false) + let persistedShortcut = StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) + let managedShortcut = StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + + KeyboardShortcutSettings.setShortcut(persistedShortcut, for: .newTab) + + try writeSettingsFile( + """ + { + "shortcuts": { + "newTab": ["ctrl+b", "c"] + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .newTab), managedShortcut) + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "t", command: true, shift: false, option: false, control: false), + for: .newTab + ) + + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .newTab), managedShortcut) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: missingSettingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertFalse(KeyboardShortcutSettings.isManagedBySettingsFile(.newTab)) + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .newTab), persistedShortcut) + } + + func testBootstrapCreatesCommentedTemplateWhenPrimaryAndFallbackAreMissing() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL + .appendingPathComponent(".config/cmux", isDirectory: true) + .appendingPathComponent("settings.json", isDirectory: false) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertTrue(FileManager.default.fileExists(atPath: settingsFileURL.path)) + XCTAssertEqual(store.activeSourcePath, settingsFileURL.path) + XCTAssertNil(store.override(for: .newTab)) + + let contents = try String(contentsOf: settingsFileURL, encoding: .utf8) + XCTAssertTrue(contents.contains(#""$schema": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json""#)) + XCTAssertTrue(contents.contains(#""schemaVersion": 1,"#)) + XCTAssertTrue(contents.contains(#"// "app" : {"#)) + XCTAssertTrue(contents.contains(#"// "colors" : {"#)) + XCTAssertTrue(contents.contains(##"// "Red" : "#C0392B""##)) + XCTAssertTrue(contents.contains(#"// "shortcuts" : {"#)) + } + + func testBootstrapDoesNotCreatePrimaryWhenFallbackAlreadyExists() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("primary/settings.json", isDirectory: false) + let fallbackURL = directoryURL.appendingPathComponent("fallback/settings.json", isDirectory: false) + try FileManager.default.createDirectory( + at: fallbackURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try writeSettingsFile( + """ + { + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: fallbackURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + + XCTAssertFalse(FileManager.default.fileExists(atPath: primaryURL.path)) + XCTAssertEqual(store.activeSourcePath, fallbackURL.path) + } + + func testSettingsFileURLForEditingUsesActiveFallbackWithoutCreatingPrimary() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("primary/settings.json", isDirectory: false) + let fallbackURL = directoryURL.appendingPathComponent("fallback/settings.json", isDirectory: false) + try FileManager.default.createDirectory( + at: fallbackURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try writeSettingsFile( + """ + { + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: fallbackURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + + XCTAssertEqual(store.settingsFileURLForEditing().path, fallbackURL.path) + XCTAssertFalse(FileManager.default.fileExists(atPath: primaryURL.path)) + } + + func testSettingsFileURLForEditingPrefersInvalidPrimaryForRepair() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("primary/settings.json", isDirectory: false) + let fallbackURL = directoryURL.appendingPathComponent("fallback/settings.json", isDirectory: false) + try FileManager.default.createDirectory( + at: primaryURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: fallbackURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try writeSettingsFile("{ not valid json", to: primaryURL) + try writeSettingsFile( + """ + { + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: fallbackURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + + XCTAssertEqual(store.settingsFileURLForEditing().path, primaryURL.path) + XCTAssertEqual(store.activeSourcePath, primaryURL.path) + } + + func testSettingsFileStoreParsesJSONCCommentsAndTrailingCommas() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "$schema": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json", + "schemaVersion": 1, + // tmux-like prefix + "shortcuts": { + "bindings": { + "newTab": [ + "ctrl+b", + "c", + ], + }, + }, + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .newTab), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + ) + } + + func testFutureSchemaVersionStillParsesKnownFields() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "schemaVersion": 999, + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .showNotifications), + StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) + ) + } + + func testManagedUserDefaultSettingRestoresBackedUpValueWhenFileSettingIsRemoved() throws { + let defaults = UserDefaults.standard + let managedKey = WorkspaceAutoReorderSettings.key + let previousValue = defaults.object(forKey: managedKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: managedKey) + } else { + defaults.removeObject(forKey: managedKey) + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + defaults.set(false, forKey: managedKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let managedSettingsURL = directoryURL.appendingPathComponent("managed.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "reorderOnNotification": true + } + } + """, + to: managedSettingsURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: managedSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(defaults.object(forKey: managedKey) as? Bool, true) + + let missingSettingsURL = directoryURL.appendingPathComponent("missing.json", isDirectory: false) + _ = KeyboardShortcutSettingsFileStore( + primaryPath: missingSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(defaults.object(forKey: managedKey) as? Bool, false) + XCTAssertNil(defaults.data(forKey: settingsFileBackupsDefaultsKey)) + } + + func testSettingsFileStoreAppliesWorkspaceColorDictionaryAndAllowsRemovingDefaults() throws { + let defaults = UserDefaults.standard + let previousPalette = defaults.dictionary(forKey: WorkspaceTabColorSettings.paletteKey) as? [String: String] + let previousLegacyOverrides = defaults.dictionary(forKey: "workspaceTabColor.defaultOverrides") as? [String: String] + let previousLegacyCustomColors = defaults.array(forKey: "workspaceTabColor.customColors") as? [String] + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + WorkspaceTabColorSettings.reset(defaults: defaults) + if let previousPalette { + defaults.set(previousPalette, forKey: WorkspaceTabColorSettings.paletteKey) + } + if let previousLegacyOverrides { + defaults.set(previousLegacyOverrides, forKey: "workspaceTabColor.defaultOverrides") + } + if let previousLegacyCustomColors { + defaults.set(previousLegacyCustomColors, forKey: "workspaceTabColor.customColors") + } + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + WorkspaceTabColorSettings.reset(defaults: defaults) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "workspaceColors": { + "colors": { + "Blue": "#2244ff", + "Neon Mint": "#00f5d4" + } + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + let palette = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual(palette.map(\.name), ["Blue", "Neon Mint"]) + XCTAssertEqual(palette.map(\.hex), ["#2244FF", "#00F5D4"]) + } + + func testManagedWorkspaceColorsRestoreLegacyPaletteWhenFileSettingIsRemoved() throws { + let defaults = UserDefaults.standard + let previousPalette = defaults.dictionary(forKey: WorkspaceTabColorSettings.paletteKey) as? [String: String] + let previousLegacyOverrides = defaults.dictionary(forKey: "workspaceTabColor.defaultOverrides") as? [String: String] + let previousLegacyCustomColors = defaults.array(forKey: "workspaceTabColor.customColors") as? [String] + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + WorkspaceTabColorSettings.reset(defaults: defaults) + if let previousPalette { + defaults.set(previousPalette, forKey: WorkspaceTabColorSettings.paletteKey) + } + if let previousLegacyOverrides { + defaults.set(previousLegacyOverrides, forKey: "workspaceTabColor.defaultOverrides") + } + if let previousLegacyCustomColors { + defaults.set(previousLegacyCustomColors, forKey: "workspaceTabColor.customColors") + } + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + WorkspaceTabColorSettings.reset(defaults: defaults) + defaults.set(["Blue": "#010203"], forKey: "workspaceTabColor.defaultOverrides") + defaults.set(["#778899"], forKey: "workspaceTabColor.customColors") + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let managedSettingsURL = directoryURL.appendingPathComponent("managed.json", isDirectory: false) + try writeSettingsFile( + """ + { + "workspaceColors": { + "colors": { + "Neon Mint": "#00F5D4" + } + } + } + """, + to: managedSettingsURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: managedSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults).map(\.name), ["Neon Mint"]) + + let missingSettingsURL = directoryURL.appendingPathComponent("missing.json", isDirectory: false) + _ = KeyboardShortcutSettingsFileStore( + primaryPath: missingSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + let restored = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual(restored.first(where: { $0.name == "Blue" })?.hex, "#010203") + XCTAssertEqual(restored.first(where: { $0.name == "Custom 1" })?.hex, "#778899") + XCTAssertNil(defaults.data(forKey: settingsFileBackupsDefaultsKey)) + } + + @MainActor + func testReloadConfigurationReloadsManagedAppSettingsFromSettingsFile() throws { + let defaults = UserDefaults.standard + let managedKey = WorkspacePlacementSettings.placementKey + let previousValue = defaults.object(forKey: managedKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: managedKey) + } else { + defaults.removeObject(forKey: managedKey) + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + defaults.removeObject(forKey: managedKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "newWorkspacePlacement": "top" + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(WorkspacePlacementSettings.current(), .top) + + try writeSettingsFile( + """ + { + "app": { + "newWorkspacePlacement": "end" + } + } + """, + to: settingsFileURL + ) + + GhosttyApp.shared.reloadConfiguration(source: "test.reload_config_app_setting") + + XCTAssertEqual(WorkspacePlacementSettings.current(), .end) + } + + @MainActor + func testManagedWorkspacePlacementChangesDefaultInsertionBehavior() throws { + let defaults = UserDefaults.standard + let managedKey = WorkspacePlacementSettings.placementKey + let previousValue = defaults.object(forKey: managedKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: managedKey) + } else { + defaults.removeObject(forKey: managedKey) + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + defaults.removeObject(forKey: managedKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "newWorkspacePlacement": "top" + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + let manager = TabManager() + guard let first = manager.tabs.first else { + XCTFail("Expected initial workspace") + return + } + + let second = manager.addWorkspace(placementOverride: .end) + let third = manager.addWorkspace(placementOverride: .end) + manager.selectWorkspace(first) + + let inserted = manager.addWorkspace() + + XCTAssertEqual(manager.tabs.map(\.id), [inserted.id, first.id, second.id, third.id]) + XCTAssertEqual(manager.selectedTabId, inserted.id) + } + + private func makeTemporaryDirectory() throws -> URL { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL + } + + private func writeSettingsFile(_ contents: String, to url: URL) throws { + try contents.write(to: url, atomically: true, encoding: .utf8) + } } @@ -211,8 +1094,27 @@ final class WorkspaceShortcutMapperTests: XCTestCase { XCTAssertNil(WorkspaceShortcutMapper.digitForWorkspace(at: 8, workspaceCount: 12)) } } +@MainActor +final class WorkspaceCustomDescriptionTests: XCTestCase { + func testSetCustomDescriptionPreservesMeaningfulLeadingAndTrailingWhitespace() { + let workspace = Workspace() + let description = " line one\n\nline two\n\n" + workspace.setCustomDescription(description) + XCTAssertEqual(workspace.customDescription, description) + XCTAssertTrue(workspace.hasCustomDescription) + } + + func testSetCustomDescriptionClearsWhitespaceOnlyDescriptions() { + let workspace = Workspace() + + workspace.setCustomDescription(" \n\t \n") + + XCTAssertNil(workspace.customDescription) + XCTAssertFalse(workspace.hasCustomDescription) + } +} final class WorkspacePlacementSettingsTests: XCTestCase { func testCurrentPlacementDefaultsToAfterCurrentWhenUnset() { let suiteName = "WorkspacePlacementSettingsTests.Default.\(UUID().uuidString)" @@ -610,6 +1512,15 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { } func testBuiltInPaletteMatchesOriginalPRPalette() { + let palette = WorkspaceTabColorSettings.defaultPalette + XCTAssertEqual(palette.count, 16) + XCTAssertEqual(palette.first?.name, "Red") + XCTAssertEqual(palette.first?.hex, "#C0392B") + XCTAssertEqual(palette.last?.name, "Charcoal") + XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) + } + + func testPaletteFallsBackToBuiltInDefaultsWhenUnset() { let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") @@ -617,16 +1528,11 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { } defer { defaults.removePersistentDomain(forName: suiteName) } - let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults) - XCTAssertEqual(palette.count, 16) - XCTAssertEqual(palette.first?.name, "Red") - XCTAssertEqual(palette.first?.hex, "#C0392B") - XCTAssertEqual(palette.last?.name, "Charcoal") - XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) + XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults), WorkspaceTabColorSettings.defaultPalette) } - func testDefaultOverrideRoundTripFallsBackWhenResetToBase() { - let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)" + func testSetColorRoundTripFallsBackWhenResetToBase() { + let suiteName = "WorkspaceTabColorSettingsTests.SetColor.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") return @@ -635,25 +1541,27 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { let first = WorkspaceTabColorSettings.defaultPalette[0] XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), first.hex ) - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults) + WorkspaceTabColorSettings.setColor(named: first.name, hex: "#00aa33", defaults: defaults) XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), "#00AA33" ) + XCTAssertNotNil(defaults.dictionary(forKey: WorkspaceTabColorSettings.paletteKey)) - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults) + WorkspaceTabColorSettings.setColor(named: first.name, hex: first.hex, defaults: defaults) XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), first.hex ) + XCTAssertNil(defaults.object(forKey: WorkspaceTabColorSettings.paletteKey)) } - func testAddCustomColorPersistsAndDeduplicatesByMostRecent() { - let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)" + func testAddCustomColorCreatesNamedEntriesAndDeduplicatesByHex() { + let suiteName = "WorkspaceTabColorSettingsTests.NamedCustomColors.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") return @@ -674,13 +1582,54 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { ) XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults)) + let customEntries = WorkspaceTabColorSettings.customPaletteEntries(defaults: defaults) + XCTAssertEqual(customEntries.map(\.name), ["Custom 1", "Custom 2"]) + XCTAssertEqual(customEntries.map(\.hex), ["#00AA33", "#112233"]) + } + + func testPaletteDictionaryCanRemoveBuiltInEntriesAndAddNamedOnes() { + let suiteName = "WorkspaceTabColorSettingsTests.DictionaryPalette.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + var palette = Dictionary(uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) }) + palette.removeValue(forKey: "Red") + palette["Neon Mint"] = "#00F5D4" + WorkspaceTabColorSettings.persistPaletteMap(palette, defaults: defaults) + + let resolved = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertFalse(resolved.contains(where: { $0.name == "Red" })) + XCTAssertEqual(resolved.first?.name, "Crimson") + XCTAssertEqual(resolved.last?.name, "Neon Mint") + XCTAssertEqual(resolved.last?.hex, "#00F5D4") + } + + func testLegacyKeysStillResolveIntoEffectivePalette() { + let suiteName = "WorkspaceTabColorSettingsTests.LegacyKeys.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(["Blue": "#010203"], forKey: "workspaceTabColor.defaultOverrides") + defaults.set(["#778899"], forKey: "workspaceTabColor.customColors") + + let resolved = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual( + resolved.first(where: { $0.name == "Blue" })?.hex, + "#010203" + ) XCTAssertEqual( - WorkspaceTabColorSettings.customColors(defaults: defaults), - ["#00AA33", "#112233"] + resolved.first(where: { $0.name == "Custom 1" })?.hex, + "#778899" ) } - func testPaletteIncludesCustomEntriesAndResetClearsAll() { + func testResetClearsNewAndLegacyStorage() { let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") @@ -688,23 +1637,16 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { } defer { defaults.removePersistentDomain(forName: suiteName) } - let first = WorkspaceTabColorSettings.defaultPalette[0] - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults) - _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults) - - let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults) - XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1) - XCTAssertEqual(paletteBeforeReset[0].hex, "#334455") - XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1") - XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899") + WorkspaceTabColorSettings.persistPaletteMap(["Neon Mint": "#00F5D4"], defaults: defaults) + defaults.set(["Blue": "#010203"], forKey: "workspaceTabColor.defaultOverrides") + defaults.set(["#778899"], forKey: "workspaceTabColor.customColors") WorkspaceTabColorSettings.reset(defaults: defaults) - XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), []) - XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), - first.hex - ) + XCTAssertNil(defaults.object(forKey: WorkspaceTabColorSettings.paletteKey)) + XCTAssertNil(defaults.object(forKey: "workspaceTabColor.defaultOverrides")) + XCTAssertNil(defaults.object(forKey: "workspaceTabColor.customColors")) + XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults), WorkspaceTabColorSettings.defaultPalette) } func testDisplayColorLightModeKeepsOriginalHex() { @@ -1400,12 +2342,13 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { ) leftPanel.hostedView.clearSuppressReparentFocus() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertTrue( - leftPanel.surface.debugDesiredFocusState(), - "Clearing reparent-focus suppression should reassert Ghostty focus when the surface still owns first responder" + let focusRecovered = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + leftPanel.surface.debugDesiredFocusState() + }, + object: NSObject() ) + wait(for: [focusRecovered], timeout: 1.0) #else throw XCTSkip("Debug-only regression test") #endif diff --git a/cmuxUITests/WorkspaceDescriptionUITests.swift b/cmuxUITests/WorkspaceDescriptionUITests.swift new file mode 100644 index 000000000000..2b889f706066 --- /dev/null +++ b/cmuxUITests/WorkspaceDescriptionUITests.swift @@ -0,0 +1,341 @@ +import XCTest +import Foundation +import CoreGraphics + +private func workspaceDescriptionPollUntil( + timeout: TimeInterval, + pollInterval: TimeInterval = 0.05, + condition: () -> Bool +) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { + return true + } + if (ProcessInfo.processInfo.systemUptime - start) >= timeout { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } +} + +final class WorkspaceDescriptionUITests: XCTestCase { + private var dataPath = "" + private var launchTag = "" + + override func setUp() { + super.setUp() + continueAfterFailure = false + dataPath = "/tmp/cmux-ui-test-workspace-description-\(UUID().uuidString).json" + launchTag = "ui-tests-workspace-description-\(UUID().uuidString.lowercased())" + try? FileManager.default.removeItem(atPath: dataPath) + } + + func testCmdShiftEAllowsImmediateTypingAndSave() { + let app = configuredApp() + launchAndEnsureForeground(app) + prepareTerminalFocusedWorkspace(app) + + let description = "Cmd Shift E focus note \(String(UUID().uuidString.prefix(8)))" + app.typeKey("e", modifierFlags: [.command, .shift]) + + let editor = requireDescriptionEditor( + in: app, + timeout: 5.0, + failureMessage: "Expected Cmd+Shift+E to open the workspace description editor while terminal is focused" + ) + + app.typeText(description) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForNonExistence(editor, timeout: 5.0), + "Expected Enter to save and dismiss the workspace description editor" + ) + assertSavedDescription(description, in: app) + } + + func testClickingDescriptionEditorAllowsTypingAndSave() { + let app = configuredApp() + launchAndEnsureForeground(app) + prepareTerminalFocusedWorkspace(app) + + let description = "Clicked description note \(String(UUID().uuidString.prefix(8)))" + app.typeKey("e", modifierFlags: [.command, .shift]) + + let editor = requireDescriptionEditor( + in: app, + timeout: 5.0, + failureMessage: "Expected Cmd+Shift+E to open the workspace description editor while terminal is focused" + ) + + clickDescriptionEditor(editor, in: app) + app.typeText(description) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForNonExistence(editor, timeout: 5.0), + "Expected Enter to save and dismiss the workspace description editor after clicking" + ) + assertSavedDescription(description, in: app) + } + + func testShiftEnterInsertsNewlineInsteadOfSubmitting() { + let app = configuredApp() + launchAndEnsureForeground(app) + prepareTerminalFocusedWorkspace(app) + + let token = String(UUID().uuidString.prefix(8)) + let firstLine = "First line \(token)" + let secondLine = "Second line \(token)" + let description = "\(firstLine)\n\(secondLine)" + + app.typeKey("e", modifierFlags: [.command, .shift]) + + let editor = requireDescriptionEditor( + in: app, + timeout: 5.0, + failureMessage: "Expected Cmd+Shift+E to open the workspace description editor before testing Shift+Enter" + ) + + app.typeText(firstLine) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.shift]) + + XCTAssertTrue( + editor.exists, + "Expected Shift+Enter to keep the workspace description editor open for multiline input" + ) + + app.typeText(secondLine) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForNonExistence(editor, timeout: 5.0), + "Expected Enter to save and dismiss the workspace description editor after multiline input" + ) + assertSavedDescription(description, in: app) + } + + func testSidebarRendersSavedDescriptionWithLineBreaks() { + let app = configuredSidebarApp() + launchAndActivate(app) + + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 6.0)) + + let token = String(UUID().uuidString.prefix(8)) + let firstLine = "Sidebar first \(token)" + let secondLine = "Sidebar second \(token)" + let description = "\(firstLine)\n\(secondLine)" + + app.typeKey("e", modifierFlags: [.command, .shift]) + + let editor = requireDescriptionEditor( + in: app, + timeout: 5.0, + failureMessage: "Expected Cmd+Shift+E to open the workspace description editor in a simple workspace" + ) + + app.typeText(firstLine) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.shift]) + app.typeText(secondLine) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + XCTAssertTrue( + waitForNonExistence(editor, timeout: 5.0), + "Expected Enter to save and dismiss the workspace description editor after multiline input" + ) + + let renderedDescription = app + .descendants(matching: .staticText) + .matching(NSPredicate(format: "label == %@", description)) + .firstMatch + + XCTAssertTrue( + workspaceDescriptionPollUntil(timeout: 5.0) { + renderedDescription.exists + }, + "Expected the sidebar to render the saved multiline description with a newline-preserving label" + ) + } + + private func configuredApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_TAG"] = launchTag + return app + } + + private func configuredSidebarApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_TAG"] = launchTag + return app + } + + private func prepareTerminalFocusedWorkspace(_ app: XCUIApplication) { + XCTAssertTrue( + waitForData(keys: ["terminalPaneId", "webViewFocused"], timeout: 10.0), + "Expected goto_split setup data to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + + guard let expectedTerminalPaneId = setup["terminalPaneId"] else { + XCTFail("Missing terminalPaneId in goto_split setup data") + return + } + + app.typeKey("h", modifierFlags: [.command, .control]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId + }, + "Expected Cmd+Ctrl+H to move focus to the terminal pane before opening the description editor" + ) + } + + private func requireDescriptionEditor( + in app: XCUIApplication, + timeout: TimeInterval, + failureMessage: String + ) -> XCUIElement { + guard let editor = firstExistingElement( + candidates: descriptionEditorCandidates(in: app), + timeout: timeout + ) else { + XCTFail(failureMessage) + return app.textViews["CommandPaletteWorkspaceDescriptionEditor"].firstMatch + } + return editor + } + + private func assertSavedDescription(_ description: String, in app: XCUIApplication) { + app.typeKey("e", modifierFlags: [.command, .shift]) + + let editor = requireDescriptionEditor( + in: app, + timeout: 5.0, + failureMessage: "Expected Cmd+Shift+E to reopen the workspace description editor for verification" + ) + + XCTAssertTrue( + waitForEditorValue(editor, expected: description, timeout: 5.0), + "Expected the saved workspace description to be restored when reopening the editor. value=\(String(describing: editor.value))" + ) + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + XCTAssertTrue( + waitForNonExistence(editor, timeout: 5.0), + "Expected Escape to dismiss the workspace description editor after verification" + ) + } + + private func clickDescriptionEditor(_ editor: XCUIElement, in app: XCUIApplication) { + if editor.exists { + editor.click() + return + } + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected app window for description editor click target") + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12)).click() + } + + private func descriptionEditorCandidates(in app: XCUIApplication) -> [XCUIElement] { + [ + app.textViews["CommandPaletteWorkspaceDescriptionEditor"], + app.scrollViews["CommandPaletteWorkspaceDescriptionEditor"], + app.otherElements["CommandPaletteWorkspaceDescriptionEditor"], + ] + } + + private func firstExistingElement( + candidates: [XCUIElement], + timeout: TimeInterval + ) -> XCUIElement? { + var match: XCUIElement? + let found = workspaceDescriptionPollUntil(timeout: timeout) { + for candidate in candidates where candidate.exists { + match = candidate + return true + } + return false + } + return found ? match : nil + } + + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("App activation may fail on headless CI runners", options: options) { + app.launch() + } + + if app.state == .runningForeground { return } + if app.state == .runningBackground { return } + + XCTFail("App failed to start. state=\(app.state.rawValue)") + } + + private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { + app.launch() + let activated = workspaceDescriptionPollUntil(timeout: activateTimeout) { + guard app.state != .runningForeground else { + return true + } + app.activate() + return app.state == .runningForeground + } + if !activated { + app.activate() + } + XCTAssertTrue( + workspaceDescriptionPollUntil(timeout: 2.0) { app.state == .runningForeground }, + "App did not reach runningForeground before UI interactions" + ) + } + + private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { + workspaceDescriptionPollUntil(timeout: timeout) { + guard let data = self.loadData() else { return false } + return keys.allSatisfy { data[$0] != nil } + } + } + + private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { + workspaceDescriptionPollUntil(timeout: timeout) { + guard let data = self.loadData() else { return false } + return predicate(data) + } + } + + private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + + private func waitForEditorValue(_ editor: XCUIElement, expected: String, timeout: TimeInterval) -> Bool { + workspaceDescriptionPollUntil(timeout: timeout) { + guard editor.exists else { return false } + let value = (editor.value as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value == expected + } + } + + private func loadData() -> [String: String]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else { + return nil + } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: String] + } +} diff --git a/tests/cmux.py b/tests/cmux.py index 60b6c8e0770f..9bf77e4437f3 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -752,6 +752,24 @@ def clear_ports(self, tab: str = None) -> None: if not response.startswith("OK"): raise cmuxError(response) + def set_agent_pid(self, key: str, pid: int, tab: str = None) -> None: + """Register an agent PID for workspace-scoped ownership tracking.""" + cmd = f"set_agent_pid {key} {pid}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_agent_pid(self, key: str, tab: str = None) -> None: + """Clear a previously registered agent PID.""" + cmd = f"clear_agent_pid {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + def report_tty(self, tty_name: str, tab: str = None, panel: str = None) -> None: """Register a TTY for batched port scanning.""" cmd = f"report_tty {tty_name}" diff --git a/tests/test_codex_hook_agent_ports.py b/tests/test_codex_hook_agent_ports.py new file mode 100644 index 000000000000..7257232cf138 --- /dev/null +++ b/tests/test_codex_hook_agent_ports.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +""" +E2E regression test for Codex hook agent PID registration and sidebar ports. + +Validates: +1) `cmux codex-hook session-start` records the inferred agent root PID. +2) a dev server launched under that agent process tree appears in sidebar ports. +3) the port disappears once the agent process tree exits. +""" + +from __future__ import annotations + +import json +import os +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from claude_teams_test_utils import resolve_cmux_cli +from cmux import cmux, cmuxError + + +_PREFERRED_BIND_HOST = "127.0.0.1" + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" ") or "=" not in line: + continue + key, value = line.split("=", 1) + data[key.strip()] = v.strip() if (v := value.strip()) else "" + return data + + +def _wait_for(predicate, timeout: float, interval: float, label: str): + start = time.time() + last_error: Exception | None = None + while time.time() - start < timeout: + try: + value = predicate() + if value: + return value + except Exception as exc: + last_error = exc + time.sleep(interval) + if last_error is not None: + raise AssertionError(f"Timed out waiting for {label}. Last error: {last_error}") + raise AssertionError(f"Timed out waiting for {label}.") + + +def _find_free_port() -> int: + for _ in range(50): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((_PREFERRED_BIND_HOST, 0)) + return int(sock.getsockname()[1]) + finally: + try: + sock.close() + except Exception: + pass + raise RuntimeError("Failed to find a free test port.") + + +def _wait_for_lsof_listen_pid(port: int, expected_pid: int | None, timeout: float = 8.0) -> int: + def pred(): + result = subprocess.run( + ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + pids = [] + for line in (result.stdout or "").splitlines(): + line = line.strip() + if not line: + continue + try: + pids.append(int(line)) + except ValueError: + continue + if not pids: + return None + if expected_pid is not None and expected_pid not in pids: + return None + return expected_pid if expected_pid is not None else pids[0] + + return int(_wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof LISTEN pid for {port}")) + + +def _wait_for_lsof_listen_gone(port: int, timeout: float = 8.0) -> None: + def pred(): + result = subprocess.run( + ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], + capture_output=True, + text=True, + ) + return result.returncode != 0 or not (result.stdout or "").strip() + + _wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof no LISTEN for {port}") + + +def _wait_for_port(client: cmux, workspace_id: str, port: int, timeout: float = 18.0) -> dict[str, str]: + def pred(): + state = _parse_sidebar_state(client.sidebar_state(tab=workspace_id)) + raw = state.get("ports", "") + if raw == "none" or not raw: + return None + try: + ports = {int(item.strip()) for item in raw.split(",") if item.strip()} + except ValueError: + return None + return state if port in ports else None + + return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports include {port}") + + +def _wait_for_port_absent(client: cmux, workspace_id: str, port: int, timeout: float = 18.0) -> dict[str, str]: + def pred(): + state = _parse_sidebar_state(client.sidebar_state(tab=workspace_id)) + raw = state.get("ports", "") + if raw == "none" or not raw: + return state + try: + ports = {int(item.strip()) for item in raw.split(",") if item.strip()} + except ValueError: + return state + return state if port not in ports else None + + return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports do not include {port}") + + +def _terminate_process_group(proc: subprocess.Popen | None) -> None: + if proc is None: + return + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + return + except Exception: + try: + proc.terminate() + except Exception: + return + try: + proc.wait(timeout=3.0) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except Exception: + try: + proc.kill() + except Exception: + pass + try: + proc.wait(timeout=2.0) + except Exception: + pass + + +def _start_fake_codex_launcher( + base: Path, + cli_path: str, + socket_path: str, + workspace_id: str, + surface_id: str, + state_path: Path, + session_id: str, + cwd: Path, + port: int, +) -> tuple[subprocess.Popen, Path, Path, Path]: + launcher_script = base / "fake_codex_launcher.py" + suffix = session_id.replace("/", "-") + ready_file = base / f"fake_codex_ready_{suffix}" + start_file = base / f"fake_codex_start_{suffix}" + server_pid_file = base / f"fake_codex_server_{suffix}.pid" + server_log_file = base / f"fake_codex_server_{suffix}.log" + launcher_script.write_text( + """#!/usr/bin/env python3 +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +host = os.environ["CMUX_TEST_BIND_HOST"] +port = int(os.environ["CMUX_TEST_PORT"]) +cli_path = os.environ["CMUX_TEST_CLI_PATH"] +socket_path = os.environ["CMUX_TEST_SOCKET_PATH"] +workspace_id = os.environ["CMUX_WORKSPACE_ID"] +surface_id = os.environ["CMUX_SURFACE_ID"] +state_path = os.environ["CMUX_CLAUDE_HOOK_STATE_PATH"] +session_id = os.environ["CMUX_TEST_SESSION_ID"] +cwd = os.environ["CMUX_TEST_CWD"] +ready_file = Path(os.environ["CMUX_TEST_READY_FILE"]) +start_file = Path(os.environ["CMUX_TEST_START_FILE"]) +server_pid_file = Path(os.environ["CMUX_TEST_SERVER_PID_FILE"]) +server_log_file = Path(os.environ["CMUX_TEST_SERVER_LOG_FILE"]) + +hook_env = os.environ.copy() +hook_env["CMUX_SOCKET_PATH"] = socket_path +hook_env["CMUX_WORKSPACE_ID"] = workspace_id +hook_env["CMUX_SURFACE_ID"] = surface_id +hook_env["CMUX_CLAUDE_HOOK_STATE_PATH"] = state_path +payload = json.dumps({"session_id": session_id, "cwd": cwd}) +result = subprocess.run( + [cli_path, "--socket", socket_path, "codex-hook", "session-start"], + input=payload, + text=True, + capture_output=True, + env=hook_env, + check=False, +) +if result.returncode != 0: + raise SystemExit( + f"codex-hook session-start failed: exit={result.returncode} " + f"stdout={result.stdout!r} stderr={result.stderr!r}" + ) + +ready_file.write_text("ok", encoding="utf-8") + +def _handle_term(signum, frame): + raise KeyboardInterrupt + +signal.signal(signal.SIGTERM, _handle_term) +signal.signal(signal.SIGINT, _handle_term) + +server = None +log_handle = None +try: + while True: + if server is None and start_file.exists(): + log_handle = server_log_file.open("w", encoding="utf-8") + server = subprocess.Popen( + [sys.executable, "-m", "http.server", str(port), "--bind", host], + cwd=cwd, + stdout=log_handle, + stderr=subprocess.STDOUT, + ) + server_pid_file.write_text(str(server.pid), encoding="utf-8") + time.sleep(0.1 if server is None else 1.0) +except KeyboardInterrupt: + pass +finally: + if server is not None: + server.terminate() + try: + server.wait(timeout=3.0) + except subprocess.TimeoutExpired: + server.kill() + if log_handle is not None: + log_handle.close() +""", + encoding="utf-8", + ) + + env = os.environ.copy() + env["CMUX_TEST_BIND_HOST"] = _PREFERRED_BIND_HOST + env["CMUX_TEST_PORT"] = str(port) + env["CMUX_TEST_CLI_PATH"] = cli_path + env["CMUX_TEST_SOCKET_PATH"] = socket_path + env["CMUX_WORKSPACE_ID"] = workspace_id + env["CMUX_SURFACE_ID"] = surface_id + env["CMUX_CLAUDE_HOOK_STATE_PATH"] = str(state_path) + env["CMUX_TEST_SESSION_ID"] = session_id + env["CMUX_TEST_CWD"] = str(cwd) + env["CMUX_TEST_READY_FILE"] = str(ready_file) + env["CMUX_TEST_START_FILE"] = str(start_file) + env["CMUX_TEST_SERVER_PID_FILE"] = str(server_pid_file) + env["CMUX_TEST_SERVER_LOG_FILE"] = str(server_log_file) + + proc = subprocess.Popen( + [sys.executable, str(launcher_script)], + cwd=str(base), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + start_new_session=True, + ) + return proc, ready_file, start_file, server_pid_file + + +def fail(message: str) -> int: + print(f"FAIL: {message}") + return 1 + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + return fail(str(exc)) + + state_path = Path(tempfile.gettempdir()) / f"cmux_codex_hook_state_{os.getpid()}.json" + lock_path = Path(str(state_path) + ".lock") + base = Path(tempfile.gettempdir()) / f"cmux_codex_ports_{os.getpid()}" + launcher_procs: list[subprocess.Popen] = [] + + try: + if base.exists(): + shutil.rmtree(base) + base.mkdir(parents=True, exist_ok=True) + if state_path.exists(): + state_path.unlink() + if lock_path.exists(): + lock_path.unlink() + + project_dir = base / "project" + project_dir.mkdir(parents=True, exist_ok=True) + session_ids = [f"codex-sess-{uuid.uuid4().hex}", f"codex-sess-{uuid.uuid4().hex}"] + ports: list[int] = [] + while len(ports) < 2: + candidate = _find_free_port() + if candidate not in ports: + ports.append(candidate) + + with cmux() as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + surfaces = client.list_surfaces(tab=workspace_id) + if not surfaces: + return fail("Expected at least one surface in new workspace") + + focused = next((surface for surface in surfaces if surface[2]), surfaces[0]) + surface_id = focused[1] + + launcher_infos: list[tuple[subprocess.Popen, str, int, Path, Path, Path]] = [] + for session_id, port in zip(session_ids, ports): + launcher_proc, ready_file, start_file, server_pid_file = _start_fake_codex_launcher( + base=base, + cli_path=cli_path, + socket_path=client.socket_path, + workspace_id=workspace_id, + surface_id=surface_id, + state_path=state_path, + session_id=session_id, + port=port, + cwd=project_dir, + ) + launcher_procs.append(launcher_proc) + launcher_infos.append((launcher_proc, session_id, port, ready_file, start_file, server_pid_file)) + + for _, session_id, port, ready_file, _, server_pid_file in launcher_infos: + _wait_for(lambda: ready_file.exists(), timeout=6.0, interval=0.1, label=f"{session_id} ready file") + if server_pid_file.exists(): + return fail(f"Server for {session_id} should not exist before start trigger") + + if not state_path.exists(): + return fail(f"Expected state file at {state_path}") + with state_path.open("r", encoding="utf-8") as handle: + state_data = json.load(handle) + sessions = state_data.get("sessions") or {} + for launcher_proc, session_id, _, _, _, _ in launcher_infos: + session_row = sessions.get(session_id) + if not session_row: + return fail(f"Expected mapped session row for {session_id} after codex session-start") + if session_row.get("pid") != launcher_proc.pid: + return fail( + f"Expected codex hook to store launcher pid {launcher_proc.pid} for {session_id}, " + f"got {session_row.get('pid')!r}" + ) + + for _, _, port, _, _, _ in launcher_infos: + _wait_for_port_absent(client, workspace_id, port, timeout=3.0) + + for _, session_id, port, _, start_file, server_pid_file in launcher_infos: + start_file.write_text("start", encoding="utf-8") + _wait_for(lambda: server_pid_file.exists(), timeout=6.0, interval=0.1, label=f"{session_id} server pid file") + server_pid = int(server_pid_file.read_text(encoding="utf-8").strip()) + _wait_for_lsof_listen_pid(port, expected_pid=server_pid, timeout=8.0) + _wait_for_port(client, workspace_id, port, timeout=18.0) + + first_launcher, first_session_id, first_port, _, _, _ = launcher_infos[0] + _terminate_process_group(first_launcher) + launcher_procs = [proc for proc in launcher_procs if proc.pid != first_launcher.pid] + _wait_for_lsof_listen_gone(first_port, timeout=8.0) + _wait_for_port_absent(client, workspace_id, first_port, timeout=18.0) + _wait_for_port(client, workspace_id, ports[1], timeout=18.0) + client.clear_agent_pid(f"codex.{first_session_id}", tab=workspace_id) + + second_launcher, second_session_id, second_port, _, _, _ = launcher_infos[1] + _terminate_process_group(second_launcher) + launcher_procs = [proc for proc in launcher_procs if proc.pid != second_launcher.pid] + _wait_for_lsof_listen_gone(second_port, timeout=8.0) + _wait_for_port_absent(client, workspace_id, second_port, timeout=18.0) + client.clear_agent_pid(f"codex.{second_session_id}", tab=workspace_id) + + print("PASS: Codex hook agent PID registration keeps multiple agent-owned ports accurate") + return 0 + + except (cmuxError, RuntimeError, AssertionError, ValueError) as exc: + return fail(str(exc)) + finally: + for launcher_proc in launcher_procs: + _terminate_process_group(launcher_proc) + try: + if state_path.exists(): + state_path.unlink() + if lock_path.exists(): + lock_path.unlink() + shutil.rmtree(base, ignore_errors=True) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_ports.py b/tests/test_sidebar_ports.py index 0fd989662993..4548c9e8993c 100644 --- a/tests/test_sidebar_ports.py +++ b/tests/test_sidebar_ports.py @@ -13,6 +13,7 @@ import os import shutil +import signal import socket import subprocess import sys @@ -96,6 +97,31 @@ def _start_external_server(base: Path, port: int) -> subprocess.Popen: return proc +def _start_agent_server(base: Path, port: int, pid_file: Path, log_file: Path) -> subprocess.Popen: + """ + Start a long-lived "agent" shell outside cmux. The shell owns a child + http.server, which should be attributed to the workspace only after the + shell PID is registered via set_agent_pid. + """ + script = ( + f"rm -f {pid_file} {log_file}; " + f"python3 -m http.server {port} --bind {_PREFERRED_BIND_HOST} > {log_file} 2>&1 & " + f"echo $! > {pid_file}; " + "wait" + ) + proc = subprocess.Popen( + ["/bin/bash", "-lc", script], + cwd=str(base), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + _wait_for(lambda: pid_file.exists(), timeout=4.0, interval=0.1, label="agent pid file") + child_pid = int(pid_file.read_text(encoding="utf-8").strip()) + _wait_for_lsof_listen_pid(port, expected_pid=child_pid, timeout=8.0) + return proc + + def _wait_for_port(client: cmux, port: int, timeout: float = 18.0) -> dict[str, str]: def pred(): state = _parse_sidebar_state(client.sidebar_state()) @@ -200,15 +226,46 @@ def pred(): _wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof no LISTEN for {port}") +def _terminate_process_group(proc: subprocess.Popen | None) -> None: + if proc is None: + return + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + return + except Exception: + try: + proc.terminate() + except Exception: + return + try: + proc.wait(timeout=3.0) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except Exception: + try: + proc.kill() + except Exception: + pass + try: + proc.wait(timeout=2.0) + except Exception: + pass + + def main() -> int: tag = os.environ.get("CMUX_TAG") or "" if not tag: print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.") base = Path("/tmp") / f"cmux_ports_test_{os.getpid()}" - pid_file = base / "server.pid" - log_file = base / "server.log" + tab_pid_file = base / "tab-server.pid" + tab_log_file = base / "tab-server.log" + agent_pid_file = base / "agent-server.pid" + agent_log_file = base / "agent-server.log" external_proc: subprocess.Popen | None = None + agent_proc: subprocess.Popen | None = None try: if base.exists(): @@ -255,13 +312,13 @@ def main() -> int: _wait_for_lsof_listen_gone(port, timeout=8.0) # Start a server in the background and capture its PID so we can clean up. - client.send(f"rm -f {pid_file} {log_file}\n") + client.send(f"rm -f {tab_pid_file} {tab_log_file}\n") client.send( - f"python3 -m http.server {port} --bind {_PREFERRED_BIND_HOST} > {log_file} 2>&1 & echo $! > {pid_file}\n" + f"python3 -m http.server {port} --bind {_PREFERRED_BIND_HOST} > {tab_log_file} 2>&1 & echo $! > {tab_pid_file}\n" ) - _wait_for(lambda: pid_file.exists(), timeout=4.0, interval=0.1, label="pid file") - pid = int(pid_file.read_text(encoding="utf-8").strip()) + _wait_for(lambda: tab_pid_file.exists(), timeout=4.0, interval=0.1, label="pid file") + pid = int(tab_pid_file.read_text(encoding="utf-8").strip()) # Ensure the server is actually listening (sanity check + reduces flakiness). _wait_for_lsof_listen_pid(port, expected_pid=pid, timeout=8.0) @@ -275,6 +332,24 @@ def main() -> int: _wait_for_lsof_listen_gone(port, timeout=8.0) _wait_for_port_absent(client, port, timeout=18.0) + # Agent-owned descendant processes should stay hidden until the agent PID is + # explicitly registered for this workspace. + agent_port = _find_free_allowed_port() + agent_proc = _start_agent_server(base, agent_port, agent_pid_file, agent_log_file) + client.ports_kick(tab=new_tab_id) + _assert_port_absent_for_duration(client, agent_port, duration=3.0) + + client.set_agent_pid("test_agent", agent_proc.pid, tab=new_tab_id) + client.ports_kick(tab=new_tab_id) + _wait_for_port(client, agent_port, timeout=18.0) + + client.clear_agent_pid("test_agent", tab=new_tab_id) + _wait_for_port_absent(client, agent_port, timeout=18.0) + + _terminate_process_group(agent_proc) + agent_proc = None + _wait_for_lsof_listen_gone(agent_port, timeout=8.0) + try: client.close_tab(new_tab_id) except Exception: @@ -296,6 +371,7 @@ def main() -> int: external_proc.kill() except Exception: pass + _terminate_process_group(agent_proc) try: shutil.rmtree(base) except Exception: diff --git a/tests/test_ssh_relay_report_tty_retry.py b/tests/test_ssh_relay_report_tty_retry.py new file mode 100644 index 000000000000..4ced860a2b94 --- /dev/null +++ b/tests/test_ssh_relay_report_tty_retry.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Regression: relay-backed shell integration must keep retrying report_tty until +the app returns an OK JSON-RPC response, not just a successful transport round +trip. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SHELL_DIR = ROOT / "Resources" / "shell-integration" + + +def _write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def _run_shell( + shell: str, + shell_args: list[str], + integration_path: Path, + tty_name: str, + bin_dir: Path, + log_path: Path, + state_path: Path, +) -> tuple[int, str]: + env = dict(os.environ) + env.update( + { + "PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_TEST_LOG": str(log_path), + "CMUX_TEST_STATE": str(state_path), + "CMUX_TEST_BIN_DIR": str(bin_dir), + } + ) + command = f""" +source "{integration_path}" +PATH="$CMUX_TEST_BIN_DIR:$PATH" +hash -r 2>/dev/null || true +: > "{log_path}" +rm -f "{state_path}" +_CMUX_TTY_NAME={tty_name} +_CMUX_TTY_REPORTED=0 +_cmux_report_tty_once || true +first="${{_CMUX_TTY_REPORTED}}:$(wc -l < "{log_path}" | tr -d ' ')" +_cmux_report_tty_once || true +second="${{_CMUX_TTY_REPORTED}}:$(wc -l < "{log_path}" | tr -d ' ')" +printf '%s\\n' "$first|$second" +""".strip() + result = subprocess.run( + [shell, *shell_args, command], + env=env, + capture_output=True, + text=True, + timeout=8, + check=False, + ) + return result.returncode, ((result.stdout or "") + (result.stderr or "")).strip() + + +def main() -> int: + failures: list[str] = [] + + with tempfile.TemporaryDirectory(prefix="cmux-ssh-relay-report-tty-retry-") as td: + tmp = Path(td) + bin_dir = tmp / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + log_path = tmp / "relay.log" + state_path = tmp / "relay.state" + + _write_executable( + bin_dir / "cmux", + """#!/bin/sh +count=0 +if [ -r "$CMUX_TEST_STATE" ]; then + count=$(cat "$CMUX_TEST_STATE") +fi +count=$((count + 1)) +printf '%s' "$count" > "$CMUX_TEST_STATE" +printf '%s\n' "$*" >> "$CMUX_TEST_LOG" +if [ "$count" -eq 1 ]; then + printf '%s\n' '{"ok":false,"error":{"code":"not_found"}}' +else + printf '%s\n' '{"ok":true,"result":{}}' +fi +""", + ) + + cases = [ + ( + "zsh", + ["-f", "-c"], + SHELL_DIR / "cmux-zsh-integration.zsh", + "ttys777", + ), + ( + "bash", + ["--noprofile", "--norc", "-c"], + SHELL_DIR / "cmux-bash-integration.bash", + "ttys888", + ), + ] + + for shell, shell_args, integration_path, tty_name in cases: + code, output = _run_shell( + shell=shell, + shell_args=shell_args, + integration_path=integration_path, + tty_name=tty_name, + bin_dir=bin_dir, + log_path=log_path, + state_path=state_path, + ) + if code != 0: + failures.append(f"{shell} exited {code}: {output}") + continue + if output != "0:1|1:2": + failures.append(f"{shell} expected 0:1|1:2, got {output!r}") + + if failures: + for failure in failures: + print(f"FAIL: {failure}") + return 1 + + print("PASS: relay report_tty retries until the app returns ok=true") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index 9764da35c41a..3bd6d764457d 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -3,6 +3,7 @@ from __future__ import annotations +import base64 import glob import json import os @@ -115,6 +116,14 @@ def _append_workspace_to_cleanup(workspaces_to_close: list[str], workspace_id: s return workspace_id +def _find_workspace_row(client: cmux, workspace_id: str) -> dict | None: + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + return row + return None + + def main() -> int: cli = _find_cli_binary() help_text = _run_cli(cli, ["ssh", "--help"], json_output=False) @@ -127,18 +136,16 @@ def main() -> int: workspace_id_case_override = "" workspace_id_invalid_proxy_port = "" workspaces_to_close: list[str] = [] + ssh_workspace_name = "ssh-meta-test" with cmux(SOCKET_PATH) as client: try: payload = _run_cli_json( cli, - ["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-test"], - ) - workspace_id = _append_workspace_to_cleanup( - workspaces_to_close, - _resolve_workspace_id_from_payload(client, payload), + ["ssh", "127.0.0.1", "--port", "1", "--name", ssh_workspace_name], ) - _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + payload_workspace_id = _resolve_workspace_id_from_payload(client, payload) selected_workspace_id = "" + listed_row = None deadline_select = time.time() + 5.0 while time.time() < deadline_select: try: @@ -146,12 +153,31 @@ def main() -> int: except cmuxError: time.sleep(0.05) continue - if selected_workspace_id == workspace_id: + listed_row = _find_workspace_row(client, selected_workspace_id) + remote = (listed_row or {}).get("remote") or {} + if not listed_row: + time.sleep(0.05) + continue + if str(listed_row.get("title") or "") != ssh_workspace_name: + time.sleep(0.05) + continue + if payload_workspace_id and selected_workspace_id != payload_workspace_id: + time.sleep(0.05) + continue + if bool(remote.get("enabled")) is True: break time.sleep(0.05) _must( - selected_workspace_id == workspace_id, - f"cmux ssh should select the newly created workspace: expected {workspace_id}, got {selected_workspace_id}", + bool(selected_workspace_id) + and listed_row is not None + and str(listed_row.get("title") or "") == ssh_workspace_name + and bool((listed_row.get("remote") or {}).get("enabled")) is True + and (not payload_workspace_id or selected_workspace_id == payload_workspace_id), + f"cmux ssh should select the new remote workspace: selected={selected_workspace_id} row={listed_row} payload={payload}", + ) + workspace_id = _append_workspace_to_cleanup( + workspaces_to_close, + payload_workspace_id or selected_workspace_id, ) remote_relay_port = payload.get("remote_relay_port") _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") @@ -162,11 +188,45 @@ def main() -> int: ssh_command.startswith("ssh "), f"cmux ssh should emit plain ssh command text (env is passed via workspace.create initial_env): {ssh_command!r}", ) + ssh_terminal_command = str(payload.get("ssh_terminal_command") or "") + _must(bool(ssh_terminal_command), f"cmux ssh output missing ssh_terminal_command: {payload}") + _must( + ssh_terminal_command.startswith("ssh "), + f"cmux ssh should emit a terminal command that launches the remote bootstrap over ssh: {ssh_terminal_command!r}", + ) ssh_startup_command = str(payload.get("ssh_startup_command") or "") _must( - ssh_startup_command.startswith("/bin/zsh -ilc "), - f"cmux ssh should launch startup command via interactive zsh for shell integration: {ssh_startup_command!r}", + "cmux-ssh-startup-" in ssh_startup_command and ssh_startup_command.rstrip("'").endswith(".sh"), + f"cmux ssh should launch a generated startup script that preserves shell integration and cleanup: {ssh_startup_command!r}", + ) + _must(os.path.isfile(ssh_startup_command), f"cmux ssh startup script should exist on disk: {ssh_startup_command!r}") + ssh_startup_script = Path(ssh_startup_command).read_text() + _must( + f"cmux_bootstrap_path=\"$HOME/.cmux/relay/{int(remote_relay_port)}.bootstrap.sh\"" in ssh_startup_script, + f"cmux ssh startup script should stage the remote bootstrap payload under ~/.cmux/relay: {ssh_startup_command!r}", + ) + _must( + "cat > \"$cmux_bootstrap_path\"" in ssh_startup_script, + f"cmux ssh startup script should upload the remote bootstrap over stdin before opening the interactive ssh session: {ssh_startup_command!r}", + ) + _must( + "/bin/sh -c " in ssh_startup_script and "/bin/sh -lc " not in ssh_startup_script, + f"cmux ssh startup script should install the bootstrap through a non-login POSIX shell: {ssh_startup_command!r}", + ) + _must( + f"/bin/sh \"$HOME/.cmux/relay/{int(remote_relay_port)}.bootstrap.sh\"" in ssh_startup_script, + f"cmux ssh startup script should execute the staged remote bootstrap file through /bin/sh: {ssh_startup_command!r}", ) + _must( + "export CMUX_BOOTSTRAP_TTY=\"$cmux_bootstrap_tty\"" in ssh_startup_script, + f"cmux ssh startup script should preserve the bootstrap tty for relay warmup: {ssh_startup_command!r}", + ) + bootstrap_b64_match = re.search(r"^cmux_remote_bootstrap_b64=([A-Za-z0-9+/=]+)$", ssh_startup_script, re.MULTILINE) + _must( + bootstrap_b64_match is not None, + f"cmux ssh startup script should embed the remote bootstrap payload: {ssh_startup_command!r}", + ) + remote_bootstrap = base64.b64decode(str(bootstrap_b64_match.group(1))).decode("utf-8") ssh_env_overrides = payload.get("ssh_env_overrides") or {} _must( str(ssh_env_overrides.get("GHOSTTY_SHELL_FEATURES") or "").endswith("ssh-env,ssh-terminfo"), @@ -178,49 +238,73 @@ def main() -> int: _must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}") _must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}") _must( - "RemoteCommand=/bin/sh -lc " in ssh_command, - f"cmux ssh should route RemoteCommand through /bin/sh for non-POSIX login shells: {ssh_command!r}", + "RemoteCommand=" not in ssh_command, + f"cmux ssh should keep the plain ssh_command separate from the terminal bootstrap wrapper: {ssh_command!r}", ) _must( - f"export PATH=\"$HOME/.cmux/bin:$PATH\"" in ssh_command, - f"cmux ssh should still prepend the remote cmux wrapper path: {ssh_command!r}", + "cmux_tmp=$(mktemp " in ssh_terminal_command, + f"cmux ssh should stage a temp startup script through ssh_terminal_command: {ssh_terminal_command!r}", ) _must( - f"export CMUX_SOCKET_PATH=127.0.0.1:{int(remote_relay_port)}" in ssh_command, - f"cmux ssh should still pin the relay socket path in RemoteCommand: {ssh_command!r}", + "export CMUX_BOOTSTRAP_TTY=\"$cmux_bootstrap_tty\"" in ssh_terminal_command, + f"cmux ssh should capture the bootstrap tty before handing off to the temp startup script: {ssh_terminal_command!r}", ) _must( - "case \"${CMUX_LOGIN_SHELL##*/}\" in" in ssh_command, - f"cmux ssh should still branch on the user's login shell when possible: {ssh_command!r}", + f"\"$HOME/.cmux/relay/{int(remote_relay_port)}.tty\"" in ssh_terminal_command, + f"cmux ssh should persist the bootstrap tty beside the relay metadata: {ssh_terminal_command!r}", ) _must( - "cat > \"$cmux_shell_dir/.zshrc\"" in ssh_command, - f"cmux ssh should install a post-rc zsh wrapper so the remote cmux wrapper stays first on PATH: {ssh_command!r}", + f"export PATH=\"$HOME/.cmux/bin:$PATH\"" in remote_bootstrap, + f"cmux ssh should still prepend the remote cmux wrapper path in the remote bootstrap: {remote_bootstrap!r}", ) _must( - "cmux_wait_attempt=0" in ssh_command, - f"cmux ssh should wait briefly for the authenticated relay before showing the remote shell: {ssh_command!r}", + f"export CMUX_SOCKET_PATH=127.0.0.1:{int(remote_relay_port)}" in remote_bootstrap, + f"cmux ssh should still pin the relay socket path in the remote bootstrap: {remote_bootstrap!r}", ) _must( - "exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i" in ssh_command, - f"cmux ssh should still support bash login shells with a post-rc wrapper file: {ssh_command!r}", + "export CMUX_WORKSPACE_ID='__CMUX_WORKSPACE_ID__'" in remote_bootstrap, + f"cmux ssh should export the remote workspace id into the bootstrap shell: {remote_bootstrap!r}", ) _must( - "exec \"$CMUX_LOGIN_SHELL\" -i" in ssh_command, - f"cmux ssh should still hand off to the user's interactive login shell when possible: {ssh_command!r}", + "export CMUX_TAB_ID='__CMUX_WORKSPACE_ID__'" in remote_bootstrap, + f"cmux ssh should keep CMUX_TAB_ID aligned with the workspace id for shell integration: {remote_bootstrap!r}", + ) + _must( + "export CMUX_SURFACE_ID='__CMUX_SURFACE_ID__'" in remote_bootstrap, + f"cmux ssh should export the remote surface id into the bootstrap shell: {remote_bootstrap!r}", + ) + _must( + "export CMUX_PANEL_ID='__CMUX_SURFACE_ID__'" in remote_bootstrap, + f"cmux ssh should keep CMUX_PANEL_ID aligned with the surface id for shell integration: {remote_bootstrap!r}", + ) + _must( + "case \"${CMUX_LOGIN_SHELL##*/}\" in" in remote_bootstrap, + f"cmux ssh should still branch on the user's login shell when possible: {remote_bootstrap!r}", + ) + _must( + "cat > \"$cmux_shell_dir/.zshrc\"" in remote_bootstrap, + f"cmux ssh should install a post-rc zsh wrapper so the remote cmux wrapper stays first on PATH: {remote_bootstrap!r}", + ) + _must( + '"$cmux_relay_cli" rpc surface.report_tty "$cmux_relay_report_tty"' in remote_bootstrap, + f"cmux ssh should synchronously report the relay TTY during bootstrap: {remote_bootstrap!r}", + ) + _must( + 'cmux_relay_tty="${CMUX_BOOTSTRAP_TTY:-}"' in remote_bootstrap, + f"cmux ssh should reuse the bootstrap tty when warming the relay-backed shell integration: {remote_bootstrap!r}", + ) + _must( + '"$cmux_relay_cli" rpc surface.ports_kick "$cmux_relay_ports_kick"' in remote_bootstrap, + f"cmux ssh should trigger an immediate relay-backed port scan during bootstrap: {remote_bootstrap!r}", + ) + _must( + "exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i" in remote_bootstrap, + f"cmux ssh should still support bash login shells with a post-rc wrapper file: {remote_bootstrap!r}", + ) + _must( + "exec \"$CMUX_LOGIN_SHELL\" -i" in remote_bootstrap, + f"cmux ssh should still hand off to the user's interactive login shell when possible: {remote_bootstrap!r}", ) - - listed_row = None - deadline = time.time() + 8.0 - while time.time() < deadline: - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("id") or "") == workspace_id: - listed_row = row - break - if listed_row is not None: - break - time.sleep(0.1) _must(listed_row is not None, f"workspace.list did not include {workspace_id}") remote = listed_row.get("remote") or {} @@ -253,6 +337,7 @@ def main() -> int: if terminal_text is not None: _must("ControlPersist=600" not in terminal_text, f"cmux ssh should not inject raw ssh command text: {terminal_text!r}") _must("GHOSTTY_SHELL_FEATURES=" not in terminal_text, f"cmux ssh should not inject env assignment text: {terminal_text!r}") + _must("BASH_EXECUTION_STRING=set" not in terminal_text, f"cmux ssh should not print the remote shell environment dump on connect: {terminal_text!r}") status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} status_remote = status.get("remote") or {} @@ -262,24 +347,41 @@ def main() -> int: str(daemon.get("state") or "") in {"unavailable", "bootstrapping", "ready", "error"}, f"workspace.remote.status should include daemon state metadata: {status_remote}", ) - # Fail-fast regression: unreachable SSH target should surface bootstrap error explicitly. + # Fail-fast regression: unreachable SSH target should not stay stuck connecting forever. + # Current main can either keep the failed remote config around long enough to expose + # a daemon bootstrap error, or drop back to a disconnected local workspace once the + # failed terminal session has fully torn down. deadline_daemon = time.time() + 12.0 last_status = status + saw_bootstrap_error = False while time.time() < deadline_daemon: last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} last_remote = last_status.get("remote") or {} last_daemon = last_remote.get("daemon") or {} if str(last_daemon.get("state") or "") == "error": + saw_bootstrap_error = True + break + if bool(last_remote.get("enabled")) is False and str(last_remote.get("state") or "") == "disconnected": break time.sleep(0.2) else: - raise cmuxError(f"unreachable host should drive daemon state to error: {last_status}") + raise cmuxError(f"unreachable host should fail fast instead of hanging in connecting: {last_status}") last_remote = last_status.get("remote") or {} last_daemon = last_remote.get("daemon") or {} - detail = str(last_daemon.get("detail") or "") - _must("bootstrap failed" in detail.lower(), f"daemon error should mention bootstrap failure: {last_status}") - _must(re.search(r"retry\s+\d+", detail.lower()) is not None, f"daemon error should include retry count: {last_status}") + if saw_bootstrap_error: + detail = str(last_daemon.get("detail") or "") + _must("bootstrap failed" in detail.lower(), f"daemon error should mention bootstrap failure: {last_status}") + _must(re.search(r"retry\s+\d+", detail.lower()) is not None, f"daemon error should include retry count: {last_status}") + else: + _must( + str(last_remote.get("state") or "") == "disconnected", + f"unreachable host should eventually disconnect if it clears remote config: {last_status}", + ) + _must( + str(last_daemon.get("state") or "") == "unavailable", + f"daemon state should reset when the failed remote session tears down: {last_status}", + ) # Lifecycle regression: disconnect with clear should reset remote/daemon metadata. disconnected = client._call( diff --git a/tests_v2/test_ssh_remote_port_detection.py b/tests_v2/test_ssh_remote_port_detection.py new file mode 100644 index 000000000000..59f478b0168a --- /dev/null +++ b/tests_v2/test_ssh_remote_port_detection.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Docker integration: remote SSH workspaces detect listening ports from the live shell.""" + +from __future__ import annotations + +import glob +import json +import os +import pty +import re +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "8000")) +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_SOCKET_PATH", None) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") from exc + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + return int(text.split(":")[-1]) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 45.0) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not reach connected+ready state: {last_status}") + + +def _is_terminal_surface_not_found(exc: Exception) -> bool: + return "terminal surface not found" in str(exc).lower() + + +def _clean_text(raw: str) -> str: + text = OSC_ESCAPE_RE.sub("", raw) + text = ANSI_ESCAPE_RE.sub("", text) + return text.replace("\r", "") + + +def _wait_surface_contains( + client: cmux, + workspace_id: str, + surface_id: str, + token: str, + *, + timeout: float = 20.0, +) -> None: + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + text = _clean_text(str(payload.get("text") or "")) + if token in text: + return + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _workspace_row(client: cmux, workspace_id: str) -> dict: + payload = client._call("workspace.list", {}) or {} + for row in payload.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + return row + raise cmuxError(f"workspace {workspace_id} missing from workspace.list payload: {payload}") + + +def _debug_terminal_row(client: cmux, workspace_id: str, surface_id: str) -> dict: + payload = client._call("debug.terminals", {}) or {} + for row in payload.get("terminals") or []: + if str(row.get("workspace_id") or "") == workspace_id and str(row.get("surface_id") or "") == surface_id: + return row + raise cmuxError( + f"debug.terminals missing workspace={workspace_id!r} surface={surface_id!r}: {payload}" + ) + + +def _wait_surface_tty(client: cmux, workspace_id: str, surface_id: str, timeout: float = 20.0) -> str: + deadline = time.time() + timeout + last_row = {} + last_error: Exception | None = None + while time.time() < deadline: + try: + last_row = _debug_terminal_row(client, workspace_id, surface_id) + except cmuxError as exc: + last_error = exc + time.sleep(0.2) + continue + tty_name = str(last_row.get("tty") or "").strip() + if tty_name: + return tty_name + time.sleep(0.2) + if last_error is not None: + raise cmuxError(f"Timed out waiting for surface tty after terminal lookup retries: {last_error}") + raise cmuxError(f"Timed out waiting for surface tty: {last_row}") + + +def _launch_startup_command_pty(startup_command: str, workspace_id: str, surface_id: str) -> tuple[subprocess.Popen[bytes], int]: + _must(bool(startup_command.strip()), "cmux ssh output missing ssh_terminal_startup_command for PTY fallback") + env = dict(os.environ) + env.pop("CMUX_SOCKET_PATH", None) + env["CMUX_WORKSPACE_ID"] = workspace_id + env["CMUX_SURFACE_ID"] = surface_id + env["CMUX_TAB_ID"] = workspace_id + env["CMUX_PANEL_ID"] = surface_id + + master_fd, slave_fd = pty.openpty() + try: + proc = subprocess.Popen( + ["/bin/sh", "-lc", startup_command], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + env=env, + start_new_session=True, + ) + except Exception: + os.close(slave_fd) + os.close(master_fd) + raise + os.close(slave_fd) + return proc, master_fd + + +def _wait_for_remote_port(client: cmux, workspace_id: str, port: int, timeout: float = 15.0) -> tuple[dict, dict]: + deadline = time.time() + timeout + last_status = {} + last_row = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + detected_ports = { + int(value) + for value in (remote.get("detected_ports") or []) + if str(value).isdigit() + } + + last_row = _workspace_row(client, workspace_id) + listening_ports = { + int(value) + for value in (last_row.get("listening_ports") or []) + if str(value).isdigit() + } + + if port in detected_ports and port in listening_ports: + return last_status, last_row + time.sleep(0.4) + + raise cmuxError( + "Remote listening port did not surface in remote status + workspace list: " + f"status={last_status} workspace={last_row}" + ) + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-port-detection-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-port-detect-{secrets.token_hex(4)}" + workspace_id = "" + surface_id = "" + pty_proc: subprocess.Popen[bytes] | None = None + pty_master_fd: int | None = None + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-p", + f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", + "docker-ssh-port-detection", + "--port", + str(host_ssh_port), + "--identity", + str(key_path), + "--ssh-option", + "UserKnownHostsFile=/dev/null", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + ready_status = _wait_remote_ready(client, workspace_id) + initial_remote = ready_status.get("remote") or {} + initial_detected_ports = { + int(value) + for value in (initial_remote.get("detected_ports") or []) + if str(value).isdigit() + } + listed = client._call("workspace.list", {}) or {} + initial_row = next( + (row for row in (listed.get("workspaces") or []) if str(row.get("id") or "") == workspace_id), + {}, + ) + initial_listening_ports = { + int(value) + for value in (initial_row.get("listening_ports") or []) + if str(value).isdigit() + } + _must( + not initial_detected_ports, + f"remote SSH workspace should not surface unrelated startup ports before the shell opens one: {ready_status}", + ) + _must( + not initial_listening_ports, + f"workspace.list should not leak unrelated startup ports before the shell opens one: {initial_row}", + ) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = str(surfaces[0][1]) + startup_command = str(payload.get("ssh_terminal_startup_command") or "") + + server_started_via_surface = True + try: + client.send_surface(surface_id, f"python3 -m http.server {REMOTE_HTTP_PORT}\n") + _wait_surface_contains(client, workspace_id, surface_id, f"port {REMOTE_HTTP_PORT}", timeout=20.0) + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + print("WARN: readable terminal surface unavailable; falling back to generated ssh startup command PTY") + server_started_via_surface = False + else: + raise + + if not server_started_via_surface: + pty_proc, pty_master_fd = _launch_startup_command_pty(startup_command, workspace_id, surface_id) + _wait_surface_tty(client, workspace_id, surface_id, timeout=20.0) + os.write(pty_master_fd, f"python3 -m http.server {REMOTE_HTTP_PORT}\n".encode("utf-8")) + + status, row = _wait_for_remote_port(client, workspace_id, REMOTE_HTTP_PORT, timeout=15.0) + remote = status.get("remote") or {} + detected_ports = { + int(value) + for value in (remote.get("detected_ports") or []) + if str(value).isdigit() + } + listening_ports = { + int(value) + for value in (row.get("listening_ports") or []) + if str(value).isdigit() + } + _must( + REMOTE_HTTP_PORT in detected_ports, + f"remote status should include detected port {REMOTE_HTTP_PORT}: {status}", + ) + _must( + REMOTE_HTTP_PORT in listening_ports, + f"workspace.list should include listening port {REMOTE_HTTP_PORT}: {row}", + ) + + if surface_id: + if pty_master_fd is not None: + os.write(pty_master_fd, b"\x03") + else: + client.send_key_surface(surface_id, "ctrl-c") + if workspace_id: + try: + client.close_workspace(workspace_id) + workspace_id = "" + except Exception: + pass + + print("PASS: remote SSH workspace surfaces listening ports from the live remote shell") + return 0 + + finally: + if pty_master_fd is not None: + try: + os.close(pty_master_fd) + except OSError: + pass + if pty_proc is not None: + if pty_proc.poll() is None: + pty_proc.terminate() + try: + pty_proc.wait(timeout=5.0) + except subprocess.TimeoutExpired: + pty_proc.kill() + + if surface_id and workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.send_key_surface(surface_id, "ctrl-c") + except Exception: + pass + + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py index 3d632b8473fd..bd93180b5c1b 100755 --- a/tests_v2/test_ssh_remote_shell_integration.py +++ b/tests_v2/test_ssh_remote_shell_integration.py @@ -6,6 +6,7 @@ import glob import json import os +import pty import re import secrets import shutil @@ -22,6 +23,9 @@ SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") +FIXTURE_REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_FIXTURE_HTTP_PORT", "43173")) +FIXTURE_REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_FIXTURE_WS_PORT", "43174")) +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "8000")) ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") @@ -231,6 +235,145 @@ def _surface_text_scrollback_lines(client: cmux, workspace_id: str, surface_id: return [_clean_line(raw) for raw in _surface_text_scrollback(client, workspace_id, surface_id).splitlines()] +def _surface_row(client: cmux, workspace_id: str, surface_id: str) -> dict: + payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} + for row in payload.get("surfaces") or []: + if str(row.get("id") or "") == surface_id: + return row + raise cmuxError(f"surface.list missing surface {surface_id!r}: {payload}") + + +def _debug_terminal_row(client: cmux, workspace_id: str, surface_id: str) -> dict: + payload = client._call("debug.terminals", {}) or {} + for row in payload.get("terminals") or []: + if str(row.get("workspace_id") or "") == workspace_id and str(row.get("surface_id") or "") == surface_id: + return row + raise cmuxError( + f"debug.terminals missing workspace={workspace_id!r} surface={surface_id!r}: {payload}" + ) + + +def _workspace_row(client: cmux, workspace_id: str) -> dict: + payload = client._call("workspace.list", {}) or {} + for row in payload.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + return row + raise cmuxError(f"workspace.list missing workspace {workspace_id!r}: {payload}") + + +def _wait_surface_tty(client: cmux, workspace_id: str, surface_id: str, timeout: float = 20.0) -> str: + deadline = time.time() + timeout + last_row = {} + while time.time() < deadline: + last_row = _debug_terminal_row(client, workspace_id, surface_id) + tty_name = str(last_row.get("tty") or "").strip() + if tty_name: + return tty_name + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for surface tty: {last_row}") + + +def _launch_startup_command_pty(startup_command: str, workspace_id: str, surface_id: str) -> tuple[subprocess.Popen[bytes], int]: + _must(bool(startup_command.strip()), "cmux ssh output missing ssh_terminal_startup_command for PTY fallback") + env = dict(os.environ) + env.pop("CMUX_SOCKET_PATH", None) + env["CMUX_WORKSPACE_ID"] = workspace_id + env["CMUX_SURFACE_ID"] = surface_id + env["CMUX_TAB_ID"] = workspace_id + env["CMUX_PANEL_ID"] = surface_id + + master_fd, slave_fd = pty.openpty() + proc = subprocess.Popen( + ["/bin/sh", "-lc", startup_command], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + env=env, + start_new_session=True, + ) + os.close(slave_fd) + return proc, master_fd + + +def _wait_for_remote_port( + client: cmux, + workspace_id: str, + port: int, + *, + forbidden_ports: set[int] | None = None, + timeout: float = 20.0, +) -> tuple[dict, dict]: + deadline = time.time() + timeout + last_status = {} + last_row = {} + forbidden_ports = forbidden_ports or set() + + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + detected_ports = { + int(value) + for value in (remote.get("detected_ports") or []) + if str(value).isdigit() + } + + last_row = _workspace_row(client, workspace_id) + listening_ports = { + int(value) + for value in (last_row.get("listening_ports") or []) + if str(value).isdigit() + } + + if port in detected_ports and port in listening_ports: + leaked = forbidden_ports.intersection(detected_ports.union(listening_ports)) + if not leaked: + return last_status, last_row + time.sleep(0.3) + + raise cmuxError( + "Timed out waiting for remote shell-integration port detection: " + f"status={last_status} workspace={last_row}" + ) + + +def _assert_remote_ports_absent( + client: cmux, + workspace_id: str, + forbidden_ports: set[int], + *, + timeout: float = 3.0, +) -> tuple[dict, dict]: + deadline = time.time() + timeout + last_status = {} + last_row = {} + + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + detected_ports = { + int(value) + for value in (remote.get("detected_ports") or []) + if str(value).isdigit() + } + + last_row = _workspace_row(client, workspace_id) + listening_ports = { + int(value) + for value in (last_row.get("listening_ports") or []) + if str(value).isdigit() + } + + leaked = forbidden_ports.intersection(detected_ports.union(listening_ports)) + _must( + not leaked, + "remote workspace leaked unrelated host ports before shell-specific detection: " + f"ports={sorted(leaked)} status={last_status} workspace={last_row}", + ) + time.sleep(0.2) + + return last_status, last_row + + def _scrollback_has_all_lines( client: cmux, workspace_id: str, @@ -323,6 +466,24 @@ def y_of(p: dict) -> float: return ("down" if target_pane == top_id else "up"), "height" +def _wait_readable_terminal_text(client: cmux, surface_id: str, timeout: float = 20.0) -> str: + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + return client.read_terminal_text(surface_id) + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for readable terminal surface: {surface_id}") + + def main() -> int: if not _docker_available(): print("SKIP: docker is not available") @@ -340,6 +501,9 @@ def main() -> int: image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" container_name = f"cmux-ssh-shell-{secrets.token_hex(4)}" workspace_id = "" + surface_id = "" + pty_proc: subprocess.Popen[bytes] | None = None + pty_master_fd: int | None = None try: key_path = temp_dir / "id_ed25519" @@ -404,8 +568,29 @@ def main() -> int: surfaces = client.list_surfaces(workspace_id) _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") - surface_id = surfaces[0][1] - terminal_text = client.read_terminal_text(surface_id) + surface_id = str(surfaces[0][1]) + startup_command = str(payload.get("ssh_terminal_startup_command") or "") + _assert_remote_ports_absent( + client, + workspace_id, + {FIXTURE_REMOTE_HTTP_PORT, FIXTURE_REMOTE_WS_PORT}, + timeout=3.0, + ) + try: + terminal_text = _wait_readable_terminal_text(client, surface_id, timeout=5.0) + except cmuxError as exc: + if not _is_terminal_surface_not_found(exc): + raise + print("WARN: readable terminal surface unavailable; falling back to generated ssh startup command PTY") + pty_proc, pty_master_fd = _launch_startup_command_pty(startup_command, workspace_id, surface_id) + _wait_surface_tty(client, workspace_id, surface_id, timeout=20.0) + try: + terminal_text = _wait_readable_terminal_text(client, surface_id, timeout=10.0) + except cmuxError as retry_exc: + if _is_terminal_surface_not_found(retry_exc): + print("SKIP: terminal surface unavailable for shell integration assertions") + return 0 + raise _must( "Reconstructed via infocmp" not in terminal_text, "ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell", @@ -450,6 +635,36 @@ def main() -> int: term_program_version = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM_VERSION:-}\"") _must(bool(term_program_version), "ssh-env should propagate non-empty TERM_PROGRAM_VERSION") + tty_retry_token = f"CMUX_TTY_RETRY_{secrets.token_hex(6)}" + client.send_surface(surface_id, f"echo {tty_retry_token}\n") + _wait_surface_contains(client, workspace_id, surface_id, tty_retry_token) + + tty_name = _wait_surface_tty(client, workspace_id, surface_id) + _must(bool(tty_name), "remote surface should report a tty once shell integration is active") + + port_token = f"CMUX_REMOTE_HTTP_{secrets.token_hex(6)}" + client.send_surface( + surface_id, + f"python3 -m http.server {REMOTE_HTTP_PORT} >/tmp/cmux-http-{port_token}.log 2>&1 & echo {port_token}\n", + ) + _wait_surface_contains(client, workspace_id, surface_id, port_token) + port_status, port_workspace_row = _wait_for_remote_port( + client, + workspace_id, + REMOTE_HTTP_PORT, + forbidden_ports={FIXTURE_REMOTE_HTTP_PORT, FIXTURE_REMOTE_WS_PORT}, + timeout=25.0, + ) + detected_ports = set((port_status.get("remote") or {}).get("detected_ports") or []) + listening_ports = set(port_workspace_row.get("listening_ports") or []) + _must( + FIXTURE_REMOTE_HTTP_PORT not in detected_ports + and FIXTURE_REMOTE_HTTP_PORT not in listening_ports + and FIXTURE_REMOTE_WS_PORT not in detected_ports + and FIXTURE_REMOTE_WS_PORT not in listening_ports, + "workspace should surface only the foreground shell port, not unrelated remote fixture daemons", + ) + ls_stamp = secrets.token_hex(4) ls_entries = [f"CMUX_RESIZE_LS_{ls_stamp}_{index:02d}" for index in range(1, 17)] ls_start = f"CMUX_RESIZE_LS_START_{ls_stamp}" @@ -561,6 +776,18 @@ def main() -> int: return 0 finally: + if pty_master_fd is not None: + try: + os.close(pty_master_fd) + except OSError: + pass + if pty_proc is not None and pty_proc.poll() is None: + pty_proc.terminate() + try: + pty_proc.wait(timeout=5.0) + except subprocess.TimeoutExpired: + pty_proc.kill() + if workspace_id: try: with cmux(SOCKET_PATH) as cleanup_client: diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 182ed116b4b2..deef6ee9e491 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -1,8 +1,79 @@ -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; import { buildAlternates } from "../../../../i18n/seo"; +import { Link } from "../../../../i18n/navigation"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; +import settingsSchema from "../../../../data/cmux-settings.schema.json"; +import { shortcutCategories, type LocalizedText } from "../../../../data/cmux-shortcuts"; + +type SchemaProperty = { + title?: string; + description?: string; + type?: string | string[]; + enum?: string[]; + default?: unknown; + properties?: Record<string, SchemaProperty>; + items?: SchemaProperty; + oneOf?: SchemaProperty[]; + propertyNames?: { + enum?: string[]; + }; +}; + +type SchemaDocument = { + $id?: string; + properties?: Record<string, SchemaProperty>; +}; + +const typedSettingsSchema = settingsSchema as SchemaDocument; +const schemaProperties = typedSettingsSchema.properties ?? {}; +const schemaUrl = + typedSettingsSchema.$id ?? + "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json"; +const schemaSourceUrl = + "https://github.com/manaflow-ai/cmux/blob/main/web/data/cmux-settings.schema.json"; +const sectionOrder = [ + "app", + "notifications", + "sidebar", + "workspaceColors", + "sidebarAppearance", + "automation", + "customCommands", + "browser", + "shortcuts", +] as const; + +const settingsFileExample = `{ + "$schema": "${schemaUrl}", + "schemaVersion": 1, + + // "app": { + // "appearance": "dark", + // "newWorkspacePlacement": "afterCurrent" + // }, + + // "browser": { + // "openTerminalLinksInCmuxBrowser": true, + // "hostsToOpenInEmbeddedBrowser": ["localhost", "*.internal.example"] + // }, + + // "workspaceColors": { + // "colors": { + // "Red": "#C0392B", + // "Blue": "#1565C0", + // "Neon Mint": "#00F5D4" + // } + // }, + + // "shortcuts": { + // "bindings": { + // "toggleSidebar": "cmd+b", + // "newTab": ["ctrl+b", "c"] + // } + // }, +}`; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; @@ -14,8 +85,139 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s }; } +function localizedText(text: LocalizedText, locale: string) { + return locale.startsWith("ja") ? text.ja : text.en; +} + +function shortcutComboToConfig(combo: string[]) { + const modifierMap: Record<string, string> = { + "⌘": "cmd", + "⇧": "shift", + "⌥": "opt", + "⌃": "ctrl", + }; + const keyMap: Record<string, string> = { + "←": "left", + "→": "right", + "↑": "up", + "↓": "down", + "↩": "enter", + "1…9": "1", + }; + + return combo + .map((part) => modifierMap[part] ?? keyMap[part] ?? part.toLowerCase()) + .join("+"); +} + +function formatSchemaType(property: SchemaProperty): string { + if (property.oneOf?.length) { + return property.oneOf.map(formatSchemaType).join(" | "); + } + if (Array.isArray(property.type)) { + return property.type.join(" | "); + } + if (property.type === "array") { + const itemType = property.items ? formatSchemaType(property.items) : "unknown"; + return `array<${itemType}>`; + } + if (property.type === "object") { + return "object"; + } + return property.type ?? "unknown"; +} + +function formatDefaultValue(value: unknown): string { + if (value === undefined) { + return "none"; + } + if (typeof value === "string") { + return JSON.stringify(value); + } + if (typeof value === "object" && value !== null) { + return JSON.stringify(value, null, 2) ?? "none"; + } + return JSON.stringify(value) ?? "none"; +} + +function hasComplexDefaultValue(value: unknown): boolean { + return typeof value === "object" && value !== null; +} + +function PropertyCard({ path, property }: { path: string; property: SchemaProperty }) { + return ( + <div className="rounded-xl border border-border/70 bg-background/40 p-4"> + <div className="mb-2 flex items-center gap-2"> + <code className="text-[12px] font-medium">{path}</code> + </div> + {property.description && <p className="mb-3 text-sm text-muted">{property.description}</p>} + <dl className="space-y-2 text-sm"> + <div> + <dt className="font-medium text-foreground">Type</dt> + <dd className="text-muted"> + <code>{formatSchemaType(property)}</code> + </dd> + </div> + <div> + <dt className="font-medium text-foreground">Default</dt> + <dd className="text-muted"> + {hasComplexDefaultValue(property.default) ? ( + <pre className="overflow-x-auto rounded-lg bg-background/60 p-3 text-xs text-foreground"> + <code>{formatDefaultValue(property.default)}</code> + </pre> + ) : ( + <code>{formatDefaultValue(property.default)}</code> + )} + </dd> + </div> + {property.enum && ( + <div> + <dt className="font-medium text-foreground">Allowed values</dt> + <dd className="text-muted"> + <code>{property.enum.join(", ")}</code> + </dd> + </div> + )} + </dl> + </div> + ); +} + +function PropertyGrid({ + prefix, + properties, + skip = [], +}: { + prefix: string; + properties: Record<string, SchemaProperty>; + skip?: string[]; +}) { + const entries = Object.entries(properties).filter(([name]) => !skip.includes(name)); + return ( + <div className="not-prose grid gap-4 md:grid-cols-2"> + {entries.map(([name, property]) => ( + <PropertyCard + key={prefix ? `${prefix}.${name}` : name} + path={prefix ? `${prefix}.${name}` : name} + property={property} + /> + ))} + </div> + ); +} + export default function ConfigurationPage() { + const locale = useLocale(); const t = useTranslations("docs.configuration"); + const shortcutTranslations = useTranslations("docs.keyboardShortcuts"); + + const metadataProperties = { + $schema: schemaProperties.$schema, + schemaVersion: schemaProperties.schemaVersion, + } satisfies Record<string, SchemaProperty | undefined>; + + const shortcutsSection = schemaProperties.shortcuts; + const shortcutProperties = shortcutsSection?.properties ?? {}; return ( <> @@ -36,103 +238,150 @@ export default function ConfigurationPage() { <CodeBlock lang="bash">{`mkdir -p ~/.config/ghostty touch ~/.config/ghostty/config`}</CodeBlock> - <h2>{t("appearance")}</h2> - - <h3>{t("font")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`font-family = JetBrains Mono -font-size = 14`}</CodeBlock> - - <h3>{t("colors")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Theme (or use individual colors below) -theme = Dracula - -# Custom colors -background = #1e1e2e -foreground = #cdd6f4 -cursor-color = #f5e0dc -cursor-text = #1e1e2e -selection-background = #585b70 -selection-foreground = #cdd6f4`}</CodeBlock> - - <h3>{t("splitPanes")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Opacity for unfocused splits (0.0 to 1.0) -unfocused-split-opacity = 0.7 - -# Fill color for unfocused splits -unfocused-split-fill = #1e1e2e - -# Divider color between splits -split-divider-color = #45475a`}</CodeBlock> - - <h2>{t("behavior")}</h2> - - <h3>{t("scrollback")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Number of lines to keep in scrollback buffer -scrollback-limit = 10000`}</CodeBlock> - - <h3>{t("workingDirectory")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Default directory for new terminals -working-directory = ~/Projects`}</CodeBlock> - - <h2>{t("appSettings")}</h2> - <p>{t("appSettingsDesc", { shortcut: "⌘," })}</p> - - <h3>{t("themeMode")}</h3> - <ul> - <li> - <strong>{t("themeSystem")}</strong> - </li> - <li> - <strong>{t("themeLight")}</strong> - </li> - <li> - <strong>{t("themeDark")}</strong> - </li> - </ul> + <h2>{t("exampleConfig")}</h2> + <CodeBlock title="~/.config/ghostty/config" lang="ini">{`font-family = SF Mono +font-size = 13 +theme = One Dark +scrollback-limit = 50000 +split-divider-color = #3e4451 +working-directory = ~/code`}</CodeBlock> - <h3>{t("automationMode")}</h3> - <p>{t("automationModeDesc")}</p> - <ul> - <li> - <strong>{t("automationOff")}</strong> - </li> + <h2>cmux settings.json</h2> + <p> + cmux keeps app-owned settings in a separate user file instead of mixing them into Ghostty + config. On launch, if neither settings location exists, cmux writes a commented template to{" "} + <code>~/.config/cmux/settings.json</code>. + </p> + <ol> <li> - <strong>{t("automationCmux")}</strong> + <code>~/.config/cmux/settings.json</code> </li> <li> - <strong>{t("automationAll")}</strong> + <code>~/Library/Application Support/com.cmuxterm.app/settings.json</code> </li> - </ul> - <Callout type="warn">{t("automationCallout")}</Callout> + </ol> + <Callout type="info"> + <strong>Precedence:</strong> <code>~/.config/cmux/settings.json</code> wins over the + Application Support fallback. File-managed values override the value saved in the Settings + window. Remove a key to fall back to the Settings value again. + </Callout> + <Callout type="info"> + <strong>Reload:</strong> edit the file, then use <code>Cmd+Shift+,</code> or{" "} + <code>cmux reload-config</code> to re-read it without restarting the app. + </Callout> + <Callout type="warn"> + <strong>Migrations:</strong> keep <code>schemaVersion</code> at <code>1</code> for now. + Future cmux versions will use that field for upgrades. If cmux sees a newer schema version, + it logs a warning and parses known keys only. + </Callout> + <p> + The file accepts JSON with comments and trailing commas. The canonical schema is published + at <a href={schemaUrl}>{schemaUrl}</a> and the source lives at{" "} + <a href={schemaSourceUrl}>{schemaSourceUrl}</a>. + </p> + <CodeBlock title="~/.config/cmux/settings.json" lang="json"> + {settingsFileExample} + </CodeBlock> - <h3>{t("browserLinkBehavior")}</h3> - <p>{t("browserLinkDesc")}</p> - <ul> - <li> - <strong>{t("browserHostsEmbed")}</strong> - </li> - <li> - <strong>{t("browserHostsHttp")}</strong> - </li> - </ul> + <h2>Schema reference</h2> + <p> + This reference covers every supported key in <code>settings.json</code>. The embedded + browser, sidebar, notifications, automation, and cmux-owned keyboard shortcuts all live + here. + </p> - <h2>{t("exampleConfig")}</h2> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Font -font-family = SF Mono -font-size = 13 + <h3>Metadata</h3> + <PropertyGrid + prefix="" + properties={Object.fromEntries( + Object.entries(metadataProperties).filter(([, property]) => property) + ) as Record<string, SchemaProperty>} + /> -# Colors -theme = One Dark + {sectionOrder.map((sectionName) => { + const property = schemaProperties[sectionName]; + if (!property?.properties) { + return null; + } -# Scrollback -scrollback-limit = 50000 + const skipBindings = sectionName === "shortcuts" ? ["bindings"] : []; -# Splits -unfocused-split-opacity = 0.85 -split-divider-color = #3e4451 + return ( + <section key={sectionName}> + <h3> + <code>{sectionName}</code> + </h3> + {property.description && <p>{property.description}</p>} + <PropertyGrid prefix={sectionName} properties={property.properties} skip={skipBindings} /> + {sectionName === "workspaceColors" && ( + <> + <p> + <code>workspaceColors.colors</code> is the full palette. Keep the built-in keys + you want, delete keys to remove colors from the picker, and add more named color + entries to extend it. Older <code>paletteOverrides</code> and{" "} + <code>customColors</code> files still parse during upgrades, but new files + should use <code>colors</code>. + </p> + <CodeBlock lang="json">{`{ + "workspaceColors": { + "colors": { + "Red": "#C0392B", + "Blue": "#1565C0", + "Neon Mint": "#00F5D4" + } + } +}`}</CodeBlock> + </> + )} + </section> + ); + })} -# Working directory -working-directory = ~/code`}</CodeBlock> + <h3> + <code>shortcuts.bindings</code> + </h3> + <p> + Use a string for a single shortcut, or a two-item array for a chord. Example:{" "} + <code>["ctrl+b", "c"]</code>. Numbered actions use <code>1</code> as + the stored default and still match digits <code>1</code> through <code>9</code>. + </p> + <p> + The defaults below are the same cmux-owned actions listed on the{" "} + <Link href="/docs/keyboard-shortcuts">keyboard shortcuts page</Link>. + </p> + {shortcutCategories.map((category) => ( + <section key={category.id}> + <h4>{shortcutTranslations(`cat.${category.titleKey}` as never)}</h4> + <div className="not-prose overflow-hidden rounded-xl border border-border/70 bg-background/40"> + {category.shortcuts.map((shortcut, index) => ( + <div + key={shortcut.id} + className={`grid gap-3 px-4 py-3 md:grid-cols-[minmax(0,1fr)_220px] ${ + index > 0 ? "border-t border-border/70" : "" + }`} + > + <div> + <div className="mb-1 flex items-center gap-2"> + <code className="text-[12px] font-medium">{shortcut.id}</code> + </div> + <p className="text-sm text-foreground/90"> + {localizedText(shortcut.description, locale)} + {shortcut.note && ( + <span className="ml-2 text-xs text-muted"> + {localizedText(shortcut.note, locale)} + </span> + )} + </p> + </div> + <div className="text-sm text-muted"> + <div className="font-medium text-foreground">Default file value</div> + <code>{shortcutComboToConfig(shortcut.combos[0] ?? [])}</code> + </div> + </div> + ))} + </div> + </section> + ))} </> ); } diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx index 70b25ea89d3d..005a4c517430 100644 --- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx @@ -1,8 +1,21 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; import { buildAlternates } from "../../../../i18n/seo"; +import { Link } from "../../../../i18n/navigation"; +import { Callout } from "../../components/callout"; +import { CodeBlock } from "../../components/code-block"; import { KeyboardShortcuts } from "../../keyboard-shortcuts"; +const shortcutChordExample = `{ + "shortcuts": { + "bindings": { + "newSurface": ["ctrl+b", "c"], + "showNotifications": ["ctrl+b", "i"], + "toggleSidebar": "cmd+b" + } + } +}`; + export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "docs.keyboardShortcuts" }); @@ -20,6 +33,22 @@ export default function KeyboardShortcutsPage() { <> <h1>{t("title")}</h1> <p>{t("description")}</p> + + <h2 id="shortcut-chords" className="scroll-mt-24">{t("chordsTitle")}</h2> + <p> + {t.rich("chordsIntro", { + settingsFile: (chunks) => <code>{chunks}</code>, + configurationLink: (chunks) => <Link href="/docs/configuration">{chunks}</Link>, + })} + </p> + <Callout type="info">{t("chordsCallout")}</Callout> + <CodeBlock title="settings.json" lang="json">{shortcutChordExample}</CodeBlock> + <ul> + <li>{t("chordsRuleSingle")}</li> + <li>{t("chordsRuleArray")}</li> + <li>{t("chordsRuleSyntax")}</li> + </ul> + <KeyboardShortcuts /> </> ); diff --git a/web/app/[locale]/keyboard-shortcuts.tsx b/web/app/[locale]/keyboard-shortcuts.tsx index c762510949f9..6072d4e037f7 100644 --- a/web/app/[locale]/keyboard-shortcuts.tsx +++ b/web/app/[locale]/keyboard-shortcuts.tsx @@ -1,112 +1,12 @@ "use client"; import { useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; +import { shortcutCategories, type LocalizedText, type Shortcut } from "../../data/cmux-shortcuts"; -type Shortcut = { - id: string; - combos: string[][]; - note?: string; -}; - -type ShortcutCategory = { - id: string; - titleKey: string; - blurbKey?: string; - shortcuts: Shortcut[]; -}; - -const CATEGORIES: ShortcutCategory[] = [ - { - id: "workspaces", - titleKey: "workspaces", - blurbKey: "workspacesBlurb", - shortcuts: [ - { id: "ws-new", combos: [["⌘", "N"]] }, - { id: "ws-prev", combos: [["⌃", "⌘", "["]] }, - { id: "ws-next", combos: [["⌃", "⌘", "]"]] }, - { id: "ws-jump-1-8", combos: [["⌘", "1–8"]] }, - { id: "ws-jump-last", combos: [["⌘", "9"]] }, - { id: "ws-close", combos: [["⌘", "⇧", "W"]] }, - { id: "ws-rename", combos: [["⌘", "⇧", "R"]] }, - ], - }, - { - id: "surfaces", - titleKey: "surfaces", - blurbKey: "surfacesBlurb", - shortcuts: [ - { id: "sf-new", combos: [["⌘", "T"]] }, - { id: "sf-prev-1", combos: [["⌘", "⇧", "["]] }, - { id: "sf-prev-2", combos: [["⌃", "⇧", "Tab"]] }, - { id: "sf-jump-1-8", combos: [["⌃", "1–8"]] }, - { id: "sf-jump-last", combos: [["⌃", "9"]] }, - { id: "sf-close", combos: [["⌘", "W"]] }, - ], - }, - { - id: "split-panes", - titleKey: "splitPanes", - shortcuts: [ - { id: "sp-right", combos: [["⌘", "D"]] }, - { id: "sp-down", combos: [["⌘", "⇧", "D"]] }, - { id: "sp-focus", combos: [["⌥", "⌘", "←/→/↑/↓"]] }, - { id: "sp-browser-right", combos: [["⌥", "⌘", "D"]] }, - { id: "sp-browser-down", combos: [["⌥", "⌘", "⇧", "D"]] }, - ], - }, - { - id: "browser", - titleKey: "browser", - shortcuts: [ - { id: "br-open", combos: [["⌘", "⇧", "L"]] }, - { id: "br-addr", combos: [["⌘", "L"]] }, - { id: "br-forward", combos: [["⌘", "]"]] }, - { id: "br-reload", combos: [["⌘", "R"]] }, - { id: "br-devtools", combos: [["⌥", "⌘", "I"]] }, - ], - }, - { - id: "notifications", - titleKey: "notifications", - shortcuts: [ - { id: "nt-panel", combos: [["⌘", "⇧", "I"]] }, - { id: "nt-latest", combos: [["⌘", "⇧", "U"]] }, - { id: "nt-flash", combos: [["⌘", "⇧", "L"]] }, - ], - }, - { - id: "find", - titleKey: "find", - shortcuts: [ - { id: "fd-find", combos: [["⌘", "F"]] }, - { id: "fd-next-prev", combos: [["⌘", "G"], ["⌘", "⇧", "G"]] }, - { id: "fd-hide", combos: [["⌘", "⇧", "F"]] }, - { id: "fd-selection", combos: [["⌘", "E"]] }, - ], - }, - { - id: "terminal", - titleKey: "terminal", - shortcuts: [ - { id: "tm-clear", combos: [["⌘", "K"]] }, - { id: "tm-copy", combos: [["⌘", "C"]] }, - { id: "tm-paste", combos: [["⌘", "V"]] }, - { id: "tm-font", combos: [["⌘", "+"], ["⌘", "-"]] }, - { id: "tm-reset", combos: [["⌘", "0"]] }, - ], - }, - { - id: "window", - titleKey: "window", - shortcuts: [ - { id: "wn-new", combos: [["⌘", "⇧", "N"]] }, - { id: "wn-settings", combos: [["⌘", ","]] }, - { id: "wn-reload", combos: [["⌘", "⇧", "R"]] }, - { id: "wn-quit", combos: [["⌘", "Q"]] }, - ], - }, -]; +function localizedText(text: LocalizedText, locale: string) { + return locale.startsWith("ja") ? text.ja : text.en; +} function normalize(s: string) { return s.toLowerCase().replace(/\s+/g, " ").trim(); @@ -123,7 +23,7 @@ function KeyCombo({ combo }: { combo: string[] }) { <span key={`${k}-${idx}`} className="inline-flex items-center"> <kbd>{k}</kbd> {idx < combo.length - 1 && ( - <span className="text-muted/30 text-[10px] mx-[3px] select-none font-mono"> + <span className="text-muted/30 mx-[3px] select-none font-mono text-[10px]"> + </span> )} @@ -133,27 +33,21 @@ function KeyCombo({ combo }: { combo: string[] }) { ); } -function ShortcutRow({ shortcut, description, note }: { shortcut: Shortcut; description: string; note?: string }) { +function ShortcutRow({ shortcut, locale }: { shortcut: Shortcut; locale: string }) { + const description = localizedText(shortcut.description, locale); + const note = shortcut.note ? localizedText(shortcut.note, locale) : undefined; + return ( - <div className="flex items-center justify-between gap-4 py-[11px] px-4 hover:bg-foreground/[0.025] transition-colors"> + <div className="flex items-center justify-between gap-4 px-4 py-[11px] transition-colors hover:bg-foreground/[0.025]"> <div className="min-w-0"> - <span className="text-[14px] text-foreground/90"> - {description} - </span> - {note && ( - <span className="text-[12px] text-muted/50 ml-2"> - {note} - </span> - )} + <span className="text-[14px] text-foreground/90">{description}</span> + {note && <span className="ml-2 text-[12px] text-muted/50">{note}</span>} </div> - <div className="flex items-center gap-3 shrink-0"> + <div className="flex shrink-0 items-center gap-3"> {shortcut.combos.map((combo, idx) => ( - <span - key={`${shortcut.id}-combo-${idx}`} - className="inline-flex items-center" - > + <span key={`${shortcut.id}-combo-${idx}`} className="inline-flex items-center"> {idx > 0 && ( - <span className="text-muted/30 text-[11px] select-none mr-3 font-mono"> + <span className="mr-3 select-none font-mono text-[11px] text-muted/30"> / </span> )} @@ -167,27 +61,28 @@ function ShortcutRow({ shortcut, description, note }: { shortcut: Shortcut; desc export function KeyboardShortcuts() { const [query, setQuery] = useState(""); + const locale = useLocale(); const t = useTranslations("docs.keyboardShortcuts"); const trimmedQuery = query.trim(); const filtered = useMemo(() => { const q = normalize(query); - if (!q) return CATEGORIES; - return CATEGORIES.map((cat) => ({ + if (!q) return shortcutCategories; + return shortcutCategories.map((cat) => ({ ...cat, - shortcuts: cat.shortcuts.filter((s) => { + shortcuts: cat.shortcuts.filter((shortcut) => { const catTitle = t(`cat.${cat.titleKey}`); - const desc = t(`sc.${s.id}`); - const combos = s.combos.map(comboToText).join(" "); - return normalize(`${catTitle} ${combos} ${desc} ${s.note ?? ""}`).includes(q); + const description = localizedText(shortcut.description, locale); + const note = shortcut.note ? localizedText(shortcut.note, locale) : ""; + const combos = shortcut.combos.map(comboToText).join(" "); + return normalize(`${catTitle} ${combos} ${description} ${note}`).includes(q); }), })).filter((cat) => cat.shortcuts.length > 0); - }, [query, t]); + }, [locale, query, t]); return ( - <div className="mt-2 mb-12"> - {/* Search */} + <div className="mb-12 mt-2"> <div className="relative mb-8"> <div className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted/40"> <svg @@ -208,24 +103,23 @@ export function KeyboardShortcuts() { value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("searchPlaceholder")} - className="w-full pl-9 pr-3 py-1.5 rounded-lg border border-border bg-transparent text-[13px] placeholder:text-muted/40 focus:outline-none focus:border-foreground/20 transition-colors" + className="w-full rounded-lg border border-border bg-transparent py-1.5 pl-9 pr-3 text-[13px] transition-colors placeholder:text-muted/40 focus:border-foreground/20 focus:outline-none" aria-label={t("searchLabel")} /> </div> - {/* Category jump links */} {!trimmedQuery && ( - <nav className="flex flex-wrap items-center gap-y-2 mb-10"> - {CATEGORIES.map((cat, idx) => ( + <nav className="mb-10 flex flex-wrap items-center gap-y-2"> + {shortcutCategories.map((cat, idx) => ( <span key={cat.id} className="inline-flex items-center"> <a href={`#${cat.id}`} - className="text-[13px] text-muted hover:text-foreground transition-colors" + className="text-[13px] text-muted transition-colors hover:text-foreground" > {t(`cat.${cat.titleKey}`)} </a> - {idx < CATEGORIES.length - 1 && ( - <span className="text-border mx-2.5 text-[10px] select-none"> + {idx < shortcutCategories.length - 1 && ( + <span className="mx-2.5 select-none text-[10px] text-border"> · </span> )} @@ -234,13 +128,10 @@ export function KeyboardShortcuts() { </nav> )} - {/* Content */} {filtered.length === 0 ? ( <div className="py-16 text-center"> <p className="text-[14px] text-muted/70">{t("noResults")}</p> - <p className="text-[13px] text-muted/40 mt-1.5"> - {t("noResultsHint")} - </p> + <p className="mt-1.5 text-[13px] text-muted/40">{t("noResultsHint")}</p> </div> ) : ( <div className="space-y-10"> @@ -251,13 +142,13 @@ export function KeyboardShortcuts() { {t(`cat.${cat.titleKey}`)} </div> {cat.blurbKey && ( - <p className="text-[13px] text-muted/50 mt-1">{t(`cat.${cat.blurbKey}`)}</p> + <p className="mt-1 text-[13px] text-muted/50">{t(`cat.${cat.blurbKey}`)}</p> )} </div> - <div className="rounded-xl border border-border overflow-hidden"> + <div className="overflow-hidden rounded-xl border border-border"> <div className="divide-y divide-border/60"> - {cat.shortcuts.map((s) => ( - <ShortcutRow key={s.id} shortcut={s} description={t(`sc.${s.id}`)} /> + {cat.shortcuts.map((shortcut) => ( + <ShortcutRow key={shortcut.id} shortcut={shortcut} locale={locale} /> ))} </div> </div> diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index a4ce63ac05ce..f78e97ce2285 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -19,7 +19,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { path: "/docs/concepts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/configuration", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/custom-commands", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 }, - { path: "/docs/keyboard-shortcuts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 }, + { path: "/docs/keyboard-shortcuts", lastModified: "2026-04-03", changeFrequency: "monthly" as const, priority: 0.7 }, { path: "/docs/api", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/notifications", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/ssh", lastModified: "2026-03-31", changeFrequency: "monthly" as const, priority: 0.8 }, diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json new file mode 100644 index 000000000000..a2b4f483755c --- /dev/null +++ b/web/data/cmux-settings.schema.json @@ -0,0 +1,542 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json", + "title": "cmux settings.json", + "description": "User-level cmux settings. cmux accepts JSON with comments (JSONC) and trailing commas, but the underlying data model matches this schema.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "default": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json", + "description": "Optional schema URL for editor completion and validation." + }, + "schemaVersion": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Schema version for forward-compatible migrations. Newer versions are parsed on a best-effort basis." + }, + "app": { + "title": "app", + "description": "General app preferences from Settings > App.", + "type": "object", + "additionalProperties": false, + "properties": { + "language": { + "type": "string", + "enum": ["system", "en", "ar", "bs", "zh-Hans", "zh-Hant", "da", "de", "es", "fr", "it", "ja", "ko", "nb", "pl", "pt-BR", "ru", "th", "tr"], + "default": "system", + "description": "Preferred app language." + }, + "appearance": { + "type": "string", + "enum": ["system", "light", "dark"], + "default": "system", + "description": "App appearance mode." + }, + "appIcon": { + "type": "string", + "enum": ["automatic", "light", "dark"], + "default": "automatic", + "description": "Dock and app switcher icon style." + }, + "newWorkspacePlacement": { + "type": "string", + "enum": ["top", "afterCurrent", "end"], + "default": "afterCurrent", + "description": "Where new workspaces are inserted in the sidebar." + }, + "minimalMode": { + "type": "boolean", + "default": false, + "description": "Hide the workspace title bar and move controls into the sidebar." + }, + "keepWorkspaceOpenWhenClosingLastSurface": { + "type": "boolean", + "default": false, + "description": "When true, closing the last surface keeps the workspace open." + }, + "focusPaneOnFirstClick": { + "type": "boolean", + "default": true, + "description": "When cmux is inactive, the first click can activate and focus the clicked pane." + }, + "preferredEditor": { + "type": "string", + "default": "", + "description": "Custom editor command used by cmux where applicable. Leave empty to use the default." + }, + "reorderOnNotification": { + "type": "boolean", + "default": true, + "description": "Move workspaces with new notifications toward the top." + }, + "sendAnonymousTelemetry": { + "type": "boolean", + "default": true, + "description": "Allow anonymous telemetry." + }, + "warnBeforeQuit": { + "type": "boolean", + "default": true, + "description": "Show a confirmation before quitting cmux." + }, + "renameSelectsExistingName": { + "type": "boolean", + "default": true, + "description": "Select the current name when opening rename flows." + }, + "commandPaletteSearchesAllSurfaces": { + "type": "boolean", + "default": false, + "description": "Search every surface in the command palette switcher instead of only the active workspace." + } + } + }, + "notifications": { + "title": "notifications", + "description": "Notification behavior from Settings > Notifications.", + "type": "object", + "additionalProperties": false, + "properties": { + "dockBadge": { + "type": "boolean", + "default": true, + "description": "Show the unread count in the Dock tile." + }, + "showInMenuBar": { + "type": "boolean", + "default": true, + "description": "Show the menu bar extra." + }, + "unreadPaneRing": { + "type": "boolean", + "default": true, + "description": "Highlight panes with unread notifications." + }, + "paneFlash": { + "type": "boolean", + "default": true, + "description": "Flash the focused pane when requested." + }, + "sound": { + "type": "string", + "enum": ["default", "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink", "custom_file", "none"], + "default": "default", + "description": "Notification sound preset." + }, + "customSoundFilePath": { + "type": "string", + "default": "", + "description": "Local path to the custom notification sound file." + }, + "command": { + "type": "string", + "default": "", + "description": "Optional shell command to run alongside notification delivery." + } + } + }, + "sidebar": { + "title": "sidebar", + "description": "Sidebar content and metadata visibility from Settings > Sidebar.", + "type": "object", + "additionalProperties": false, + "properties": { + "hideAllDetails": { + "type": "boolean", + "default": false, + "description": "Hide all per-workspace detail rows." + }, + "branchLayout": { + "type": "string", + "enum": ["vertical", "inline"], + "default": "vertical", + "description": "Show git branch details stacked vertically or inline." + }, + "showNotificationMessage": { + "type": "boolean", + "default": true, + "description": "Show the latest notification text in the sidebar." + }, + "showBranchDirectory": { + "type": "boolean", + "default": true, + "description": "Show the workspace working directory." + }, + "showPullRequests": { + "type": "boolean", + "default": true, + "description": "Show pull request metadata in the sidebar." + }, + "openPullRequestLinksInCmuxBrowser": { + "type": "boolean", + "default": true, + "description": "Open sidebar pull request links in the embedded cmux browser." + }, + "openPortLinksInCmuxBrowser": { + "type": "boolean", + "default": true, + "description": "Open sidebar port links in the embedded cmux browser." + }, + "showSSH": { + "type": "boolean", + "default": true, + "description": "Show SSH connection details." + }, + "showPorts": { + "type": "boolean", + "default": true, + "description": "Show listening ports." + }, + "showLog": { + "type": "boolean", + "default": true, + "description": "Show recent log snippets." + }, + "showProgress": { + "type": "boolean", + "default": true, + "description": "Show progress indicators." + }, + "showCustomMetadata": { + "type": "boolean", + "default": true, + "description": "Show custom metadata pills." + } + } + }, + "workspaceColors": { + "title": "workspaceColors", + "description": "Workspace tab and badge colors from Settings > Workspace Colors.", + "type": "object", + "additionalProperties": false, + "properties": { + "indicatorStyle": { + "type": "string", + "enum": ["leftRail", "solidFill", "rail", "border", "wash", "lift", "typography", "washRail", "blueWashColorRail"], + "default": "leftRail", + "description": "Active workspace indicator style. Legacy aliases are accepted and normalized." + }, + "selectionColor": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Override the selected workspace background color." + }, + "notificationBadgeColor": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Override the unread notification badge color." + }, + "colors": { + "type": "object", + "description": "Full named workspace color palette. Include built-in entries you want to keep, remove keys to remove colors, and add more named entries to extend the picker.", + "default": { + "Red": "#C0392B", + "Crimson": "#922B21", + "Orange": "#A04000", + "Amber": "#7D6608", + "Olive": "#4A5C18", + "Green": "#196F3D", + "Teal": "#006B6B", + "Aqua": "#0E6B8C", + "Blue": "#1565C0", + "Navy": "#1A5276", + "Indigo": "#283593", + "Purple": "#6A1B9A", + "Magenta": "#AD1457", + "Rose": "#880E4F", + "Brown": "#7B3F00", + "Charcoal": "#3E4B5E" + }, + "additionalProperties": { + "$ref": "#/$defs/colorHex" + } + } + } + }, + "sidebarAppearance": { + "title": "sidebarAppearance", + "description": "Sidebar tint settings from Settings > Sidebar Appearance.", + "type": "object", + "additionalProperties": false, + "properties": { + "matchTerminalBackground": { + "type": "boolean", + "default": false, + "description": "Use the terminal background instead of the sidebar tint." + }, + "tintColor": { + "$ref": "#/$defs/colorHex", + "default": "#000000", + "description": "Base sidebar tint color used when light/dark overrides are not set." + }, + "lightModeTintColor": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Sidebar tint override for light appearance." + }, + "darkModeTintColor": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Sidebar tint override for dark appearance." + }, + "tintOpacity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.03, + "description": "Sidebar tint opacity from 0 to 1." + } + } + }, + "automation": { + "title": "automation", + "description": "Socket control and automation settings from Settings > Automation.", + "type": "object", + "additionalProperties": false, + "properties": { + "socketControlMode": { + "type": "string", + "enum": ["off", "cmuxOnly", "automation", "password", "allowAll", "openAccess", "fullOpenAccess", "notifications", "full"], + "default": "cmuxOnly", + "description": "Socket control mode. Legacy aliases are accepted and normalized." + }, + "socketPassword": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "description": "Password for password-mode socket access. Use null or an empty string to clear it." + }, + "claudeCodeIntegration": { + "type": "boolean", + "default": true, + "description": "Enable cmux integration hooks for Claude Code." + }, + "claudeBinaryPath": { + "type": "string", + "default": "", + "description": "Custom path to the claude binary." + }, + "portBase": { + "type": "integer", + "minimum": 1, + "default": 9100, + "description": "Starting value for workspace CMUX_PORT assignments." + }, + "portRange": { + "type": "integer", + "minimum": 1, + "default": 10, + "description": "Number of ports reserved per workspace." + } + } + }, + "customCommands": { + "title": "customCommands", + "description": "Custom command trust settings from Settings > Custom Commands.", + "type": "object", + "additionalProperties": false, + "properties": { + "trustedDirectories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Directories whose cmux.json commands can run without confirmation." + } + } + }, + "browser": { + "title": "browser", + "description": "Embedded browser settings from Settings > Browser.", + "type": "object", + "additionalProperties": false, + "properties": { + "defaultSearchEngine": { + "type": "string", + "enum": ["google", "duckduckgo", "bing", "kagi", "startpage"], + "default": "google", + "description": "Default search engine for non-URL queries." + }, + "showSearchSuggestions": { + "type": "boolean", + "default": true, + "description": "Show omnibar search suggestions." + }, + "theme": { + "type": "string", + "enum": ["system", "light", "dark"], + "default": "system", + "description": "Embedded browser theme." + }, + "openTerminalLinksInCmuxBrowser": { + "type": "boolean", + "default": true, + "description": "Open clicked terminal links in the embedded browser." + }, + "interceptTerminalOpenCommandInCmuxBrowser": { + "type": "boolean", + "default": true, + "description": "Intercept terminal open http(s) commands and route them through the embedded browser." + }, + "hostsToOpenInEmbeddedBrowser": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Allowlist of hosts that should stay inside the embedded browser." + }, + "urlsToAlwaysOpenExternally": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Rules that always open matching URLs in the system browser." + }, + "insecureHttpHostsAllowedInEmbeddedBrowser": { + "type": "array", + "items": { + "type": "string" + }, + "default": ["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"], + "description": "HTTP hosts allowed in the embedded browser without a warning prompt." + }, + "showImportHintOnBlankTabs": { + "type": "boolean", + "default": true, + "description": "Show the browser import hint on blank tabs." + }, + "reactGrabVersion": { + "type": "string", + "default": "0.1.29", + "description": "Pinned react-grab version for the browser toolbar helper." + } + } + }, + "shortcuts": { + "title": "shortcuts", + "description": "Keyboard shortcut settings from Settings > Keyboard Shortcuts.", + "type": "object", + "additionalProperties": false, + "properties": { + "showModifierHoldHints": { + "type": "boolean", + "default": true, + "description": "Show shortcut hint pills while holding Cmd or Ctrl." + }, + "bindings": { + "type": "object", + "default": {}, + "description": "Shortcut overrides keyed by cmux action id. Use a string for a single shortcut or an array for a chord.", + "propertyNames": { + "enum": [ + "openSettings", + "reloadConfiguration", + "newWindow", + "closeWindow", + "toggleFullScreen", + "quit", + "toggleSidebar", + "newTab", + "openFolder", + "goToWorkspace", + "commandPalette", + "sendFeedback", + "showNotifications", + "jumpToUnread", + "triggerFlash", + "nextSurface", + "prevSurface", + "selectSurfaceByNumber", + "nextSidebarTab", + "prevSidebarTab", + "selectWorkspaceByNumber", + "renameTab", + "renameWorkspace", + "closeTab", + "closeOtherTabsInPane", + "closeWorkspace", + "reopenClosedBrowserPanel", + "newSurface", + "toggleTerminalCopyMode", + "focusLeft", + "focusRight", + "focusUp", + "focusDown", + "splitRight", + "splitDown", + "toggleSplitZoom", + "splitBrowserRight", + "splitBrowserDown", + "openBrowser", + "focusBrowserAddressBar", + "browserBack", + "browserForward", + "browserReload", + "browserZoomIn", + "browserZoomOut", + "browserZoomReset", + "find", + "findNext", + "findPrevious", + "hideFind", + "useSelectionForFind", + "toggleBrowserDeveloperTools", + "showBrowserJavaScriptConsole", + "toggleReactGrab" + ] + }, + "additionalProperties": { + "$ref": "#/$defs/shortcutBinding" + } + } + } + } + }, + "$defs": { + "colorHex": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$", + "description": "Uppercase or lowercase 6-digit hex color." + }, + "colorHexOrNull": { + "oneOf": [ + { + "$ref": "#/$defs/colorHex" + }, + { + "type": "null" + } + ] + }, + "shortcutBinding": { + "oneOf": [ + { + "type": "string", + "description": "Single-stroke shortcut, for example cmd+n." + }, + { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "string" + }, + "description": "Chorded shortcut. Example: [\"ctrl+b\", \"c\"]." + } + ] + } + } +} diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts new file mode 100644 index 000000000000..cc5cbb53085b --- /dev/null +++ b/web/data/cmux-shortcuts.ts @@ -0,0 +1,135 @@ +export type LocalizedText = { + en: string; + ja: string; +}; + +export type Shortcut = { + id: string; + combos: string[][]; + description: LocalizedText; + note?: LocalizedText; +}; + +export type ShortcutCategory = { + id: string; + titleKey: string; + blurbKey?: string; + shortcuts: Shortcut[]; +}; + +export const shortcutCategories: ShortcutCategory[] = [ + { + id: "app", + titleKey: "app", + blurbKey: "appBlurb", + shortcuts: [ + { id: "openSettings", combos: [["⌘", ","]], description: { en: "Settings", ja: "設定" } }, + { id: "reloadConfiguration", combos: [["⌘", "⇧", ","]], description: { en: "Reload configuration", ja: "構成を再読み込み" } }, + { id: "commandPalette", combos: [["⌘", "⇧", "P"]], description: { en: "Command palette", ja: "コマンドパレット" } }, + { id: "newWindow", combos: [["⌘", "⇧", "N"]], description: { en: "New window", ja: "新規ウインドウ" } }, + { id: "closeWindow", combos: [["⌃", "⌘", "W"]], description: { en: "Close window", ja: "ウインドウを閉じる" } }, + { id: "toggleFullScreen", combos: [["⌃", "⌘", "F"]], description: { en: "Toggle full screen", ja: "フルスクリーンを切り替え" } }, + { id: "sendFeedback", combos: [["⌥", "⌘", "F"]], description: { en: "Send feedback", ja: "フィードバックを送信" } }, + { id: "quit", combos: [["⌘", "Q"]], description: { en: "Quit cmux", ja: "cmuxを終了" } }, + ], + }, + { + id: "workspaces", + titleKey: "workspaces", + blurbKey: "workspacesBlurb", + shortcuts: [ + { id: "toggleSidebar", combos: [["⌘", "B"]], description: { en: "Toggle sidebar", ja: "サイドバーを切り替え" } }, + { id: "newTab", combos: [["⌘", "N"]], description: { en: "New workspace", ja: "新規ワークスペース" } }, + { id: "openFolder", combos: [["⌘", "O"]], description: { en: "Open folder", ja: "フォルダを開く" } }, + { + id: "goToWorkspace", + combos: [["⌘", "P"]], + description: { en: "Go to workspace", ja: "ワークスペースへ移動" }, + note: { en: "workspace switcher", ja: "ワークスペーススイッチャー" }, + }, + { id: "nextSidebarTab", combos: [["⌃", "⌘", "]"]], description: { en: "Next workspace", ja: "次のワークスペース" } }, + { id: "prevSidebarTab", combos: [["⌃", "⌘", "["]], description: { en: "Previous workspace", ja: "前のワークスペース" } }, + { id: "selectWorkspaceByNumber", combos: [["⌘", "1…9"]], description: { en: "Select workspace 1…9", ja: "ワークスペース1…9を選択" } }, + { id: "renameWorkspace", combos: [["⌘", "⇧", "R"]], description: { en: "Rename workspace", ja: "ワークスペース名を変更" } }, + { id: "closeWorkspace", combos: [["⌘", "⇧", "W"]], description: { en: "Close workspace", ja: "ワークスペースを閉じる" } }, + ], + }, + { + id: "surfaces", + titleKey: "surfaces", + blurbKey: "surfacesBlurb", + shortcuts: [ + { id: "newSurface", combos: [["⌘", "T"]], description: { en: "New surface", ja: "新規サーフェス" } }, + { id: "nextSurface", combos: [["⌘", "⇧", "]"]], description: { en: "Next surface", ja: "次のサーフェス" } }, + { id: "prevSurface", combos: [["⌘", "⇧", "["]], description: { en: "Previous surface", ja: "前のサーフェス" } }, + { id: "selectSurfaceByNumber", combos: [["⌃", "1…9"]], description: { en: "Select surface 1…9", ja: "サーフェス1…9を選択" } }, + { id: "renameTab", combos: [["⌘", "R"]], description: { en: "Rename tab", ja: "タブ名を変更" } }, + { id: "closeTab", combos: [["⌘", "W"]], description: { en: "Close tab", ja: "タブを閉じる" } }, + { id: "closeOtherTabsInPane", combos: [["⌥", "⌘", "T"]], description: { en: "Close other tabs in pane", ja: "ペイン内の他のタブを閉じる" } }, + { id: "reopenClosedBrowserPanel", combos: [["⌘", "⇧", "T"]], description: { en: "Reopen closed browser panel", ja: "閉じたブラウザパネルを再度開く" } }, + { id: "toggleTerminalCopyMode", combos: [["⌘", "⇧", "M"]], description: { en: "Toggle terminal copy mode", ja: "ターミナルコピーモードを切り替え" } }, + ], + }, + { + id: "split-panes", + titleKey: "splitPanes", + shortcuts: [ + { id: "focusLeft", combos: [["⌥", "⌘", "←"]], description: { en: "Focus pane left", ja: "左のペインにフォーカス" } }, + { id: "focusRight", combos: [["⌥", "⌘", "→"]], description: { en: "Focus pane right", ja: "右のペインにフォーカス" } }, + { id: "focusUp", combos: [["⌥", "⌘", "↑"]], description: { en: "Focus pane up", ja: "上のペインにフォーカス" } }, + { id: "focusDown", combos: [["⌥", "⌘", "↓"]], description: { en: "Focus pane down", ja: "下のペインにフォーカス" } }, + { id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } }, + { id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } }, + { id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } }, + { id: "splitBrowserDown", combos: [["⌥", "⌘", "⇧", "D"]], description: { en: "Split browser down", ja: "下にブラウザ分割" } }, + { id: "toggleSplitZoom", combos: [["⌘", "⇧", "↩"]], description: { en: "Toggle pane zoom", ja: "ペインズームを切り替え" } }, + ], + }, + { + id: "browser", + titleKey: "browser", + shortcuts: [ + { id: "openBrowser", combos: [["⌘", "⇧", "L"]], description: { en: "Open browser", ja: "ブラウザを開く" } }, + { id: "focusBrowserAddressBar", combos: [["⌘", "L"]], description: { en: "Focus address bar", ja: "アドレスバーにフォーカス" } }, + { id: "browserBack", combos: [["⌘", "["]], description: { en: "Back", ja: "戻る" } }, + { id: "browserForward", combos: [["⌘", "]"]], description: { en: "Forward", ja: "進む" } }, + { + id: "browserReload", + combos: [["⌘", "R"]], + description: { en: "Reload page", ja: "ページを再読み込み" }, + note: { en: "focused browser", ja: "フォーカス中のブラウザ" }, + }, + { id: "browserZoomIn", combos: [["⌘", "="]], description: { en: "Zoom in", ja: "拡大" } }, + { id: "browserZoomOut", combos: [["⌘", "-"]], description: { en: "Zoom out", ja: "縮小" } }, + { id: "browserZoomReset", combos: [["⌘", "0"]], description: { en: "Actual size", ja: "実寸表示" } }, + { id: "toggleBrowserDeveloperTools", combos: [["⌥", "⌘", "I"]], description: { en: "Toggle browser developer tools", ja: "ブラウザ開発者ツールを切り替え" } }, + { id: "showBrowserJavaScriptConsole", combos: [["⌥", "⌘", "C"]], description: { en: "Show browser JavaScript console", ja: "ブラウザJavaScriptコンソールを表示" } }, + { + id: "toggleReactGrab", + combos: [["⌥", "⌘", "G"]], + description: { en: "Toggle React Grab", ja: "React Grabを切り替え" }, + note: { en: "focused browser", ja: "フォーカス中のブラウザ" }, + }, + ], + }, + { + id: "find", + titleKey: "find", + shortcuts: [ + { id: "find", combos: [["⌘", "F"]], description: { en: "Find", ja: "検索" } }, + { id: "findNext", combos: [["⌘", "G"]], description: { en: "Find next", ja: "次を検索" } }, + { id: "findPrevious", combos: [["⌘", "⇧", "G"]], description: { en: "Find previous", ja: "前を検索" } }, + { id: "hideFind", combos: [["⌘", "⇧", "F"]], description: { en: "Hide find bar", ja: "検索バーを隠す" } }, + { id: "useSelectionForFind", combos: [["⌘", "E"]], description: { en: "Use selection for find", ja: "選択範囲で検索" } }, + ], + }, + { + id: "notifications", + titleKey: "notifications", + shortcuts: [ + { id: "showNotifications", combos: [["⌘", "I"]], description: { en: "Show notifications", ja: "通知を表示" } }, + { id: "jumpToUnread", combos: [["⌘", "⇧", "U"]], description: { en: "Jump to latest unread", ja: "最新の未読へ移動" } }, + { id: "triggerFlash", combos: [["⌘", "⇧", "H"]], description: { en: "Flash focused panel", ja: "フォーカス中のパネルをフラッシュ" } }, + ], + }, +]; diff --git a/web/messages/en.json b/web/messages/en.json index 0e377b1b7e30..1d419a82679e 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -316,8 +316,8 @@ "configuration": { "title": "Configuration", "metaTitle": "Configuration", - "metaDescription": "Configure cmux via Ghostty config files. Font, theme, colors, split pane styling, scrollback, and app settings for automation mode.", - "intro": "cmux reads configuration from Ghostty config files, giving you familiar options if you're coming from Ghostty.", + "metaDescription": "Configure cmux via Ghostty config files and ~/.config/cmux/settings.json. Terminal rendering stays in Ghostty config, while cmux app settings, shortcuts, automation, sidebar, notifications, and browser behavior live in settings.json.", + "intro": "cmux reads terminal configuration from Ghostty config files. cmux-owned app settings also live in ~/.config/cmux/settings.json, including shortcuts, automation, sidebar behavior, notifications, and browser preferences.", "configLocations": "Config file locations", "configLocationsDesc": "cmux looks for configuration in these locations (in order):", "createConfig": "Create the config file if it doesn't exist:", @@ -344,6 +344,10 @@ "browserLinkDesc": "In Settings > Browser, cmux exposes two host lists with different purposes:", "browserHostsEmbed": "Hosts to Open in Embedded Browser: applies to links clicked from terminal output. Hosts in this list open in cmux; other hosts open in your default browser. Supports one host or wildcard per line (for example: example.com, *.internal.example).", "browserHostsHttp": "HTTP Hosts Allowed in Embedded Browser: applies only to HTTP (non-HTTPS) URLs. Hosts in this list can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.", + "settingsFile": "settings.json", + "settingsFileDesc": "cmux also reads a user settings file for cmux-owned shortcuts. Today the file supports the shortcuts section.", + "settingsFilePrecedence": "Shortcuts defined in settings.json override the in-app Settings value. Managed shortcuts stay visible in Settings but become read-only there.", + "settingsFileChords": "Use a string for a single shortcut or a two-item array for a chord. Example: [\"ctrl+b\", \"c\"] means press Ctrl+B, then C.", "exampleConfig": "Example config" }, "customCommands": { @@ -412,14 +416,22 @@ }, "keyboardShortcuts": { "title": "Keyboard Shortcuts", - "description": "All keyboard shortcuts available in cmux, grouped by category.", + "description": "Default cmux keyboard shortcuts. Every cmux-owned shortcut can be changed in Settings or ~/.config/cmux/settings.json, including two-step chords.", "metaTitle": "Keyboard Shortcuts", - "metaDescription": "All cmux keyboard shortcuts for workspaces, surfaces, split panes, browser, notifications, find, and window management on macOS.", + "metaDescription": "Default cmux keyboard shortcuts for workspaces, surfaces, split panes, browser, notifications, find, and app controls on macOS. All cmux-owned shortcuts are configurable in Settings or settings.json.", + "chordsTitle": "Shortcut chords", + "chordsIntro": "cmux supports two-step shortcut chords in <settingsFile>~/.config/cmux/settings.json</settingsFile>. For the full settings file schema, see the <configurationLink>configuration docs</configurationLink>.", + "chordsCallout": "Settings can edit shortcuts directly. Use settings.json when you want an exact tmux-style prefix binding or you keep shortcuts in dotfiles.", + "chordsRuleSingle": "Use a plain string for a one-step shortcut.", + "chordsRuleArray": "Use a two-item array for a chord. The first item is the prefix stroke, the second is the key that follows it.", + "chordsRuleSyntax": "Each item uses the same syntax as regular bindings, for example cmd+b, ctrl+b, shift+/, or ctrl+1.", "searchPlaceholder": "Search shortcuts...", "searchLabel": "Search keyboard shortcuts", "noResults": "No shortcuts found", "noResultsHint": "Try a different search term", "cat": { + "app": "App", + "appBlurb": "Application, window, and top-level command shortcuts.", "workspaces": "Workspaces", "workspacesBlurb": "Workspaces live in the sidebar. Each workspace has its own set of panes and surfaces.", "surfaces": "Surfaces", diff --git a/web/messages/ja.json b/web/messages/ja.json index 8542d1b0b465..e9aec5b6e0cf 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -271,8 +271,8 @@ }, "configuration": { "title": "設定", - "metaDescription": "Ghostty設定ファイルによるcmuxの設定。フォント、テーマ、カラー、分割ペインのスタイル、スクロールバック、自動化モードのアプリ設定。", - "intro": "cmuxはGhosttyの設定ファイルから設定を読み込むため、Ghosttyから移行する場合は馴染みのあるオプションを使えます。", + "metaDescription": "Ghostty設定ファイルと ~/.config/cmux/settings.json によるcmuxの設定。ターミナル描画はGhostty設定、cmux側のアプリ設定、ショートカット、自動化、サイドバー、通知、ブラウザ動作は settings.json で管理します。", + "intro": "cmuxはターミナル設定をGhosttyの設定ファイルから読み込みます。cmux管理のアプリ設定も ~/.config/cmux/settings.json で管理でき、ショートカット、自動化、サイドバー、通知、ブラウザ設定を含みます。", "configLocations": "設定ファイルの場所", "configLocationsDesc": "cmuxは以下の場所から設定を検索します(順番に):", "createConfig": "設定ファイルが存在しない場合は作成してください:", @@ -299,6 +299,10 @@ "browserLinkDesc": "設定 > ブラウザで、cmuxは異なる目的の2つのホストリストを提供します:", "browserHostsEmbed": "組み込みブラウザで開くホスト:ターミナル出力からクリックしたリンクに適用されます。このリストのホストはcmuxで開き、それ以外はデフォルトブラウザで開きます。1行に1つのホストまたはワイルドカードを指定できます(例:example.com、*.internal.example)。", "browserHostsHttp": "組み込みブラウザで許可するHTTPホスト:HTTP(非HTTPS)URLのみに適用されます。このリストのホストは警告プロンプトなしでcmuxで開けます。デフォルトはlocalhost、127.0.0.1、::1、0.0.0.0、*.localtest.meです。", + "settingsFile": "settings.json", + "settingsFileDesc": "cmuxはcmux管理ショートカット用のユーザー設定ファイルも読み込みます。現在このファイルで対応しているのは shortcuts セクションです。", + "settingsFilePrecedence": "settings.json で定義したショートカットはアプリ内設定より優先されます。管理対象のショートカットは設定画面に表示されたままですが、そこでの編集は無効になります。", + "settingsFileChords": "単発ショートカットは文字列、コードは2要素の配列で指定します。例: [\"ctrl+b\", \"c\"] は Ctrl+B の後に C を押します。", "exampleConfig": "設定例", "metaTitle": "設定" }, @@ -368,13 +372,21 @@ }, "keyboardShortcuts": { "title": "キーボードショートカット", - "description": "cmuxで使用可能なすべてのキーボードショートカット(カテゴリ別)。", - "metaDescription": "cmuxのmacOS向けキーボードショートカット一覧。ワークスペース、サーフェス、分割ペイン、ブラウザ、通知、検索、ウィンドウ管理。", + "description": "cmuxのデフォルトショートカット一覧です。cmux管理のショートカットはすべて設定画面または ~/.config/cmux/settings.json で変更でき、2段階のコードにも対応しています。", + "chordsTitle": "ショートカットコード", + "chordsIntro": "cmux は <settingsFile>~/.config/cmux/settings.json</settingsFile> で2段階のショートカットコードを定義できます。設定ファイル全体の仕様は <configurationLink>設定ドキュメント</configurationLink> を参照してください。", + "chordsCallout": "ショートカットは設定画面でも編集できますが、tmux 風のプレフィックスを正確に書きたい場合や dotfiles で管理したい場合は settings.json が分かりやすい方法です。", + "chordsRuleSingle": "1回のショートカットは文字列で指定します。", + "chordsRuleArray": "コードは2要素の配列で指定します。1つ目がプレフィックス、2つ目が続けて押すキーです。", + "chordsRuleSyntax": "各要素は通常のショートカットと同じ構文で、例: cmd+b、ctrl+b、shift+/、ctrl+1。", + "metaDescription": "cmuxのmacOS向けデフォルトキーボードショートカット一覧。ワークスペース、サーフェス、分割ペイン、ブラウザ、通知、検索、アプリ操作を収録し、すべて設定画面または settings.json で変更できます。", "searchPlaceholder": "ショートカットを検索...", "searchLabel": "キーボードショートカットを検索", "noResults": "ショートカットが見つかりません", "noResultsHint": "別の検索ワードをお試しください", "cat": { + "app": "アプリ", + "appBlurb": "アプリ全体、ウインドウ、上位コマンドのショートカットです。", "workspaces": "ワークスペース", "workspacesBlurb": "ワークスペースはサイドバーに表示されます。各ワークスペースには独自のペインとサーフェスがあります。", "surfaces": "サーフェス",