diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index a5a714cddff9..2dec4e974710 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -83,6 +83,8 @@ A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; A5001652 /* CmuxConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* CmuxConfigExecutor.swift */; }; A5001654 /* CmuxDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxDirectoryTrust.swift */; }; + A5001658 /* KeybindingsConfigFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001656 /* KeybindingsConfigFile.swift */; }; + A5001659 /* CustomCommandStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001657 /* CustomCommandStore.swift */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; A5007422 /* BrowserWebAuthnSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007423 /* BrowserWebAuthnSupport.swift */; }; @@ -297,6 +299,8 @@ A5001651 /* CmuxConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfig.swift; sourceTree = ""; }; A5001653 /* CmuxConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigExecutor.swift; sourceTree = ""; }; A5001655 /* CmuxDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxDirectoryTrust.swift; sourceTree = ""; }; + A5001656 /* KeybindingsConfigFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindingsConfigFile.swift; sourceTree = ""; }; + A5001657 /* CustomCommandStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommandStore.swift; sourceTree = ""; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = ""; }; A5007423 /* BrowserWebAuthnSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebAuthnSupport.swift; sourceTree = ""; }; @@ -485,6 +489,8 @@ A50012F0 /* Backport.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, + A5001656 /* KeybindingsConfigFile.swift */, + A5001657 /* CustomCommandStore.swift */, A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, A5001511 /* UITestRecorder.swift */, @@ -845,6 +851,8 @@ A50012F1 /* Backport.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, + A5001658 /* KeybindingsConfigFile.swift in Sources */, + A5001659 /* CustomCommandStore.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 794a306b6c01..287b32dddef2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2584,6 +2584,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + _ = CustomCommandStore.shared + if telemetryEnabled { // Pre-warm locale before Sentry to avoid a startup data race. // Locale initialization (os.locale.ensureLocale / NSLocale._preferredLanguages) @@ -11441,6 +11443,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + let customCommand = KeyboardShortcutSettings.matchingCustomCommand(for: event) { + return tabManager?.openSurfaceAndRunCommand( + target: customCommand.target, + cwd: customCommand.resolvedWorkingDirectory, + command: customCommand.command, + customCommandID: customCommand.id + ) ?? false + } + // Open browser: Cmd+Shift+L if matchConfiguredShortcut(event: event, action: .openBrowser) { _ = openBrowserAndFocusAddressBar(insertAtEnd: true) diff --git a/Sources/CustomCommandStore.swift b/Sources/CustomCommandStore.swift new file mode 100644 index 000000000000..62717563f916 --- /dev/null +++ b/Sources/CustomCommandStore.swift @@ -0,0 +1,103 @@ +import AppKit +import Foundation + +final class CustomCommandStore { + static let shared = CustomCommandStore() + + private struct ResolvedBinding { + let binding: CustomCommandBinding + let shortcut: StoredShortcut + } + + private let fileManager: FileManager + private let path: String + private var watcher: ShortcutSettingsFileWatcher? + private var bindings: [ResolvedBinding] = [] + + init( + fileManager: FileManager = .default, + path: String? = nil + ) { + self.fileManager = fileManager + self.path = path ?? Self.defaultPath(fileManager: fileManager) + watcher = ShortcutSettingsFileWatcher(path: self.path, fileManager: fileManager) { [weak self] in + DispatchQueue.main.async { + self?.reload() + } + } + reload() + } + + func reload() { + guard fileManager.fileExists(atPath: path), + let data = fileManager.contents(atPath: path), + !data.isEmpty else { + bindings = [] + return + } + + do { + let sanitized = try JSONCParser.preprocess(data: data) + let schema = try JSONDecoder().decode(KeybindingsConfigFile.Schema.self, from: sanitized) + bindings = resolveBindings(schema.custom_commands ?? []) + } catch { + NSLog("[CustomCommandStore] parse error at %@: %@", path, String(describing: error)) + bindings = [] + } + } + + func matchingCommand(for event: NSEvent) -> CustomCommandBinding? { + bindings.first { $0.shortcut.matches(event: event) }?.binding + } + + private func resolveBindings(_ rawBindings: [CustomCommandBinding]) -> [ResolvedBinding] { + var seenIDs = Set() + var resolved: [ResolvedBinding] = [] + + for binding in rawBindings { + let id = binding.id.trimmingCharacters(in: .whitespacesAndNewlines) + let shortcutString = binding.shortcut.trimmingCharacters(in: .whitespacesAndNewlines) + let command = binding.command.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !id.isEmpty else { + NSLog("[CustomCommandStore] ignoring custom command with empty id in %@", path) + continue + } + guard seenIDs.insert(id).inserted else { + NSLog("[CustomCommandStore] ignoring duplicate custom command id '%@' in %@", id, path) + continue + } + guard !shortcutString.isEmpty, + let shortcut = StoredShortcut.parse(rawValue: shortcutString), + !shortcut.hasChord else { + NSLog("[CustomCommandStore] ignoring custom command '%@' with invalid shortcut '%@' in %@", id, binding.shortcut, path) + continue + } + guard !command.isEmpty else { + NSLog("[CustomCommandStore] ignoring custom command '%@' with empty command in %@", id, path) + continue + } + + resolved.append( + ResolvedBinding( + binding: CustomCommandBinding( + id: id, + shortcut: shortcutString, + command: command, + label: binding.label?.trimmingCharacters(in: .whitespacesAndNewlines), + target: binding.target, + cwd: binding.cwd + ), + shortcut: shortcut + ) + ) + } + + return resolved + } + + private static func defaultPath(fileManager: FileManager) -> String { + let home = fileManager.homeDirectoryForCurrentUser.path + return (home as NSString).appendingPathComponent(".config/cmux/keybindings.json") + } +} diff --git a/Sources/KeybindingsConfigFile.swift b/Sources/KeybindingsConfigFile.swift new file mode 100644 index 000000000000..e79710d143d9 --- /dev/null +++ b/Sources/KeybindingsConfigFile.swift @@ -0,0 +1,66 @@ +import Foundation + +enum KeybindingsConfigFile { + struct Schema: Codable { + var version: Int? + var custom_commands: [CustomCommandBinding]? + } +} + +struct CustomCommandBinding: Codable, Equatable, Identifiable { + var id: String + var shortcut: String + var command: String + var label: String? + var target: CustomCommandTarget + var cwd: CustomCommandWorkingDirectory? + + var resolvedWorkingDirectory: CustomCommandWorkingDirectory { + cwd ?? .workspace + } +} + +enum CustomCommandTarget: String, Codable, Equatable { + case splitRight = "split_right" + case splitDown = "split_down" + case newSurface = "new_surface" + case newTab = "new_tab" + case newWorkspace = "new_workspace" +} + +enum CustomCommandWorkingDirectory: Codable, Equatable { + case workspace + case pane + case absolutePath(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + switch rawValue { + case "workspace": + self = .workspace + case "pane": + self = .pane + default: + guard rawValue.hasPrefix("/") else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "cwd must be \"workspace\", \"pane\", or an absolute path" + ) + } + self = .absolutePath(rawValue) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .workspace: + try container.encode("workspace") + case .pane: + try container.encode("pane") + case .absolutePath(let path): + try container.encode(path) + } + } +} diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index effd8a3a4717..d57688d85693 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -8,11 +8,13 @@ enum KeyboardShortcutSettings { static let didChangeNotification = Notification.Name("cmux.keyboardShortcutSettingsDidChange") static let actionUserInfoKey = "action" static let settingsFileDisplayPath = "~/.config/cmux/settings.json" + static let keybindingsFileDisplayPath = "~/.config/cmux/keybindings.json" static var settingsFileStore: KeyboardShortcutSettingsFileStore = .shared { didSet { notifySettingsFileDidChange() } } + static var customCommandStore: CustomCommandStore = .shared enum Action: String, CaseIterable, Identifiable { // App / window @@ -407,6 +409,10 @@ enum KeyboardShortcutSettings { return String(localized: "settings.shortcuts.managedByFile", defaultValue: "Managed in settings.json") } + static func matchingCustomCommand(for event: NSEvent) -> CustomCommandBinding? { + customCommandStore.matchingCommand(for: event) + } + static func setShortcut(_ shortcut: StoredShortcut, for action: Action) { guard !isManagedBySettingsFile(action) else { return } @@ -1009,6 +1015,46 @@ struct ShortcutStroke: Equatable { return stroke } + static func parse(rawValue: String) -> ShortcutStroke? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(separator: "+", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !parts.isEmpty, let lastPart = parts.last, !lastPart.isEmpty else { + return nil + } + + var command = false + var shift = false + var option = false + var control = false + + for modifier in parts.dropLast() { + switch modifier.lowercased() { + case "cmd", "command", "⌘": + command = true + case "shift", "⇧": + shift = true + case "opt", "option", "alt", "⌥": + option = true + case "ctrl", "control", "ctl", "⌃": + control = true + default: + return nil + } + } + + guard let key = parseKeyToken(String(lastPart)) else { return nil } + return ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + } + static func normalizedModifierFlags(from flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { flags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) @@ -1137,6 +1183,52 @@ struct ShortcutStroke: Equatable { return nil } + static func parseKeyToken(_ rawValue: String) -> String? { + let lowered = rawValue.lowercased() + switch lowered { + case "left", "arrowleft", "leftarrow", "←": + return "←" + case "right", "arrowright", "rightarrow", "→": + return "→" + case "up", "arrowup", "uparrow", "↑": + return "↑" + case "down", "arrowdown", "downarrow", "↓": + return "↓" + case "tab": + return "\t" + case "return", "enter", "↩": + return "\r" + case "space": + return " " + case "comma": + return "," + case "period", "dot": + return "." + case "slash": + return "/" + case "backslash": + return "\\" + case "semicolon": + return ";" + case "quote", "apostrophe": + return "'" + case "backtick", "grave": + return "`" + case "minus", "hyphen": + return "-" + case "plus", "equals": + return "=" + case "leftbracket", "openbracket": + return "[" + case "rightbracket", "closebracket": + return "]" + default: + let normalized = lowered.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count == 1 else { return nil } + return normalized + } + } + static func normalizedShortcutEventCharacter( _ eventCharacter: String, applyShiftSymbolNormalization: Bool, @@ -1492,6 +1584,21 @@ struct StoredShortcut: Codable, Equatable { return StoredShortcut(first: stroke) } + static func parse(rawValue: String) -> StoredShortcut? { + parse(strokes: [rawValue]) + } + + static func parse(strokes: [String]) -> StoredShortcut? { + guard !strokes.isEmpty, strokes.count <= 2 else { return nil } + let parsedStrokes = strokes.compactMap(ShortcutStroke.parse(rawValue:)) + guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { + return nil + } + guard !firstStroke.modifierFlags.isEmpty else { return nil } + let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil + return StoredShortcut(first: firstStroke, second: secondStroke) + } + func matches( event: NSEvent, layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 7c055ffb4b60..5c07e51cbf42 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -862,9 +862,9 @@ final class CmuxSettingsFileStore { ) -> StoredShortcut? { let shortcut: StoredShortcut? if let stroke = jsonString(rawValue) { - shortcut = parseStoredShortcut(strokes: [stroke]) + shortcut = StoredShortcut.parse(rawValue: stroke) } else if let strokes = jsonStringArray(rawValue) { - shortcut = parseStoredShortcut(strokes: strokes) + shortcut = StoredShortcut.parse(strokes: strokes) } else { shortcut = nil } @@ -876,102 +876,6 @@ final class CmuxSettingsFileStore { return action.usesNumberedDigitMatching ? nil : shortcut } - private func parseStoredShortcut(strokes: [String]) -> StoredShortcut? { - guard !strokes.isEmpty, strokes.count <= 2 else { return nil } - let parsedStrokes = strokes.compactMap(parseStroke(_:)) - guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { - return nil - } - guard !firstStroke.modifierFlags.isEmpty else { return nil } - let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil - return StoredShortcut(first: firstStroke, second: secondStroke) - } - - private func parseStroke(_ rawValue: String) -> ShortcutStroke? { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let parts = trimmed.split(separator: "+", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !parts.isEmpty, let lastPart = parts.last, !lastPart.isEmpty else { - return nil - } - - var command = false - var shift = false - var option = false - var control = false - - for modifier in parts.dropLast() { - switch modifier.lowercased() { - case "cmd", "command", "⌘": - command = true - case "shift", "⇧": - shift = true - case "opt", "option", "alt", "⌥": - option = true - case "ctrl", "control", "ctl", "⌃": - control = true - default: - return nil - } - } - - guard let key = parseKeyToken(lastPart) else { return nil } - return ShortcutStroke( - key: key, - command: command, - shift: shift, - option: option, - control: control - ) - } - - private func parseKeyToken(_ rawValue: String) -> String? { - let lowered = rawValue.lowercased() - switch lowered { - case "left", "arrowleft", "leftarrow", "←": - return "←" - case "right", "arrowright", "rightarrow", "→": - return "→" - case "up", "arrowup", "uparrow", "↑": - return "↑" - case "down", "arrowdown", "downarrow", "↓": - return "↓" - case "tab": - return "\t" - case "return", "enter", "↩": - return "\r" - case "space": - return " " - case "comma": - return "," - case "period", "dot": - return "." - case "slash": - return "/" - case "backslash": - return "\\" - case "semicolon": - return ";" - case "quote", "apostrophe": - return "'" - case "backtick", "grave": - return "`" - case "minus", "hyphen": - return "-" - case "plus", "equals": - return "=" - case "leftbracket", "openbracket": - return "[" - case "rightbracket", "closebracket": - return "]" - default: - guard lowered.count == 1 else { return nil } - return lowered - } - } - private func parseNullableHex( _ rawValue: Any?, path: String, @@ -1623,7 +1527,7 @@ private enum BackupValue: Codable, Equatable { } } -private enum JSONCParser { +enum JSONCParser { static func preprocess(data: Data) throws -> Data { guard let source = String(data: data, encoding: .utf8) else { throw JSONCError.invalidUTF8 @@ -1749,7 +1653,7 @@ private enum JSONCParser { } } -private final class ShortcutSettingsFileWatcher { +final class ShortcutSettingsFileWatcher { private let path: String private let fileManager: FileManager private let onChange: () -> Void diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 53268ec13846..7109d01fb415 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -4972,6 +4972,159 @@ class TabManager: ObservableObject { selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true) } + @discardableResult + func openSurfaceAndRunCommand( + target: CustomCommandTarget, + cwd: CustomCommandWorkingDirectory = .workspace, + command: String, + customCommandID: String? = nil + ) -> Bool { + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCommand.isEmpty else { return false } + + let launchContext = customCommandLaunchContext() + let fallbackDirectory = FileManager.default.homeDirectoryForCurrentUser.path + let workspaceDirectory = launchContext?.workspaceDirectory ?? fallbackDirectory + let paneDirectory = launchContext?.paneDirectory ?? workspaceDirectory + let resolvedWorkingDirectory = resolvedCustomCommandWorkingDirectory( + cwd, + workspaceDirectory: workspaceDirectory, + paneDirectory: paneDirectory + ) + let environment = customCommandEnvironment( + customCommandID: customCommandID, + workspaceDirectory: workspaceDirectory, + paneDirectory: paneDirectory + ) + let input = trimmedCommand + "\n" + + switch target { + case .splitRight, .splitDown: + guard let workspace = launchContext?.workspace, + let sourcePanelId = launchContext?.sourcePanelId else { + return false + } + workspace.clearSplitZoom() + let orientation: SplitOrientation = (target == .splitRight) ? .horizontal : .vertical + guard let panel = workspace.newTerminalSplit( + from: sourcePanelId, + orientation: orientation, + insertFirst: false, + focus: true, + workingDirectory: resolvedWorkingDirectory, + startupEnvironment: environment + ) else { + return false + } + workspace.sendTerminalInputWhenReady(input, to: panel) + scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command") + return true + + case .newSurface: + guard let workspace = launchContext?.workspace else { return false } + let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first + guard let paneId else { return false } + workspace.clearSplitZoom() + guard let panel = workspace.newTerminalSurface( + inPane: paneId, + focus: true, + workingDirectory: resolvedWorkingDirectory, + startupEnvironment: environment + ) else { + return false + } + workspace.sendTerminalInputWhenReady(input, to: panel) + scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command") + return true + + case .newTab, .newWorkspace: + let workspace = addWorkspace( + workingDirectory: resolvedWorkingDirectory, + initialTerminalEnvironment: environment, + select: true + ) + guard let panel = workspace.focusedTerminalPanel else { return false } + workspace.sendTerminalInputWhenReady(input, to: panel) + scheduleInitialWorkspaceGitMetadataRefreshIfPossible(workspaceId: workspace.id, panelId: panel.id, reason: "custom_command") + return true + } + } + + private struct CustomCommandLaunchContext { + let workspace: Workspace + let sourcePanelId: UUID? + let workspaceDirectory: String? + let paneDirectory: String? + } + + private func customCommandLaunchContext() -> CustomCommandLaunchContext? { + guard let workspace = selectedWorkspace else { return nil } + let sourcePanelId = preferredSurfaceCreationPanelId(in: workspace) + let workspaceDirectory = resolvedWorkspaceDirectory(for: workspace) + let paneDirectory = sourcePanelId.flatMap { gitProbeDirectory(for: workspace, panelId: $0) } ?? workspaceDirectory + return CustomCommandLaunchContext( + workspace: workspace, + sourcePanelId: sourcePanelId, + workspaceDirectory: workspaceDirectory, + paneDirectory: paneDirectory + ) + } + + private func preferredSurfaceCreationPanelId(in workspace: Workspace) -> UUID? { + if let focusedPanelId = workspace.focusedPanelId, + workspace.panels[focusedPanelId] != nil { + return focusedPanelId + } + + let candidatePane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first + if let candidatePane, + let selectedTabId = workspace.bonsplitController.selectedTab(inPane: candidatePane)?.id + ?? workspace.bonsplitController.tabs(inPane: candidatePane).first?.id, + let panelId = workspace.panelIdFromSurfaceId(selectedTabId), + workspace.panels[panelId] != nil { + return panelId + } + + return workspace.panels.keys.first + } + + private func resolvedWorkspaceDirectory(for workspace: Workspace) -> String? { + normalizedWorkingDirectory(workspace.currentDirectory) + ?? preferredWorkingDirectoryForNewTab(workspace: workspace) + ?? workspace.focusedPanelId.flatMap { gitProbeDirectory(for: workspace, panelId: $0) } + } + + private func resolvedCustomCommandWorkingDirectory( + _ cwd: CustomCommandWorkingDirectory, + workspaceDirectory: String, + paneDirectory: String + ) -> String { + switch cwd { + case .workspace: + return workspaceDirectory + case .pane: + return paneDirectory + case .absolutePath(let path): + return path + } + } + + private func customCommandEnvironment( + customCommandID: String?, + workspaceDirectory: String, + paneDirectory: String + ) -> [String: String] { + var environment: [String: String] = [ + "CMUX_WORKSPACE_CWD": workspaceDirectory, + "CMUX_PANE_CWD": paneDirectory, + ] + if let customCommandID, + !customCommandID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + environment["CMUX_CUSTOM_COMMAND_ID"] = customCommandID + } + return environment + } + // MARK: - Split Creation /// Create a new split in the current tab diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 55c09bf619cd..4a81c8433046 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -879,14 +879,14 @@ extension Workspace { _ = closePanel(panelId, force: true) if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } - if let command = surface.command { sendInputWhenReady(command + "\n", to: panel) } + if let command = surface.command { sendTerminalInputWhenReady(command + "\n", to: panel) } } case .terminal: if let name = surface.name { setPanelCustomTitle(panelId: panelId, title: name) } if surface.focus == true { focusPanelId = panelId } if let command = surface.command, let terminal = terminalPanel(for: panelId) { - sendInputWhenReady(command + "\n", to: terminal) + sendTerminalInputWhenReady(command + "\n", to: terminal) } case .browser: @@ -916,7 +916,7 @@ extension Workspace { ) { if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } - if let command = surface.command { sendInputWhenReady(command + "\n", to: panel) } + if let command = surface.command { sendTerminalInputWhenReady(command + "\n", to: panel) } } case .browser: @@ -950,7 +950,7 @@ extension Workspace { } } - private func sendInputWhenReady(_ text: String, to panel: TerminalPanel) { + func sendTerminalInputWhenReady(_ text: String, to panel: TerminalPanel) { if panel.surface.surface != nil { panel.sendInput(text) return @@ -8814,7 +8814,9 @@ final class Workspace: Identifiable, ObservableObject { from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, - focus: Bool = true + focus: Bool = true, + workingDirectory overrideWorkingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] ) -> TerminalPanel? { // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } @@ -8835,6 +8837,10 @@ final class Workspace: Identifiable, ObservableObject { // then its requested startup cwd if shell integration has not reported // back yet, and finally fall back to the workspace's current directory. let splitWorkingDirectory: String? = { + if let overrideWorkingDirectory, + !overrideWorkingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return overrideWorkingDirectory + } if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), !panelDirectory.isEmpty { return panelDirectory @@ -8861,7 +8867,8 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, workingDirectory: splitWorkingDirectory, portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand + initialCommand: remoteTerminalStartupCommand, + additionalEnvironment: startupEnvironment ) configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel diff --git a/tests_v2/test_custom_command_shortcut.py b/tests_v2/test_custom_command_shortcut.py new file mode 100644 index 000000000000..db5e43c809d7 --- /dev/null +++ b/tests_v2/test_custom_command_shortcut.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Regression: custom command shortcuts spawn a new surface and run in it.""" + +from __future__ import annotations + +import base64 +import json +import os +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +KEYBINDINGS_PATH = Path.home() / ".config" / "cmux" / "keybindings.json" +SHORTCUT = "cmd+ctrl+shift+k" + + +def _must(condition: bool, message: str) -> None: + if not condition: + raise cmuxError(message) + + +def _surface_rows(client: cmux, workspace_id: str) -> list[dict]: + payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} + return list(payload.get("surfaces") or []) + + +def _pane_count(client: cmux, workspace_id: str) -> int: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + return len(payload.get("panes") or []) + + +def _read_surface_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + { + "workspace_id": workspace_id, + "surface_id": surface_id, + "scrollback": True, + }, + ) or {} + if "text" in payload: + return str(payload.get("text") or "") + encoded = str(payload.get("base64") or "") + raw = base64.b64decode(encoded) if encoded else b"" + return raw.decode("utf-8", errors="replace") + + +def _wait_for_new_surface( + client: cmux, + workspace_id: str, + before_ids: set[str], + timeout_s: float = 10.0, +) -> dict: + deadline = time.time() + timeout_s + last_rows: list[dict] = [] + while time.time() < deadline: + rows = _surface_rows(client, workspace_id) + new_rows = [row for row in rows if str(row.get("id") or "") not in before_ids] + if len(new_rows) == 1: + return new_rows[0] + last_rows = rows + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for a new surface in {workspace_id}: {last_rows}") + + +def _wait_for_text( + client: cmux, + workspace_id: str, + surface_id: str, + needle: str, + timeout_s: float = 10.0, +) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + last_text = _read_surface_text(client, workspace_id, surface_id) + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in surface {surface_id}: {last_text!r}") + + +def _write_keybindings_config(payload: dict) -> bytes | None: + KEYBINDINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + previous = KEYBINDINGS_PATH.read_bytes() if KEYBINDINGS_PATH.exists() else None + KEYBINDINGS_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return previous + + +def _restore_keybindings_config(previous: bytes | None) -> None: + if previous is None: + try: + KEYBINDINGS_PATH.unlink() + except FileNotFoundError: + pass + return + KEYBINDINGS_PATH.write_bytes(previous) + + +def main() -> int: + previous_keybindings = None + workspace_dir = Path(tempfile.mkdtemp(prefix="cmux-custom-command-")) + + token = f"custom-command-{int(time.time() * 1000)}" + command = ( + "printf 'CMUX_CUSTOM_COMMAND_OK=%s id=%s workspace=%s pane=%s pwd=%s\\n' " + f"'{token}' " + "\"$CMUX_CUSTOM_COMMAND_ID\" " + "\"$CMUX_WORKSPACE_CWD\" " + "\"$CMUX_PANE_CWD\" " + "\"$PWD\"" + ) + + config = { + "version": 1, + "custom_commands": [ + { + "id": "launch-custom-command-right", + "shortcut": SHORTCUT, + "command": command, + "label": "Launch custom command", + "target": "split_right", + "cwd": "workspace", + } + ], + } + + with cmux(SOCKET_PATH) as client: + baseline_workspace = client.current_workspace() + created_workspace = "" + try: + previous_keybindings = _write_keybindings_config(config) + time.sleep(0.5) + + created = client._call( + "workspace.create", + { + "title": "custom_command_shortcut_test", + "cwd": str(workspace_dir), + }, + ) or {} + created_workspace = str(created.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {created}") + + client.select_workspace(created_workspace) + client.activate_app() + time.sleep(0.5) + + before_rows = _surface_rows(client, created_workspace) + before_ids = {str(row.get("id") or "") for row in before_rows if row.get("id")} + before_panes = _pane_count(client, created_workspace) + + client.simulate_shortcut(SHORTCUT) + + new_surface = _wait_for_new_surface(client, created_workspace, before_ids) + new_surface_id = str(new_surface.get("id") or "") + _must(bool(new_surface_id), f"New surface row missing id: {new_surface}") + + after_panes = _pane_count(client, created_workspace) + _must( + after_panes == before_panes + 1, + f"Expected pane count to increase by 1, got before={before_panes} after={after_panes}", + ) + + requested_cwd = str(new_surface.get("requested_working_directory") or "") + _must( + requested_cwd == str(workspace_dir), + f"Expected new surface requested_working_directory={workspace_dir}, got {requested_cwd!r}: {new_surface}", + ) + + text = _wait_for_text(client, created_workspace, new_surface_id, f"CMUX_CUSTOM_COMMAND_OK={token}") + _must( + "id=launch-custom-command-right" in text, + f"Expected CMUX_CUSTOM_COMMAND_ID in terminal output: {text!r}", + ) + _must( + f"workspace={workspace_dir}" in text, + f"Expected CMUX_WORKSPACE_CWD in terminal output: {text!r}", + ) + _must( + f"pane={workspace_dir}" in text, + f"Expected CMUX_PANE_CWD in terminal output: {text!r}", + ) + _must( + f"pwd={workspace_dir}" in text, + f"Expected command to run in {workspace_dir}, got {text!r}", + ) + + client.select_workspace(baseline_workspace) + finally: + if created_workspace: + try: + client.close_workspace(created_workspace) + except Exception: + pass + if previous_keybindings is not None or KEYBINDINGS_PATH.exists(): + _restore_keybindings_config(previous_keybindings) + shutil.rmtree(workspace_dir, ignore_errors=True) + + print("PASS: custom command shortcut creates a new split and runs the command") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/bonsplit b/vendor/bonsplit index cffd9a66e9ed..73bd9f9cfd8c 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit cffd9a66e9ede9aaedefcfd0c391f0ad49652d72 +Subproject commit 73bd9f9cfd8c41ebc6f2d43eb48d3eafa4dfec00