diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bdbd264b..26fe7e78 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import Security struct CLIError: Error, CustomStringConvertible { let message: String @@ -235,6 +236,46 @@ enum CLIIDFormat: String { } } +private enum SocketPasswordResolver { + private static let service = "com.cmuxterm.app.socket-control" + private static let account = "local-socket-password" + + static func resolve(explicit: String?) -> String? { + if let explicit = normalized(explicit), !explicit.isEmpty { + return explicit + } + if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty { + return env + } + return loadFromKeychain() + } + + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .newlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func loadFromKeychain() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { + return nil + } + guard let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } +} + final class SocketClient { private let path: String private var socketFD: Int32 = -1 @@ -253,6 +294,10 @@ final class SocketClient { self.path = path } + var socketPath: String { + path + } + func connect() throws { if socketFD >= 0 { return } @@ -397,11 +442,60 @@ final class SocketClient { struct CMUXCLI { let args: [String] + private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path" + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func pathIsSocket(_ path: String) -> Bool { + var st = stat() + guard lstat(path, &st) == 0 else { return false } + return (st.st_mode & S_IFMT) == S_IFSOCK + } + + private static func debugSocketPathFromHintFile() -> String? { +#if DEBUG + guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else { + return nil + } + guard let hinted = normalizedEnvValue(raw), + hinted.hasPrefix("/tmp/cmux-debug"), + hinted.hasSuffix(".sock"), + pathIsSocket(hinted) else { + return nil + } + return hinted +#else + return nil +#endif + } + + private static func defaultSocketPath(environment: [String: String]) -> String { + if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) { + return explicit + } +#if DEBUG + if let hinted = debugSocketPathFromHintFile() { + return hinted + } + return "/tmp/cmux-debug.sock" +#else + return "/tmp/cmux.sock" +#endif + } + func run() throws { - var socketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock" + let environment = ProcessInfo.processInfo.environment + var socketPath = Self.defaultSocketPath(environment: environment) var jsonOutput = false var idFormatArg: String? = nil var windowId: String? = nil + var socketPasswordArg: String? = nil var index = 1 while index < args.count { @@ -435,6 +529,18 @@ struct CMUXCLI { index += 2 continue } + if arg == "--password" { + guard index + 1 < args.count else { + throw CLIError(message: "--password requires a value") + } + socketPasswordArg = args[index + 1] + index += 2 + continue + } + if arg == "-v" || arg == "--version" { + print(versionSummary()) + return + } if arg == "-h" || arg == "--help" { print(usage()) return @@ -450,6 +556,11 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) + if command == "version" { + print(versionSummary()) + return + } + // Check for --help/-h on subcommands before connecting to the socket, // so help text is available even when cmux is not running. if commandArgs.contains("--help") || commandArgs.contains("-h") { @@ -462,6 +573,14 @@ struct CMUXCLI { try client.connect() defer { client.close() } + if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { + let authResponse = try client.send(command: "auth \(socketPassword)") + if authResponse.hasPrefix("ERROR:"), + !authResponse.contains("Unknown command 'auth'") { + throw CLIError(message: authResponse) + } + } + let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) // If the user explicitly targets a window, focus it first so commands route correctly. @@ -472,7 +591,7 @@ struct CMUXCLI { switch command { case "ping": - let response = try client.send(command: "ping") + let response = try sendV1Command("ping", client: client) print(response) case "capabilities": @@ -515,7 +634,7 @@ struct CMUXCLI { print(jsonString(formatIDs(response, mode: idFormat))) case "list-windows": - let response = try client.send(command: "list_windows") + let response = try sendV1Command("list_windows", client: client) if jsonOutput { let windows = parseWindows(response) let payload = windows.map { item -> [String: Any] in @@ -534,7 +653,7 @@ struct CMUXCLI { } case "current-window": - let response = try client.send(command: "current_window") + let response = try sendV1Command("current_window", client: client) if jsonOutput { print(jsonString(["window_id": response])) } else { @@ -542,21 +661,21 @@ struct CMUXCLI { } case "new-window": - let response = try client.send(command: "new_window") + let response = try sendV1Command("new_window", client: client) print(response) case "focus-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "focus-window requires --window") } - let response = try client.send(command: "focus_window \(target)") + let response = try sendV1Command("focus_window \(target)", client: client) print(response) case "close-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "close-window requires --window") } - let response = try client.send(command: "close_window \(target)") + let response = try sendV1Command("close_window \(target)", client: client) print(response) case "move-workspace-to-window": @@ -589,6 +708,9 @@ struct CMUXCLI { case "tab-action": try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) + case "rename-tab": + try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) + case "list-workspaces": let payload = try client.sendV2(method: "workspace.list") if jsonOutput { @@ -626,7 +748,7 @@ struct CMUXCLI { if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command ") } - let response = try client.send(command: "new_workspace") + let response = try sendV1Command("new_workspace", client: client) print(response) if let commandText = commandOpt { guard response.hasPrefix("OK ") else { @@ -771,11 +893,11 @@ struct CMUXCLI { guard let direction = rem1.first else { throw CLIError(message: "drag-surface-to-split requires a direction") } - let response = try client.send(command: "drag_surface_to_split \(surface) \(direction)") + let response = try sendV1Command("drag_surface_to_split \(surface) \(direction)", client: client) print(response) case "refresh-surfaces": - let response = try client.send(command: "refresh_surfaces") + let response = try sendV1Command("refresh_surfaces", client: client) print(response) case "surface-health": @@ -891,7 +1013,7 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "current-workspace": - let response = try client.send(command: "current_workspace") + let response = try sendV1Command("current_workspace", client: client) if jsonOutput { print(jsonString(["workspace_id": response])) } else { @@ -1015,11 +1137,11 @@ struct CMUXCLI { let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) let payload = "\(title)|\(subtitle)|\(body)" - let response = try client.send(command: "notify_target \(targetWorkspace) \(targetSurface) \(payload)") + let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) print(response) case "list-notifications": - let response = try client.send(command: "list_notifications") + let response = try sendV1Command("list_notifications", client: client) if jsonOutput { let notifications = parseNotifications(response) let payload = notifications.map { item in @@ -1040,7 +1162,7 @@ struct CMUXCLI { } case "clear-notifications": - let response = try client.send(command: "clear_notifications") + let response = try sendV1Command("clear_notifications", client: client) print(response) case "claude-hook": @@ -1048,11 +1170,11 @@ struct CMUXCLI { case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } - let response = try client.send(command: "set_app_focus \(value)") + let response = try sendV1Command("set_app_focus \(value)", client: client) print(response) case "simulate-app-active": - let response = try client.send(command: "simulate_app_active") + let response = try sendV1Command("simulate_app_active", client: client) print(response) case "capture-pane", @@ -1140,6 +1262,14 @@ struct CMUXCLI { return .refs } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { + let response = try client.send(command: command) + if response.hasPrefix("ERROR:") { + throw CLIError(message: response) + } + return response + } + private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -1738,6 +1868,55 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } + private func runRenameTab( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") + let (tabOpt, rem1) = parseOption(rem0, name: "--tab") + let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface") + let (titleOpt, rem3) = parseOption(rem2, name: "--title") + + if rem3.contains("--action") { + throw CLIError(message: "rename-tab does not accept --action (it always performs rename)") + } + if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) { + throw CLIError(message: "rename-tab: unknown flag '\(unknown)'") + } + + let inferredTitle = rem3 + .dropFirst(rem3.first == "--" ? 1 : 0) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))? + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let title, !title.isEmpty else { + throw CLIError(message: "rename-tab requires a title") + } + + var forwarded: [String] = ["--action", "rename", "--title", title] + if let workspaceOpt { + forwarded += ["--workspace", workspaceOpt] + } + if let tabOpt { + forwarded += ["--tab", tabOpt] + } else if let surfaceOpt { + forwarded += ["--surface", surfaceOpt] + } + + try runTabAction( + commandArgs: forwarded, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + } + private struct SSHCommandOptions { let destination: String let port: Int? @@ -1760,14 +1939,15 @@ struct CMUXCLI { jsonOutput: Bool, idFormat: CLIIDFormat ) throws { - let localSocketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock" + // Use the socket path from this invocation (supports --socket overrides). + let localSocketPath = client.socketPath let remoteRelayPort = generateRemoteRelayPort() let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) let sshCommand = buildSSHCommandText(sshOptions) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() let sshStartupCommand = buildSSHStartupCommand(sshCommand: sshCommand, shellFeatures: shellFeaturesValue) - var workspaceCreateParams: [String: Any] = [ + let workspaceCreateParams: [String: Any] = [ "initial_command": sshStartupCommand, ] @@ -3324,6 +3504,29 @@ fi cmux tab-action --action close-right cmux tab-action --tab tab:2 --action rename --title "build logs" """ + case "rename-tab": + return """ + Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] + + Compatibility alias for tab-action rename. + + Resolution order for target tab: + 1) --tab + 2) --surface + 3) $CMUX_TAB_ID / $CMUX_SURFACE_ID + 4) currently focused tab (optionally within --workspace) + + Flags: + --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) + --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) + --surface <id|ref> Alias for --tab + --title <text> Explicit title (or use trailing positional title) + + Examples: + cmux rename-tab "build logs" + cmux rename-tab --tab tab:3 "staging server" + cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" + """ case "new-workspace": return """ Usage: cmux new-workspace @@ -4580,20 +4783,214 @@ fi return truncate(normalized, maxLength: 180) } + private func versionSummary() -> String { + let info = resolvedVersionInfo() + if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] { + return "cmux \(version) (\(build))" + } + if let version = info["CFBundleShortVersionString"] { + return "cmux \(version)" + } + if let build = info["CFBundleVersion"] { + return "cmux build \(build)" + } + return "cmux version unknown" + } + + private func resolvedVersionInfo() -> [String: String] { + if let main = versionInfo(from: Bundle.main.infoDictionary) { + return main + } + + for plistURL in candidateInfoPlistURLs() { + guard let data = try? Data(contentsOf: plistURL), + let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), + let dictionary = raw as? [String: Any], + let parsed = versionInfo(from: dictionary) + else { + continue + } + return parsed + } + + if let fromProject = versionInfoFromProjectFile() { + return fromProject + } + + return [:] + } + + private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? { + guard let dictionary else { return nil } + + var info: [String: String] = [:] + if let version = dictionary["CFBundleShortVersionString"] as? String { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && !trimmed.contains("$(") { + info["CFBundleShortVersionString"] = trimmed + } + } + if let build = dictionary["CFBundleVersion"] as? String { + let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && !trimmed.contains("$(") { + info["CFBundleVersion"] = trimmed + } + } + return info.isEmpty ? nil : info + } + + private func versionInfoFromProjectFile() -> [String: String]? { + guard let executable = currentExecutablePath(), !executable.isEmpty else { + return nil + } + + let fileManager = FileManager.default + var current = URL(fileURLWithPath: executable) + .resolvingSymlinksInPath() + .standardizedFileURL + .deletingLastPathComponent() + + while true { + let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") + if fileManager.fileExists(atPath: projectFile.path), + let contents = try? String(contentsOf: projectFile, encoding: .utf8) { + var info: [String: String] = [:] + if let version = firstProjectSetting("MARKETING_VERSION", in: contents) { + info["CFBundleShortVersionString"] = version + } + if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) { + info["CFBundleVersion"] = build + } + if !info.isEmpty { + return info + } + } + + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + break + } + current = parent + } + + return nil + } + + private func firstProjectSetting(_ key: String, in source: String) -> String? { + let pattern = NSRegularExpression.escapedPattern(for: key) + "\\s*=\\s*([^;]+);" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return nil + } + let searchRange = NSRange(source.startIndex..<source.endIndex, in: source) + guard let match = regex.firstMatch(in: source, options: [], range: searchRange), + match.numberOfRanges > 1, + let valueRange = Range(match.range(at: 1), in: source) + else { + return nil + } + let value = source[valueRange] + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + guard !value.isEmpty, !value.contains("$(") else { + return nil + } + return value + } + + private func candidateInfoPlistURLs() -> [URL] { + guard let executable = currentExecutablePath(), !executable.isEmpty else { + return [] + } + + let fileManager = FileManager.default + let executableURL = URL(fileURLWithPath: executable) + .resolvingSymlinksInPath() + .standardizedFileURL + + var candidates: [URL] = [] + var current = executableURL.deletingLastPathComponent() + while true { + if current.pathExtension == "app" { + candidates.append(current.appendingPathComponent("Contents/Info.plist")) + } + if current.lastPathComponent == "Contents" { + candidates.append(current.appendingPathComponent("Info.plist")) + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") + let repoInfo = current.appendingPathComponent("Resources/Info.plist") + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.fileExists(atPath: repoInfo.path) { + candidates.append(repoInfo) + break + } + + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + break + } + current = parent + } + + let searchRoots = [ + executableURL.deletingLastPathComponent(), + executableURL.deletingLastPathComponent().deletingLastPathComponent() + ] + for root in searchRoots { + guard let entries = try? fileManager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + for entry in entries where entry.pathExtension == "app" { + candidates.append(entry.appendingPathComponent("Contents/Info.plist")) + } + } + + var seen: Set<String> = [] + return candidates.filter { url in + let path = url.path + guard !path.isEmpty else { return false } + guard seen.insert(path).inserted else { return false } + return fileManager.fileExists(atPath: path) + } + } + + private func currentExecutablePath() -> String? { + var size: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &size) + if size > 0 { + var buffer = Array<CChar>(repeating: 0, count: Int(size)) + if _NSGetExecutablePath(&buffer, &size) == 0 { + let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + if !path.isEmpty { + return path + } + } + } + return Bundle.main.executableURL?.path ?? args.first + } + private func usage() -> String { return """ cmux - control cmux via Unix socket Usage: - cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] <command> [options] + cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] [--version] <command> [options] Handle Inputs: For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes. `tab-action` also accepts `tab:<n>` in addition to `surface:<n>`. Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. + Socket Auth: + --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then keychain password saved in Settings. + Commands: ping + version capabilities identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] list-windows @@ -4617,6 +5014,7 @@ fi move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>] reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>] + 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 surface-health [--workspace <id|ref>] @@ -4707,9 +5105,11 @@ fi Environment: CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for ALL commands (send, list-panels, new-split, notify, etc.). - CMUX_TAB_ID Optional alias used by `tab-action` as default --tab. + CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. - CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock). + CMUX_SOCKET_PATH Override the default Unix socket path. + Debug CLI defaults: /tmp/cmux-last-socket-path -> /tmp/cmux-debug.sock. + Release CLI default: /tmp/cmux.sock. """ } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a6dd9174..bf36864a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5,6 +5,60 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit +func sidebarActiveForegroundNSColor( + opacity: CGFloat, + appAppearance: NSAppearance? = NSApp?.effectiveAppearance +) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + let baseColor: NSColor = (bestMatch == .darkAqua) ? .white : .black + return baseColor.withAlphaComponent(clampedOpacity) +} + +func cmuxAccentNSColor(for colorScheme: ColorScheme) -> NSColor { + switch colorScheme { + case .dark: + return NSColor( + srgbRed: 0, + green: 145.0 / 255.0, + blue: 1.0, + alpha: 1.0 + ) + default: + return NSColor( + srgbRed: 0, + green: 136.0 / 255.0, + blue: 1.0, + alpha: 1.0 + ) + } +} + +func cmuxAccentNSColor(for appAppearance: NSAppearance?) -> NSColor { + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + let scheme: ColorScheme = (bestMatch == .darkAqua) ? .dark : .light + return cmuxAccentNSColor(for: scheme) +} + +func cmuxAccentNSColor() -> NSColor { + NSColor(name: nil) { appearance in + cmuxAccentNSColor(for: appearance) + } +} + +func cmuxAccentColor() -> Color { + Color(nsColor: cmuxAccentNSColor()) +} + +func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { + cmuxAccentNSColor(for: colorScheme) +} + +func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + return NSColor.white.withAlphaComponent(clampedOpacity) +} + struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -152,13 +206,29 @@ enum WindowGlassEffect { } final class SidebarState: ObservableObject { - @Published var isVisible: Bool = true + @Published var isVisible: Bool + @Published var persistedWidth: CGFloat + + init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) { + self.isVisible = isVisible + let sanitized = SessionPersistencePolicy.sanitizedSidebarWidth(Double(persistedWidth)) + self.persistedWidth = CGFloat(sanitized) + } func toggle() { isVisible.toggle() } } +enum SidebarResizeInteraction { + static let handleWidth: CGFloat = 6 + static let hitInset: CGFloat = 3 + + static var hitWidthPerSide: CGFloat { + hitInset + (handleWidth / 2) + } +} + // MARK: - File Drop Overlay enum DragOverlayRoutingPolicy { @@ -615,6 +685,7 @@ final class FileDropOverlayView: NSView { } var fileDropOverlayKey: UInt8 = 0 +let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. @@ -833,7 +904,8 @@ struct ContentView: View { workspace: tab, isWorkspaceVisible: isVisible, isWorkspaceInputActive: isInputActive, - workspacePortalPriority: portalPriority + workspacePortalPriority: portalPriority, + onThemeRefreshRequest: nil ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) @@ -2145,6 +2217,31 @@ private struct SidebarEmptyArea: View { } } +struct SidebarRemoteErrorCopyEntry: Equatable { + let workspaceTitle: String + let target: String + let detail: String +} + +enum SidebarRemoteErrorCopySupport { + static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + return entries.count == 1 ? "Copy Error" : "Copy Errors" + } + + static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1 { + let entry = entries[0] + return "SSH error (\(entry.target)): \(entry.detail)" + } + + return entries.enumerated().map { index, entry in + "\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)" + }.joined(separator: "\n") + } +} + private struct TabItemView: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -2446,6 +2543,7 @@ private struct TabItemView: View { let targetIds = contextTargetIds() let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } + let remoteWorkspaceErrors = remoteErrorCopyEntries(in: remoteTargetWorkspaces) let reconnectLabel = remoteTargetWorkspaces.count > 1 ? "Reconnect Workspaces" : "Reconnect Workspace" let disconnectLabel = remoteTargetWorkspaces.count > 1 ? "Disconnect Workspaces" : "Disconnect Workspace" let shouldPin = !tab.isPinned @@ -2490,6 +2588,13 @@ private struct TabItemView: View { } } .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) + + if let copyErrorLabel = SidebarRemoteErrorCopySupport.menuLabel(for: remoteWorkspaceErrors), + let copyErrorText = SidebarRemoteErrorCopySupport.clipboardText(for: remoteWorkspaceErrors) { + Button(copyErrorLabel) { + copyTextToPasteboard(copyErrorText) + } + } } Divider() @@ -2682,6 +2787,27 @@ private struct TabItemView: View { return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } } + private func remoteErrorCopyEntries(in workspaces: [Tab]) -> [SidebarRemoteErrorCopyEntry] { + workspaces.compactMap { workspace in + guard workspace.remoteConnectionState == .error else { return nil } + guard let detail = workspace.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty else { + return nil + } + return SidebarRemoteErrorCopyEntry( + workspaceTitle: workspace.title, + target: workspace.remoteDisplayTarget ?? "remote host", + detail: detail + ) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + private func syncSelectionAfterMutation() { let existingIds = Set(tabManager.tabs.map { $0.id }) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } @@ -3472,7 +3598,7 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable { } } -private final class DraggableFolderNSView: NSView, NSDraggingSource { +final class DraggableFolderNSView: NSView, NSDraggingSource { var directory: String private var imageView: NSImageView! @@ -3598,6 +3724,20 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { } } +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState +} + /// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested private struct SidebarVisualEffectBackground: NSViewRepresentable { let material: NSVisualEffectView.Material @@ -3677,11 +3817,16 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { /// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + private struct TitlebarLeadingInsetReader: NSViewRepresentable { @Binding var inset: CGFloat func makeNSView(context: Context) -> NSView { - let view = NSView() + let view = TitlebarLeadingInsetPassthroughView() view.setFrameSize(.zero) return view } @@ -3967,3 +4112,27 @@ extension NSColor { ) } } + +extension ContentView { + static func commandPaletteScrollPositionAnchor( + selectedIndex: Int, + resultCount: Int + ) -> UnitPoint? { + guard resultCount > 0 else { return nil } + if selectedIndex <= 0 { + return UnitPoint.top + } + if selectedIndex >= resultCount - 1 { + return UnitPoint.bottom + } + return nil + } + + static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: Bool, + focusedBrowserAddressBarPanelId: UUID?, + focusedPanelId: UUID + ) -> Bool { + focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 028f9fb2..f7087a21 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -124,6 +124,79 @@ enum TerminalOpenURLTarget: Equatable { } } +enum GhosttyDefaultBackgroundUpdateScope: Int { + case unscoped = 0 + case app = 1 + case surface = 2 + + var logLabel: String { + switch self { + case .unscoped: return "unscoped" + case .app: return "app" + case .surface: return "surface" + } + } +} + +/// Coalesces Ghostty background notifications so consumers observe the latest +/// runtime background after a short burst window. +final class GhosttyDefaultBackgroundNotificationDispatcher { + private let coalescer: NotificationBurstCoalescer + private let postNotification: ([AnyHashable: Any]) -> Void + private var pendingUserInfo: [AnyHashable: Any]? + private var pendingEventId: UInt64 = 0 + private var pendingSource: String = "unspecified" + private let logEvent: ((String) -> Void)? + + init( + delay: TimeInterval = 1.0 / 30.0, + logEvent: ((String) -> Void)? = nil, + postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: userInfo + ) + } + ) { + coalescer = NotificationBurstCoalescer(delay: delay) + self.logEvent = logEvent + self.postNotification = postNotification + } + + func signal(backgroundColor: NSColor, opacity: Double, eventId: UInt64, source: String) { + let signalOnMain = { [self] in + pendingEventId = eventId + pendingSource = source + pendingUserInfo = [ + GhosttyNotificationKey.backgroundColor: backgroundColor, + GhosttyNotificationKey.backgroundOpacity: opacity, + GhosttyNotificationKey.backgroundEventId: NSNumber(value: eventId), + GhosttyNotificationKey.backgroundSource: source, + ] + logEvent?( + "bg notify queued id=\(eventId) source=\(source) color=\(backgroundColor.hexString()) opacity=\(String(format: "%.3f", opacity))" + ) + coalescer.signal { [self] in + guard let userInfo = pendingUserInfo else { return } + let eventId = pendingEventId + let source = pendingSource + pendingUserInfo = nil + logEvent?("bg notify flushed id=\(eventId) source=\(source)") + logEvent?("bg notify posting id=\(eventId) source=\(source)") + postNotification(userInfo) + logEvent?("bg notify posted id=\(eventId) source=\(source)") + } + } + + if Thread.isMainThread { + signalOnMain() + } else { + DispatchQueue.main.async(execute: signalOnMain) + } + } +} + func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -477,6 +550,20 @@ class GhosttyApp { return true } + static func shouldApplyDefaultBackgroundUpdate( + currentScope: GhosttyDefaultBackgroundUpdateScope, + incomingScope: GhosttyDefaultBackgroundUpdateScope + ) -> Bool { + incomingScope.rawValue >= currentScope.rawValue + } + + static func shouldReloadConfigurationForAppearanceChange( + previousColorScheme: GhosttyConfig.ColorSchemePreference?, + currentColorScheme: GhosttyConfig.ColorSchemePreference + ) -> Bool { + previousColorScheme != currentColorScheme + } + private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real @@ -524,7 +611,7 @@ class GhosttyApp { } } - func reloadConfiguration(soft: Bool = false) { + func reloadConfiguration(soft: Bool = false, source _: String = "unspecified") { guard let app else { return } if soft, let config { ghostty_app_update_config(app, config) @@ -2146,6 +2233,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator } + + // Test-only IME point override so firstRect behavior can be regression tested. + private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? + + func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { + imePointOverrideForTesting = (x, y, width, height) + } + + func clearIMEPointForTesting() { + imePointOverrideForTesting = nil + } #endif #if DEBUG @@ -2916,6 +3014,8 @@ enum GhosttyNotificationKey { static let title = "ghostty.title" static let backgroundColor = "ghostty.backgroundColor" static let backgroundOpacity = "ghostty.backgroundOpacity" + static let backgroundEventId = "ghostty.backgroundEventId" + static let backgroundSource = "ghostty.backgroundSource" } extension Notification.Name { @@ -2970,6 +3070,7 @@ final class GhosttySurfaceScrollView: NSView { private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer + private var hasSearchOverlay = false private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false @@ -3353,6 +3454,16 @@ final class GhosttySurfaceScrollView: NSView { CATransaction.commit() } + func setSearchOverlay(searchState: TerminalSurface.SearchState?) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.setSearchOverlay(searchState: searchState) + } + return + } + hasSearchOverlay = (searchState != nil) + } + private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { let padding: CGFloat = 4 switch zone { @@ -3554,6 +3665,10 @@ final class GhosttySurfaceScrollView: NSView { } } + func refreshSurfaceNow() { + surfaceView.forceRefreshSurface() + } + func setActive(_ active: Bool) { let wasActive = isActive isActive = active @@ -3633,6 +3748,10 @@ final class GhosttySurfaceScrollView: NSView { ) } + func debugHasSearchOverlay() -> Bool { + hasSearchOverlay + } + #endif /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. @@ -4237,15 +4356,26 @@ extension GhosttyNSView: NSTextInputClient { var y: Double = 0 var w: Double = 0 var h: Double = 0 +#if DEBUG + if let override = imePointOverrideForTesting { + x = override.x + y = override.y + w = override.width + h = override.height + } else if let surface = surface { + ghostty_surface_ime_point(surface, &x, &y, &w, &h) + } +#else if let surface = surface { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } +#endif // Ghostty coordinates are top-left origin; AppKit expects bottom-left. let viewRect = NSRect( x: x, y: frame.size.height - y, - width: 0, + width: max(w, cellSize.width), height: max(h, cellSize.height) ) let winRect = convert(viewRect, to: nil) @@ -4291,6 +4421,7 @@ struct GhosttyTerminalView: NSViewRepresentable { var portalZPriority: Int = 0 var showsInactiveOverlay: Bool = false var showsUnreadNotificationRing: Bool = false + var searchState: TerminalSurface.SearchState? = nil var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 var reattachToken: UInt64 = 0 @@ -4344,6 +4475,16 @@ struct GhosttyTerminalView: NSViewRepresentable { Coordinator() } + static func shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: Bool, + hostedViewHasSuperview: Bool, + isBoundToCurrentHost: Bool + ) -> Bool { + if !hostWindowAttached { return true } + if isBoundToCurrentHost { return true } + return !hostedViewHasSuperview + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false @@ -4394,6 +4535,7 @@ struct GhosttyTerminalView: NSViewRepresentable { visible: showsInactiveOverlay ) hostedView.setNotificationRing(visible: showsUnreadNotificationRing) + hostedView.setSearchOverlay(searchState: searchState) hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) let forwardedDropZone = isVisibleInUI ? paneDropZone : nil diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 200104df..a98c5338 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -24,9 +24,9 @@ struct TerminalPanelView: View { portalZPriority: portalPriority, showsInactiveOverlay: isSplit && !isFocused, showsUnreadNotificationRing: hasUnreadNotification, + searchState: panel.searchState, inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, - searchState: panel.searchState, reattachToken: panel.viewReattachToken, onFocus: { _ in onFocus() }, onTriggerFlash: onTriggerFlash diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 31f12387..243eca8d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -51,6 +51,267 @@ enum WorkspaceAutoReorderSettings { } } +enum SidebarBranchLayoutSettings { + static let key = "sidebarBranchVerticalLayout" + static let defaultVerticalLayout = true + + static func usesVerticalLayout(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: key) == nil { + return defaultVerticalLayout + } + return defaults.bool(forKey: key) + } +} + +enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { + case leftRail + case solidFill + + var id: String { rawValue } + + var displayName: String { + switch self { + case .leftRail: + return "Left Rail" + case .solidFill: + return "Solid Fill" + } + } +} + +enum SidebarActiveTabIndicatorSettings { + static let styleKey = "sidebarActiveTabIndicatorStyle" + static let defaultStyle: SidebarActiveTabIndicatorStyle = .leftRail + + static func resolvedStyle(rawValue: String?) -> SidebarActiveTabIndicatorStyle { + guard let rawValue else { return defaultStyle } + if let style = SidebarActiveTabIndicatorStyle(rawValue: rawValue) { + return style + } + + // Legacy values from earlier iterations map to the closest modern option. + switch rawValue { + case "rail": + return .leftRail + case "border", "wash", "lift", "typography", "washRail", "blueWashColorRail": + return .solidFill + default: + return defaultStyle + } + } + + static func current(defaults: UserDefaults = .standard) -> SidebarActiveTabIndicatorStyle { + resolvedStyle(rawValue: defaults.string(forKey: styleKey)) + } +} + +struct WorkspaceTabColorEntry: Equatable, Identifiable { + let name: String + let hex: String + + var id: String { "\(name)-\(hex)" } +} + +enum WorkspaceTabColorSettings { + static let defaultOverridesKey = "workspaceTabColor.defaultOverrides" + static let customColorsKey = "workspaceTabColor.customColors" + static let maxCustomColors = 24 + + private static let originalPRPalette: [WorkspaceTabColorEntry] = [ + WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"), + WorkspaceTabColorEntry(name: "Crimson", hex: "#922B21"), + WorkspaceTabColorEntry(name: "Orange", hex: "#A04000"), + WorkspaceTabColorEntry(name: "Amber", hex: "#7D6608"), + WorkspaceTabColorEntry(name: "Olive", hex: "#4A5C18"), + WorkspaceTabColorEntry(name: "Green", hex: "#196F3D"), + WorkspaceTabColorEntry(name: "Teal", hex: "#006B6B"), + WorkspaceTabColorEntry(name: "Aqua", hex: "#0E6B8C"), + WorkspaceTabColorEntry(name: "Blue", hex: "#1565C0"), + WorkspaceTabColorEntry(name: "Navy", hex: "#1A5276"), + WorkspaceTabColorEntry(name: "Indigo", hex: "#283593"), + WorkspaceTabColorEntry(name: "Purple", hex: "#6A1B9A"), + WorkspaceTabColorEntry(name: "Magenta", hex: "#AD1457"), + WorkspaceTabColorEntry(name: "Rose", hex: "#880E4F"), + WorkspaceTabColorEntry(name: "Brown", hex: "#7B3F00"), + WorkspaceTabColorEntry(name: "Charcoal", hex: "#3E4B5E"), + ] + + static var defaultPalette: [WorkspaceTabColorEntry] { + originalPRPalette + } + + static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + defaultPaletteWithOverrides(defaults: defaults) + customColorEntries(defaults: defaults) + } + + 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 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 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 } + + var overrides = defaultOverrideMap(defaults: defaults) + if normalized == entry.hex { + overrides.removeValue(forKey: name) + } else { + overrides[name] = normalized + } + saveDefaultOverrideMap(overrides, 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 customColorEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + customColors(defaults: defaults).enumerated().map { index, hex in + WorkspaceTabColorEntry(name: "Custom \(index + 1)", hex: hex) + } + } + + @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 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 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 } + } + + if normalizedColors.isEmpty { + defaults.removeObject(forKey: customColorsKey) + } else { + defaults.set(normalizedColors, forKey: customColorsKey) + } + } + + static func reset(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultOverridesKey) + defaults.removeObject(forKey: customColorsKey) + } + + static func normalizedHex(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let body = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard body.count == 6 else { return nil } + guard UInt64(body, radix: 16) != nil else { return nil } + return "#" + body.uppercased() + } + + static func displayColor( + hex: String, + colorScheme: ColorScheme, + forceBright: Bool = false + ) -> Color? { + guard let color = displayNSColor(hex: hex, colorScheme: colorScheme, forceBright: forceBright) else { + return nil + } + return Color(nsColor: color) + } + + static func displayNSColor( + hex: String, + colorScheme: ColorScheme, + forceBright: Bool = false + ) -> NSColor? { + guard let normalized = normalizedHex(hex), + let baseColor = NSColor(hex: normalized) else { + return nil + } + + if forceBright || colorScheme == .dark { + return brightenedForDarkAppearance(baseColor) + } + 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)) + var normalized: [String: String] = [:] + for (name, hex) in raw { + guard validNames.contains(name), + let normalizedHex = normalizedHex(hex) else { continue } + normalized[name] = normalizedHex + } + 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 func brightenedForDarkAppearance(_ color: NSColor) -> NSColor { + let rgbColor = color.usingColorSpace(.sRGB) ?? color + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + rgbColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + let boostedBrightness = min(1, max(brightness, 0.62) + ((1 - brightness) * 0.28)) + // Preserve neutral grays when brightening to avoid introducing hue shifts. + let boostedSaturation: CGFloat + if saturation <= 0.08 { + boostedSaturation = saturation + } else { + boostedSaturation = min(1, saturation + ((1 - saturation) * 0.12)) + } + + return NSColor( + hue: hue, + saturation: boostedSaturation, + brightness: boostedBrightness, + alpha: alpha + ) + } +} + enum WorkspacePlacementSettings { static let placementKey = "newWorkspacePlacement" static let defaultPlacement: NewWorkspacePlacement = .afterCurrent @@ -129,6 +390,30 @@ final class NotificationBurstCoalescer { } } +struct RecentlyClosedBrowserStack { + private(set) var entries: [ClosedBrowserPanelRestoreSnapshot] = [] + let capacity: Int + + init(capacity: Int) { + self.capacity = max(1, capacity) + } + + var isEmpty: Bool { + entries.isEmpty + } + + mutating func push(_ snapshot: ClosedBrowserPanelRestoreSnapshot) { + entries.append(snapshot) + if entries.count > capacity { + entries.removeFirst(entries.count - capacity) + } + } + + mutating func pop() -> ClosedBrowserPanelRestoreSnapshot? { + entries.popLast() + } +} + #if DEBUG // Sample the actual IOSurface-backed terminal layer at vsync cadence so UI tests can reliably // catch a single compositor-frame blank flash and any transient compositor scaling (stretched text). @@ -275,6 +560,7 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( class TabManager: ObservableObject { @Published var tabs: [Workspace] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false + weak var window: NSWindow? /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). @@ -330,6 +616,7 @@ class TabManager: ObservableObject { } private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:] private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) + private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -394,6 +681,16 @@ class TabManager: ObservableObject { workspaceCycleCooldownTask?.cancel() } + private func wireClosedBrowserTracking(for workspace: Workspace) { + workspace.onClosedBrowserPanel = { [weak self] snapshot in + self?.recentlyClosedBrowsers.push(snapshot) + } + } + + private func unwireClosedBrowserTracking(for workspace: Workspace) { + workspace.onClosedBrowserPanel = nil + } + var selectedWorkspace: Workspace? { guard let selectedTabId else { return nil } return tabs.first(where: { $0.id == selectedTabId }) @@ -458,7 +755,9 @@ class TabManager: ObservableObject { func addWorkspace( workingDirectory overrideWorkingDirectory: String? = nil, initialTerminalCommand: String? = nil, - initialTerminalEnvironment: [String: String] = [:] + initialTerminalEnvironment: [String: String] = [:], + placementOverride: NewWorkspacePlacement? = nil, + select: Bool = true ) -> Workspace { let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let ordinal = Self.nextPortOrdinal @@ -470,23 +769,26 @@ class TabManager: ObservableObject { initialTerminalCommand: initialTerminalCommand, initialTerminalEnvironment: initialTerminalEnvironment ) - let insertIndex = newTabInsertIndex() + wireClosedBrowserTracking(for: newWorkspace) + let insertIndex = newTabInsertIndex(placementOverride: placementOverride) if insertIndex >= 0 && insertIndex <= tabs.count { tabs.insert(newWorkspace, at: insertIndex) } else { tabs.append(newWorkspace) } - selectedTabId = newWorkspace.id - NotificationCenter.default.post( - name: .ghosttyDidFocusTab, - object: nil, - userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id] - ) + if select { + selectedTabId = newWorkspace.id + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id] + ) + } #if DEBUG UITestRecorder.incrementInt("addTabInvocations") UITestRecorder.record([ "tabCount": String(tabs.count), - "selectedTabId": newWorkspace.id.uuidString + "selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "") ]) #endif return newWorkspace @@ -494,7 +796,86 @@ class TabManager: ObservableObject { // Keep addTab as convenience alias @discardableResult - func addTab() -> Workspace { addWorkspace() } + func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) } + + func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? { + guard let workspace = selectedWorkspace else { return nil } + if let focusedTerminal = workspace.focusedTerminalPanel { + return focusedTerminal + } + if let focusedPaneId = workspace.bonsplitController.focusedPaneId, + let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { + return paneTerminal + } + return workspace.terminalPanelForConfigInheritance() + } + + func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { + let workspaceSnapshots = tabs + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } + let selectedWorkspaceIndex = selectedTabId.flatMap { selectedId in + tabs.firstIndex(where: { $0.id == selectedId }) + } + return SessionTabManagerSnapshot( + selectedWorkspaceIndex: selectedWorkspaceIndex, + workspaces: workspaceSnapshots + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) { + for tab in tabs { + unwireClosedBrowserTracking(for: tab) + } + + tabs.removeAll(keepingCapacity: false) + lastFocusedPanelByTab.removeAll() + pendingPanelTitleUpdates.removeAll() + tabHistory.removeAll() + historyIndex = -1 + isNavigatingHistory = false + pendingWorkspaceUnfocusTarget = nil + workspaceCycleCooldownTask?.cancel() + workspaceCycleCooldownTask = nil + isWorkspaceCycleHot = false + selectionSideEffectsGeneration &+= 1 + recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) + + let workspaceSnapshots = snapshot.workspaces + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + for workspaceSnapshot in workspaceSnapshots { + let ordinal = Self.nextPortOrdinal + Self.nextPortOrdinal += 1 + let workspace = Workspace( + title: workspaceSnapshot.processTitle, + workingDirectory: workspaceSnapshot.currentDirectory, + portOrdinal: ordinal + ) + workspace.restoreSessionSnapshot(workspaceSnapshot) + wireClosedBrowserTracking(for: workspace) + tabs.append(workspace) + } + + if tabs.isEmpty { + _ = addWorkspace(select: false) + } + + selectedTabId = nil + if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex, + tabs.indices.contains(selectedWorkspaceIndex) { + selectedTabId = tabs[selectedWorkspaceIndex].id + } else { + selectedTabId = tabs.first?.id + } + + if let selectedTabId { + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: selectedTabId] + ) + } + } private func normalizedWorkingDirectory(_ directory: String?) -> String? { guard let directory else { return nil } @@ -503,8 +884,8 @@ class TabManager: ObservableObject { return trimmed.isEmpty ? nil : normalized } - private func newTabInsertIndex() -> Int { - let placement = WorkspacePlacementSettings.current() + private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int { + let placement = placementOverride ?? WorkspacePlacementSettings.current() let pinnedCount = tabs.filter { $0.isPinned }.count let selectedIndex = selectedTabId.flatMap { tabId in tabs.firstIndex(where: { $0.id == tabId }) @@ -604,6 +985,11 @@ class TabManager: ObservableObject { reorderTabForPinnedState(tab) } + func setTabColor(tabId: UUID, color: String?) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.setCustomColor(color) + } + private func reorderTabForPinnedState(_ tab: Workspace) { guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return } tabs.remove(at: index) @@ -636,6 +1022,7 @@ class TabManager: ObservableObject { AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) workspace.teardownRemoteConnection() + unwireClosedBrowserTracking(for: workspace) if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { tabs.remove(at: index) @@ -657,6 +1044,7 @@ class TabManager: ObservableObject { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } let removed = tabs.remove(at: index) + unwireClosedBrowserTracking(for: removed) lastFocusedPanelByTab.removeValue(forKey: removed.id) if tabs.isEmpty { @@ -675,6 +1063,7 @@ class TabManager: ObservableObject { /// Attach an existing workspace to this window. func attachWorkspace(_ workspace: Workspace, at index: Int? = nil, select: Bool = true) { + wireClosedBrowserTracking(for: workspace) let insertIndex: Int = { guard let index else { return tabs.count } return max(0, min(index, tabs.count)) @@ -1557,6 +1946,123 @@ class TabManager: ObservableObject { return panel?.id } + @discardableResult + func reopenMostRecentlyClosedBrowserPanel() -> Bool { + while let snapshot = recentlyClosedBrowsers.pop() { + guard let targetWorkspace = + tabs.first(where: { $0.id == snapshot.workspaceId }) + ?? selectedWorkspace + ?? tabs.first else { + return false + } + let preReopenFocusedPanelId = focusedPanelId(for: targetWorkspace.id) + + if selectedTabId != targetWorkspace.id { + selectedTabId = targetWorkspace.id + } + + if let reopenedPanelId = reopenClosedBrowserPanel(snapshot, in: targetWorkspace) { + enforceReopenedBrowserFocus( + tabId: targetWorkspace.id, + reopenedPanelId: reopenedPanelId, + preReopenFocusedPanelId: preReopenFocusedPanelId + ) + return true + } + } + + return false + } + + private func enforceReopenedBrowserFocus( + tabId: UUID, + reopenedPanelId: UUID, + preReopenFocusedPanelId: UUID? + ) { + // Keep workspace-switch restoration pinned to the reopened browser panel. + rememberFocusedSurface(tabId: tabId, surfaceId: reopenedPanelId) + enforceReopenedBrowserFocusIfNeeded( + tabId: tabId, + reopenedPanelId: reopenedPanelId, + preReopenFocusedPanelId: preReopenFocusedPanelId + ) + + // Some stale focus callbacks can land one runloop turn later. Re-assert focus in two + // consecutive turns, but only when focus drifted back to the pre-reopen panel. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.enforceReopenedBrowserFocusIfNeeded( + tabId: tabId, + reopenedPanelId: reopenedPanelId, + preReopenFocusedPanelId: preReopenFocusedPanelId + ) + DispatchQueue.main.async { [weak self] in + self?.enforceReopenedBrowserFocusIfNeeded( + tabId: tabId, + reopenedPanelId: reopenedPanelId, + preReopenFocusedPanelId: preReopenFocusedPanelId + ) + } + } + } + + private func enforceReopenedBrowserFocusIfNeeded( + tabId: UUID, + reopenedPanelId: UUID, + preReopenFocusedPanelId: UUID? + ) { + guard selectedTabId == tabId, + let tab = tabs.first(where: { $0.id == tabId }), + tab.panels[reopenedPanelId] != nil else { + return + } + + rememberFocusedSurface(tabId: tabId, surfaceId: reopenedPanelId) + + guard tab.focusedPanelId != reopenedPanelId else { return } + + if let focusedPanelId = tab.focusedPanelId, + let preReopenFocusedPanelId, + focusedPanelId != preReopenFocusedPanelId { + return + } + + tab.focusPanel(reopenedPanelId) + } + + private func reopenClosedBrowserPanel( + _ snapshot: ClosedBrowserPanelRestoreSnapshot, + in workspace: Workspace + ) -> UUID? { + if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }), + let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) { + let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count + let maxIndex = max(0, tabCount - 1) + let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex) + _ = workspace.reorderSurface(panelId: browserPanel.id, toIndex: targetIndex) + return browserPanel.id + } + + if let orientation = snapshot.fallbackSplitOrientation, + let fallbackAnchorPaneId = snapshot.fallbackAnchorPaneId, + let anchorPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == fallbackAnchorPaneId }), + let anchorTab = workspace.bonsplitController.selectedTab(inPane: anchorPane) ?? workspace.bonsplitController.tabs(inPane: anchorPane).first, + let anchorPanelId = workspace.panelIdFromSurfaceId(anchorTab.id), + let browserPanelId = workspace.newBrowserSplit( + from: anchorPanelId, + orientation: orientation, + insertFirst: snapshot.fallbackSplitInsertFirst, + url: snapshot.url + )?.id { + return browserPanelId + } + + guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { + return nil + } + return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id + } + /// Flash the currently focused panel so the user can visually confirm focus. func triggerFocusFlash() { guard let tab = selectedWorkspace, @@ -2617,6 +3123,13 @@ enum ResizeDirection { } extension Notification.Name { + static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested") + static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested") + static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") + static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") + static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection") + static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested") + static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested") static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") @@ -2626,6 +3139,7 @@ extension Notification.Name { static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") + static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ea40a579..54efb366 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -18,6 +18,37 @@ class TerminalController { private var tabManager: TabManager? private var accessMode: SocketControlMode = .cmuxOnly private let myPid = getpid() + private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0 + private nonisolated(unsafe) static var socketCommandFocusAllowanceStack: [Bool] = [] + private nonisolated static let socketCommandPolicyLock = NSLock() + + private static let focusIntentV1Commands: Set<String> = [ + "focus_window", + "select_workspace", + "focus_surface", + "focus_pane", + "focus_surface_by_panel", + "focus_webview", + "focus_notification", + "activate_app" + ] + + private static let focusIntentV2Methods: Set<String> = [ + "window.focus", + "workspace.select", + "workspace.next", + "workspace.previous", + "workspace.last", + "surface.focus", + "pane.focus", + "pane.last", + "browser.focus_webview", + "browser.focus", + "browser.tab.switch", + "debug.command_palette.toggle", + "debug.notification.focus", + "debug.app.activate" + ] private enum V2HandleKind: String, CaseIterable { case window @@ -68,6 +99,177 @@ class TerminalController { private init() {} + nonisolated static func shouldSuppressSocketCommandActivation() -> Bool { + socketCommandPolicyLock.lock() + defer { socketCommandPolicyLock.unlock() } + return socketCommandPolicyDepth > 0 + } + + nonisolated static func socketCommandAllowsInAppFocusMutations() -> Bool { + allowsInAppFocusMutationsForActiveSocketCommand() + } + + private nonisolated static func allowsInAppFocusMutationsForActiveSocketCommand() -> Bool { + socketCommandPolicyLock.lock() + defer { socketCommandPolicyLock.unlock() } + return socketCommandFocusAllowanceStack.last ?? false + } + + private func socketCommandAllowsInAppFocusMutations() -> Bool { + Self.allowsInAppFocusMutationsForActiveSocketCommand() + } + + private func v2FocusAllowed(requested: Bool = true) -> Bool { + requested && socketCommandAllowsInAppFocusMutations() + } + + private func v2MaybeFocusWindow(for tabManager: TabManager) { + guard socketCommandAllowsInAppFocusMutations(), + let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { + guard socketCommandAllowsInAppFocusMutations() else { return } + if tabManager.selectedTabId != workspace.id { + tabManager.selectWorkspace(workspace) + } + } + + private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool { + if isV2 { + return focusIntentV2Methods.contains(commandKey) + } + return focusIntentV1Commands.contains(commandKey) + } + + private func withSocketCommandPolicy<T>(commandKey: String, isV2: Bool, _ body: () -> T) -> T { + let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations(commandKey: commandKey, isV2: isV2) + Self.socketCommandPolicyLock.lock() + Self.socketCommandPolicyDepth += 1 + Self.socketCommandFocusAllowanceStack.append(allowsFocusMutation) + Self.socketCommandPolicyLock.unlock() + defer { + Self.socketCommandPolicyLock.lock() + if !Self.socketCommandFocusAllowanceStack.isEmpty { + _ = Self.socketCommandFocusAllowanceStack.popLast() + } + Self.socketCommandPolicyDepth = max(0, Self.socketCommandPolicyDepth - 1) + Self.socketCommandPolicyLock.unlock() + } + return body() + } + +#if DEBUG + static func debugSocketCommandPolicySnapshot( + commandKey: String, + isV2: Bool + ) -> (insideSuppressed: Bool, insideAllowsFocus: Bool, outsideSuppressed: Bool, outsideAllowsFocus: Bool) { + var insideSuppressed = false + var insideAllowsFocus = false + _ = Self.shared.withSocketCommandPolicy(commandKey: commandKey, isV2: isV2) { + insideSuppressed = Self.shouldSuppressSocketCommandActivation() + insideAllowsFocus = Self.socketCommandAllowsInAppFocusMutations() + return 0 + } + return ( + insideSuppressed: insideSuppressed, + insideAllowsFocus: insideAllowsFocus, + outsideSuppressed: Self.shouldSuppressSocketCommandActivation(), + outsideAllowsFocus: Self.socketCommandAllowsInAppFocusMutations() + ) + } +#endif + + nonisolated static func shouldReplaceStatusEntry( + current: SidebarStatusEntry?, + key: String, + value: String, + icon: String?, + color: String? + ) -> Bool { + guard let current else { return true } + return current.key != key || current.value != value || current.icon != icon || current.color != color + } + + nonisolated static func shouldReplaceProgress( + current: SidebarProgressState?, + value: Double, + label: String? + ) -> Bool { + guard let current else { return true } + return current.value != value || current.label != label + } + + nonisolated static func shouldReplaceGitBranch( + current: SidebarGitBranchState?, + branch: String, + isDirty: Bool + ) -> Bool { + guard let current else { return true } + return current.branch != branch || current.isDirty != isDirty + } + + nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { + let currentSorted = Array(Set(current ?? [])).sorted() + let nextSorted = Array(Set(next)).sorted() + return currentSorted != nextSorted + } + + nonisolated static func explicitSocketScope( + options: [String: String] + ) -> (workspaceId: UUID, panelId: UUID)? { + guard let tabRaw = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !tabRaw.isEmpty, + let panelRaw = (options["panel"] ?? options["surface"])?.trimmingCharacters(in: .whitespacesAndNewlines), + !panelRaw.isEmpty, + let workspaceId = UUID(uuidString: tabRaw), + let panelId = UUID(uuidString: panelRaw) else { + return nil + } + return (workspaceId, panelId) + } + + nonisolated static func normalizeReportedDirectory(_ directory: String) -> String { + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return directory } + if trimmed.hasPrefix("file://"), let url = URL(string: trimmed), !url.path.isEmpty { + return url.path + } + return trimmed + } + + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenFile( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let standardizedFile = fileURL.standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return standardizedFile.path.hasPrefix(temporary.path + "/") + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -135,6 +337,7 @@ class TerminalController { if isRunning { if self.socketPath == socketPath && acceptLoopAlive { self.accessMode = accessMode + applySocketPermissions() return } stop() @@ -174,8 +377,7 @@ class TerminalController { return } - // Restrict socket to owner only (0600) - chmod(socketPath, 0o600) + applySocketPermissions() // Listen guard listen(serverSocket, 5) >= 0 else { @@ -214,6 +416,104 @@ class TerminalController { unlink(socketPath) } + private func applySocketPermissions() { + let permissions = mode_t(accessMode.socketFilePermissions) + if chmod(socketPath, permissions) != 0 { + print("TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(socketPath)") + } + } + + private func writeSocketResponse(_ response: String, to socket: Int32) { + let payload = response + "\n" + payload.withCString { ptr in + _ = write(socket, ptr, strlen(ptr)) + } + } + + private func passwordAuthRequiredResponse(for command: String) -> String { + let message = "Authentication required. Send auth <password> first." + guard command.hasPrefix("{"), + let data = command.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + return "ERROR: Authentication required — send auth <password> first" + } + let id = dict["id"] + return v2Error(id: id, code: "auth_required", message: message) + } + + private func passwordLoginV1ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + let lowered = command.lowercased() + guard lowered == "auth" || lowered.hasPrefix("auth ") else { + return nil + } + guard SocketControlPasswordStore.hasConfiguredPassword() else { + return "ERROR: Password mode is enabled but no socket password is configured in Settings." + } + + let provided: String + if lowered == "auth" { + provided = "" + } else { + provided = String(command.dropFirst(5)) + } + guard !provided.isEmpty else { + return "ERROR: Missing password. Usage: auth <password>" + } + guard SocketControlPasswordStore.verify(password: provided) else { + return "ERROR: Invalid password" + } + authenticated = true + return "OK: Authenticated" + } + + private func passwordLoginV2ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + guard command.hasPrefix("{"), + let data = command.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + return nil + } + let id = dict["id"] + let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard method == "auth.login" else { + return nil + } + + guard let params = dict["params"] as? [String: Any], + let provided = params["password"] as? String else { + return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password") + } + + guard SocketControlPasswordStore.hasConfiguredPassword() else { + return v2Error( + id: id, + code: "auth_unconfigured", + message: "Password mode is enabled but no socket password is configured in Settings." + ) + } + + guard SocketControlPasswordStore.verify(password: provided) else { + return v2Error(id: id, code: "auth_failed", message: "Invalid password") + } + authenticated = true + return v2Ok(id: id, result: ["authenticated": true]) + } + + private func authResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + guard accessMode.requiresPasswordAuth else { + return nil + } + if let v2Response = passwordLoginV2ResponseIfNeeded(for: command, authenticated: &authenticated) { + return v2Response + } + if let v1Response = passwordLoginV1ResponseIfNeeded(for: command, authenticated: &authenticated) { + return v1Response + } + if !authenticated { + return passwordAuthRequiredResponse(for: command) + } + return nil + } + private nonisolated func acceptLoop() { acceptLoopAlive = true defer { @@ -293,6 +593,7 @@ class TerminalController { var buffer = [UInt8](repeating: 0, count: 4096) var pending = "" + var authenticated = false while isRunning { let bytesRead = read(socket, &buffer, buffer.count - 1) @@ -307,11 +608,13 @@ class TerminalController { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } - let response = processCommand(trimmed) - let payload = response + "\n" - payload.withCString { ptr in - _ = write(socket, ptr, strlen(ptr)) + if let authResponse = authResponseIfNeeded(for: trimmed, authenticated: &authenticated) { + writeSocketResponse(authResponse, to: socket) + continue } + + let response = processCommand(trimmed) + writeSocketResponse(response, to: socket) } } } @@ -331,10 +634,14 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - switch cmd { + return withSocketCommandPolicy(commandKey: cmd, isV2: false) { + switch cmd { case "ping": return "PONG" + case "auth": + return "OK: Authentication not required" + case "list_windows": return listWindows() @@ -615,11 +922,12 @@ class TerminalController { case "refresh_surfaces": return refreshSurfaces() - case "surface_health": - return surfaceHealth(args) + case "surface_health": + return surfaceHealth(args) - default: - return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + default: + return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + } } } @@ -655,7 +963,8 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - switch method { + return withSocketCommandPolicy(commandKey: method, isV2: true) { + switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) case "system.capabilities": @@ -663,6 +972,14 @@ class TerminalController { case "system.identify": return v2Ok(id: id, result: v2Identify(params: params)) + case "auth.login": + return v2Ok( + id: id, + result: [ + "authenticated": true, + "required": accessMode.requiresPasswordAuth + ] + ) // Windows case "window.list": @@ -968,6 +1285,28 @@ class TerminalController { return v2Result(id: id, self.v2DebugType(params: params)) case "debug.app.activate": return v2Result(id: id, self.v2DebugActivateApp()) + case "debug.command_palette.toggle": + return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params)) + case "debug.command_palette.rename_tab.open": + return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params)) + case "debug.command_palette.visible": + return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params)) + case "debug.command_palette.selection": + return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params)) + case "debug.command_palette.results": + return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params)) + case "debug.command_palette.rename_input.interact": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params)) + case "debug.command_palette.rename_input.delete_backward": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params)) + case "debug.command_palette.rename_input.selection": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params)) + case "debug.command_palette.rename_input.select_all": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params)) + case "debug.browser.address_bar_focused": + return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params)) + case "debug.sidebar.visible": + return v2Result(id: id, self.v2DebugSidebarVisible(params: params)) case "debug.terminal.is_focused": return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params)) case "debug.terminal.read_text": @@ -998,8 +1337,9 @@ class TerminalController { return v2Result(id: id, self.v2DebugScreenshot(params: params)) #endif - default: - return v2Error(id: id, code: "method_not_found", message: "Unknown method") + default: + return v2Error(id: id, code: "method_not_found", message: "Unknown method") + } } } @@ -1008,6 +1348,7 @@ class TerminalController { "system.ping", "system.capabilities", "system.identify", + "auth.login", "window.list", "window.current", "window.focus", @@ -1154,6 +1495,17 @@ class TerminalController { "debug.shortcut.simulate", "debug.type", "debug.app.activate", + "debug.command_palette.toggle", + "debug.command_palette.rename_tab.open", + "debug.command_palette.visible", + "debug.command_palette.selection", + "debug.command_palette.results", + "debug.command_palette.rename_input.interact", + "debug.command_palette.rename_input.delete_backward", + "debug.command_palette.rename_input.selection", + "debug.command_palette.rename_input.select_all", + "debug.browser.address_bar_focused", + "debug.sidebar.visible", "debug.terminal.is_focused", "debug.terminal.read_text", "debug.terminal.render_stats", @@ -1655,11 +2007,13 @@ class TerminalController { } var newId: UUID? + let shouldFocus = v2FocusAllowed() v2MainSync { let ws = tabManager.addWorkspace( workingDirectory: workingDirectory, initialTerminalCommand: initialCommand, - initialTerminalEnvironment: initialEnv + initialTerminalEnvironment: initialEnv, + select: shouldFocus ) newId = ws.id } @@ -2753,13 +3107,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let paneUUID = v2UUID(params, "pane_id") let paneId: PaneID? = { @@ -2776,9 +3125,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: true)?.id + newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed())?.id } else { - newPanelId = ws.newTerminalSurface(inPane: paneId, focus: true)?.id + newPanelId = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed())?.id } guard let newPanelId else { @@ -3581,13 +3930,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) guard let focusedPanelId = ws.focusedPanelId else { result = .err(code: "not_found", message: "No focused surface to split", data: nil) return @@ -3595,9 +3939,20 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id + newPanelId = ws.newBrowserSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: v2FocusAllowed() + )?.id } else { - newPanelId = ws.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id + newPanelId = ws.newTerminalSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: v2FocusAllowed() + )?.id } guard let newPanelId else { @@ -7485,6 +7840,294 @@ class TerminalController { return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } + private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow) + } + return result + } + + private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + + private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + var selectedIndex = 0 + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex) + ]) + } + + private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let requestedLimit = params["limit"] as? Int + let limit = max(1, min(100, requestedLimit ?? 20)) + + var visible = false + var selectedIndex = 0 + var snapshot = CommandPaletteDebugSnapshot.empty + + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty + } + + let rows = Array(snapshot.results.prefix(limit)).map { row in + [ + "command_id": row.commandId, + "title": row.title, + "shortcut_hint": v2OrNull(row.shortcutHint), + "trailing_label": v2OrNull(row.trailingLabel), + "score": row.score + ] as [String: Any] + } + + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex), + "query": snapshot.query, + "mode": snapshot.mode, + "results": rows + ]) + } + + private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + + var result: V2CallResult = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": false, + "selection_location": 0, + "selection_length": 0, + "text_length": 0 + ]) + + DispatchQueue.main.sync { + guard let window = AppDelegate.shared?.mainWindow(for: windowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + return + } + guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { + return + } + let selectedRange = editor.selectedRange() + let textLength = (editor.string as NSString).length + result = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": true, + "selection_location": max(0, selectedRange.location), + "selection_length": max(0, selectedRange.length), + "text_length": max(0, textLength) + ]) + } + + return result + } + + private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult { + if let rawEnabled = params["enabled"] { + guard let enabled = rawEnabled as? Bool else { + return .err( + code: "invalid_params", + message: "enabled must be a bool", + data: ["enabled": rawEnabled] + ) + } + DispatchQueue.main.sync { + UserDefaults.standard.set( + enabled, + forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey + ) + } + } + + var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + DispatchQueue.main.sync { + enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + } + + return .ok([ + "enabled": enabled + ]) + } + + private func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult { + let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id") + var focusedSurfaceId: UUID? + DispatchQueue.main.sync { + focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId() + } + + var payload: [String: Any] = [ + "focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString), + "focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), + "focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString), + "focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), + "focused": focusedSurfaceId != nil + ] + + if let requestedSurfaceId { + payload["surface_id"] = requestedSurfaceId.uuidString + payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) + payload["panel_id"] = requestedSurfaceId.uuidString + payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) + payload["focused"] = (focusedSurfaceId == requestedSurfaceId) + } + + return .ok(payload) + } + + private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visibility: Bool? + DispatchQueue.main.sync { + visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId) + } + guard let visible = visibility else { + return .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) @@ -8838,9 +9481,10 @@ class TerminalController { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } var newTabId: UUID? + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { - tabManager.addTab() - newTabId = tabManager.selectedTabId + let workspace = tabManager.addTab(select: focus) + newTabId = workspace.id } return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -9821,6 +10465,46 @@ class TerminalController { sendKeyEvent(surface: surface, keycode: 0, text: text) } + enum SocketTextChunk: Equatable { + case text(String) + case control(UnicodeScalar) + } + + nonisolated static func socketTextChunks(_ text: String) -> [SocketTextChunk] { + guard !text.isEmpty else { return [] } + + var chunks: [SocketTextChunk] = [] + chunks.reserveCapacity(8) + var bufferedText = "" + bufferedText.reserveCapacity(text.count) + + func flushBufferedText() { + guard !bufferedText.isEmpty else { return } + chunks.append(.text(bufferedText)) + bufferedText.removeAll(keepingCapacity: true) + } + + for scalar in text.unicodeScalars { + if isSocketControlScalar(scalar) { + flushBufferedText() + chunks.append(.control(scalar)) + } else { + bufferedText.unicodeScalars.append(scalar) + } + } + flushBufferedText() + return chunks + } + + private nonisolated static func isSocketControlScalar(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x0A, 0x0D, 0x09, 0x1B, 0x7F: + return true + default: + return false + } + } + private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { switch scalar.value { case 0x0A, 0x0D: diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4587b4ff..f995779b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -13,6 +13,11 @@ struct SidebarStatusEntry { let timestamp: Date } +private struct SessionPaneRestoreEntry { + let paneId: PaneID + let snapshot: SessionPaneLayoutSnapshot +} + private final class WorkspaceRemoteSessionController { private struct ForwardEntry { let process: Process @@ -431,7 +436,11 @@ private final class WorkspaceRemoteSessionController { // Kill orphaned relay SSH processes from previous app sessions that reverse-forward // to the same socket path (they survive pkill because they're reparented to launchd). - Self.killOrphanedRelayProcesses(socketPath: localSocketPath, destination: configuration.destination) + Self.killOrphanedRelayProcesses( + relayPort: relayPort, + socketPath: localSocketPath, + destination: configuration.destination + ) let process = Process() let stderrPipe = Pipe() @@ -944,7 +953,9 @@ private final class WorkspaceRemoteSessionController { private static func probeScript() -> String { """ set -eu - CMUX_LAST="" + # Force an initial emission so the controller can transition out of + # "connecting" even when no ports are detected. + CMUX_LAST="__cmux_init__" while true; do if command -v ss >/dev/null 2>&1; then PORTS="$(ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | sort -n -u | tr '\\n' ' ')" @@ -1095,11 +1106,15 @@ private final class WorkspaceRemoteSessionController { /// Kills orphaned SSH relay processes from previous app sessions. /// These processes survive app restarts because `pkill` doesn't trigger graceful cleanup. - private static func killOrphanedRelayProcesses(socketPath: String, destination: String) { + private static func killOrphanedRelayProcesses(relayPort: Int, socketPath: String, destination: String) { + guard relayPort > 0 else { return } let pipe = Pipe() let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") - process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:.*:\(socketPath).*\(destination)"] + let socketPathPattern = NSRegularExpression.escapedPattern(for: socketPath) + let destinationPattern = NSRegularExpression.escapedPattern(for: destination) + let relayPattern = "ssh.*-R[[:space:]]*127\\.0\\.0\\.1:\(relayPort):\(socketPathPattern).*\(destinationPattern)" + process.arguments = ["-f", relayPattern] process.standardOutput = pipe process.standardError = pipe do { @@ -1182,6 +1197,187 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum SidebarBranchOrdering { + struct BranchEntry: Equatable { + let name: String + let isDirty: Bool + } + + struct BranchDirectoryEntry: Equatable { + let branch: String? + let isDirty: Bool + let directory: String? + } + + static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { + switch tree { + case .pane(let pane): + return [pane.id] + case .split(let split): + return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) + } + } + + static func orderedPanelIds( + tree: ExternalTreeNode, + paneTabs: [String: [UUID]], + fallbackPanelIds: [UUID] + ) -> [UUID] { + var ordered: [UUID] = [] + var seen: Set<UUID> = [] + + for paneId in orderedPaneIds(tree: tree) { + for panelId in paneTabs[paneId] ?? [] { + if seen.insert(panelId).inserted { + ordered.append(panelId) + } + } + } + + for panelId in fallbackPanelIds { + if seen.insert(panelId).inserted { + ordered.append(panelId) + } + } + + return ordered + } + + static func orderedUniqueBranches( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + fallbackBranch: SidebarGitBranchState? + ) -> [BranchEntry] { + var orderedNames: [String] = [] + var branchDirty: [String: Bool] = [:] + + for panelId in orderedPanelIds { + guard let state = panelBranches[panelId] else { continue } + let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + + if branchDirty[name] == nil { + orderedNames.append(name) + branchDirty[name] = state.isDirty + } else if state.isDirty { + branchDirty[name] = true + } + } + + if orderedNames.isEmpty, let fallbackBranch { + let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] + } + } + + return orderedNames.map { name in + BranchEntry(name: name, isDirty: branchDirty[name] ?? false) + } + } + + static func orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + panelDirectories: [UUID: String], + defaultDirectory: String?, + fallbackBranch: SidebarGitBranchState? + ) -> [BranchDirectoryEntry] { + struct EntryKey: Hashable { + let directory: String? + let branch: String? + } + + struct MutableEntry { + var branch: String? + var isDirty: Bool + var directory: String? + } + + func normalized(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func canonicalDirectoryKey(_ directory: String?) -> String? { + guard let directory = normalized(directory) else { return nil } + let expanded = NSString(string: directory).expandingTildeInPath + let standardized = NSString(string: expanded).standardizingPath + let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + + let normalizedFallbackBranch = normalized(fallbackBranch?.branch) + let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains { + normalized(panelBranches[$0]?.branch) != nil + } + let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil + let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false + + var order: [EntryKey] = [] + var entries: [EntryKey: MutableEntry] = [:] + + for panelId in orderedPanelIds { + let panelBranch = normalized(panelBranches[panelId]?.branch) + let branch = panelBranch ?? defaultBranchForPanels + let directory = normalized(panelDirectories[panelId] ?? defaultDirectory) + guard branch != nil || directory != nil else { continue } + + let panelDirty = panelBranch != nil + ? (panelBranches[panelId]?.isDirty ?? false) + : defaultBranchDirty + + let key: EntryKey + if let directoryKey = canonicalDirectoryKey(directory) { + key = EntryKey(directory: directoryKey, branch: nil) + } else { + key = EntryKey(directory: nil, branch: branch) + } + + if entries[key] == nil { + order.append(key) + entries[key] = MutableEntry( + branch: branch, + isDirty: panelDirty, + directory: directory + ) + } else { + if panelDirty { + entries[key]?.isDirty = true + } + if let branch { + entries[key]?.branch = branch + } + if let directory { + entries[key]?.directory = directory + } + } + } + + if order.isEmpty, let fallbackBranch { + let branch = normalized(fallbackBranch.branch) + let directory = normalized(defaultDirectory) + if branch != nil || directory != nil { + return [BranchDirectoryEntry( + branch: branch, + isDirty: fallbackBranch.isDirty, + directory: directory + )] + } + } + + return order.compactMap { key in + guard let entry = entries[key] else { return nil } + return BranchDirectoryEntry( + branch: entry.branch, + isDirty: entry.isDirty, + directory: entry.directory + ) + } + } +} + enum WorkspaceRemoteConnectionState: String { case disconnected case connecting @@ -1230,6 +1426,16 @@ struct WorkspaceRemoteConfiguration: Equatable { } } +struct ClosedBrowserPanelRestoreSnapshot { + let workspaceId: UUID + let url: URL? + let originalPaneId: UUID + let originalTabIndex: Int + let fallbackSplitOrientation: SplitOrientation? + let fallbackSplitInsertFirst: Bool + let fallbackAnchorPaneId: UUID? +} + /// Workspace represents a sidebar tab. /// Each workspace contains one BonsplitController that manages split panes and nested surfaces. @MainActor @@ -1238,6 +1444,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var title: String @Published var customTitle: String? @Published var isPinned: Bool = false + @Published var customColor: String? @Published var currentDirectory: String /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) @@ -1254,6 +1461,485 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false + var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? + + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String? + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { + let tree = bonsplitController.treeSnapshot() + let layout = sessionLayoutSnapshot(from: tree) + + let orderedPanelIds = sidebarOrderedPanelIds() + var seen: Set<UUID> = [] + var allPanelIds: [UUID] = [] + for panelId in orderedPanelIds where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + + let panelSnapshots = allPanelIds + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) + .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } + + let statusSnapshots = statusEntries.values + .sorted { lhs, rhs in lhs.key < rhs.key } + .map { entry in + SessionStatusEntrySnapshot( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let logSnapshots = logEntries.map { entry in + SessionLogEntrySnapshot( + message: entry.message, + level: entry.level.rawValue, + source: entry.source, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + + let progressSnapshot = progress.map { progress in + SessionProgressSnapshot(value: progress.value, label: progress.label) + } + let gitBranchSnapshot = gitBranch.map { branch in + SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) + } + + return SessionWorkspaceSnapshot( + processTitle: processTitle, + customTitle: customTitle, + customColor: customColor, + isPinned: isPinned, + currentDirectory: currentDirectory, + focusedPanelId: focusedPanelId, + layout: layout, + panels: panelSnapshots, + statusEntries: statusSnapshots, + logEntries: logSnapshots, + progress: progressSnapshot, + gitBranch: gitBranchSnapshot + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + + let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedCurrentDirectory.isEmpty { + currentDirectory = normalizedCurrentDirectory + } + + let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) + let leafEntries = restoreSessionLayout(snapshot.layout) + var oldToNewPanelIds: [UUID: UUID] = [:] + + for entry in leafEntries { + restorePane( + entry.paneId, + snapshot: entry.snapshot, + panelSnapshotsById: panelSnapshotsById, + oldToNewPanelIds: &oldToNewPanelIds + ) + } + + pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) + applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + + applyProcessTitle(snapshot.processTitle) + setCustomTitle(snapshot.customTitle) + setCustomColor(snapshot.customColor) + isPinned = snapshot.isPinned + + statusEntries = Dictionary( + uniqueKeysWithValues: snapshot.statusEntries.map { entry in + ( + entry.key, + SidebarStatusEntry( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + ) + } + ) + logEntries = snapshot.logEntries.map { entry in + SidebarLogEntry( + message: entry.message, + level: SidebarLogLevel(rawValue: entry.level) ?? .info, + source: entry.source, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + } + progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } + gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } + + recomputeListeningPorts() + + if let focusedOldPanelId = snapshot.focusedPanelId, + let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], + panels[focusedNewPanelId] != nil { + focusPanel(focusedNewPanelId) + } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { + focusPanel(fallbackFocusedPanelId) + } else { + scheduleFocusReconcile() + } + } + + private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { + switch node { + case .pane(let pane): + let panelIds = sessionPanelIDs(for: pane) + let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) + return .pane( + SessionPaneLayoutSnapshot( + panelIds: panelIds, + selectedPanelId: selectedPanelId + ) + ) + case .split(let split): + return .split( + SessionSplitLayoutSnapshot( + orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + dividerPosition: split.dividerPosition, + first: sessionLayoutSnapshot(from: split.first), + second: sessionLayoutSnapshot(from: split.second) + ) + ) + } + } + + private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { + var panelIds: [UUID] = [] + var seen = Set<UUID>() + for tab in pane.tabs { + guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } + if seen.insert(panelId).inserted { + panelIds.append(panelId) + } + } + return panelIds + } + + private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { + guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } + for (surfaceId, panelId) in surfaceIdToPanelId { + guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } + if surfaceUUID == tabUUID { + return panelId + } + } + return nil + } + + private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { + struct EncodedSurfaceID: Decodable { + let id: UUID + } + + guard let data = try? JSONEncoder().encode(surfaceId), + let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + return nil + } + return decoded.id + } + + private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { + guard let panel = panels[panelId] else { return nil } + + let panelTitle = panelTitle(panelId: panelId) + let customTitle = panelCustomTitles[panelId] + let directory = panelDirectories[panelId] + let isPinned = pinnedPanelIds.contains(panelId) + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let branchSnapshot = panelGitBranches[panelId].map { + SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + } + let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + let ttyName = surfaceTTYNames[panelId] + + let terminalSnapshot: SessionTerminalPanelSnapshot? + let browserSnapshot: SessionBrowserPanelSnapshot? + switch panel.panelType { + case .terminal: + guard let _ = panel as? TerminalPanel else { return nil } + let resolvedScrollback = terminalSnapshotScrollback( + panelId: panelId, + capturedScrollback: nil, + includeScrollback: includeScrollback + ) + terminalSnapshot = SessionTerminalPanelSnapshot( + workingDirectory: panelDirectories[panelId], + scrollback: resolvedScrollback + ) + browserSnapshot = nil + case .browser: + guard let browserPanel = panel as? BrowserPanel else { return nil } + terminalSnapshot = nil + let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() + browserSnapshot = SessionBrowserPanelSnapshot( + urlString: browserPanel.preferredURLStringForOmnibar(), + shouldRenderWebView: browserPanel.shouldRenderWebView, + pageZoom: Double(browserPanel.webView.pageZoom), + developerToolsVisible: browserPanel.isDeveloperToolsVisible(), + backHistoryURLStrings: historySnapshot.backHistoryURLStrings, + forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings + ) + } + + return SessionPanelSnapshot( + id: panelId, + type: panel.panelType, + title: panelTitle, + customTitle: customTitle, + directory: directory, + isPinned: isPinned, + isManuallyUnread: isManuallyUnread, + gitBranch: branchSnapshot, + listeningPorts: listeningPorts, + ttyName: ttyName, + terminal: terminalSnapshot, + browser: browserSnapshot + ) + } + + private func terminalSnapshotScrollback( + panelId: UUID, + capturedScrollback: String?, + includeScrollback: Bool + ) -> String? { + guard includeScrollback else { return nil } + let fallback = restoredTerminalScrollbackByPanelId[panelId] + let resolved = Self.resolvedSnapshotTerminalScrollback( + capturedScrollback: capturedScrollback, + fallbackScrollback: fallback + ) + if let resolved { + restoredTerminalScrollbackByPanelId[panelId] = resolved + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + return resolved + } + + private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { + guard let rootPaneId = bonsplitController.allPaneIds.first else { + return [] + } + + var leaves: [SessionPaneRestoreEntry] = [] + restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) + return leaves + } + + private func restoreSessionLayoutNode( + _ node: SessionWorkspaceLayoutSnapshot, + inPane paneId: PaneID, + leaves: inout [SessionPaneRestoreEntry] + ) { + switch node { + case .pane(let pane): + leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) + case .split(let split): + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.orientation.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append( + SessionPaneRestoreEntry( + paneId: paneId, + snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + ) + return + } + + restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) + restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + } + } + + private func restorePane( + _ paneId: PaneID, + snapshot: SessionPaneLayoutSnapshot, + panelSnapshotsById: [UUID: SessionPanelSnapshot], + oldToNewPanelIds: inout [UUID: UUID] + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } + + var createdPanelIds: [UUID] = [] + for oldPanelId in desiredOldPanelIds { + guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } + guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + createdPanelIds.append(createdPanelId) + oldToNewPanelIds[oldPanelId] = createdPanelId + } + + guard !createdPanelIds.isEmpty else { return } + + for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { + _ = closePanel(oldPanelId, force: true) + } + + for (index, panelId) in createdPanelIds.enumerated() { + _ = reorderSurface(panelId: panelId, toIndex: index) + } + + let selectedPanelId: UUID? = { + if let selectedOldId = snapshot.selectedPanelId { + return oldToNewPanelIds[selectedOldId] + } + return createdPanelIds.first + }() + + if let selectedPanelId, + let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(selectedTabId) + } + } + + private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + switch snapshot.type { + case .terminal: + let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( + for: snapshot.terminal?.scrollback + ) + guard let terminalPanel = newTerminalSurface( + inPane: paneId, + focus: false, + workingDirectory: workingDirectory, + startupEnvironment: replayEnvironment + ) else { + return nil + } + let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) + if let fallbackScrollback { + restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) + } + applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) + return terminalPanel.id + case .browser: + let initialURL = snapshot.browser?.urlString.flatMap { URL(string: $0) } + guard let browserPanel = newBrowserSurface( + inPane: paneId, + url: initialURL, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) + return browserPanel.id + } + } + + private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { + if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + panelTitles[panelId] = title + } + + setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) + setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) + + if snapshot.isManuallyUnread { + markPanelUnread(panelId) + } else { + clearManualUnread(panelId: panelId) + } + + if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { + updatePanelDirectory(panelId: panelId, directory: directory) + } + + if let branch = snapshot.gitBranch { + panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + } else { + panelGitBranches.removeValue(forKey: panelId) + } + + surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + + if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[panelId] = ttyName + } else { + surfaceTTYNames.removeValue(forKey: panelId) + } + + if let browserSnapshot = snapshot.browser, + let browserPanel = browserPanel(for: panelId) { + browserPanel.restoreSessionNavigationHistory( + backHistoryURLStrings: browserSnapshot.backHistoryURLStrings ?? [], + forwardHistoryURLStrings: browserSnapshot.forwardHistoryURLStrings ?? [], + currentURLString: browserSnapshot.urlString + ) + + let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) + if pageZoom.isFinite { + browserPanel.webView.pageZoom = pageZoom + } + + if browserSnapshot.developerToolsVisible { + _ = browserPanel.showDeveloperTools() + browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") + } else { + _ = browserPanel.hideDeveloperTools() + } + } + } + + private func applySessionDividerPositions( + snapshotNode: SessionWorkspaceLayoutSnapshot, + liveNode: ExternalTreeNode + ) { + switch (snapshotNode, liveNode) { + case (.split(let snapshotSplit), .split(let liveSplit)): + if let splitID = UUID(uuidString: liveSplit.id) { + _ = bonsplitController.setDividerPosition( + CGFloat(snapshotSplit.dividerPosition), + forSplit: splitID, + fromExternal: true + ) + } + applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) + applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) + default: + return + } + } // Closing tabs mutates split layout immediately; terminal views handle their own AppKit @@ -1283,6 +1969,7 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set<UUID> = [] @Published private(set) var manualUnreadPanelIds: Set<UUID> = [] + @Published private(set) var panelGitBranches: [UUID: SidebarGitBranchState] = [:] @Published var statusEntries: [String: SidebarStatusEntry] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @@ -1297,6 +1984,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var remotePortConflicts: [Int] = [] @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] + private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] private var remoteSessionController: WorkspaceRemoteSessionController? private var remoteLastErrorFingerprint: String? private var remoteLastDaemonErrorFingerprint: String? @@ -1333,11 +2021,18 @@ final class Workspace: Identifiable, ObservableObject { bonsplitAppearance(from: config.backgroundColor) } + nonisolated static func resolvedChromeColors( + from backgroundColor: NSColor + ) -> BonsplitConfiguration.Appearance.ChromeColors { + .init(backgroundHex: backgroundColor.hexString()) + } + private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { - BonsplitConfiguration.Appearance( + let chromeColors = resolvedChromeColors(from: backgroundColor) + return BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, - chromeColors: .init(backgroundHex: backgroundColor.hexString()) + chromeColors: chromeColors ) } @@ -1365,6 +2060,7 @@ final class Workspace: Identifiable, ObservableObject { self.processTitle = title self.title = title self.customTitle = nil + self.customColor = nil let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty @@ -1471,12 +2167,24 @@ final class Workspace: Identifiable, ObservableObject { /// Deterministic tab selection to apply after a tab closes. /// Keyed by the closing tab ID, value is the tab ID we want to select next. private var postCloseSelectTabId: [TabID: TabID] = [:] + private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false private var pendingTabSelection: (tabId: TabID, pane: PaneID)? private var isReconcilingFocusState = false private var focusReconcileScheduled = false +#if DEBUG + private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 +#endif private var geometryReconcileScheduled = false private var isNormalizingPinnedTabOrder = false + private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? + private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 + + private struct PendingNonFocusSplitFocusReassert { + let generation: UInt64 + let preferredPanelId: UUID + let splitPanelId: UUID + } struct DetachedSurfaceTransfer { let panelId: UUID @@ -1598,6 +2306,10 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) } + static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { + hasUnreadNotification || isManuallyUnread + } + private func normalizePinnedTabs(in paneId: PaneID) { guard !isNormalizingPinnedTabOrder else { return } isNormalizingPinnedTabOrder = true @@ -1665,6 +2377,12 @@ final class Workspace: Identifiable, ObservableObject { return surfaceKind(for: panel) } + func panelTitle(panelId: UUID) -> String? { + guard let panel = panels[panelId] else { return nil } + let fallback = panelTitles[panelId] ?? panel.displayTitle + return resolvedPanelTitle(panelId: panelId, fallback: fallback) + } + func setPanelPinned(panelId: UUID, pinned: Bool) { guard panels[panelId] != nil else { return } let wasPinned = pinnedPanelIds.contains(panelId) @@ -1705,6 +2423,14 @@ final class Workspace: Identifiable, ObservableObject { self.title = title } + func setCustomColor(_ hex: String?) { + if let hex { + customColor = WorkspaceTabColorSettings.normalizedHex(hex) + } else { + customColor = nil + } + } + func setCustomTitle(_ title: String?) { let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { @@ -1730,6 +2456,24 @@ final class Workspace: Identifiable, ObservableObject { } } + func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { + let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) + let existing = panelGitBranches[panelId] + if existing?.branch != branch || existing?.isDirty != isDirty { + panelGitBranches[panelId] = state + } + if panelId == focusedPanelId { + gitBranch = state + } + } + + func clearPanelGitBranch(panelId: UUID) { + panelGitBranches.removeValue(forKey: panelId) + if panelId == focusedPanelId { + gitBranch = nil + } + } + @discardableResult func updatePanelTitle(panelId: UUID, title: String) -> Bool { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1774,6 +2518,7 @@ final class Workspace: Identifiable, ObservableObject { panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) } manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) } + panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() @@ -1784,6 +2529,45 @@ final class Workspace: Identifiable, ObservableObject { listeningPorts = unique.sorted() } + func sidebarOrderedPanelIds() -> [UUID] { + let paneTabs: [String: [UUID]] = Dictionary( + uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in + let panelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + return (paneId.id.uuidString, panelIds) + } + ) + + let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } + let tree = bonsplitController.treeSnapshot() + return SidebarBranchOrdering.orderedPanelIds( + tree: tree, + paneTabs: paneTabs, + fallbackPanelIds: fallbackPanelIds + ) + } + + func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { + SidebarBranchOrdering + .orderedUniqueBranches( + orderedPanelIds: sidebarOrderedPanelIds(), + panelBranches: panelGitBranches, + fallbackBranch: gitBranch + ) + .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } + } + + func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { + SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: sidebarOrderedPanelIds(), + panelBranches: panelGitBranches, + panelDirectories: panelDirectories, + defaultDirectory: currentDirectory, + fallbackBranch: gitBranch + ) + } + var isRemoteWorkspace: Bool { remoteConfiguration != nil } @@ -1976,6 +2760,52 @@ final class Workspace: Identifiable, ObservableObject { } } + private func terminalPanelConfigInheritanceCandidates( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> [TerminalPanel] { + var candidates: [TerminalPanel] = [] + var seen: Set<UUID> = [] + + func appendCandidate(_ panel: TerminalPanel?) { + guard let panel else { return } + guard seen.insert(panel.id).inserted else { return } + candidates.append(panel) + } + + if let preferredPanelId, let preferredTerminal = terminalPanel(for: preferredPanelId) { + appendCandidate(preferredTerminal) + } + + appendCandidate(focusedTerminalPanel) + + if let preferredPaneId { + for tab in bonsplitController.tabs(inPane: preferredPaneId) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { continue } + appendCandidate(terminalPanel) + } + } + + for terminalPanel in panels.values + .compactMap({ $0 as? TerminalPanel }) + .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + appendCandidate(terminalPanel) + } + + return candidates + } + + func terminalPanelForConfigInheritance( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> TerminalPanel? { + terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ).first + } + // MARK: - Panel Operations /// Create a new split with a terminal panel @@ -1983,7 +2813,8 @@ final class Workspace: Identifiable, ObservableObject { func newTerminalSplit( from panelId: UUID, orientation: SplitOrientation, - insertFirst: Bool = false + insertFirst: Bool = false, + focus: Bool = true ) -> TerminalPanel? { // Get inherited config from the source terminal when possible. // If the split is initiated from a non-terminal panel (for example browser), @@ -2034,6 +2865,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = newPanel.id + let previousFocusedPanelId = focusedPanelId // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, // so we can hand it to focusPanel as the "move focus FROM" view. @@ -2056,11 +2888,19 @@ final class Workspace: Identifiable, ObservableObject { // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, // stealing focus from the new panel and creating model/surface divergence. - previousHostedView?.suppressReparentFocus() - focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) + } return newPanel } @@ -2070,7 +2910,12 @@ final class Workspace: Identifiable, ObservableObject { /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult - func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? { + func newTerminalSurface( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) // Get an existing terminal panel to inherit config from @@ -2089,7 +2934,9 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - portOrdinal: portOrdinal + workingDirectory: workingDirectory, + portOrdinal: portOrdinal, + initialEnvironmentOverrides: startupEnvironment ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle @@ -2128,7 +2975,8 @@ final class Workspace: Identifiable, ObservableObject { from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, - url: URL? = nil + url: URL? = nil, + focus: Bool = true ) -> BrowserPanel? { // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } @@ -2158,6 +3006,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id + let previousFocusedPanelId = focusedPanelId // Create the split with the browser tab already present. // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. @@ -2172,10 +3021,18 @@ final class Workspace: Identifiable, ObservableObject { // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView - previousHostedView?.suppressReparentFocus() - focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(browserPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) } installBrowserPanelSubscription(browserPanel) @@ -2362,6 +3219,115 @@ final class Workspace: Identifiable, ObservableObject { } } + private struct BrowserCloseFallbackPlan { + let orientation: SplitOrientation + let insertFirst: Bool + let anchorPaneId: UUID? + } + + private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let browserPanel = browserPanel(for: panelId), + let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id) + return + } + + let fallbackPlan = browserCloseFallbackPlan( + forPaneId: pane.id.uuidString, + in: bonsplitController.treeSnapshot() + ) + let resolvedURL = browserPanel.currentURL + ?? browserPanel.webView.url + ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + + pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( + workspaceId: id, + url: resolvedURL, + originalPaneId: pane.id, + originalTabIndex: tabIndex, + fallbackSplitOrientation: fallbackPlan?.orientation, + fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false, + fallbackAnchorPaneId: fallbackPlan?.anchorPaneId + ) + } + + private func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) + } + + private func browserCloseFallbackPlan( + forPaneId targetPaneId: String, + in node: ExternalTreeNode + ) -> BrowserCloseFallbackPlan? { + switch node { + case .pane: + return nil + case .split(let splitNode): + if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: true, + anchorPaneId: browserNearestPaneId( + in: splitNode.second, + targetCenter: browserPaneCenter(firstPane) + ) + ) + } + + if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: false, + anchorPaneId: browserNearestPaneId( + in: splitNode.first, + targetCenter: browserPaneCenter(secondPane) + ) + ) + } + + if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) { + return nested + } + return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second) + } + } + + private func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) { + ( + x: pane.frame.x + (pane.frame.width * 0.5), + y: pane.frame.y + (pane.frame.height * 0.5) + ) + } + + private func browserNearestPaneId( + in node: ExternalTreeNode, + targetCenter: (x: Double, y: Double)? + ) -> UUID? { + var panes: [ExternalPaneNode] = [] + browserCollectPaneNodes(node: node, into: &panes) + guard !panes.isEmpty else { return nil } + + let bestPane: ExternalPaneNode? + if let targetCenter { + bestPane = panes.min { lhs, rhs in + let lhsCenter = browserPaneCenter(lhs) + let rhsCenter = browserPaneCenter(rhs) + let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2) + let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance + } + return lhs.id < rhs.id + } + } else { + bestPane = panes.first + } + + guard let bestPane else { return nil } + return UUID(uuidString: bestPane.id) + } + @discardableResult func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } @@ -2490,7 +3456,131 @@ final class Workspace: Identifiable, ObservableObject { } // MARK: - Focus Management + private func preserveFocusAfterNonFocusSplit( + preferredPanelId: UUID?, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView? + ) { + guard let preferredPanelId, panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert() + scheduleFocusReconcile() + return + } + + let generation = beginNonFocusSplitFocusReassert( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + + reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: true + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + self.scheduleFocusReconcile() + self.clearNonFocusSplitFocusReassert(generation: generation) + } + } + } + + private func reassertFocusAfterNonFocusSplit( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView?, + allowPreviousHostedView: Bool + ) { + guard matchesPendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) else { + return + } + + guard panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert(generation: generation) + return + } + + if focusedPanelId == splitPanelId { + focusPanel( + preferredPanelId, + previousHostedView: allowPreviousHostedView ? previousHostedView : nil + ) + return + } + + guard focusedPanelId == preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) else { + return + } + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) + } + + private func beginNonFocusSplitFocusReassert( + preferredPanelId: UUID, + splitPanelId: UUID + ) -> UInt64 { + nonFocusSplitFocusReassertGeneration &+= 1 + let generation = nonFocusSplitFocusReassertGeneration + pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + return generation + } + + private func matchesPendingNonFocusSplitFocusReassert( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID + ) -> Bool { + guard let pending = pendingNonFocusSplitFocusReassert else { return false } + return pending.generation == generation && + pending.preferredPanelId == preferredPanelId && + pending.splitPanelId == splitPanelId + } + + private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { + guard let pending = pendingNonFocusSplitFocusReassert else { return } + if let generation, pending.generation != generation { return } + pendingNonFocusSplitFocusReassert = nil + } + + private func markExplicitFocusIntent(on panelId: UUID) { + guard let pending = pendingNonFocusSplitFocusReassert, + pending.splitPanelId == panelId else { + return + } + pendingNonFocusSplitFocusReassert = nil + } + func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { + markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)") @@ -2746,11 +3836,20 @@ final class Workspace: Identifiable, ObservableObject { if let terminalPanel = targetPanel as? TerminalPanel { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) } + if let dir = panelDirectories[targetPanelId] { + currentDirectory = dir + } + gitBranch = panelGitBranches[targetPanelId] } /// Reconcile focus/first-responder convergence. /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. private func scheduleFocusReconcile() { +#if DEBUG + if !detachingTabIds.isEmpty { + debugFocusReconcileScheduledDuringDetachCount += 1 + } +#endif guard !focusReconcileScheduled else { return } focusReconcileScheduled = true DispatchQueue.main.async { [weak self] in @@ -2948,6 +4047,7 @@ extension Workspace: BonsplitDelegate { if let dir = panelDirectories[panelId] { currentDirectory = dir } + refreshFocusedGitBranchState() // Post notification NotificationCenter.default.post( @@ -2960,6 +4060,14 @@ extension Workspace: BonsplitDelegate { ) } + private func refreshFocusedGitBranchState() { + if let focusedPanelId { + gitBranch = panelGitBranches[focusedPanelId] + } else { + gitBranch = nil + } + } + func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { func recordPostCloseSelection() { let tabs = controller.tabs(inPane: pane) @@ -2982,12 +4090,14 @@ extension Workspace: BonsplitDelegate { } if forceCloseTabIds.contains(tab.id) { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) recordPostCloseSelection() return true } if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) NSSound.beep() return false } @@ -2995,6 +4105,7 @@ extension Workspace: BonsplitDelegate { // Check if the panel needs close confirmation guard let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId) else { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) recordPostCloseSelection() return true } @@ -3003,6 +4114,7 @@ extension Workspace: BonsplitDelegate { // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass // this gating on the second pass. if terminalPanel.needsConfirmClose() { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) if pendingCloseConfirmTabIds.contains(tab.id) { return false } @@ -3028,6 +4140,7 @@ extension Workspace: BonsplitDelegate { return false } + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) recordPostCloseSelection() return true } @@ -3035,12 +4148,14 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) { forceCloseTabIds.remove(tabId) let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) + let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() return @@ -3055,10 +4170,11 @@ extension Workspace: BonsplitDelegate { if isDetaching, let panel { let browserPanel = panel as? BrowserPanel + let cachedTitle = panelTitles[panelId] ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: resolvedPanelTitle(panelId: panelId, fallback: panel.displayTitle), + title: resolvedPanelTitle(panelId: panelId, fallback: cachedTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), @@ -3070,6 +4186,9 @@ extension Workspace: BonsplitDelegate { manuallyUnread: manualUnreadPanelIds.contains(panelId) ) } else { + if let closedBrowserRestoreSnapshot { + onClosedBrowserPanel?(closedBrowserRestoreSnapshot) + } panel?.close() } @@ -3080,13 +4199,19 @@ extension Workspace: BonsplitDelegate { panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) manualUnreadPanelIds.remove(panelId) + panelGitBranches.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) // Keep the workspace invariant: always retain at least one real panel. // This prevents runtime close callbacks from ever collapsing into a tabless workspace. if panels.isEmpty { + if isDetaching { + gitBranch = nil + return + } let replacement = createReplacementTerminalPanel() if let replacementTabId = surfaceIdFromPanelId(replacement.id), let replacementPane = bonsplitController.allPaneIds.first { @@ -3094,6 +4219,7 @@ extension Workspace: BonsplitDelegate { bonsplitController.selectTab(replacementTabId) applyTabSelection(tabId: replacementTabId, inPane: replacementPane) } + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() return @@ -3112,6 +4238,7 @@ extension Workspace: BonsplitDelegate { if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() } @@ -3155,6 +4282,26 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { _ = paneId + let liveTabIds: Set<TabID> = Set( + controller.allPaneIds.flatMap { controller.tabs(inPane: $0).map(\.id) } + ) + let staleMappings = surfaceIdToPanelId.filter { !liveTabIds.contains($0.key) } + for (staleTabId, stalePanelId) in staleMappings { + panels[stalePanelId]?.close() + panels.removeValue(forKey: stalePanelId) + surfaceIdToPanelId.removeValue(forKey: staleTabId) + panelDirectories.removeValue(forKey: stalePanelId) + panelTitles.removeValue(forKey: stalePanelId) + panelCustomTitles.removeValue(forKey: stalePanelId) + pinnedPanelIds.remove(stalePanelId) + manualUnreadPanelIds.remove(stalePanelId) + panelGitBranches.removeValue(forKey: stalePanelId) + panelSubscriptions.removeValue(forKey: stalePanelId) + surfaceTTYNames.removeValue(forKey: stalePanelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: stalePanelId) + } + + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() } @@ -3381,6 +4528,9 @@ extension Workspace: BonsplitDelegate { closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) case .closeOthers: closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) + case .move: + // TODO: Wire this to a move target picker. + return case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: @@ -3395,6 +4545,10 @@ extension Workspace: BonsplitDelegate { guard let panelId = panelIdFromSurfaceId(tab.id) else { return } let shouldPin = !pinnedPanelIds.contains(panelId) setPanelPinned(panelId: panelId, pinned: shouldPin) + case .markAsRead: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + clearManualUnread(panelId: panelId) + AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) case .markAsUnread: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } markPanelUnread(panelId) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 3e058a47..dda1241e 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -53,51 +53,12 @@ struct WorkspaceContentView: View { }() BonsplitView(controller: workspace.bonsplitController) { tab, paneId in - // Content for each tab in bonsplit - let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) - if let panel = workspace.panel(for: tab.id) { - let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id - let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = Self.panelVisibleInUI( - isWorkspaceVisible: isWorkspaceVisible, - isSelectedInPane: isSelectedInPane, - isFocused: isFocused - ) - let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), - isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) - ) - PanelContentView( - panel: panel, - isFocused: isFocused, - isSelectedInPane: isSelectedInPane, - isVisibleInUI: isVisibleInUI, - portalPriority: workspacePortalPriority, - isSplit: isSplit, - appearance: appearance, - hasUnreadNotification: hasUnreadNotification, - onFocus: { - // Keep bonsplit focus in sync with the AppKit first responder for the - // active workspace. This prevents divergence between the blue focused-tab - // indicator and where keyboard input/flash-focus actually lands. - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) - }, - onRequestPanelFocus: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id) - }, - onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } - ) - .onTapGesture { - workspace.bonsplitController.focusPane(paneId) - } - } else { - // Fallback for tabs without panels (shouldn't happen normally) - EmptyPanelView(workspace: workspace, paneId: paneId) - } + panelView( + tab: tab, + paneId: paneId, + isSplit: isSplit, + appearance: appearance + ) } emptyPane: { paneId in // Empty pane content EmptyPanelView(workspace: workspace, paneId: paneId) @@ -142,6 +103,55 @@ struct WorkspaceContentView: View { } } + @ViewBuilder + private func panelView( + tab: Bonsplit.Tab, + paneId: PaneID, + isSplit: Bool, + appearance: PanelAppearance + ) -> some View { + let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) + if let panel = workspace.panel(for: tab.id) { + let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id + let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id + let isVisibleInUI = Self.panelVisibleInUI( + isWorkspaceVisible: isWorkspaceVisible, + isSelectedInPane: isSelectedInPane, + isFocused: isFocused + ) + let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), + isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) + ) + PanelContentView( + panel: panel, + isFocused: isFocused, + isSelectedInPane: isSelectedInPane, + isVisibleInUI: isVisibleInUI, + portalPriority: workspacePortalPriority, + isSplit: isSplit, + appearance: appearance, + hasUnreadNotification: hasUnreadNotification, + onFocus: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.focusPanel(panel.id) + }, + onRequestPanelFocus: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.focusPanel(panel.id) + }, + onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } + ) + .onTapGesture { + workspace.bonsplitController.focusPane(paneId) + } + } else { + EmptyPanelView(workspace: workspace, paneId: paneId) + } + } + private func syncBonsplitNotificationBadges() { let unreadFromNotifications: Set<UUID> = Set( notificationStore.notifications @@ -242,7 +252,8 @@ struct WorkspaceContentView: View { ) let chromeReason = "refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)" - workspace.applyGhosttyChrome(from: next, reason: chromeReason) + _ = chromeReason + workspace.applyGhosttyChrome(from: next) if let terminalPanel = workspace.focusedTerminalPanel { terminalPanel.applyWindowBackgroundIfActive() logTheme( diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9a0e10b6..5a674736 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2680,6 +2680,53 @@ final class UpdateChannelSettingsTests: XCTestCase { } } +final class SidebarRemoteErrorCopySupportTests: XCTestCase { + func testMenuLabelIsNilWhenThereAreNoErrors() { + XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) + XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) + } + + func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to start reverse relay" + ) + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + "SSH error (devbox:22): failed to start reverse relay" + ) + } + + func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox-a:22", + detail: "connection timed out" + ), + SidebarRemoteErrorCopyEntry( + workspaceTitle: "beta", + target: "devbox-b:22", + detail: "permission denied" + ), + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + """ + 1. alpha (devbox-a:22): connection timed out + 2. beta (devbox-b:22): permission denied + """ + ) + } +} + final class WorkspaceReorderTests: XCTestCase { @MainActor func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift new file mode 100644 index 00000000..af954ee2 --- /dev/null +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TabManagerSessionSnapshotTests: XCTestCase { + func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() { + let manager = TabManager() + guard let firstWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial workspace") + return + } + firstWorkspace.setCustomTitle("First") + + let secondWorkspace = manager.addWorkspace(select: true) + secondWorkspace.setCustomTitle("Second") + XCTAssertEqual(manager.tabs.count, 2) + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.workspaces.count, 2) + XCTAssertEqual(snapshot.selectedWorkspaceIndex, 1) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + XCTAssertEqual(restored.tabs.count, 2) + XCTAssertEqual(restored.selectedTabId, restored.tabs[1].id) + XCTAssertEqual(restored.tabs[0].customTitle, "First") + XCTAssertEqual(restored.tabs[1].customTitle, "Second") + } + + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { + let manager = TabManager() + let emptySnapshot = SessionTabManagerSnapshot( + selectedWorkspaceIndex: nil, + workspaces: [] + ) + + manager.restoreSessionSnapshot(emptySnapshot) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertNotNil(manager.selectedTabId) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift new file mode 100644 index 00000000..ccf3f116 --- /dev/null +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -0,0 +1,214 @@ +import XCTest +import AppKit +import Darwin + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalControllerSocketSecurityTests: XCTestCase { + private func makeSocketPath(_ name: String) -> String { + FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).sock") + .path + } + + override func setUp() { + super.setUp() + TerminalController.shared.stop() + } + + override func tearDown() { + TerminalController.shared.stop() + super.tearDown() + } + + func testSocketPermissionsFollowAccessMode() throws { + let tabManager = TabManager() + + let allowAllPath = makeSocketPath("allow-all") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: allowAllPath, + accessMode: .allowAll + ) + try waitForSocket(at: allowAllPath) + XCTAssertEqual(try socketMode(at: allowAllPath), 0o666) + + TerminalController.shared.stop() + + let restrictedPath = makeSocketPath("cmux-only") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: restrictedPath, + accessMode: .cmuxOnly + ) + try waitForSocket(at: restrictedPath) + XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) + } + + func testPasswordModeRejectsUnauthenticatedCommands() throws { + let socketPath = makeSocketPath("password-mode") + let tabManager = TabManager() + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .password + ) + try waitForSocket(at: socketPath) + + let pingOnly = try sendCommands(["ping"], to: socketPath) + XCTAssertEqual(pingOnly.count, 1) + XCTAssertTrue(pingOnly[0].hasPrefix("ERROR:")) + XCTAssertFalse(pingOnly[0].localizedCaseInsensitiveContains("PONG")) + + let wrongAuthThenPing = try sendCommands( + ["auth not-the-password", "ping"], + to: socketPath + ) + XCTAssertEqual(wrongAuthThenPing.count, 2) + XCTAssertTrue(wrongAuthThenPing[0].hasPrefix("ERROR:")) + XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:")) + } + + func testSocketCommandPolicyDistinguishesFocusIntent() throws { +#if DEBUG + let nonFocus = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "ping", + isV2: false + ) + XCTAssertTrue(nonFocus.insideSuppressed) + XCTAssertFalse(nonFocus.insideAllowsFocus) + XCTAssertFalse(nonFocus.outsideSuppressed) + XCTAssertFalse(nonFocus.outsideAllowsFocus) + + let focusV1 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "focus_window", + isV2: false + ) + XCTAssertTrue(focusV1.insideSuppressed) + XCTAssertTrue(focusV1.insideAllowsFocus) + XCTAssertFalse(focusV1.outsideSuppressed) + + let focusV2 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "workspace.select", + isV2: true + ) + XCTAssertTrue(focusV2.insideSuppressed) + XCTAssertTrue(focusV2.insideAllowsFocus) + XCTAssertFalse(focusV2.outsideSuppressed) +#else + throw XCTSkip("Socket command policy snapshot helper is debug-only.") +#endif + } + + private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { + return + } + usleep(20_000) + } + XCTFail("Timed out waiting for socket at \(path)") + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) + } + + private func socketMode(at path: String) throws -> UInt16 { + var fileInfo = stat() + guard lstat(path, &fileInfo) == 0 else { + throw posixError("lstat(\(path))") + } + return UInt16(fileInfo.st_mode & 0o777) + } + + private func sendCommands(_ commands: [String], to socketPath: String) throws -> [String] { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket(AF_UNIX)") + } + defer { Darwin.close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let bytes = Array(socketPath.utf8) + let maxPathLen = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count < maxPathLen else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG)) + } + + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let cPath = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + cPath.initialize(repeating: 0, count: maxPathLen) + for (index, byte) in bytes.enumerated() { + cPath[index] = CChar(bitPattern: byte) + } + } + + let addrLen = socklen_t(MemoryLayout<sa_family_t>.size + bytes.count + 1) + let connectResult = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { + throw posixError("connect(\(socketPath))") + } + + var responses: [String] = [] + for command in commands { + try writeLine(command, to: fd) + responses.append(try readLine(from: fd)) + } + return responses + } + + private func writeLine(_ command: String, to fd: Int32) throws { + let payload = Array((command + "\n").utf8) + var offset = 0 + while offset < payload.count { + let wrote = payload.withUnsafeBytes { raw in + Darwin.write(fd, raw.baseAddress!.advanced(by: offset), payload.count - offset) + } + guard wrote >= 0 else { + throw posixError("write(\(command))") + } + offset += wrote + } + } + + private func readLine(from fd: Int32) throws -> String { + var buffer = [UInt8](repeating: 0, count: 1) + var data = Data() + + while true { + let count = Darwin.read(fd, &buffer, 1) + guard count >= 0 else { + throw posixError("read") + } + if count == 0 { break } + if buffer[0] == 0x0A { break } + data.append(buffer[0]) + } + + guard let line = String(data: data, encoding: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Invalid UTF-8 response from socket" + ]) + } + return line + } + + private func posixError(_ operation: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))"] + ) + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index eea20ed9..77654e7d 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -5,7 +5,9 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "net" "os" "path/filepath" @@ -433,12 +435,42 @@ func socketRoundTrip(socketPath, command string, refreshAddr func() string) (str return "", fmt.Errorf("failed to send command: %w", err) } + // V1 handlers may return multiple lines (e.g. list_windows). Read until + // the stream goes idle briefly after seeing at least one newline. reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) + var response strings.Builder + sawNewline := false + + for { + readTimeout := 15 * time.Second + if sawNewline { + readTimeout = 120 * time.Millisecond + } + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + + chunk, err := reader.ReadString('\n') + if chunk != "" { + response.WriteString(chunk) + if strings.Contains(chunk, "\n") { + sawNewline = true + } + } + + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + if sawNewline { + break + } + return "", fmt.Errorf("failed to read response: timeout waiting for response") + } + if errors.Is(err, io.EOF) { + break + } + return "", fmt.Errorf("failed to read response: %w", err) + } } - return strings.TrimRight(line, "\n"), nil + + return strings.TrimRight(response.String(), "\n"), nil } // socketRoundTripV2 sends a JSON-RPC request and returns the result JSON. diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index 44f5db6f..bfb876b1 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -217,6 +217,18 @@ func TestCLINewWindowV1(t *testing.T) { } } +func TestSocketRoundTripReadsFullMultilineV1Response(t *testing.T) { + addr := startMockTCPSocket(t, "window:alpha\nwindow:beta\nwindow:gamma") + resp, err := socketRoundTrip(addr, "list_windows", nil) + if err != nil { + t.Fatalf("socketRoundTrip should succeed, got error: %v", err) + } + want := "window:alpha\nwindow:beta\nwindow:gamma" + if resp != want { + t.Fatalf("socketRoundTrip truncated v1 response: got %q want %q", resp, want) + } +} + func TestCLICloseWindowV1(t *testing.T) { // Verify that the flag value is appended to the v1 command dir := t.TempDir() diff --git a/scripts/reload.sh b/scripts/reload.sh index 3cd2bb63..e9ba2655 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -322,6 +322,7 @@ if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then elif [[ -n "${TAG_SLUG:-}" ]]; then "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH" else + echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true "${OPEN_CLEAN_ENV[@]}" open "$APP_PATH" fi diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py new file mode 100644 index 00000000..e09741fd --- /dev/null +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Regression: global CLI flags still parse and v1 ERROR responses fail with non-zero exit.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +LAST_SOCKET_HINT_PATH = Path("/tmp/cmux-last-socket-path") + + +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) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + + +def _merged_output(proc: subprocess.CompletedProcess[str]) -> str: + return f"{proc.stdout}\n{proc.stderr}".strip() + + +def main() -> int: + cli = _find_cli_binary() + + # Global --version should be handled before socket command dispatch. + version_proc = _run([cli, "--version"]) + version_out = _merged_output(version_proc).lower() + _must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}") + _must("cmux" in version_out, f"--version output should mention cmux: {version_out!r}") + + # Debug builds should auto-resolve the active debug socket via /tmp/cmux-last-socket-path + # when CMUX_SOCKET_PATH is not set. + hint_backup: str | None = None + hint_had_file = LAST_SOCKET_HINT_PATH.exists() + if hint_had_file: + hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8") + try: + LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8") + auto_env = dict(os.environ) + auto_env.pop("CMUX_SOCKET_PATH", None) + auto_ping = _run([cli, "ping"], env=auto_env) + auto_ping_out = _merged_output(auto_ping).lower() + _must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}") + _must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}") + finally: + try: + if hint_had_file: + LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8") + else: + LAST_SOCKET_HINT_PATH.unlink(missing_ok=True) + except OSError: + pass + + # Global --password should parse as a flag (not a command name) and still allow non-password sockets. + ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-cmuxonly", "ping"]) + ping_out = _merged_output(ping_proc).lower() + _must(ping_proc.returncode == 0, f"ping with --password should succeed: {ping_proc.returncode} {ping_out!r}") + _must("pong" in ping_out, f"ping should still return pong: {ping_out!r}") + + # V1 errors must produce non-zero exit codes for automation correctness. + bad_focus = _run([cli, "--socket", SOCKET_PATH, "focus-window", "--window", "window:999999"]) + bad_out = _merged_output(bad_focus).lower() + _must(bad_focus.returncode != 0, f"focus-window with invalid target should fail non-zero: {bad_out!r}") + _must("error" in bad_out, f"focus-window failure should surface an error: {bad_out!r}") + + print("PASS: global flags parse correctly and v1 ERROR responses fail the CLI process") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_tab_cli_parity.py b/tests_v2/test_rename_tab_cli_parity.py index a60055fa..e7ea1b94 100644 --- a/tests_v2/test_rename_tab_cli_parity.py +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -55,14 +55,6 @@ def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> return proc.stdout.strip() -def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str: - payload = c._call("surface.list", {"workspace_id": workspace_id}) or {} - for row in payload.get("surfaces") or []: - if str(row.get("id") or "") == surface_id: - return str(row.get("title") or "") - raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") - - def main() -> int: cli = _find_cli_binary() stamp = int(time.time() * 1000) @@ -82,7 +74,7 @@ def main() -> int: _must(bool(surface_id), f"surface.current returned no surface_id: {current}") socket_title = f"socket rename {stamp}" - c._call( + socket_payload = c._call( "tab.action", { "workspace_id": ws_id, @@ -91,14 +83,20 @@ def main() -> int: "title": socket_title, }, ) - _must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title") + _must( + str((socket_payload or {}).get("title") or "") == socket_title, + f"tab.action rename response missing requested title: {socket_payload}", + ) cli_title = f"cli rename {stamp}" - _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) - _must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title") + cli_out = _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) + _must( + "action=rename" in cli_out.lower() and "tab=" in cli_out.lower(), + f"rename-tab --tab should route to tab.action rename summary, got: {cli_out!r}", + ) env_title = f"env rename {stamp}" - _run_cli( + env_out = _run_cli( cli, ["rename-tab", env_title], env={ @@ -106,7 +104,10 @@ def main() -> int: "CMUX_TAB_ID": surface_id, }, ) - _must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title") + _must( + "action=rename" in env_out.lower() and "tab=" in env_out.lower(), + f"rename-tab via CMUX_TAB_ID should route to tab.action rename summary, got: {env_out!r}", + ) invalid = subprocess.run( [cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id], diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py index d5125b7f..2ba2afcf 100644 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -19,7 +19,9 @@ from cmux import cmux, cmuxError SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") -REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +# Keep the fixture's extra HTTP server below 1024 so there are no eligible +# (>1023) ports to auto-forward. This guards the "connecting forever" regression. +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "81")) def _must(cond: bool, msg: str) -> None: @@ -55,6 +57,8 @@ def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = Tru def _run_cli_json(cli: str, args: list[str]) -> dict: env = dict(os.environ) + # Ensure --socket is what drives the relay path during tests. + env.pop("CMUX_SOCKET_PATH", None) env.pop("CMUX_WORKSPACE_ID", None) env.pop("CMUX_SURFACE_ID", None) env.pop("CMUX_TAB_ID", None) @@ -111,6 +115,33 @@ def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20 raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") +def _wait_for_remote_ready(client, 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 {} + state = str(remote.get("state") or "") + daemon_state = str(daemon.get("state") or "") + if state == "connected" and daemon_state == "ready": + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote daemon did not become ready: {last_status}") + + +def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None: + ping_result = _ssh_run( + host, host_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", + check=False, + ) + _must( + ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), + f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + ) + + def main() -> int: if not _docker_available(): print("SKIP: docker is not available") @@ -125,6 +156,7 @@ def main() -> int: image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}" workspace_id = "" + workspace_id_2 = "" try: # Generate SSH key pair @@ -180,19 +212,18 @@ def main() -> int: remote_socket_addr = f"127.0.0.1:{remote_relay_port}" # Wait for daemon to be ready - deadline = time.time() + 45.0 - 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 {} - state = str(remote.get("state") or "") - daemon_state = str(daemon.get("state") or "") - if state == "connected" and daemon_state == "ready": - break - time.sleep(0.5) - else: - raise cmuxError(f"Remote daemon did not become ready: {last_status}") + first_status = _wait_for_remote_ready(client, workspace_id) + first_remote = first_status.get("remote") or {} + # Regression: should transition to connected even with no eligible + # (>1023, non-ephemeral) remote ports. + _must( + not (first_remote.get("detected_ports") or []), + f"expected no eligible detected ports in fixture: {first_status}", + ) + _must( + not (first_remote.get("forwarded_ports") or []), + f"expected no forwarded ports when none are eligible: {first_status}", + ) # Verify the cmux symlink exists on the remote symlink_check = _ssh_run( @@ -205,16 +236,49 @@ def main() -> int: f"Expected cmux symlink at ~/.cmux/bin/cmux on remote: {symlink_check.stdout} {symlink_check.stderr}", ) - # Test 1: cmux ping (v1) - ping_result = _ssh_run( - host, host_ssh_port, key_path, - f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", - check=False, + # Start a second SSH workspace to the same destination and verify both + # relays remain healthy (regression: same-host workspaces killed each other). + payload_2 = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay-2", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], ) + workspace_id_2 = str(payload_2.get("workspace_id") or "") + workspace_ref_2 = str(payload_2.get("workspace_ref") or "") + if not workspace_id_2 and workspace_ref_2.startswith("workspace:"): + listed_2 = client._call("workspace.list", {}) or {} + for row in listed_2.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_2: + workspace_id_2 = str(row.get("id") or "") + break + _must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}") + + remote_relay_port_2 = payload_2.get("remote_relay_port") + _must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}") + remote_relay_port_2 = int(remote_relay_port_2) + _must(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {remote_relay_port_2}") _must( - ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), - f"cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + remote_relay_port_2 != remote_relay_port, + f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", ) + remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}" + _ = _wait_for_remote_ready(client, workspace_id_2) + + stability_deadline = time.time() + 8.0 + while time.time() < stability_deadline: + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay") + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay") + time.sleep(0.5) + + # Test 1: cmux ping (v1) + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux") # Test 2: cmux list-workspaces --json (v2) list_ws_result = _ssh_run( @@ -265,6 +329,12 @@ def main() -> int: except Exception: pass workspace_id = "" + if workspace_id_2: + try: + client.close_workspace(workspace_id_2) + except Exception: + pass + workspace_id_2 = "" print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding") return 0 @@ -276,6 +346,12 @@ def main() -> int: cleanup_client.close_workspace(workspace_id) except Exception: pass + if workspace_id_2: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_2) + except Exception: + pass _run(["docker", "rm", "-f", container_name], check=False) _run(["docker", "rmi", "-f", image_tag], check=False) diff --git a/tests_v2/test_workspace_create_initial_env.py b/tests_v2/test_workspace_create_initial_env.py new file mode 100644 index 00000000..33b56c2e --- /dev/null +++ b/tests_v2/test_workspace_create_initial_env.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Regression: workspace.create must apply initial_env to the initial terminal.""" + +import os +import sys +import time +import base64 +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + payload = c._call( + "surface.read_text", + {"workspace_id": workspace_id}, + ) or {} + if "text" in payload: + last_text = str(payload.get("text") or "") + else: + b64 = str(payload.get("base64") or "") + raw = base64.b64decode(b64) if b64 else b"" + last_text = raw.decode("utf-8", errors="replace") + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + created_workspace = "" + try: + token = f"tok_{int(time.time() * 1000)}" + payload = c._call( + "workspace.create", + { + "initial_env": {"CMUX_INITIAL_ENV_TOKEN": token}, + }, + ) or {} + created_workspace = str(payload.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") + _must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus") + + # Terminal surfaces in background workspaces may not be attached/render-ready yet. + # Select it before reading text so the initial command output is available. + c.select_workspace(created_workspace) + listed = c._call("surface.list", {"workspace_id": created_workspace}) or {} + rows = list(listed.get("surfaces") or []) + _must(bool(rows), "Expected at least one surface in the created workspace") + terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None) + _must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}") + + c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n") + text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}") + _must( + f"CMUX_ENV_CHECK={token}" in text, + f"initial_env token missing from terminal output: {text!r}", + ) + c.select_workspace(baseline_workspace) + finally: + if created_workspace: + try: + c.close_workspace(created_workspace) + except Exception: + pass + + print("PASS: workspace.create applies initial_env to initial terminal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())