Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -277,6 +279,8 @@
2F0C05000000000000000001 /* MainWindowFocusController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowFocusController.swift; sourceTree = "<group>"; };
3023B1003023B1003023B100 /* ConfigSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSource.swift; sourceTree = "<group>"; };
3023B1013023B1013023B101 /* ConfigSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSettingsView.swift; sourceTree = "<group>"; };
3023B1023023B1023023B102 /* WorkspaceCommandsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/WorkspaceCommandsStore.swift; sourceTree = "<group>"; };
3023B1033023B1033023B103 /* WorkspaceCommandsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/WorkspaceCommandsSettingsView.swift; sourceTree = "<group>"; };
312DE7503B4658DD173121B8 /* AuthManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
FEED0000000000000000F001 /* FeedCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedCoordinator.swift; sourceTree = "<group>"; };
FEED0000000000000000F004 /* FeedPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
118 changes: 104 additions & 14 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5851,13 +5851,38 @@ 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(
tabManager: manager,
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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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?
Expand Down
75 changes: 75 additions & 0 deletions Sources/CmuxConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading