diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ba5303db1e9a..3b21287a4ed3 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; 3023A1003023A1003023A100 /* ConfigSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023B1003023B1003023B100 /* ConfigSource.swift */; }; 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023B1013023B1013023B101 /* ConfigSettingsView.swift */; }; + 3023A1023023A1023023A102 /* WorkspaceCommandsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023B1023023B1023023B102 /* WorkspaceCommandsStore.swift */; }; + 3023A1033023A1033023A103 /* WorkspaceCommandsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023B1033023B1033023B103 /* WorkspaceCommandsSettingsView.swift */; }; 350DAC5EBD38642A3E81471A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312DE7503B4658DD173121B8 /* AuthManager.swift */; }; 36CE99ED050785B5E96B72BB /* AuthCallbackRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A19A4145F034965110CF87 /* AuthCallbackRouter.swift */; }; 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */; }; @@ -277,6 +279,8 @@ 2F0C05000000000000000001 /* MainWindowFocusController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowFocusController.swift; sourceTree = ""; }; 3023B1003023B1003023B100 /* ConfigSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSource.swift; sourceTree = ""; }; 3023B1013023B1013023B101 /* ConfigSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSettingsView.swift; sourceTree = ""; }; + 3023B1023023B1023023B102 /* WorkspaceCommandsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/WorkspaceCommandsStore.swift; sourceTree = ""; }; + 3023B1033023B1033023B103 /* WorkspaceCommandsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/WorkspaceCommandsSettingsView.swift; sourceTree = ""; }; 312DE7503B4658DD173121B8 /* AuthManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; FEED0000000000000000F001 /* FeedCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedCoordinator.swift; sourceTree = ""; }; FEED0000000000000000F004 /* FeedPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelView.swift; sourceTree = ""; }; @@ -610,6 +614,8 @@ children = ( 3023B1003023B1003023B100 /* ConfigSource.swift */, 3023B1013023B1013023B101 /* ConfigSettingsView.swift */, + 3023B1023023B1023023B102 /* WorkspaceCommandsStore.swift */, + 3023B1033023B1033023B103 /* WorkspaceCommandsSettingsView.swift */, A5001011 /* cmuxApp.swift */, A50019A1 /* SettingsNavigation.swift */, A50019B1 /* SettingsSearchAliases.swift */, @@ -1013,6 +1019,8 @@ files = ( 3023A1003023A1003023A100 /* ConfigSource.swift in Sources */, 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */, + 3023A1023023A1023023A102 /* WorkspaceCommandsStore.swift in Sources */, + 3023A1033023A1033023A103 /* WorkspaceCommandsSettingsView.swift in Sources */, A5001001 /* cmuxApp.swift in Sources */, A50019A0 /* SettingsNavigation.swift in Sources */, A50019B0 /* SettingsSearchAliases.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e51eea691a40..06d6f3779532 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5851,6 +5851,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func bootstrapInitialMainWindowIfNeeded(debugSource: String, shouldActivate: Bool = true) -> UUID { + // Detect whether this call is creating a brand-new window (vs. surfacing + // one that session restore already populated). When fresh, we run the + // configured default workspace command so the first window opens with + // the user's chosen profile (e.g. a remote SSH workspace) instead of a + // bare local terminal that they'd immediately close. + // Treat a launch as "fresh" only when there's no main window yet AND + // we aren't about to restore a persisted session. Otherwise the user's + // configured default command would replace a restored workspace. + let willRestoreStartupSession = + startupSessionSnapshot != nil && !didHandleExplicitOpenIntentAtStartup + let isFreshLaunch = mainWindowContexts.isEmpty && !willRestoreStartupSession let windowId = ensureInitialMainWindowIfNeeded(shouldActivate: shouldActivate) if let manager = tabManagerFor(windowId: windowId) { startSocketListenerIfEnabled( @@ -5858,6 +5869,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent source: "bootstrapInitialMainWindow.\(debugSource)" ) } + // Only override the bare initial workspace when the user explicitly + // picked a non-Local default. Built-in `Local` (defaultCommandID == nil) + // is the implicit fallback and doesn't add anything over the plain + // workspace TabManager.init already created. + if isFreshLaunch, + WorkspaceCommandsStore.shared.defaultCommandID != nil, + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + let initialWorkspace = context.tabManager.selectedWorkspace + _ = executeConfiguredNewWorkspaceCommandIfAvailable( + in: context, + debugSource: "bootstrap.\(debugSource)", + replacingInitialWorkspace: initialWorkspace + ) + } guard !didBootstrapInitialMainWindow else { return windowId } didBootstrapInitialMainWindow = true @@ -5967,39 +5992,104 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent debugSource: String, replacingInitialWorkspace initialWorkspace: Workspace? = nil ) -> Bool { - guard let cmuxConfigStore = context.cmuxConfigStore, - let configured = cmuxConfigStore.resolvedNewWorkspaceCommand() else { - return false - } guard resolvedWindow(for: context) != nil else { discardOrphanedMainWindowContext(context) return false } + guard let configured = WorkspaceCommandsStore.shared.defaultCommand() else { + return false + } let rawCwd = context.tabManager.selectedWorkspace?.currentDirectory let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd! : FileManager.default.homeDirectoryForCurrentUser.path #if DEBUG cmuxDebugLog( "newWorkspace.configCommand source=\(debugSource) " + - "command=\(configured.command.name) windowId=\(String(context.windowId.uuidString.prefix(8)))" + "command=\(configured.name) windowId=\(String(context.windowId.uuidString.prefix(8)))" ) #endif let initialWorkspaceId = initialWorkspace?.id + let globalConfigPath = context.cmuxConfigStore?.globalConfigPath + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/cmux/cmux.json").path let didExecute = CmuxConfigExecutor.execute( - command: configured.command, + command: configured.asCmuxCommandDefinition(), tabManager: context.tabManager, baseCwd: baseCwd, - configSourcePath: configured.sourcePath, - globalConfigPath: cmuxConfigStore.globalConfigPath - ) { [weak self, weak context] in - self?.closeInitialWorkspaceIfNeeded( - initialWorkspaceId: initialWorkspaceId, - in: context - ) - } + configSourcePath: nil, + globalConfigPath: globalConfigPath, + onExecuted: { [weak self, weak context] in + self?.closeInitialWorkspaceIfNeeded( + initialWorkspaceId: initialWorkspaceId, + in: context + ) + } + ) return didExecute } + /// Returns the workspace commands the user has configured in the + /// Preferences "Workspaces" section. Used to populate the titlebar `+` + /// split menu and the command palette. + func availableWorkspaceCommands() -> [WorkspaceCommandConfig] { + WorkspaceCommandsStore.shared.commands + } + + /// Runs a workspace command by its identifier. Returns true if a matching + /// command was found and executed. + @discardableResult + func performWorkspaceCommand(id: WorkspaceCommandConfig.ID, debugSource: String) -> Bool { + guard let config = WorkspaceCommandsStore.shared.command(id: id) else { return false } + return runWorkspaceCommandConfig(config, debugSource: debugSource) + } + + @discardableResult + private func runWorkspaceCommandConfig( + _ config: WorkspaceCommandConfig, + debugSource: String + ) -> Bool { + guard let context = preferredMainWindowContextForWorkspaceCreation( + event: nil, + debugSource: debugSource + ) else { + return false + } + let rawCwd = context.tabManager.selectedWorkspace?.currentDirectory + let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd! + : FileManager.default.homeDirectoryForCurrentUser.path + let globalConfigPath = context.cmuxConfigStore?.globalConfigPath + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/cmux/cmux.json").path + return CmuxConfigExecutor.execute( + command: config.asCmuxCommandDefinition(), + tabManager: context.tabManager, + baseCwd: baseCwd, + configSourcePath: nil, + globalConfigPath: globalConfigPath + ) + } + + /// Runs the user's configured default workspace command (set in + /// Preferences → Workspaces). Returns false if no default is selected so + /// callers can fall back to the plain `addWorkspace()` path. + @discardableResult + func performDefaultWorkspaceCommand(debugSource: String) -> Bool { + guard let config = WorkspaceCommandsStore.shared.defaultCommand() else { return false } + return runWorkspaceCommandConfig(config, debugSource: debugSource) + } + + /// Opens the Preferences → Workspaces editor window via + /// `WorkspaceCommandsWindowPresenter`. SwiftUI's `openWindow(id:)` is only + /// reachable from a scene context, so the main `WindowGroup`'s onAppear + /// hands an opener closure to the presenter at launch and we route through + /// it from anywhere in AppKit. + func openWorkspaceCommandsWindow(debugSource: String) { +#if DEBUG + cmuxDebugLog("workspaceCommands.openWindow source=\(debugSource)") +#endif + WorkspaceCommandsWindowPresenter.show() + } + private func closeInitialWorkspaceIfNeeded( initialWorkspaceId: UUID?, in context: MainWindowContext? diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index fec499f0a916..89706aec1f2a 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -1588,6 +1588,81 @@ enum CmuxRestartBehavior: String, Codable, Sendable { case recreate case ignore case confirm + case always +} + +struct CmuxRemoteDefinition: Codable, Sendable, Equatable { + var host: String + var port: Int? + var identityFile: String? + var sshOptions: [String]? + var startupCommand: String? + + init( + host: String, + port: Int? = nil, + identityFile: String? = nil, + sshOptions: [String]? = nil, + startupCommand: String? = nil + ) { + self.host = host + self.port = port + self.identityFile = identityFile + self.sshOptions = sshOptions + self.startupCommand = startupCommand + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let rawHost = try container.decode(String.self, forKey: .host) + let trimmedHost = rawHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .host, + in: container, + debugDescription: "Remote 'host' must not be blank" + ) + } + host = trimmedHost + + if let rawPort = try container.decodeIfPresent(Int.self, forKey: .port) { + guard rawPort > 0, rawPort <= 65535 else { + throw DecodingError.dataCorruptedError( + forKey: .port, + in: container, + debugDescription: "Remote 'port' must be between 1 and 65535" + ) + } + port = rawPort + } else { + port = nil + } + + if let rawIdentityFile = try container.decodeIfPresent(String.self, forKey: .identityFile) { + let trimmed = rawIdentityFile.trimmingCharacters(in: .whitespacesAndNewlines) + identityFile = trimmed.isEmpty ? nil : trimmed + } else { + identityFile = nil + } + + if let rawOptions = try container.decodeIfPresent([String].self, forKey: .sshOptions) { + let cleaned = rawOptions.compactMap { option -> String? in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + sshOptions = cleaned.isEmpty ? nil : cleaned + } else { + sshOptions = nil + } + + if let rawStartup = try container.decodeIfPresent(String.self, forKey: .startupCommand) { + let trimmed = rawStartup.trimmingCharacters(in: .whitespacesAndNewlines) + startupCommand = trimmed.isEmpty ? nil : trimmed + } else { + startupCommand = nil + } + } } indirect enum CmuxLayoutNode: Codable, Sendable { diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index 6940ecc7f11e..229687d81152 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -427,16 +427,29 @@ struct CmuxConfigExecutor { tabManager: TabManager, baseCwd: String ) { - let workspaceName = wsDef.name ?? command.name - let restart = command.restart ?? .ignore + let baseWorkspaceName = wsDef.name ?? command.name + let restart = command.restart ?? .always + let workspaceName: String = { + guard restart == .always else { return baseWorkspaceName } + let existingTitles = Set(tabManager.tabs.compactMap { $0.customTitle }) + guard existingTitles.contains(baseWorkspaceName) else { return baseWorkspaceName } + var n = 2 + while existingTitles.contains("\(baseWorkspaceName) \(n)") { n += 1 } + return "\(baseWorkspaceName) \(n)" + }() - if let existing = tabManager.tabs.first(where: { $0.customTitle == workspaceName }) { + if restart != .always, + let existing = tabManager.tabs.first(where: { $0.customTitle == workspaceName }) { switch restart { case .ignore: tabManager.selectWorkspace(existing) return case .recreate: tabManager.closeWorkspace(existing) + case .always: + // Unreachable: the enclosing `if restart != .always` excludes this case. + // Required for switch exhaustiveness. + break case .confirm: let alert = NSAlert() alert.messageText = String( @@ -459,13 +472,94 @@ struct CmuxConfigExecutor { } let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd) - let newWorkspace = tabManager.addWorkspace(workingDirectory: resolvedCwd) + let remoteStartupCommand: String? = wsDef.remote.map { buildRemoteTerminalStartupCommand(remote: $0) } + let resolvedProgram: String? = { + guard wsDef.remote == nil else { return nil } + let trimmed = wsDef.program?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false) ? trimmed : nil + }() + let initialCommand = remoteStartupCommand ?? resolvedProgram + let newWorkspace = tabManager.addWorkspace( + workingDirectory: resolvedCwd, + initialTerminalCommand: initialCommand, + closePanesOnInitialCommandExit: initialCommand != nil + ) newWorkspace.setCustomTitle(workspaceName) if let color = wsDef.color { newWorkspace.setCustomColor(color) } + if let remote = wsDef.remote, let remoteStartupCommand { + let config = WorkspaceRemoteConfiguration( + transport: .ssh, + destination: remote.host, + port: remote.port, + identityFile: remote.identityFile.flatMap { + let path = expandTildePath($0) + return path.isEmpty ? nil : path + }, + sshOptions: remote.sshOptions ?? [], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: nil, + terminalStartupCommand: remoteStartupCommand, + skipDaemonBootstrap: false + ) + newWorkspace.configureRemoteConnection(config, autoConnect: true) + } + guard let layout = wsDef.layout else { return } newWorkspace.applyCustomLayout(layout, baseCwd: resolvedCwd) } + + /// Builds the shell command that each terminal pane in a remote workspace runs to + /// establish (or re-establish) the SSH connection. Mirrors the simplified form of + /// what `cmux ssh` builds in `CLI/cmux.swift`, minus the daemon bootstrap relay + /// — those plumbing pieces require a live CLI invocation to allocate. + private static func buildRemoteTerminalStartupCommand(remote: CmuxRemoteDefinition) -> String { + var args: [String] = ["ssh"] + if let identityFile = remote.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append("-i") + args.append(shellQuoteArgument(expandTildePath(identityFile))) + } + if let port = remote.port { + args.append("-p") + args.append(String(port)) + } + for option in remote.sshOptions ?? [] { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + args.append("-o") + args.append(shellQuoteArgument(trimmed)) + } + // ssh(1) parses options up to `destination`; anything after is treated + // as the remote command. `-t` therefore must precede the host or it + // gets passed to the remote shell instead of allocating a TTY. + let startupCommand = remote.startupCommand?.trimmingCharacters(in: .whitespacesAndNewlines) + if let startupCommand, !startupCommand.isEmpty { + args.append("-t") + } + args.append(shellQuoteArgument(remote.host)) + if let startupCommand, !startupCommand.isEmpty { + args.append(shellQuoteArgument(startupCommand)) + } + return args.joined(separator: " ") + } + + private static func expandTildePath(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return trimmed } + return NSString(string: trimmed).expandingTildeInPath + } + + private static func shellQuoteArgument(_ value: String) -> String { + let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" + if value.range(of: safePattern, options: .regularExpression) != nil { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } } diff --git a/Sources/CmuxWorkspaceDefinition.swift b/Sources/CmuxWorkspaceDefinition.swift index 9ec0c810715b..5887f325bf2e 100644 --- a/Sources/CmuxWorkspaceDefinition.swift +++ b/Sources/CmuxWorkspaceDefinition.swift @@ -5,12 +5,26 @@ struct CmuxWorkspaceDefinition: Codable, Sendable { var cwd: String? var color: String? var layout: CmuxLayoutNode? + var remote: CmuxRemoteDefinition? + /// Program to run as the surface's child process for non-remote workspaces. + /// Empty/nil falls back to Ghostty's default shell. Ignored when + /// `remote` is set — the SSH invocation always wins. + var program: String? - init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) { + init( + name: String? = nil, + cwd: String? = nil, + color: String? = nil, + layout: CmuxLayoutNode? = nil, + remote: CmuxRemoteDefinition? = nil, + program: String? = nil + ) { self.name = name self.cwd = cwd self.color = color self.layout = layout + self.remote = remote + self.program = program } init(from decoder: Decoder) throws { @@ -18,6 +32,12 @@ struct CmuxWorkspaceDefinition: Codable, Sendable { name = try container.decodeIfPresent(String.self, forKey: .name) cwd = try container.decodeIfPresent(String.self, forKey: .cwd) layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout) + // `remote` and `program` are runtime-only — they're populated by the + // `WorkspaceCommandsStore` projection when the executor runs a + // workspace command. Workspace commands aren't authored in cmux.json + // anymore, so the JSON decoder does not accept these keys. + remote = nil + program = nil if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) { let defaults = decoder.userInfo[.cmuxWorkspaceColorDefaults] as? UserDefaults ?? .standard diff --git a/Sources/Settings/WorkspaceCommandsSettingsView.swift b/Sources/Settings/WorkspaceCommandsSettingsView.swift new file mode 100644 index 000000000000..df603eda9cd7 --- /dev/null +++ b/Sources/Settings/WorkspaceCommandsSettingsView.swift @@ -0,0 +1,445 @@ +import AppKit +import SwiftUI + +/// List/detail editor for the user's workspace commands. Surfaced in its own +/// window (opened from `Settings → Workspaces → Manage Workspaces…`) because +/// the schema is wide enough that inlining it in the main settings scroll +/// would dominate the page. +struct WorkspaceCommandsSettingsView: View { + static let windowID = "workspace-commands-editor" + + @ObservedObject private var store = WorkspaceCommandsStore.shared + @State private var selectedID: WorkspaceCommandConfig.ID? + + @State private var showRestoreConfirmation = false + + var body: some View { + NavigationSplitView { + commandList + } detail: { + if let id = selectedID, + let snapshot = store.command(id: id) { + if store.isBuiltIn(id: id) { + BuiltInWorkspaceCommandView( + command: snapshot, + isDefault: store.defaultCommandID == nil + || store.defaultCommandID == id, + onMakeDefault: { store.setDefault(id: nil) } + ) + .id(id) + } else if let binding = bindingForCommand(id: id) { + WorkspaceCommandDetailEditor( + command: binding, + isDefault: store.defaultCommandID == id, + onToggleDefault: { store.setDefault(id: $0 ? id : nil) }, + onDelete: { + store.remove(id: id) + selectedID = WorkspaceCommandsStore.builtInLocalID + } + ) + .id(id) + } + } else { + emptyDetail + } + } + .navigationTitle(String( + localized: "settings.workspaces.windowTitle", + defaultValue: "Workspaces" + )) + .frame(minWidth: 720, minHeight: 460) + .onAppear { + if selectedID == nil { + selectedID = WorkspaceCommandsStore.builtInLocalID + } + } + .alert( + String( + localized: "settings.workspaces.restoreDefaults.confirm.title", + defaultValue: "Restore Default Workspaces?" + ), + isPresented: $showRestoreConfirmation + ) { + Button(role: .destructive) { + store.restoreDefaults() + selectedID = WorkspaceCommandsStore.builtInLocalID + } label: { + Text(String( + localized: "settings.workspaces.restoreDefaults.confirm.button", + defaultValue: "Restore" + )) + } + Button(role: .cancel) {} label: { + Text(String( + localized: "settings.workspaces.restoreDefaults.confirm.cancel", + defaultValue: "Cancel" + )) + } + } message: { + Text(String( + localized: "settings.workspaces.restoreDefaults.confirm.message", + defaultValue: "This removes every workspace command you've added and leaves only the built-in Local workspace. This cannot be undone." + )) + } + } + + private var commandList: some View { + VStack(spacing: 0) { + List(selection: $selectedID) { + ForEach(store.commands) { command in + WorkspaceCommandListRow( + name: command.name, + isRemote: command.remote != nil, + isDefault: store.defaultCommandID == command.id + || (store.defaultCommandID == nil + && command.id == WorkspaceCommandsStore.builtInLocalID) + ) + .tag(command.id as WorkspaceCommandConfig.ID?) + } + .onMove { source, destination in + store.move(fromOffsets: source, toOffset: destination) + } + } + .listStyle(.sidebar) + + Divider() + + HStack(spacing: 8) { + Button { + let new = store.addCommand() + selectedID = new.id + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + .help(String( + localized: "settings.workspaces.addCommand", + defaultValue: "Add workspace command" + )) + + Button { + if let id = selectedID, !store.isBuiltIn(id: id) { + store.remove(id: id) + selectedID = WorkspaceCommandsStore.builtInLocalID + } + } label: { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + .disabled(selectedID.map { store.isBuiltIn(id: $0) } ?? true) + .help(String( + localized: "settings.workspaces.removeCommand", + defaultValue: "Remove selected command" + )) + + Spacer() + + Button { + showRestoreConfirmation = true + } label: { + Text(String( + localized: "settings.workspaces.restoreDefaults", + defaultValue: "Restore Defaults" + )) + .font(.caption) + } + .buttonStyle(.borderless) + .disabled(store.userCommands.isEmpty && store.defaultCommandID == nil) + .help(String( + localized: "settings.workspaces.restoreDefaults.help", + defaultValue: "Remove all custom commands and reset to just Local" + )) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(nsColor: .underPageBackgroundColor)) + } + .navigationSplitViewColumnWidth(min: 280, ideal: 300, max: 360) + } + + private var emptyDetail: some View { + VStack(spacing: 12) { + Image(systemName: "rectangle.stack.badge.plus") + .font(.system(size: 36, weight: .light)) + .foregroundColor(.secondary) + Text(String( + localized: "settings.workspaces.empty.title", + defaultValue: "No workspace selected" + )) + .font(.headline) + Text(String( + localized: "settings.workspaces.empty.subtitle", + defaultValue: "Select a workspace command on the left, or click + to create one." + )) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 320) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func bindingForCommand(id: WorkspaceCommandConfig.ID) -> Binding? { + guard let initial = store.command(id: id) else { return nil } + return Binding( + get: { store.command(id: id) ?? initial }, + set: { store.update($0) } + ) + } +} + +private struct WorkspaceCommandListRow: View { + let name: String + let isRemote: Bool + let isDefault: Bool + + var body: some View { + HStack(spacing: 8) { + Image(systemName: isRemote ? "network" : "terminal") + .foregroundColor(.secondary) + Text(name.isEmpty + ? String(localized: "settings.workspaces.unnamed", defaultValue: "Untitled") + : name) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if isDefault { + Text(String( + localized: "settings.workspaces.defaultBadge", + defaultValue: "Default" + )) + .font(.caption2) + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.secondary.opacity(0.15)) + ) + } + } + } +} + +private struct BuiltInWorkspaceCommandView: View { + let command: WorkspaceCommandConfig + let isDefault: Bool + let onMakeDefault: () -> Void + + var body: some View { + Form { + Section { + LabeledContent { + Text(command.name) + .foregroundColor(.secondary) + } label: { + Text(String(localized: "settings.workspaces.field.name", defaultValue: "Name")) + } + Toggle( + String( + localized: "settings.workspaces.field.useAsDefault", + defaultValue: "Use as default for new workspaces (Cmd-N)" + ), + isOn: Binding( + get: { isDefault }, + set: { isOn in + // Local is the fallback when no default is set, so we + // only act on "turn on": switch the explicit default + // back to Local. Turning it off has no meaning — pick + // a different command's "default" toggle instead. + if isOn { onMakeDefault() } + } + ) + ) + .disabled(isDefault) + } + Section { + Text(String( + localized: "settings.workspaces.builtIn.local.note", + defaultValue: "Local is built into cmux. It opens a new workspace using your default shell. To customize it, add a separate workspace command." + )) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .formStyle(.grouped) + .padding(.top, 4) + } +} + +private struct WorkspaceCommandDetailEditor: View { + @Binding var command: WorkspaceCommandConfig + let isDefault: Bool + let onToggleDefault: (Bool) -> Void + let onDelete: () -> Void + + var body: some View { + Form { + Section { + TextField( + String(localized: "settings.workspaces.field.name", defaultValue: "Name"), + text: $command.name + ) + Picker( + String(localized: "settings.workspaces.field.restart", defaultValue: "Open behavior"), + selection: $command.restart + ) { + ForEach(WorkspaceCommandConfig.Restart.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + Toggle( + String( + localized: "settings.workspaces.field.useAsDefault", + defaultValue: "Use as default for new workspaces (Cmd-N)" + ), + isOn: Binding( + get: { isDefault }, + set: { onToggleDefault($0) } + ) + ) + } + + if command.remote == nil { + Section(String( + localized: "settings.workspaces.section.local", + defaultValue: "Local" + )) { + let programBinding = Binding( + get: { command.program ?? "" }, + set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + command.program = trimmed.isEmpty ? nil : trimmed + } + ) + TextField( + String( + localized: "settings.workspaces.field.program", + defaultValue: "Program" + ), + text: programBinding, + prompt: Text(verbatim: "/bin/zsh -l") + ) + Text(String( + localized: "settings.workspaces.field.program.help", + defaultValue: "Optional. Leave empty to use your default shell. Provide a full path (e.g. /opt/homebrew/bin/fish) or a command with arguments. The pane closes automatically when the program exits." + )) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Section(String(localized: "settings.workspaces.section.remote", defaultValue: "Remote (SSH)")) { + Toggle( + String( + localized: "settings.workspaces.field.remoteEnabled", + defaultValue: "Connect via SSH" + ), + isOn: Binding( + get: { command.remote != nil }, + set: { enabled in + if enabled, command.remote == nil { + command.remote = WorkspaceCommandConfig.Remote() + } else if !enabled { + command.remote = nil + } + } + ) + ) + + if command.remote != nil { + remoteFields + } + } + + Section { + Button(role: .destructive, action: onDelete) { + Text(String( + localized: "settings.workspaces.deleteCommand", + defaultValue: "Delete Workspace Command" + )) + } + } + } + .formStyle(.grouped) + .padding(.top, 4) + } + + @ViewBuilder + private var remoteFields: some View { + let hostBinding = Binding( + get: { command.remote?.host ?? "" }, + set: { command.remote?.host = $0 } + ) + TextField( + String(localized: "settings.workspaces.field.host", defaultValue: "Host"), + text: hostBinding, + prompt: Text(verbatim: "user@host.example.com") + ) + + let portBinding = Binding( + get: { command.remote?.port.map(String.init) ?? "" }, + set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + command.remote?.port = nil + } else if let port = Int(trimmed), (1...65535).contains(port) { + command.remote?.port = port + } + } + ) + TextField( + String(localized: "settings.workspaces.field.port", defaultValue: "Port"), + text: portBinding, + prompt: Text(verbatim: "22") + ) + + let identityBinding = Binding( + get: { command.remote?.identityFile ?? "" }, + set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + command.remote?.identityFile = trimmed.isEmpty ? nil : trimmed + } + ) + TextField( + String(localized: "settings.workspaces.field.identityFile", defaultValue: "Identity file"), + text: identityBinding, + prompt: Text(verbatim: "~/.ssh/id_ed25519") + ) + + let optionsBinding = Binding( + get: { (command.remote?.sshOptions ?? []).joined(separator: "\n") }, + set: { newValue in + let lines = newValue + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + command.remote?.sshOptions = lines + } + ) + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "settings.workspaces.field.sshOptions", defaultValue: "SSH options (one per line)")) + .font(.subheadline) + TextEditor(text: optionsBinding) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 100) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + + let startupBinding = Binding( + get: { command.remote?.startupCommand ?? "" }, + set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + command.remote?.startupCommand = trimmed.isEmpty ? nil : trimmed + } + ) + TextField( + String(localized: "settings.workspaces.field.startupCommand", defaultValue: "Startup command"), + text: startupBinding, + prompt: Text(verbatim: "tmux attach || tmux new") + ) + } +} diff --git a/Sources/Settings/WorkspaceCommandsStore.swift b/Sources/Settings/WorkspaceCommandsStore.swift new file mode 100644 index 000000000000..1322a2d4e3cf --- /dev/null +++ b/Sources/Settings/WorkspaceCommandsStore.swift @@ -0,0 +1,331 @@ +import AppKit +import Combine +import Foundation + +/// Single source of truth for the named workspace commands surfaced by the +/// titlebar `+` picker, the command palette, and Cmd+N. Persisted to +/// UserDefaults as a single JSON blob so we can evolve the shape without +/// schema migrations across many keys. There is intentionally no JSON-file +/// fallback: workspace commands live in UserDefaults and are edited from the +/// Preferences UI. +@MainActor +final class WorkspaceCommandsStore: ObservableObject { + static let shared = WorkspaceCommandsStore() + + static let didChange = Notification.Name("cmux.workspaceCommandsStore.didChange") + + private static let storageKey = "cmux.workspaceCommands.v1" + + /// Full command list surfaced to the UI: the built-in `Local` entry + /// always comes first, followed by any commands the user added. + var commands: [WorkspaceCommandConfig] { + [Self.builtInLocal] + userCommands + } + /// User-added commands. Only these are persisted; the built-in `Local` + /// command is synthesized at runtime so it can never be deleted. + @Published private(set) var userCommands: [WorkspaceCommandConfig] = [] + /// Identifier of the command treated as the default for Cmd+N. `nil` + /// resolves to the built-in `Local` command. + @Published private(set) var defaultCommandID: WorkspaceCommandConfig.ID? + + private let defaults: UserDefaults + private var suppressPersist = false + + /// Stable identifier for the built-in `Local` command so persisted + /// `defaultCommandID` references survive across launches. + static let builtInLocalID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + + static var builtInLocal: WorkspaceCommandConfig { + WorkspaceCommandConfig( + id: builtInLocalID, + name: String( + localized: "settings.workspaces.builtIn.local.name", + defaultValue: "Local" + ), + color: nil, + restart: .always, + remote: nil + ) + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } + + func command(id: WorkspaceCommandConfig.ID) -> WorkspaceCommandConfig? { + commands.first(where: { $0.id == id }) + } + + /// Returns true when the given identifier refers to a built-in command + /// the user can't rename or delete. + func isBuiltIn(id: WorkspaceCommandConfig.ID) -> Bool { + id == Self.builtInLocalID + } + + func defaultCommand() -> WorkspaceCommandConfig? { + if let id = defaultCommandID, let match = command(id: id) { + return match + } + return Self.builtInLocal + } + + /// Resets the store to its empty/built-in default state. + func restoreDefaults() { + defaultCommandID = nil + applyUserCommands([]) + } + + func addCommand() -> WorkspaceCommandConfig { + let new = WorkspaceCommandConfig( + id: UUID(), + name: defaultNewCommandName(), + color: nil, + restart: .always, + remote: nil + ) + var updated = userCommands + updated.append(new) + applyUserCommands(updated) + return new + } + + func update(_ command: WorkspaceCommandConfig) { + guard !isBuiltIn(id: command.id) else { return } + guard let index = userCommands.firstIndex(where: { $0.id == command.id }) else { return } + var updated = userCommands + updated[index] = command + applyUserCommands(updated) + } + + func remove(id: WorkspaceCommandConfig.ID) { + guard !isBuiltIn(id: id) else { return } + var updated = userCommands + updated.removeAll(where: { $0.id == id }) + applyUserCommands(updated) + if defaultCommandID == id { + setDefault(id: nil) + } + } + + func move(fromOffsets source: IndexSet, toOffset destination: Int) { + // Convert offsets from the surfaced list (built-in first) into the + // persisted user-list. Drop any move that touches the built-in row; + // it's pinned to the top. + let builtInIndex = 0 + var translatedSource = IndexSet() + for offset in source where offset != builtInIndex { + translatedSource.insert(offset - 1) + } + guard !translatedSource.isEmpty else { return } + let translatedDestination = max(destination - 1, 0) + var updated = userCommands + updated.move(fromOffsets: translatedSource, toOffset: translatedDestination) + applyUserCommands(updated) + } + + func setDefault(id: WorkspaceCommandConfig.ID?) { + let sanitized: WorkspaceCommandConfig.ID? + switch id { + case nil: + sanitized = nil + case let id? where id == Self.builtInLocalID + || userCommands.contains(where: { $0.id == id }): + sanitized = id + default: + sanitized = nil + } + guard defaultCommandID != sanitized else { return } + defaultCommandID = sanitized + persist() + } + + private func defaultNewCommandName() -> String { + let base = String(localized: "settings.workspaces.newCommand.defaultName", defaultValue: "New Workspace") + let existing = Set(commands.map(\.name)) + if !existing.contains(base) { return base } + var n = 2 + while true { + let candidate = String( + format: String( + localized: "settings.workspaces.newCommand.numberedName", + defaultValue: "%@ %lld" + ), + base, + Int64(n) + ) + if !existing.contains(candidate) { return candidate } + n += 1 + } + } + + private func applyUserCommands(_ next: [WorkspaceCommandConfig]) { + userCommands = next + if let defaultID = defaultCommandID, + defaultID != Self.builtInLocalID, + !next.contains(where: { $0.id == defaultID }) { + defaultCommandID = nil + } + persist() + } + + private func persist() { + guard !suppressPersist else { return } + let snapshot = StoredSnapshot( + userCommands: userCommands, + defaultCommandID: defaultCommandID + ) + if let data = try? JSONEncoder().encode(snapshot) { + defaults.set(data, forKey: Self.storageKey) + } + NotificationCenter.default.post(name: Self.didChange, object: self) + } + + private func load() { + guard let data = defaults.data(forKey: Self.storageKey) else { return } + guard let snapshot = try? JSONDecoder().decode(StoredSnapshot.self, from: data) else { return } + suppressPersist = true + // Drop any persisted entry colliding with the built-in Local ID and + // any default that no longer points to a real user command. + let sanitizedUserCommands = snapshot.userCommands.filter { $0.id != Self.builtInLocalID } + userCommands = sanitizedUserCommands + if let id = snapshot.defaultCommandID, + id != Self.builtInLocalID, + sanitizedUserCommands.contains(where: { $0.id == id }) { + defaultCommandID = id + } else { + defaultCommandID = nil + } + suppressPersist = false + } + + private struct StoredSnapshot: Codable { + var userCommands: [WorkspaceCommandConfig] + var defaultCommandID: WorkspaceCommandConfig.ID? + } +} + +struct WorkspaceCommandConfig: Codable, Identifiable, Equatable, Sendable { + var id: UUID + var name: String + /// Tab/title color as `#RRGGBB`. Optional. + var color: String? + var restart: Restart + /// Program to run as the surface's child process for *non-remote* commands. + /// Empty/nil falls back to Ghostty's default (the user's login shell or + /// whatever `command =` is set to in `~/.config/ghostty/config`). Ignored + /// when `remote != nil` — the SSH invocation always wins. + var program: String? + var remote: Remote? + + init( + id: UUID, + name: String, + color: String? = nil, + restart: Restart = .always, + program: String? = nil, + remote: Remote? = nil + ) { + self.id = id + self.name = name + self.color = color + self.restart = restart + self.program = program + self.remote = remote + } + + enum Restart: String, Codable, Sendable, CaseIterable, Identifiable { + case always + case ignore + case recreate + case confirm + + var id: String { rawValue } + + var displayName: String { + switch self { + case .always: + return String(localized: "settings.workspaces.restart.always", defaultValue: "Always create new") + case .ignore: + return String(localized: "settings.workspaces.restart.ignore", defaultValue: "Reuse existing") + case .recreate: + return String(localized: "settings.workspaces.restart.recreate", defaultValue: "Replace existing") + case .confirm: + return String(localized: "settings.workspaces.restart.confirm", defaultValue: "Ask before replacing") + } + } + } + + struct Remote: Codable, Equatable, Sendable { + var host: String + var port: Int? + var identityFile: String? + var sshOptions: [String] + var startupCommand: String? + + init( + host: String = "", + port: Int? = nil, + identityFile: String? = nil, + sshOptions: [String] = [], + startupCommand: String? = nil + ) { + self.host = host + self.port = port + self.identityFile = identityFile + self.sshOptions = sshOptions + self.startupCommand = startupCommand + } + } +} + +extension WorkspaceCommandConfig { + /// Project the UserDefaults-backed configuration into the existing + /// `CmuxCommandDefinition` shape so the existing executor pipeline can run + /// it without changes. + func asCmuxCommandDefinition() -> CmuxCommandDefinition { + let trimmedProgram = program?.trimmingCharacters(in: .whitespacesAndNewlines) + let workspace = CmuxWorkspaceDefinition( + name: name, + cwd: nil, + color: color, + layout: nil, + remote: remote.map { remote in + let host = remote.host.trimmingCharacters(in: .whitespacesAndNewlines) + let identityFile = remote.identityFile? + .trimmingCharacters(in: .whitespacesAndNewlines) + let startupCommand = remote.startupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + let sshOptions = remote.sshOptions + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return CmuxRemoteDefinition( + host: host, + port: remote.port, + identityFile: (identityFile?.isEmpty == false) ? identityFile : nil, + sshOptions: sshOptions.isEmpty ? nil : sshOptions, + startupCommand: (startupCommand?.isEmpty == false) ? startupCommand : nil + ) + }, + program: (trimmedProgram?.isEmpty == false) ? trimmedProgram : nil + ) + let restartBehavior: CmuxRestartBehavior = { + switch restart { + case .always: return .always + case .ignore: return .ignore + case .recreate: return .recreate + case .confirm: return .confirm + } + }() + return CmuxCommandDefinition( + name: name, + description: nil, + keywords: nil, + restart: restartBehavior, + workspace: workspace, + command: nil, + confirm: nil + ) + } +} + diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index bcd4c201a42d..ed58c4a5a31d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1994,7 +1994,8 @@ class TabManager: ObservableObject { configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, initialTerminalInput: String? = nil, - initialTerminalEnvironment: [String: String] + initialTerminalEnvironment: [String: String], + closePanesOnInitialCommandExit: Bool = false ) -> Workspace { Workspace( title: title, @@ -2003,7 +2004,8 @@ class TabManager: ObservableObject { configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, initialTerminalInput: initialTerminalInput, - initialTerminalEnvironment: initialTerminalEnvironment + initialTerminalEnvironment: initialTerminalEnvironment, + closePanesOnInitialCommandExit: closePanesOnInitialCommandExit ) } @@ -2072,7 +2074,8 @@ class TabManager: ObservableObject { select: Bool = true, eagerLoadTerminal: Bool = false, placementOverride: NewWorkspacePlacement? = nil, - autoWelcomeIfNeeded: Bool = true + autoWelcomeIfNeeded: Bool = true, + closePanesOnInitialCommandExit: Bool = false ) -> Workspace { let sourceWorkspace = selectedWorkspace let capturedTabs = tabs @@ -2116,7 +2119,8 @@ class TabManager: ObservableObject { configTemplate: inheritedConfig, initialTerminalCommand: initialTerminalCommand, initialTerminalInput: initialTerminalInput, - initialTerminalEnvironment: initialTerminalEnvironment + initialTerminalEnvironment: initialTerminalEnvironment, + closePanesOnInitialCommandExit: closePanesOnInitialCommandExit ) applyCreationChromeInheritance( to: newWorkspace, diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index fe1c9b1ac0c1..889dbd7ac781 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -347,6 +347,181 @@ func titlebarShortcutHintVerticalOffset(for config: TitlebarControlsStyleConfig) max(0, floor(config.buttonSize - titlebarShortcutHintHeight(for: config))) } +/// Split button: the `+` half runs the default new-workspace action; +/// clicking the chevron opens an NSMenu listing every workspace command from +/// the user's store. Backed by AppKit so the menu rebuilds via +/// `menuNeedsUpdate(_:)` whenever the user opens it — the previous SwiftUI +/// `Menu` inside the titlebar's detached `NSHostingView` did not reliably +/// re-render in response to `WorkspaceCommandsStore` mutations. +struct NewWorkspaceTitlebarControl: NSViewRepresentable { + let config: TitlebarControlsStyleConfig + let onNewTab: () -> Void + let onOpenManager: () -> Void + + func makeNSView(context: Context) -> NewWorkspaceSplitButtonView { + let view = NewWorkspaceSplitButtonView(config: config) + view.onNewTab = onNewTab + view.onOpenManager = onOpenManager + return view + } + + func updateNSView(_ nsView: NewWorkspaceSplitButtonView, context: Context) { + nsView.applyConfig(config) + nsView.onNewTab = onNewTab + nsView.onOpenManager = onOpenManager + } +} + +/// AppKit-backed `+` button with an attached chevron that opens an `NSMenu`. +/// SwiftUI `Menu` inside the titlebar accessory's `NSHostingView` was not +/// reliably re-rendering when `WorkspaceCommandsStore` changed (singleton +/// observation in detached hosting views is fragile). `NSMenu` rebuilds its +/// items every time the user opens it via `menuNeedsUpdate(_:)`, so the list +/// is always in sync with the current store. +final class NewWorkspaceSplitButtonView: NSView, NSMenuDelegate { + var onNewTab: (() -> Void)? + var onOpenManager: (() -> Void)? + + private var styleConfig: TitlebarControlsStyleConfig + private let primaryButton = NSButton() + private let chevronButton = NSButton() + private let dynamicMenu = NSMenu() + + init(config: TitlebarControlsStyleConfig) { + self.styleConfig = config + super.init(frame: .zero) + wantsLayer = true + translatesAutoresizingMaskIntoConstraints = false + + primaryButton.translatesAutoresizingMaskIntoConstraints = false + primaryButton.bezelStyle = .accessoryBarAction + primaryButton.isBordered = false + primaryButton.image = NSImage( + systemSymbolName: "plus", + accessibilityDescription: String( + localized: "titlebar.newWorkspace.accessibilityLabel", + defaultValue: "New Workspace" + ) + ) + primaryButton.imageScaling = .scaleProportionallyDown + primaryButton.target = self + primaryButton.action = #selector(primaryTapped) + primaryButton.toolTip = String( + localized: "titlebar.newWorkspace.tooltip", + defaultValue: "New workspace" + ) + addSubview(primaryButton) + + chevronButton.translatesAutoresizingMaskIntoConstraints = false + chevronButton.bezelStyle = .accessoryBarAction + chevronButton.isBordered = false + chevronButton.image = NSImage( + systemSymbolName: "chevron.down", + accessibilityDescription: String( + localized: "titlebar.newWorkspace.menu.accessibilityLabel", + defaultValue: "Pick Workspace Command" + ) + ) + chevronButton.imageScaling = .scaleProportionallyDown + chevronButton.target = self + chevronButton.action = #selector(chevronTapped) + addSubview(chevronButton) + + dynamicMenu.delegate = self + dynamicMenu.autoenablesItems = false + + applyConfig(config) + rebuildLayoutConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func applyConfig(_ config: TitlebarControlsStyleConfig) { + styleConfig = config + let pointSize = config.iconSize + let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold) + primaryButton.symbolConfiguration = symbolConfig + chevronButton.symbolConfiguration = NSImage.SymbolConfiguration( + pointSize: max(7, pointSize - 4), + weight: .semibold + ) + } + + private func rebuildLayoutConstraints() { + let buttonSize = styleConfig.buttonSize + let chevronWidth: CGFloat = 14 + NSLayoutConstraint.activate([ + primaryButton.leadingAnchor.constraint(equalTo: leadingAnchor), + primaryButton.topAnchor.constraint(equalTo: topAnchor), + primaryButton.bottomAnchor.constraint(equalTo: bottomAnchor), + primaryButton.widthAnchor.constraint(equalToConstant: buttonSize), + + chevronButton.leadingAnchor.constraint(equalTo: primaryButton.trailingAnchor, constant: -2), + chevronButton.topAnchor.constraint(equalTo: topAnchor), + chevronButton.bottomAnchor.constraint(equalTo: bottomAnchor), + chevronButton.widthAnchor.constraint(equalToConstant: chevronWidth), + chevronButton.trailingAnchor.constraint(equalTo: trailingAnchor), + + heightAnchor.constraint(equalToConstant: buttonSize), + ]) + } + + @objc private func primaryTapped() { + #if DEBUG + cmuxDebugLog("titlebar.newTab") + #endif + onNewTab?() + } + + @objc private func chevronTapped() { + let location = NSPoint(x: 0, y: bounds.height + 2) + dynamicMenu.popUp(positioning: nil, at: location, in: self) + } + + // MARK: - NSMenuDelegate + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + let commands = WorkspaceCommandsStore.shared.commands + for command in commands { + let item = NSMenuItem( + title: command.name, + action: #selector(menuItemSelected(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = command.id + menu.addItem(item) + } + menu.addItem(NSMenuItem.separator()) + let manage = NSMenuItem( + title: String( + localized: "titlebar.newWorkspace.menu.manage", + defaultValue: "Manage Workspaces…" + ), + action: #selector(manageTapped), + keyEquivalent: "" + ) + manage.target = self + menu.addItem(manage) + } + + @objc private func menuItemSelected(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? UUID else { return } + AppDelegate.shared?.performWorkspaceCommand( + id: id, + debugSource: "titlebar.workspaceCommandMenu" + ) + } + + @objc private func manageTapped() { + onOpenManager?() + } +} + struct TitlebarControlButton: View { let config: TitlebarControlsStyleConfig let accessibilityIdentifier: String @@ -541,18 +716,18 @@ struct TitlebarControlsView: View { .background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }) .safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications"))) - TitlebarControlButton( + NewWorkspaceTitlebarControl( config: config, - accessibilityIdentifier: "titlebarControl.newTab", - accessibilityLabel: String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"), - action: { - #if DEBUG - cmuxDebugLog("titlebar.newTab") - #endif - onNewTab() - }) { - iconLabel(systemName: "plus", config: config) - } + onNewTab: onNewTab, + onOpenManager: { + AppDelegate.shared?.openWorkspaceCommandsWindow( + debugSource: "titlebar.workspaceCommandMenu" + ) + } + ) + .frame(width: config.buttonSize + 14, height: config.buttonSize) + .accessibilityIdentifier("titlebarControl.newTab") + .accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace")) .safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace"))) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5e3df718044a..19799fca62c2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7202,6 +7202,11 @@ final class Workspace: Identifiable, ObservableObject { @Published var customTitle: String? @Published var customDescription: String? @Published var isPinned: Bool = false + /// When true, terminal panes auto-close after the initial command exits + /// instead of holding the PTY open with a "Process exited" prompt. Set by + /// `CmuxConfigExecutor` for config-driven remote workspaces; the cmux ssh / + /// vm new flows leave it false so they keep showing the SSH exit message. + var closePanesOnInitialCommandExit: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" @Published private(set) var terminalScrollBarHidden: Bool = false @Published var currentDirectory: String @@ -7770,8 +7775,10 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: CmuxSurfaceConfigTemplate? = nil, initialTerminalCommand: String? = nil, initialTerminalInput: String? = nil, - initialTerminalEnvironment: [String: String] = [:] + initialTerminalEnvironment: [String: String] = [:], + closePanesOnInitialCommandExit: Bool = false ) { + self.closePanesOnInitialCommandExit = closePanesOnInitialCommandExit self.id = UUID() self.portOrdinal = portOrdinal self.processTitle = title @@ -7825,7 +7832,7 @@ final class Workspace: Identifiable, ObservableObject { if let trimmedCommand = initialTerminalCommand?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmedCommand.isEmpty { var template = resolvedConfigTemplate ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true + template.waitAfterCommand = !closePanesOnInitialCommandExit resolvedConfigTemplate = template } @@ -9897,7 +9904,7 @@ final class Workspace: Identifiable, ObservableObject { // local prompt — which is what we saw during dogfood. if remoteTerminalStartupCommand != nil { var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true + template.waitAfterCommand = !closePanesOnInitialCommandExit inheritedConfig = template } #if DEBUG @@ -10050,7 +10057,7 @@ final class Workspace: Identifiable, ObservableObject { // local login shell. if remoteTerminalStartupCommand != nil { var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true + template.waitAfterCommand = !closePanesOnInitialCommandExit inheritedConfig = template } @@ -12382,7 +12389,13 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: String?, initialInput: String? ) -> TerminalPanel? { - let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + var inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let remoteStartupCommand = remoteTerminalStartupCommand() + if remoteStartupCommand != nil { + var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() + template.waitAfterCommand = !closePanesOnInitialCommandExit + inheritedConfig = template + } let newPanel = TerminalPanel( workspaceId: id, @@ -12390,11 +12403,15 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, workingDirectory: workingDirectory, portOrdinal: portOrdinal, + initialCommand: remoteStartupCommand, initialInput: initialInput ) configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) let newTab = Bonsplit.Tab( @@ -12417,6 +12434,9 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + if remoteStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e122cd85cc86..1565e3b48111 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -192,6 +192,9 @@ struct cmuxApp: App { SettingsWindowPresenter.configure { openWindow(id: SettingsWindowPresenter.windowID) } + WorkspaceCommandsWindowPresenter.configure { + openWindow(id: WorkspaceCommandsSettingsView.windowID) + } #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { UpdateLogStore.shared.append("ui test: cmuxApp onAppear") @@ -755,6 +758,15 @@ struct cmuxApp: App { Window(String(localized: "settings.config.windowTitle", defaultValue: "Config"), id: ConfigSettingsView.windowID) { ConfigSettingsView() } + + Window( + String(localized: "settings.workspaces.windowTitle", defaultValue: "Workspaces"), + id: WorkspaceCommandsSettingsView.windowID + ) { + WorkspaceCommandsSettingsView() + } + .defaultSize(width: 760, height: 500) + .windowResizability(.contentMinSize) } private func showAboutPanel() { @@ -6398,6 +6410,11 @@ struct SettingsView: View { .disabled(sidebarHideAllDetails) } + SettingsSectionHeader(title: String(localized: "settings.section.workspaces", defaultValue: "Workspaces")) + SettingsCard { + WorkspaceCommandsSettingsRow(openWindow: openWindow) + } + SettingsSectionHeader(title: String(localized: "settings.section.terminal", defaultValue: "Terminal")) .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .terminal)) SettingsCard { @@ -7529,6 +7546,93 @@ private struct SettingsSectionHeader: View { } } +/// Presenter for the Workspaces editor `Window` scene. Mirrors the existing +/// `SettingsWindowPresenter` pattern: the main `WindowGroup`'s onAppear hands +/// in an `openWindow(id:)` closure once, and `AppDelegate.openWorkspaceCommandsWindow` +/// (or anything else) calls `show()` to invoke it. Avoids attaching a view +/// modifier that would change the WindowGroup's content type identity, which +/// silently breaks SwiftUI's scene routing and per-window frame persistence. +@MainActor +enum WorkspaceCommandsWindowPresenter { + private static var openWindow: (@MainActor () -> Void)? + private static var shouldOpenWhenConfigured = false + + static func configure(openWindow: @escaping @MainActor () -> Void) { + self.openWindow = openWindow + if shouldOpenWhenConfigured { + shouldOpenWhenConfigured = false + openWindow() + } + } + + static func show() { + guard let openWindow else { + shouldOpenWhenConfigured = true + return + } + openWindow() + } +} + +private struct WorkspaceCommandsSettingsRow: View { + let openWindow: OpenWindowAction + @ObservedObject private var store = WorkspaceCommandsStore.shared + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(String( + localized: "settings.workspaces.row.title", + defaultValue: "Workspace commands" + )) + .font(.system(size: 13, weight: .medium)) + Text(summary) + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + Button(String( + localized: "settings.workspaces.row.manage", + defaultValue: "Manage Workspaces…" + )) { + openWindow(id: WorkspaceCommandsSettingsView.windowID) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private var summary: String { + if store.userCommands.isEmpty { + return String( + localized: "settings.workspaces.row.summary.empty", + defaultValue: "No commands yet. Add a Local or Remote (SSH) workspace to launch from Cmd-N or the titlebar +." + ) + } + let count = store.commands.count + let defaultName = store.defaultCommand()?.name + let countLabel = String( + format: String( + localized: "settings.workspaces.row.summary.count", + defaultValue: "%d configured" + ), + count + ) + if let defaultName, !defaultName.isEmpty { + return countLabel + " · " + String( + format: String( + localized: "settings.workspaces.row.summary.default", + defaultValue: "Default: %@" + ), + defaultName + ) + } + return countLabel + } +} + private struct AuthSettingsRow: View { @ObservedObject var authManager: AuthManager diff --git a/cmuxTests/CmuxConfigTests.swift b/cmuxTests/CmuxConfigTests.swift index 736e24e89e03..77483af932fa 100644 --- a/cmuxTests/CmuxConfigTests.swift +++ b/cmuxTests/CmuxConfigTests.swift @@ -1045,7 +1045,7 @@ final class CmuxConfigDecodingTests: XCTestCase { } func testDecodeRestartBehaviors() throws { - for behavior in ["recreate", "ignore", "confirm"] { + for behavior in ["recreate", "ignore", "confirm", "always"] { let json = """ { "commands": [{