diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index fb2b76aaab67..8f32360a7858 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -97,7 +97,7 @@ A5001660 /* RestorableAgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* RestorableAgentSession.swift */; }; 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 */; }; + A5001654 /* CmuxActionTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxActionTrust.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 */; }; @@ -326,7 +326,7 @@ A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; 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 = ""; }; + A5001655 /* CmuxActionTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxActionTrust.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 = ""; }; @@ -585,7 +585,7 @@ A5001641 /* RemoteRelayZshBootstrap.swift */, A5001651 /* CmuxConfig.swift */, A5001653 /* CmuxConfigExecutor.swift */, - A5001655 /* CmuxDirectoryTrust.swift */, + A5001655 /* CmuxActionTrust.swift */, FE001001 /* FileExplorerStore.swift */, FE001002 /* FileExplorerView.swift */, FE003001 /* SessionIndexStore.swift */, @@ -959,7 +959,7 @@ A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, A5001652 /* CmuxConfigExecutor.swift in Sources */, - A5001654 /* CmuxDirectoryTrust.swift in Sources */, + A5001654 /* CmuxActionTrust.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, FE001102 /* FileExplorerView.swift in Sources */, FE003101 /* SessionIndexStore.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index dfe5d7aa7ef3..5d3135257e53 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -16813,121 +16813,19 @@ } } }, - "dialog.cmuxConfig.confirmCommand.message": { + "dialog.cmuxConfig.confirmCommand.messageWithCommand": { "extractionState": "manual", "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "هل أنت متأكد أنك تريد تشغيل هذا الأمر؟" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Jeste li sigurni da želite pokrenuti ovu naredbu?" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Er du sikker på, at du vil køre denne kommando?" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Möchten Sie diesen Befehl wirklich ausführen?" - } - }, "en": { "stringUnit": { "state": "translated", - "value": "Are you sure you want to run this command?" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "¿Estás seguro de que deseas ejecutar este comando?" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Êtes-vous sûr de vouloir exécuter cette commande ?" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Sei sicuro di voler eseguire questo comando?" + "value": "This project action comes from:\n\n%@\n\nIt will run:\n\n%@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このコマンドを実行してもよろしいですか?" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "이 명령을 실행하시겠습니까?" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Er du sikker på at du vil kjøre denne kommandoen?" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Czy na pewno chcesz uruchomić to polecenie?" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Tem certeza de que deseja executar este comando?" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Вы уверены, что хотите выполнить эту команду?" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "คุณแน่ใจหรือไม่ว่าต้องการรันคำสั่งนี้?" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Bu komutu çalıştırmak istediğinizden emin misiniz?" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "确定要运行此命令吗?" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "確定要執行此命令嗎?" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Ви впевнені, що хочете виконати цю команду?" + "value": "このプロジェクトアクションは次の設定から読み込まれました:\n\n%@\n\n実行内容:\n\n%@" } } } @@ -16962,7 +16860,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Run" + "value": "Run Once" } }, "es": { @@ -16986,7 +16884,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "実行" + "value": "1回のみ実行" } }, "ko": { @@ -17051,6 +16949,23 @@ } } }, + "dialog.cmuxConfig.confirmCommand.trustAndRun": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trust and Run" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "信頼して実行" + } + } + } + }, "dialog.cmuxConfig.confirmCommand.title": { "extractionState": "manual", "localizations": { @@ -17081,7 +16996,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Run Command" + "value": "Run Project Action?" } }, "es": { @@ -17105,7 +17020,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "コマンドを実行" + "value": "プロジェクトアクションを実行しますか?" } }, "ko": { @@ -17765,6 +17680,40 @@ } } }, + "command.cmuxConfig.defaultClaudeCodeTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude Code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code" + } + } + } + }, + "command.cmuxConfig.defaultCodexTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex" + } + } + } + }, "command.cmuxConfig.customTitle": { "extractionState": "manual", "localizations": { @@ -17884,6 +17833,142 @@ } } }, + "command.cmuxConfig.issue.newWorkspaceActionRequiresWorkspaceCommand.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ must reference a workspace command action" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はワークスペースコマンドアクションを参照する必要があります" + } + } + } + }, + "command.cmuxConfig.issue.newWorkspaceCommandNotFound.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ references missing command '%@'" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は存在しないコマンド '%@' を参照しています" + } + } + } + }, + "command.cmuxConfig.issue.newWorkspaceCommandRequiresWorkspace.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ '%@' must reference a workspace command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ '%@' はワークスペースコマンドを参照する必要があります" + } + } + } + }, + "command.cmuxConfig.issue.schemaError.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + } + } + }, + "command.cmuxConfig.issue.schemaError.fallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid cmux.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効な cmux.json" + } + } + } + }, + "command.cmuxConfig.issue.schemaError.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux.json Schema Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux.json スキーマエラー" + } + } + } + }, + "command.cmuxConfig.issue.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: %@" + } + } + } + }, + "command.cmuxConfig.issue.warning.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux.json Configuration Warning" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux.json 設定警告" + } + } + } + }, "command.equalizeSplits.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 48895d0b16b0..b689fec3c0e1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -564,6 +564,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let tabManager: TabManager let sidebarState: SidebarState let sidebarSelectionState: SidebarSelectionState + var cmuxConfigStore: CmuxConfigStore? weak var window: NSWindow? init( @@ -571,12 +572,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: TabManager, sidebarState: SidebarState, sidebarSelectionState: SidebarSelectionState, + cmuxConfigStore: CmuxConfigStore?, window: NSWindow? ) { self.windowId = windowId self.tabManager = tabManager self.sidebarState = sidebarState self.sidebarSelectionState = sidebarSelectionState + self.cmuxConfigStore = cmuxConfigStore self.window = window } } @@ -3281,7 +3284,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowId: UUID, tabManager: TabManager, sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState + sidebarSelectionState: SidebarSelectionState, + cmuxConfigStore: CmuxConfigStore? = nil ) { tabManager.window = window @@ -3291,8 +3295,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif if let existing = mainWindowContexts[key] { existing.window = window + if let cmuxConfigStore { + existing.cmuxConfigStore = cmuxConfigStore + } } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { existing.window = window + if let cmuxConfigStore { + existing.cmuxConfigStore = cmuxConfigStore + } reindexMainWindowContextIfNeeded(existing, for: window) } else { mainWindowContexts[key] = MainWindowContext( @@ -3300,6 +3310,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, + cmuxConfigStore: cmuxConfigStore, window: window ) NotificationCenter.default.addObserver( @@ -5039,6 +5050,129 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ = createMainWindow() } + @discardableResult + func performNewWorkspaceAction( + tabManager preferredTabManager: TabManager? = nil, + event: NSEvent? = nil, + debugSource: String = "newWorkspace" + ) -> Bool { + let preferredContext = preferredTabManager.flatMap { mainWindowContext(for: $0) } + let livePreferredContext: MainWindowContext? = { + guard let preferredContext else { return nil } + guard resolvedWindow(for: preferredContext) != nil else { + discardOrphanedMainWindowContext(preferredContext) + return nil + } + return preferredContext + }() + + if mainWindowContexts.isEmpty && livePreferredContext == nil { +#if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: debugSource, + reason: "no_main_windows", + event: event, + chosenContext: nil + ) +#endif + let windowId = createMainWindow() + if let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + let initialWorkspace = context.tabManager.selectedWorkspace + _ = executeConfiguredNewWorkspaceCommandIfAvailable( + in: context, + debugSource: debugSource, + replacingInitialWorkspace: initialWorkspace + ) + } + return true + } + + let context = livePreferredContext + ?? preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) + + if let context, + executeConfiguredNewWorkspaceCommandIfAvailable(in: context, debugSource: debugSource) { + return true + } + + if let preferredTabManager, + preferredContext == nil || livePreferredContext != nil { + preferredTabManager.addWorkspace() + return true + } + + if addWorkspaceInPreferredMainWindow(event: event, debugSource: debugSource) == nil { +#if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: debugSource, + reason: "workspace_creation_returned_nil", + event: event, + chosenContext: nil + ) +#endif + openNewMainWindow(nil) + } + return true + } + + private func mainWindowContext(for tabManager: TabManager) -> MainWindowContext? { + mainWindowContexts.values.first(where: { $0.tabManager === tabManager }) + } + + private func executeConfiguredNewWorkspaceCommandIfAvailable( + in context: MainWindowContext, + 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 + } + let rawCwd = context.tabManager.selectedWorkspace?.currentDirectory + let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd! + : FileManager.default.homeDirectoryForCurrentUser.path +#if DEBUG + dlog( + "newWorkspace.configCommand source=\(debugSource) " + + "command=\(configured.command.name) windowId=\(String(context.windowId.uuidString.prefix(8)))" + ) +#endif + let initialWorkspaceId = initialWorkspace?.id + let didExecute = CmuxConfigExecutor.execute( + command: configured.command, + tabManager: context.tabManager, + baseCwd: baseCwd, + configSourcePath: configured.sourcePath, + globalConfigPath: cmuxConfigStore.globalConfigPath + ) { [weak self, weak context] in + self?.closeInitialWorkspaceIfNeeded( + initialWorkspaceId: initialWorkspaceId, + in: context + ) + } + return didExecute + } + + private func closeInitialWorkspaceIfNeeded( + initialWorkspaceId: UUID?, + in context: MainWindowContext? + ) { + guard let initialWorkspaceId, + let context, + context.tabManager.tabs.count > 1, + let initialWorkspace = context.tabManager.tabs.first(where: { $0.id == initialWorkspaceId }), + context.tabManager.selectedWorkspace?.id != initialWorkspaceId else { + return + } + context.tabManager.closeWorkspace(initialWorkspace) + } + /// Shows the "Open Folder" panel and creates a workspace for the selected directory. /// Called from both the SwiftUI menu and `handleCustomShortcut`. func showOpenFolderPanel() { @@ -5696,7 +5830,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState + sidebarSelectionState: sidebarSelectionState, + cmuxConfigStore: cmuxConfigStore ) installFileDropOverlay(on: window, tabManager: tabManager) if !shouldActivate || TerminalController.shouldSuppressSocketCommandActivation() { @@ -9496,8 +9631,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + let configuredCmuxShortcutContext = preferredMainWindowContextForShortcutRouting(event: event) + let configuredCmuxShortcutActions = configuredCmuxShortcutActions(for: configuredCmuxShortcutContext) + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event) { + armConfiguredShortcutChordIfNeeded( + event: event, + shortcuts: configuredCmuxShortcutActions.compactMap(\.shortcut) + ) { return true } @@ -9534,6 +9675,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if handleConfiguredCmuxShortcut( + event: event, + actions: configuredCmuxShortcutActions, + context: configuredCmuxShortcutContext + ) { + return true + } + // Primary UI shortcuts if matchConfiguredShortcut(event: event, action: .toggleSidebar) { _ = toggleSidebarInActiveMainWindow() @@ -9544,32 +9693,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif - // Cmd+N semantics: - // - If there are no main windows, create a new window. - // - Otherwise, create a new workspace in the active window. - if mainWindowContexts.isEmpty { - #if DEBUG - logWorkspaceCreationRouting( - phase: "fallback_new_window", - source: "shortcut.cmdN", - reason: "no_main_windows", - event: event, - chosenContext: nil - ) - #endif - openNewMainWindow(nil) - } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { - #if DEBUG - logWorkspaceCreationRouting( - phase: "fallback_new_window", - source: "shortcut.cmdN", - reason: "workspace_creation_returned_nil", - event: event, - chosenContext: nil - ) - #endif - openNewMainWindow(nil) - } + performNewWorkspaceAction(event: event, debugSource: "shortcut.cmdN") return true } @@ -10848,8 +10972,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) + private func matchConfiguredShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, shortcut.firstStroke == prefix else { @@ -10861,6 +10984,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) } + private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { + matchConfiguredShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: action)) + } + private func numberedConfiguredShortcutDigit( event: NSEvent, action: KeyboardShortcutSettings.Action @@ -10917,10 +11044,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func armConfiguredShortcutChordIfNeeded( event: NSEvent, - actions: [KeyboardShortcutSettings.Action]? = nil + actions: [KeyboardShortcutSettings.Action]? = nil, + shortcuts: [StoredShortcut] = [] ) -> Bool { - for action in actions ?? configuredShortcutChordActions { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) + var seen = Set() + let configuredShortcuts = (actions ?? configuredShortcutChordActions).map { + KeyboardShortcutSettings.shortcut(for: $0) + } + shortcuts + for shortcut in configuredShortcuts { + guard seen.insert(shortcut).inserted else { continue } guard shortcut.hasChord else { continue } if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { pendingConfiguredShortcutChord = PendingConfiguredShortcutChord( @@ -10933,6 +11065,85 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + private func configuredCmuxShortcutActions( + for context: MainWindowContext? + ) -> [CmuxResolvedConfigAction] { + context?.cmuxConfigStore?.shortcutActions() ?? [] + } + + private func handleConfiguredCmuxShortcut( + event: NSEvent, + actions: [CmuxResolvedConfigAction], + context: MainWindowContext? + ) -> Bool { + for action in actions { + guard let shortcut = action.shortcut, + matchConfiguredShortcut(event: event, shortcut: shortcut) else { + continue + } + return executeConfiguredCmuxActionShortcut( + action, + event: event, + context: context + ) + } + return false + } + + private func executeConfiguredCmuxActionShortcut( + _ action: CmuxResolvedConfigAction, + event: NSEvent, + context: MainWindowContext? + ) -> Bool { + switch action.action { + case .builtIn(let builtIn): + switch builtIn { + case .newTerminal: + tabManager?.newSurface() + return true + case .newBrowser: + _ = openBrowserAndFocusAddressBar(insertAtEnd: true) + return true + case .splitRight: + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } + _ = performSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + case .splitDown: + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { + return true + } + _ = performSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + case .command, .agent, .workspaceCommand: + guard let context, + let cmuxConfigStore = context.cmuxConfigStore else { + return false + } + let rawCwd = context.tabManager.selectedWorkspace?.currentDirectory + let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd! + : FileManager.default.homeDirectoryForCurrentUser.path + return CmuxConfigExecutor.execute( + action: action, + commands: cmuxConfigStore.loadedCommands, + commandSourcePaths: cmuxConfigStore.commandSourcePaths, + tabManager: context.tabManager, + baseCwd: baseCwd, + globalConfigPath: cmuxConfigStore.globalConfigPath + ) + case .actionReference: + return false + } + } + /// Match a shortcut stroke against an event, handling normal keys. private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { stroke.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider) diff --git a/Sources/CmuxActionTrust.swift b/Sources/CmuxActionTrust.swift new file mode 100644 index 000000000000..0dd218b00b10 --- /dev/null +++ b/Sources/CmuxActionTrust.swift @@ -0,0 +1,77 @@ +import CryptoKit +import Foundation + +struct CmuxActionTrustDescriptor: Codable, Sendable { + var schemaVersion: Int = 1 + var actionID: String + var kind: String + var command: String? + var target: String? + var workspaceCommand: CmuxCommandDefinition? + var configPath: String? + var projectRoot: String? + var iconFingerprint: String? + + var fingerprint: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = (try? encoder.encode(self)) ?? Data() + return Self.sha256Hex(data) + } + + private static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +final class CmuxActionTrust { + static let shared = CmuxActionTrust() + static let didChangeNotification = Notification.Name("cmux.actionTrustDidChange") + + private let storePath: String + private var trustedFingerprints: Set + + private init() { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("cmux") + storePath = appSupport.appendingPathComponent("trusted-actions.json").path + + let fm = FileManager.default + if !fm.fileExists(atPath: appSupport.path) { + try? fm.createDirectory(atPath: appSupport.path, withIntermediateDirectories: true) + } + + if let data = fm.contents(atPath: storePath), + let values = try? JSONDecoder().decode([String].self, from: data) { + trustedFingerprints = Set(values) + } else { + trustedFingerprints = [] + } + } + + func isTrusted(_ descriptor: CmuxActionTrustDescriptor) -> Bool { + trustedFingerprints.contains(descriptor.fingerprint) + } + + func trust(_ descriptor: CmuxActionTrustDescriptor) { + trustedFingerprints.insert(descriptor.fingerprint) + save() + } + + func clearAll() { + trustedFingerprints.removeAll() + save() + } + + var allTrustedFingerprints: [String] { + trustedFingerprints.sorted() + } + + private func save() { + guard let data = try? JSONEncoder().encode(trustedFingerprints.sorted()) else { return } + FileManager.default.createFile(atPath: storePath, contents: data) + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) + } +} diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 4fe7f8ec0b77..8b442a7e4b62 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -1,9 +1,1505 @@ import Bonsplit import Combine +import CryptoKit import Foundation struct CmuxConfigFile: Codable, Sendable { + var actions: [String: CmuxConfigActionDefinition] + var ui: CmuxConfigUIDefinition? + var newWorkspaceCommand: String? + var surfaceTabBarButtons: [CmuxSurfaceTabBarButton]? var commands: [CmuxCommandDefinition] + + private enum CodingKeys: String, CodingKey { + case actions + case ui + case newWorkspaceCommand + case surfaceTabBarButtons + case commands + } + + init( + actions: [String: CmuxConfigActionDefinition] = [:], + ui: CmuxConfigUIDefinition? = nil, + newWorkspaceCommand: String? = nil, + surfaceTabBarButtons: [CmuxSurfaceTabBarButton]? = nil, + commands: [CmuxCommandDefinition] = [] + ) { + self.actions = actions + self.ui = ui + self.newWorkspaceCommand = newWorkspaceCommand + self.surfaceTabBarButtons = surfaceTabBarButtons + self.commands = commands + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedActions = try container.decodeIfPresent( + [String: CmuxConfigActionDefinition].self, + forKey: .actions + ) ?? [:] + actions = try Self.normalizedActions( + decodedActions, + codingPath: decoder.codingPath + [CodingKeys.actions] + ) + ui = try container.decodeIfPresent(CmuxConfigUIDefinition.self, forKey: .ui) + + if let rawNewWorkspaceCommand = try container.decodeIfPresent(String.self, forKey: .newWorkspaceCommand) { + let trimmed = rawNewWorkspaceCommand.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath + [CodingKeys.newWorkspaceCommand], + debugDescription: "newWorkspaceCommand must not be blank" + ) + ) + } + newWorkspaceCommand = trimmed + } else { + newWorkspaceCommand = nil + } + + let rootSurfaceButtons = try container.decodeIfPresent( + [CmuxSurfaceTabBarButton].self, + forKey: .surfaceTabBarButtons + ) + let configuredSurfaceButtons = ui?.surfaceTabBar?.buttons ?? rootSurfaceButtons + if let configuredSurfaceButtons { + surfaceTabBarButtons = try Self.validatedSurfaceTabBarButtons( + configuredSurfaceButtons, + codingPath: decoder.codingPath + [ + ui?.surfaceTabBar?.buttons == nil ? CodingKeys.surfaceTabBarButtons : CodingKeys.ui + ] + ) + } else { + surfaceTabBarButtons = nil + } + commands = try container.decodeIfPresent([CmuxCommandDefinition].self, forKey: .commands) ?? [] + } + + private static func normalizedActions( + _ decodedActions: [String: CmuxConfigActionDefinition], + codingPath: [CodingKey] + ) throws -> [String: CmuxConfigActionDefinition] { + var actions: [String: CmuxConfigActionDefinition] = [:] + for (rawID, action) in decodedActions { + let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "actions keys must not be blank" + ) + ) + } + if actions[id] != nil { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "actions must not contain duplicate ids" + ) + ) + } + actions[id] = action + } + return actions + } + + private static func validatedSurfaceTabBarButtons( + _ buttons: [CmuxSurfaceTabBarButton], + codingPath: [CodingKey] + ) throws -> [CmuxSurfaceTabBarButton] { + var seen = Set() + for button in buttons { + if !seen.insert(button.id).inserted { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "surface tab bar buttons must not contain duplicate ids" + ) + ) + } + } + return buttons + } +} + +enum CmuxSurfaceTabBarBuiltInAction: String, Codable, Sendable, CaseIterable, Hashable { + case newTerminal = "cmux.newTerminal" + case newBrowser = "cmux.newBrowser" + case splitRight = "cmux.splitRight" + case splitDown = "cmux.splitDown" + + init?(configID: String) { + switch configID { + case "cmux.newTerminal", "newTerminal": + self = .newTerminal + case "cmux.newBrowser", "newBrowser": + self = .newBrowser + case "cmux.splitRight", "splitRight": + self = .splitRight + case "cmux.splitDown", "splitDown": + self = .splitDown + default: + return nil + } + } + + var configID: String { + rawValue + } + + var defaultIcon: String { + switch self { + case .newTerminal: + return "terminal" + case .newBrowser: + return "globe" + case .splitRight: + return "square.split.2x1" + case .splitDown: + return "square.split.1x2" + } + } + + var bonsplitAction: BonsplitConfiguration.SplitActionButton.Action { + switch self { + case .newTerminal: + return .newTerminal + case .newBrowser: + return .newBrowser + case .splitRight: + return .splitRight + case .splitDown: + return .splitDown + } + } +} + +enum CmuxConfigTerminalCommandTarget: String, Codable, Sendable, Hashable { + case currentTerminal + case newTabInCurrentPane + + static let defaultForActions: CmuxConfigTerminalCommandTarget = .newTabInCurrentPane +} + +extension CmuxSurfaceTabBarBuiltInAction { + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let action = Self(configID: value) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown built-in action '\(value)'" + ) + ) + } + self = action + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(configID) + } +} + +enum CmuxConfigAgentKind: Sendable, Hashable { + case codex + case claudeCode + + var commandName: String { + switch self { + case .codex: + return "codex" + case .claudeCode: + return "claude" + } + } + + var defaultIcon: CmuxButtonIcon { + switch self { + case .codex: + return .symbol("sparkles") + case .claudeCode: + return .symbol("brain.head.profile") + } + } +} + +extension CmuxConfigAgentKind: Codable { + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + switch value { + case "codex": + self = .codex + case "claude", "claudeCode", "claude-code": + self = .claudeCode + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown agent '\(value)'" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .codex: + try container.encode("codex") + case .claudeCode: + try container.encode("claude") + } + } +} + +enum CmuxButtonIcon: Codable, Sendable, Hashable { + case symbol(String) + case emoji(String, scale: Double = 1) + case imagePath(String) + + var symbolName: String { + if case .symbol(let name) = self { + return name + } + return "questionmark.circle" + } + + private enum CodingKeys: String, CodingKey { + case type + case name + case value + case path + case scale + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try Self.trimmedString(forKey: .type, in: container) + switch type { + case "symbol", "sfSymbol", "systemImage": + self = .symbol(try Self.trimmedString(forKey: .name, in: container)) + case "emoji": + self = .emoji( + try Self.trimmedString(forKey: .value, in: container), + scale: try Self.emojiScale(in: container) + ) + case "image", "file": + self = .imagePath(try Self.trimmedString(forKey: .path, in: container)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown icon type '\(type)'" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .symbol(let name): + try container.encode("symbol", forKey: .type) + try container.encode(name, forKey: .name) + case .emoji(let value, let scale): + try container.encode("emoji", forKey: .type) + try container.encode(value, forKey: .value) + if scale != 1 { + try container.encode(scale, forKey: .scale) + } + case .imagePath(let path): + try container.encode("image", forKey: .type) + try container.encode(path, forKey: .path) + } + } + + func bonsplitIcon( + configSourcePath: String?, + globalConfigPath: String, + allowProjectLocalImage: Bool = true + ) -> BonsplitConfiguration.SplitActionButton.Icon { + switch self { + case .symbol(let name): + return .systemImage(name) + case .emoji(let value, let scale): + return .emoji(value, scale: scale) + case .imagePath(let path): + guard let preparedImage = Self.preparedImageAsset( + path, + relativeToConfig: configSourcePath, + globalConfigPath: globalConfigPath + ) else { + NSLog("[CmuxConfig] icon image path is not allowed: %@", path) + return .systemImage("questionmark.circle") + } + if preparedImage.isProjectLocal && !allowProjectLocalImage { + return .systemImage("lock.fill") + } + return .imageData(preparedImage.data) + } + } + + func projectLocalImageFingerprint(configSourcePath: String?, globalConfigPath: String) -> String? { + guard case .imagePath(let path) = self, + let preparedImage = Self.preparedImageAsset( + path, + relativeToConfig: configSourcePath, + globalConfigPath: globalConfigPath + ), + preparedImage.isProjectLocal else { + return nil + } + return preparedImage.fingerprint + } + + func resolvingRelativeImagePath(relativeToConfig configSourcePath: String?) -> CmuxButtonIcon { + guard case .imagePath(let path) = self else { return self } + return .imagePath(Self.resolvePath(path, relativeToConfig: configSourcePath)) + } + + private static let maxImageBytes = 1_000_000 + + private static func emojiScale(in container: KeyedDecodingContainer) throws -> Double { + let scale = try container.decodeIfPresent(Double.self, forKey: .scale) ?? 1 + guard scale.isFinite, scale > 0 else { + throw DecodingError.dataCorruptedError( + forKey: .scale, + in: container, + debugDescription: "Emoji icon scale must be a positive number" + ) + } + return scale + } + + private struct PreparedImageAsset { + let data: Data + let fingerprint: String + let isProjectLocal: Bool + } + + private static func looksLikeSVGPath(_ value: String) -> Bool { + (value as NSString).pathExtension.lowercased() == "svg" + } + + private static func isSafeSVG(data: Data) -> Bool { + guard let text = String(data: data, encoding: .utf8) else { return false } + let lowered = text.lowercased() + guard !lowered.contains(" String { + let expanded = (path as NSString).expandingTildeInPath + if (expanded as NSString).isAbsolutePath { + return expanded + } + guard let configSourcePath else { return expanded } + let base = (configSourcePath as NSString).deletingLastPathComponent + return (base as NSString).appendingPathComponent(expanded) + } + + private static func safeResolvedImagePath( + _ path: String, + relativeToConfig configSourcePath: String?, + globalConfigPath: String + ) -> String? { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.lowercased().hasPrefix("http://"), + !trimmed.lowercased().hasPrefix("https://") else { + return nil + } + + let isGlobal = configSourcePath == nil || configSourcePath == globalConfigPath + if !isGlobal { + let expanded = (trimmed as NSString).expandingTildeInPath + guard !(expanded as NSString).isAbsolutePath, + expanded == trimmed else { + return nil + } + } + + let resolvedPath = resolvePath(trimmed, relativeToConfig: configSourcePath) + guard !resolvedPath.isEmpty else { return nil } + let standardizedPath = (resolvedPath as NSString).standardizingPath + + guard !isGlobal, let configSourcePath else { + return standardizedPath + } + + let allowedRoot = projectRoot(forConfigPath: configSourcePath) + let resolvedURL = URL(fileURLWithPath: standardizedPath).resolvingSymlinksInPath() + let allowedURL = URL(fileURLWithPath: allowedRoot).resolvingSymlinksInPath() + let resolved = resolvedURL.path + let allowed = allowedURL.path + guard resolved == allowed || resolved.hasPrefix(allowed + "/") else { + return nil + } + return resolved + } + + private static func preparedImageAsset( + _ path: String, + relativeToConfig configSourcePath: String?, + globalConfigPath: String + ) -> PreparedImageAsset? { + guard let resolvedPath = safeResolvedImagePath( + path, + relativeToConfig: configSourcePath, + globalConfigPath: globalConfigPath + ) else { + return nil + } + guard let data = FileManager.default.contents(atPath: resolvedPath) else { + NSLog("[CmuxConfig] icon image does not exist: %@", resolvedPath) + return nil + } + guard data.count <= maxImageBytes else { + NSLog("[CmuxConfig] icon image is too large: %@", resolvedPath) + return nil + } + if looksLikeSVGPath(resolvedPath), !isSafeSVG(data: data) { + NSLog("[CmuxConfig] icon SVG contains unsupported content: %@", resolvedPath) + return nil + } + + let isProjectLocal = configSourcePath != nil && configSourcePath != globalConfigPath + let fingerprint = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return PreparedImageAsset(data: data, fingerprint: fingerprint, isProjectLocal: isProjectLocal) + } + + static func projectRoot(forConfigPath configPath: String) -> String { + let configDir = (configPath as NSString).deletingLastPathComponent + if (configDir as NSString).lastPathComponent == ".cmux" { + return (configDir as NSString).deletingLastPathComponent + } + return configDir + } + + private final class SVGSecurityInspector: NSObject, XMLParserDelegate { + private var isSafe = true + private var elementStack: [String] = [] + + func parse(data: Data) -> Bool { + let parser = XMLParser(data: data) + parser.delegate = self + parser.shouldProcessNamespaces = false + parser.shouldResolveExternalEntities = false + let parsed = parser.parse() + return parsed && isSafe + } + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + let loweredName = elementName.lowercased() + elementStack.append(loweredName) + + if loweredName == "script" || loweredName == "foreignobject" { + markUnsafe(parser) + return + } + + for (name, value) in attributeDict { + guard Self.isSafeSVGAttribute(name: name, value: value) else { + markUnsafe(parser) + return + } + } + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + guard !elementStack.isEmpty else { return } + elementStack.removeLast() + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + guard elementStack.last == "style" else { return } + guard Self.isSafeSVGStyle(string) else { + markUnsafe(parser) + return + } + } + + func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { + guard elementStack.last == "style", + let text = String(data: CDATABlock, encoding: .utf8) else { + return + } + guard Self.isSafeSVGStyle(text) else { + markUnsafe(parser) + return + } + } + + func parser( + _ parser: XMLParser, + foundProcessingInstructionWithTarget target: String, + data: String? + ) { + if target.lowercased() == "xml-stylesheet" { + markUnsafe(parser) + } + } + + private func markUnsafe(_ parser: XMLParser) { + isSafe = false + parser.abortParsing() + } + + private static func isSafeSVGAttribute(name: String, value: String) -> Bool { + let loweredName = name.lowercased() + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + let loweredValue = trimmedValue.lowercased() + + if loweredName.hasPrefix("on") { + return false + } + + if loweredName == "xmlns" || loweredName.hasPrefix("xmlns:") { + return true + } + + if loweredName == "href" || loweredName == "xlink:href" { + return isSafeSVGReference(trimmedValue) + } + + if containsBlockedSVGValue(loweredValue) { + return false + } + + if loweredValue.contains("url(") { + return containsOnlyInternalSVGURLs(trimmedValue) + } + + return true + } + + private static func isSafeSVGStyle(_ value: String) -> Bool { + let loweredValue = value.lowercased() + guard !loweredValue.contains("@import"), + !containsBlockedSVGValue(loweredValue) else { + return false + } + if loweredValue.contains("url(") { + return containsOnlyInternalSVGURLs(value) + } + return true + } + + private static func isSafeSVGReference(_ value: String) -> Bool { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedValue.isEmpty else { return true } + if trimmedValue.hasPrefix("#") { + return true + } + if trimmedValue.lowercased().hasPrefix("url(") { + return containsOnlyInternalSVGURLs(trimmedValue) + } + return false + } + + private static func containsBlockedSVGValue(_ value: String) -> Bool { + let blockedFragments = [ + "javascript:", + "data:", + "http://", + "https://", + "file://", + "blob:" + ] + return blockedFragments.contains { value.contains($0) } + } + + private static func containsOnlyInternalSVGURLs(_ value: String) -> Bool { + let loweredValue = value.lowercased() + var searchStart = loweredValue.startIndex + + while let range = loweredValue.range( + of: "url(", + options: [], + range: searchStart.. + ) throws -> String { + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } +} + +struct CmuxConfigUIDefinition: Codable, Sendable, Hashable { + var newWorkspace: CmuxConfigButtonPlacement? + var surfaceTabBar: CmuxSurfaceTabBarUIDefinition? +} + +struct CmuxSurfaceTabBarUIDefinition: Codable, Sendable, Hashable { + var buttons: [CmuxSurfaceTabBarButton]? +} + +struct CmuxConfigButtonPlacement: Codable, Sendable, Hashable { + var action: String? + var icon: CmuxButtonIcon? + var tooltip: String? + + private enum CodingKeys: String, CodingKey { + case action + case icon + case tooltip + } + + init(action: String? = nil, icon: CmuxButtonIcon? = nil, tooltip: String? = nil) { + self.action = action + self.icon = icon + self.tooltip = tooltip + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + action = try Self.trimmedString(forKey: .action, in: container) + icon = try container.decodeIfPresent(CmuxButtonIcon.self, forKey: .icon) + tooltip = try Self.trimmedString(forKey: .tooltip, in: container, allowBlankAsNil: true) + } + + private static func trimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer, + allowBlankAsNil: Bool = false + ) throws -> String? { + guard container.contains(key) else { return nil } + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + if allowBlankAsNil { return nil } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } +} + +struct CmuxConfigActionDefinition: Codable, Sendable, Hashable { + var action: CmuxSurfaceTabBarButtonAction? + var title: String? + var subtitle: String? + var keywords: [String]? + var palette: Bool? + var shortcut: StoredShortcut? + var icon: CmuxButtonIcon? + var tooltip: String? + var confirm: Bool? + var terminalCommandTarget: CmuxConfigTerminalCommandTarget? + + private enum CodingKeys: String, CodingKey { + case type + case builtin + case command + case commandName + case name + case agent + case args + case title + case subtitle + case description + case keywords + case palette + case shortcut + case icon + case tooltip + case confirm + case target + } + + init( + action: CmuxSurfaceTabBarButtonAction? = nil, + title: String? = nil, + subtitle: String? = nil, + keywords: [String]? = nil, + palette: Bool? = nil, + shortcut: StoredShortcut? = nil, + icon: CmuxButtonIcon? = nil, + tooltip: String? = nil, + confirm: Bool? = nil, + terminalCommandTarget: CmuxConfigTerminalCommandTarget? = nil + ) { + self.action = action + self.title = title + self.subtitle = subtitle + self.keywords = keywords + self.palette = palette + self.shortcut = shortcut + self.icon = icon + self.tooltip = tooltip + self.confirm = confirm + self.terminalCommandTarget = terminalCommandTarget + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try Self.trimmedString(forKey: .type, in: container) + title = try Self.trimmedString(forKey: .title, in: container, allowBlankAsNil: true) + subtitle = try Self.trimmedString(forKey: .subtitle, in: container, allowBlankAsNil: true) + ?? Self.trimmedString(forKey: .description, in: container, allowBlankAsNil: true) + keywords = try container.decodeIfPresent([String].self, forKey: .keywords)? + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + palette = try container.decodeIfPresent(Bool.self, forKey: .palette) + shortcut = try Self.decodeShortcut(forKey: .shortcut, in: container) + icon = try container.decodeIfPresent(CmuxButtonIcon.self, forKey: .icon) + tooltip = try Self.trimmedString(forKey: .tooltip, in: container, allowBlankAsNil: true) + confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm) + terminalCommandTarget = try container.decodeIfPresent(CmuxConfigTerminalCommandTarget.self, forKey: .target) + + let inferredType: String? + if let type { + inferredType = type + } else if container.contains(.agent) { + inferredType = "agent" + } else if container.contains(.builtin) { + inferredType = "builtin" + } else if container.contains(.command) { + inferredType = "command" + } else { + inferredType = nil + } + + switch inferredType { + case "builtin": + let raw = try Self.trimmedString(forKey: .builtin, in: container) ?? "" + guard let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: raw) else { + throw DecodingError.dataCorruptedError( + forKey: .builtin, + in: container, + debugDescription: "Unknown built-in action '\(raw)'" + ) + } + action = .builtIn(builtIn) + case "command": + let command = try Self.requiredTrimmedString(forKey: .command, in: container) + action = .command(command) + case "agent": + let agent = try container.decode(CmuxConfigAgentKind.self, forKey: .agent) + let args = try Self.trimmedString(forKey: .args, in: container, allowBlankAsNil: true) + action = .agent(agent, args: args) + case "workspaceCommand": + let commandName = try Self.trimmedString(forKey: .commandName, in: container) + ?? Self.trimmedString(forKey: .name, in: container) + ?? Self.trimmedString(forKey: .command, in: container) + guard let commandName else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "workspaceCommand actions require commandName" + ) + ) + } + action = .workspaceCommand(commandName) + case nil: + action = nil + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown action type '\(inferredType ?? "")'" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(subtitle, forKey: .subtitle) + try container.encodeIfPresent(keywords, forKey: .keywords) + try container.encodeIfPresent(palette, forKey: .palette) + try Self.encodeShortcut(shortcut, forKey: .shortcut, in: &container) + try container.encodeIfPresent(icon, forKey: .icon) + try container.encodeIfPresent(tooltip, forKey: .tooltip) + try container.encodeIfPresent(confirm, forKey: .confirm) + try container.encodeIfPresent(terminalCommandTarget, forKey: .target) + guard let action else { return } + switch action { + case .builtIn(let builtIn): + try container.encode("builtin", forKey: .type) + try container.encode(builtIn.configID, forKey: .builtin) + case .command(let command): + try container.encode("command", forKey: .type) + try container.encode(command, forKey: .command) + case .agent(let agent, let args): + try container.encode("agent", forKey: .type) + try container.encode(agent, forKey: .agent) + try container.encodeIfPresent(args, forKey: .args) + case .workspaceCommand(let commandName): + try container.encode("workspaceCommand", forKey: .type) + try container.encode(commandName, forKey: .commandName) + case .actionReference(let identifier): + try container.encode("builtin", forKey: .type) + try container.encode(identifier, forKey: .builtin) + } + } + + private static func requiredTrimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer + ) throws -> String { + guard let value = try trimmedString(forKey: key, in: container) else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "\(key.stringValue) is required" + ) + ) + } + return value + } + + private static func trimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer, + allowBlankAsNil: Bool = false + ) throws -> String? { + guard container.contains(key) else { return nil } + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + if allowBlankAsNil { return nil } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } + + private static func decodeShortcut( + forKey key: CodingKeys, + in container: KeyedDecodingContainer + ) throws -> StoredShortcut? { + guard container.contains(key) else { return nil } + if let rawShortcut = try? container.decode(String.self, forKey: key) { + guard let shortcut = StoredShortcut.parseConfig(rawShortcut) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "shortcut must use modifier+key syntax like 'cmd+shift+t'" + ) + } + return shortcut + } + if let rawShortcut = try? container.decode([String].self, forKey: key) { + guard let shortcut = StoredShortcut.parseConfig(strokes: rawShortcut) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "shortcut chords must be one or two non-empty strokes" + ) + } + return shortcut + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "shortcut must be a string or array of one or two strings" + ) + } + + private static func encodeShortcut( + _ shortcut: StoredShortcut?, + forKey key: CodingKeys, + in container: inout KeyedEncodingContainer + ) throws { + guard let shortcut else { return } + if let secondStroke = shortcut.secondStroke { + try container.encode( + [shortcut.firstStroke.configString(), secondStroke.configString()], + forKey: key + ) + } else { + try container.encode(shortcut.firstStroke.configString(), forKey: key) + } + } +} + +enum CmuxSurfaceTabBarButtonAction: Sendable, Hashable { + case builtIn(CmuxSurfaceTabBarBuiltInAction) + case command(String) + case agent(CmuxConfigAgentKind, args: String?) + case workspaceCommand(String) + case actionReference(String) + + var defaultId: String { + switch self { + case .builtIn(let action): + return action.configID + case .command(let command): + return "command." + Self.generatedCommandId(for: command) + case .agent(let agent, _): + return agent.commandName + case .workspaceCommand(let commandName): + return "workspaceCommand." + Self.generatedCommandId(for: commandName) + case .actionReference(let identifier): + return identifier + } + } + + var defaultIcon: String { + defaultButtonIcon.symbolName + } + + var defaultButtonIcon: CmuxButtonIcon { + switch self { + case .builtIn(let action): + return .symbol(action.defaultIcon) + case .command: + return .symbol("terminal") + case .agent(let agent, _): + return agent.defaultIcon + case .workspaceCommand: + return .symbol("rectangle.stack.badge.plus") + case .actionReference: + return .symbol("questionmark.circle") + } + } + + var terminalCommand: String? { + switch self { + case .command(let command): + return command + case .agent(let agent, let args): + let trimmedArgs = args?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedArgs.isEmpty ? agent.commandName : "\(agent.commandName) \(trimmedArgs)" + case .builtIn, .workspaceCommand, .actionReference: + return nil + } + } + + var workspaceCommandName: String? { + if case .workspaceCommand(let name) = self { + return name + } + return nil + } + + private static func generatedCommandId(for command: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let encoded = command.addingPercentEncoding(withAllowedCharacters: allowed) ?? command + return encoded.isEmpty ? "command" : encoded + } +} + +struct CmuxSurfaceTabBarButton: Codable, Sendable, Hashable, Identifiable { + var id: String + var title: String? + var icon: CmuxButtonIcon? + var tooltip: String? + var action: CmuxSurfaceTabBarButtonAction + var confirm: Bool? + var terminalCommandTarget: CmuxConfigTerminalCommandTarget? + var actionSourcePath: String? + var iconSourcePath: String? + + private enum CodingKeys: String, CodingKey { + case id + case title + case icon + case tooltip + case action + case builtin + case command + case agent + case args + case type + case commandName + case name + case confirm + case target + } + + static let newTerminal = actionReference(CmuxSurfaceTabBarBuiltInAction.newTerminal.configID) + static let newBrowser = actionReference(CmuxSurfaceTabBarBuiltInAction.newBrowser.configID) + static let splitRight = actionReference(CmuxSurfaceTabBarBuiltInAction.splitRight.configID) + static let splitDown = actionReference(CmuxSurfaceTabBarBuiltInAction.splitDown.configID) + + static let defaults: [CmuxSurfaceTabBarButton] = [ + .newTerminal, + .newBrowser, + .splitRight, + .splitDown + ] + + static func builtIn( + _ action: CmuxSurfaceTabBarBuiltInAction, + id: String? = nil, + title: String? = nil, + icon: CmuxButtonIcon? = nil, + tooltip: String? = nil + ) -> CmuxSurfaceTabBarButton { + CmuxSurfaceTabBarButton( + id: id ?? action.configID, + title: title, + icon: icon, + tooltip: tooltip, + action: .builtIn(action), + confirm: nil, + terminalCommandTarget: nil + ) + } + + static func actionReference( + _ actionID: String, + title: String? = nil, + icon: CmuxButtonIcon? = nil, + tooltip: String? = nil + ) -> CmuxSurfaceTabBarButton { + CmuxSurfaceTabBarButton( + id: actionID, + title: title, + icon: icon, + tooltip: tooltip, + action: .actionReference(actionID) + ) + } + + var command: String? { + action.terminalCommand + } + + var terminalCommand: String? { + action.terminalCommand + } + + var resolvedTerminalCommandTarget: CmuxConfigTerminalCommandTarget { + terminalCommandTarget ?? CmuxConfigTerminalCommandTarget.defaultForActions + } + + var workspaceCommandName: String? { + action.workspaceCommandName + } + + func bonsplitActionButton( + configSourcePath: String?, + globalConfigPath: String, + allowProjectLocalIcon: Bool = true + ) -> BonsplitConfiguration.SplitActionButton { + let bonsplitAction: BonsplitConfiguration.SplitActionButton.Action = { + switch action { + case .builtIn(let builtIn): + return builtIn.bonsplitAction + case .command, .agent, .workspaceCommand, .actionReference: + return .custom(id) + } + }() + + return BonsplitConfiguration.SplitActionButton( + id: id, + icon: (icon ?? action.defaultButtonIcon).bonsplitIcon( + configSourcePath: iconSourcePath ?? configSourcePath, + globalConfigPath: globalConfigPath, + allowProjectLocalImage: allowProjectLocalIcon + ), + tooltip: tooltip ?? title ?? terminalCommand, + action: bonsplitAction + ) + } + + init( + id: String, + title: String? = nil, + icon: CmuxButtonIcon? = nil, + tooltip: String? = nil, + action: CmuxSurfaceTabBarButtonAction, + confirm: Bool? = nil, + terminalCommandTarget: CmuxConfigTerminalCommandTarget? = nil, + actionSourcePath: String? = nil, + iconSourcePath: String? = nil + ) { + self.id = id + self.title = title + self.icon = icon + self.tooltip = tooltip + self.action = action + self.confirm = confirm + self.terminalCommandTarget = terminalCommandTarget + self.actionSourcePath = actionSourcePath + self.iconSourcePath = iconSourcePath + } + + init(from decoder: Decoder) throws { + if let legacy = try? decoder.singleValueContainer().decode(String.self) { + let trimmed = legacy.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "surface tab bar button action must not be blank" + ) + ) + } + self = CmuxSurfaceTabBarButton( + id: CmuxSurfaceTabBarBuiltInAction(configID: trimmed)?.configID ?? trimmed, + action: .actionReference(CmuxSurfaceTabBarBuiltInAction(configID: trimmed)?.configID ?? trimmed) + ) + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + let explicitId = try Self.trimmedString(forKey: .id, in: container) + let explicitTitle = try Self.trimmedString(forKey: .title, in: container, allowBlankAsNil: true) + let explicitIcon = try container.decodeIfPresent(CmuxButtonIcon.self, forKey: .icon) + let explicitTooltip = try Self.trimmedString(forKey: .tooltip, in: container, allowBlankAsNil: true) + let rawAction = try Self.trimmedString(forKey: .action, in: container) + let rawBuiltin = try Self.trimmedString(forKey: .builtin, in: container) + let rawCommand = try Self.trimmedString(forKey: .command, in: container) + let rawAgent = try container.decodeIfPresent(CmuxConfigAgentKind.self, forKey: .agent) + let rawArgs = try Self.trimmedString(forKey: .args, in: container, allowBlankAsNil: true) + let rawType = try Self.trimmedString(forKey: .type, in: container) + let rawCommandName = try Self.trimmedString(forKey: .commandName, in: container) + ?? Self.trimmedString(forKey: .name, in: container) + confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm) + terminalCommandTarget = try container.decodeIfPresent(CmuxConfigTerminalCommandTarget.self, forKey: .target) + actionSourcePath = nil + iconSourcePath = nil + + let definedActionForms = [ + rawAction != nil, + rawBuiltin != nil, + rawCommand != nil, + rawAgent != nil, + rawType != nil + ].filter(\.self).count + if definedActionForms > 1 { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "surfaceTabBarButtons entries must define only one of 'action', 'builtin', 'command', 'agent', or 'type'" + ) + ) + } + + if let rawType { + switch rawType { + case "workspaceCommand": + guard let rawCommandName else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "workspaceCommand surface tab bar buttons require commandName" + ) + ) + } + action = .workspaceCommand(rawCommandName) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown surface tab bar button type '\(rawType)'" + ) + } + } else if let rawCommand { + action = .command(rawCommand) + } else if let rawAgent { + action = .agent(rawAgent, args: rawArgs) + } else if let rawBuiltin { + guard let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: rawBuiltin) else { + throw DecodingError.dataCorruptedError( + forKey: .builtin, + in: container, + debugDescription: "Unknown built-in surface tab bar action '\(rawBuiltin)'" + ) + } + action = .builtIn(builtIn) + } else if let rawAction { + action = .actionReference(rawAction) + } else if let explicitId, + let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: explicitId) { + action = .builtIn(builtIn) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "surfaceTabBarButtons entries must define 'action', 'builtin', 'command', 'agent', or 'type'" + ) + ) + } + + id = explicitId ?? action.defaultId + title = explicitTitle + icon = explicitIcon + tooltip = explicitTooltip + } + + func resolved( + actions: [String: CmuxResolvedConfigAction], + codingPath: [CodingKey] + ) throws -> CmuxSurfaceTabBarButton { + guard case .actionReference(let identifier) = action else { + return self + } + + if let definition = actions[identifier] { + return CmuxSurfaceTabBarButton( + id: id, + title: title ?? definition.title, + icon: icon ?? definition.icon, + tooltip: tooltip ?? definition.tooltip, + action: definition.action, + confirm: confirm ?? definition.confirm, + terminalCommandTarget: terminalCommandTarget ?? definition.terminalCommandTarget, + actionSourcePath: definition.actionSourcePath, + iconSourcePath: icon == nil ? definition.iconSourcePath : iconSourcePath + ) + } + + if let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: identifier) { + return CmuxSurfaceTabBarButton( + id: id, + title: title, + icon: icon, + tooltip: tooltip, + action: .builtIn(builtIn), + confirm: confirm, + terminalCommandTarget: terminalCommandTarget + ) + } + + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "Unknown action reference '\(identifier)'" + ) + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(icon, forKey: .icon) + try container.encodeIfPresent(tooltip, forKey: .tooltip) + try container.encodeIfPresent(confirm, forKey: .confirm) + try container.encodeIfPresent(terminalCommandTarget, forKey: .target) + + switch action { + case .builtIn(let builtIn): + try container.encode(builtIn.configID, forKey: .builtin) + case .command(let command): + try container.encode(command, forKey: .command) + case .agent(let agent, let args): + try container.encode(agent, forKey: .agent) + try container.encodeIfPresent(args, forKey: .args) + case .workspaceCommand(let commandName): + try container.encode("workspaceCommand", forKey: .type) + try container.encode(commandName, forKey: .commandName) + case .actionReference(let identifier): + try container.encode(identifier, forKey: .action) + } + } + + private static func trimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer, + allowBlankAsNil: Bool = false + ) throws -> String? { + guard container.contains(key) else { return nil } + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + if allowBlankAsNil { return nil } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } +} + +struct CmuxResolvedConfigAction: Identifiable, Sendable, Hashable { + var id: String + var title: String + var subtitle: String? + var keywords: [String] + var palette: Bool + var shortcut: StoredShortcut? + var icon: CmuxButtonIcon? + var tooltip: String? + var action: CmuxSurfaceTabBarButtonAction + var confirm: Bool? + var terminalCommandTarget: CmuxConfigTerminalCommandTarget? + var actionSourcePath: String? + var iconSourcePath: String? + + var terminalCommand: String? { + action.terminalCommand + } + + var workspaceCommandName: String? { + action.workspaceCommandName + } + + func applying( + _ definition: CmuxConfigActionDefinition, + sourcePath: String? + ) -> CmuxResolvedConfigAction? { + var next = self + next.title = definition.title ?? next.title + next.subtitle = definition.subtitle ?? next.subtitle + if let keywords = definition.keywords { + next.keywords = keywords + } + next.palette = definition.palette ?? next.palette + next.shortcut = definition.shortcut ?? next.shortcut + if let icon = definition.icon { + next.icon = icon + next.iconSourcePath = sourcePath + } + next.tooltip = definition.tooltip ?? next.tooltip + next.confirm = definition.confirm ?? next.confirm + next.terminalCommandTarget = definition.terminalCommandTarget ?? next.terminalCommandTarget + next.actionSourcePath = sourcePath ?? next.actionSourcePath + if let action = definition.action { + next.action = action + } + return next + } + + static func fromDefinition( + id: String, + definition: CmuxConfigActionDefinition, + sourcePath: String? + ) -> CmuxResolvedConfigAction? { + guard let action = definition.action else { return nil } + let title = definition.title + ?? definition.tooltip + ?? Self.defaultTitle(for: id, action: action) + return CmuxResolvedConfigAction( + id: id, + title: title, + subtitle: definition.subtitle, + keywords: definition.keywords ?? [], + palette: definition.palette ?? true, + shortcut: definition.shortcut, + icon: definition.icon ?? action.defaultButtonIcon, + tooltip: definition.tooltip, + action: action, + confirm: definition.confirm, + terminalCommandTarget: definition.terminalCommandTarget, + actionSourcePath: sourcePath, + iconSourcePath: definition.icon == nil ? nil : sourcePath + ) + } + + static func builtIn(_ builtIn: CmuxSurfaceTabBarBuiltInAction) -> CmuxResolvedConfigAction { + let title: String + let keywords: [String] + switch builtIn { + case .newTerminal: + title = String(localized: "command.newTerminalTab.title", defaultValue: "New Terminal Tab") + keywords = ["new", "terminal", "tab", "surface"] + case .newBrowser: + title = String(localized: "command.newBrowserTab.title", defaultValue: "New Browser Tab") + keywords = ["new", "browser", "tab", "surface"] + case .splitRight: + title = String(localized: "command.terminalSplitRight.title", defaultValue: "Split Right") + keywords = ["terminal", "split", "right"] + case .splitDown: + title = String(localized: "command.terminalSplitDown.title", defaultValue: "Split Down") + keywords = ["terminal", "split", "down"] + } + + return CmuxResolvedConfigAction( + id: builtIn.configID, + title: title, + subtitle: String(localized: "command.cmuxConfig.builtInSubtitle", defaultValue: "cmux"), + keywords: keywords, + palette: true, + shortcut: nil, + icon: .symbol(builtIn.defaultIcon), + tooltip: nil, + action: .builtIn(builtIn), + confirm: nil, + terminalCommandTarget: nil, + actionSourcePath: nil, + iconSourcePath: nil + ) + } + + private static func defaultTitle(for id: String, action: CmuxSurfaceTabBarButtonAction) -> String { + switch action { + case .agent(let agent, _): + switch agent { + case .codex: + return String(localized: "command.cmuxConfig.defaultCodexTitle", defaultValue: "Codex") + case .claudeCode: + return String(localized: "command.cmuxConfig.defaultClaudeCodeTitle", defaultValue: "Claude Code") + } + case .command: + return id + case .workspaceCommand(let commandName): + return commandName + case .builtIn(let builtIn): + return builtIn.configID + case .actionReference(let identifier): + return identifier + } + } } struct CmuxCommandDefinition: Codable, Sendable, Identifiable { @@ -256,23 +1752,131 @@ enum CmuxSurfaceType: String, Codable, Sendable { case browser } +struct CmuxResolvedCommand: Sendable { + let command: CmuxCommandDefinition + let sourcePath: String? +} + +struct CmuxConfigIssue: Identifiable, Equatable, Sendable { + enum Kind: String, Sendable { + case newWorkspaceActionRequiresWorkspaceCommand + case newWorkspaceCommandNotFound + case newWorkspaceCommandRequiresWorkspace + case schemaError + } + + let kind: Kind + let settingName: String + let commandName: String? + let sourcePath: String? + let message: String? + + init( + kind: Kind, + settingName: String, + commandName: String? = nil, + sourcePath: String? = nil, + message: String? = nil + ) { + self.kind = kind + self.settingName = settingName + self.commandName = commandName + self.sourcePath = sourcePath + self.message = message + } + + var id: String { + [ + kind.rawValue, + settingName, + commandName ?? "", + sourcePath ?? "", + message ?? "" + ].joined(separator: "|") + } + + var logMessage: String { + switch kind { + case .newWorkspaceActionRequiresWorkspaceCommand: + return "\(settingName) must reference a workspaceCommand action" + case .newWorkspaceCommandNotFound: + return "\(settingName) '\(commandName ?? "")' does not match any loaded command" + case .newWorkspaceCommandRequiresWorkspace: + return "\(settingName) '\(commandName ?? "")' must reference a workspace command" + case .schemaError: + return "\(settingName) has a schema error: \(message ?? "unknown error")" + } + } +} + @MainActor final class CmuxConfigStore: ObservableObject { @Published private(set) var loadedCommands: [CmuxCommandDefinition] = [] + @Published private(set) var loadedActions: [CmuxResolvedConfigAction] = [] + @Published private(set) var newWorkspaceCommandName: String? + @Published private(set) var newWorkspaceAction: CmuxConfigActionDefinition? + @Published private(set) var surfaceTabBarButtons: [CmuxSurfaceTabBarButton] = CmuxSurfaceTabBarButton.defaults + @Published private(set) var configurationIssues: [CmuxConfigIssue] = [] @Published private(set) var configRevision: UInt64 = 0 /// Which config file each command came from, keyed by command id. private(set) var commandSourcePaths: [String: String] = [:] + private(set) var actionLookup: [String: CmuxResolvedConfigAction] = [:] + private(set) var surfaceTabBarButtonSourcePath: String? + private(set) var surfaceTabBarCommandSourcePaths: [String: String] = [:] + private(set) var newWorkspaceActionSourcePath: String? private(set) var localConfigPath: String? - let globalConfigPath: String = { + private weak var tabManager: TabManager? + let globalConfigPath: String + + nonisolated private static func defaultGlobalConfigPath() -> String { let home = FileManager.default.homeDirectoryForCurrentUser.path return (home as NSString).appendingPathComponent(".config/cmux/cmux.json") - }() + } + + private struct ActionEntry { + let definition: CmuxConfigActionDefinition + let sourcePath: String? + } + + private struct ResolvedSurfaceTabBarButtonEntry { + let button: CmuxSurfaceTabBarButton + let terminalCommandSourcePath: String? + } + + private struct ResolvedSurfaceTabBarButtons { + let buttons: [CmuxSurfaceTabBarButton] + let terminalCommandSourcePaths: [String: String] + } + + private struct NewWorkspaceCommandResolution { + let command: CmuxResolvedCommand? + let issue: CmuxConfigIssue? + } + + private struct ParsedConfigCacheEntry { + let fileSize: UInt64 + let modificationDate: Date? + let config: CmuxConfigFile? + let issue: CmuxConfigIssue? + } + + private struct ParsedConfigResult { + let config: CmuxConfigFile? + let issue: CmuxConfigIssue? + } - private var cancellables = Set() + private var surfaceTabBarWorkspaceCommands: [String: CmuxResolvedCommand] = [:] + private var resolvedNewWorkspaceCommandCache: CmuxResolvedCommand? + private var parsedConfigCache: [String: ParsedConfigCacheEntry] = [:] + private var lifetimeCancellables = Set() + private var trackingCancellables = Set() private var localFileWatchSource: DispatchSourceFileSystemObject? private var localFileDescriptor: Int32 = -1 + private var localConfigSearchDirectory: String? + private var localFallbackDirectoryWatchSource: DispatchSourceFileSystemObject? + private var localFallbackDirectoryDescriptor: Int32 = -1 private var globalFileWatchSource: DispatchSourceFileSystemObject? private var globalFileDescriptor: Int32 = -1 private let watchQueue = DispatchQueue(label: "com.cmux.config-file-watch") @@ -280,19 +1884,49 @@ final class CmuxConfigStore: ObservableObject { private static let maxReattachAttempts = 5 private static let reattachDelay: TimeInterval = 0.5 - init() { - startGlobalFileWatcher() + private static func searchDirectoryForLocalConfigPath(_ path: String) -> String { + let configDirectory = (path as NSString).deletingLastPathComponent + if (configDirectory as NSString).lastPathComponent == ".cmux" { + return (configDirectory as NSString).deletingLastPathComponent + } + return configDirectory + } + + init( + globalConfigPath: String = CmuxConfigStore.defaultGlobalConfigPath(), + localConfigPath: String? = nil, + startFileWatchers: Bool = true + ) { + self.globalConfigPath = globalConfigPath + self.localConfigPath = localConfigPath + self.localConfigSearchDirectory = localConfigPath.map(Self.searchDirectoryForLocalConfigPath(_:)) + NotificationCenter.default.publisher(for: CmuxActionTrust.didChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + self.applySurfaceTabBarButtonsToCurrentManager() + self.configRevision &+= 1 + } + .store(in: &lifetimeCancellables) + if startFileWatchers { + if localConfigPath != nil { + startLocalFileWatcher() + } + startGlobalFileWatcher() + } } deinit { localFileWatchSource?.cancel() + localFallbackDirectoryWatchSource?.cancel() globalFileWatchSource?.cancel() } // MARK: - Public API func wireDirectoryTracking(tabManager: TabManager) { - cancellables.removeAll() + trackingCancellables.removeAll() + self.tabManager = tabManager tabManager.$selectedTabId .compactMap { [weak tabManager] tabId -> Workspace? in @@ -300,28 +1934,33 @@ final class CmuxConfigStore: ObservableObject { return tabManager.tabs.first(where: { $0.id == tabId }) } .removeDuplicates(by: { $0.id == $1.id }) - .map { workspace -> AnyPublisher in - workspace.$currentDirectory.eraseToAnyPublisher() + .map { workspace -> AnyPublisher in + workspace.$surfaceTabBarDirectory.eraseToAnyPublisher() } .switchToLatest() .removeDuplicates() - .receive(on: DispatchQueue.main) .sink { [weak self] directory in self?.updateLocalConfigPath(directory) } - .store(in: &cancellables) + .store(in: &trackingCancellables) - if let directory = tabManager.selectedWorkspace?.currentDirectory { - updateLocalConfigPath(directory) - } + tabManager.$tabs + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.applySurfaceTabBarButtonsToCurrentManager() + } + .store(in: &trackingCancellables) + + updateLocalConfigPath(tabManager.selectedWorkspace?.surfaceTabBarDirectory) } private func updateLocalConfigPath(_ directory: String?) { let newPath: String? if let directory, !directory.isEmpty { - newPath = findCmuxConfig(startingFrom: directory) - ?? (directory as NSString).appendingPathComponent("cmux.json") + localConfigSearchDirectory = directory + newPath = resolvedLocalConfigPath(startingFrom: directory) } else { + localConfigSearchDirectory = nil newPath = nil } @@ -334,12 +1973,26 @@ final class CmuxConfigStore: ObservableObject { loadAll() } + private func resolvedLocalConfigPath(startingFrom directory: String) -> String { + findCmuxConfig(startingFrom: directory) + ?? defaultLocalConfigPath(startingFrom: directory) + } + + private func defaultLocalConfigPath(startingFrom directory: String) -> String { + (((directory as NSString).appendingPathComponent(".cmux") as NSString) + .appendingPathComponent("cmux.json")) + } + private func findCmuxConfig(startingFrom directory: String) -> String? { var current = directory let fs = FileManager.default while true { - let candidate = (current as NSString).appendingPathComponent("cmux.json") - if fs.fileExists(atPath: candidate) { + let candidates = [ + ((current as NSString).appendingPathComponent(".cmux") as NSString) + .appendingPathComponent("cmux.json"), + (current as NSString).appendingPathComponent("cmux.json") + ] + for candidate in candidates where fs.fileExists(atPath: candidate) { return candidate } let parent = (current as NSString).deletingLastPathComponent @@ -353,14 +2006,52 @@ final class CmuxConfigStore: ObservableObject { var commands: [CmuxCommandDefinition] = [] var seenNames = Set() var sourcePaths: [String: String] = [:] + var configuredNewWorkspaceCommandName: String? + var configuredNewWorkspaceCommandSourcePath: String? + var configuredNewWorkspaceAction: CmuxConfigActionDefinition? + var configuredNewWorkspaceActionSourcePath: String? + var configuredSurfaceTabBarButtons: [CmuxSurfaceTabBarButton]? + var configuredSurfaceTabBarButtonSourcePath: String? + let localPath = localConfigPath + let localParseResult = localPath.map { parseConfig(at: $0) } + let globalParseResult = parseConfig(at: globalConfigPath) + let localConfig = localParseResult?.config + let globalConfig = globalParseResult.config + var issues = [CmuxConfigIssue]() + if let issue = localParseResult?.issue { + issues.append(issue) + } + if let issue = globalParseResult.issue { + issues.append(issue) + } + let localActions = localConfig.map { actionEntries(from: $0.actions, sourcePath: localPath) } ?? [:] + let globalActions = globalConfig.map { actionEntries(from: $0.actions, sourcePath: globalConfigPath) } ?? [:] + let localActionLookup = mergedActionEntries(primary: localActions, fallback: globalActions) // Local config takes precedence - if let localPath = localConfigPath { - if let localConfig = parseConfig(at: localPath) { - for command in localConfig.commands { - if !seenNames.contains(command.name) { - commands.append(command) - seenNames.insert(command.name) + if let localConfig { + if let newWorkspaceActionID = localConfig.ui?.newWorkspace?.action { + if let action = localActionLookup[newWorkspaceActionID] { + configuredNewWorkspaceAction = action.definition + configuredNewWorkspaceActionSourcePath = action.sourcePath + } else { + NSLog("[CmuxConfig] ui.newWorkspace.action '%@' does not match any local or global action", newWorkspaceActionID) + } + } + if configuredNewWorkspaceAction == nil, + let newWorkspaceCommand = localConfig.newWorkspaceCommand { + configuredNewWorkspaceCommandName = newWorkspaceCommand + configuredNewWorkspaceCommandSourcePath = localPath + } + if let buttons = localConfig.surfaceTabBarButtons { + configuredSurfaceTabBarButtons = buttons + configuredSurfaceTabBarButtonSourcePath = localPath + } + for command in localConfig.commands { + if !seenNames.contains(command.name) { + commands.append(command) + seenNames.insert(command.name) + if let localPath { sourcePaths[command.id] = localPath } } @@ -368,7 +2059,28 @@ final class CmuxConfigStore: ObservableObject { } // Global config fills in the rest - if let globalConfig = parseConfig(at: globalConfigPath) { + if let globalConfig { + if configuredNewWorkspaceAction == nil, + configuredNewWorkspaceCommandName == nil, + let newWorkspaceActionID = globalConfig.ui?.newWorkspace?.action { + if let action = localActionLookup[newWorkspaceActionID] { + configuredNewWorkspaceAction = action.definition + configuredNewWorkspaceActionSourcePath = action.sourcePath + } else { + NSLog("[CmuxConfig] ui.newWorkspace.action '%@' does not match any local or global action", newWorkspaceActionID) + } + } + if configuredNewWorkspaceAction == nil, + configuredNewWorkspaceCommandName == nil, + let newWorkspaceCommand = globalConfig.newWorkspaceCommand { + configuredNewWorkspaceCommandName = newWorkspaceCommand + configuredNewWorkspaceCommandSourcePath = globalConfigPath + } + if configuredSurfaceTabBarButtons == nil, + let buttons = globalConfig.surfaceTabBarButtons { + configuredSurfaceTabBarButtons = buttons + configuredSurfaceTabBarButtonSourcePath = globalConfigPath + } for command in globalConfig.commands { if !seenNames.contains(command.name) { commands.append(command) @@ -378,27 +2090,506 @@ final class CmuxConfigStore: ObservableObject { } } + let resolvedActions = resolvedActionRegistry( + globalActions: globalActions, + localActions: localActions, + commands: commands, + commandSourcePaths: sourcePaths + ) + let resolvedActionLookup = Dictionary(uniqueKeysWithValues: resolvedActions.map { ($0.id, $0) }) + let configuredButtons = configuredSurfaceTabBarButtons ?? CmuxSurfaceTabBarButton.defaults + let defaultResolvedButtons = (try? CmuxSurfaceTabBarButton.defaults.map { + try $0.resolved(actions: resolvedActionLookup, codingPath: []) + }) ?? [ + .builtIn(.newTerminal), + .builtIn(.newBrowser), + .builtIn(.splitRight), + .builtIn(.splitDown) + ] + let resolvedButtons = resolvedSurfaceTabBarButtons( + configuredButtons, + actions: resolvedActionLookup, + settingName: "ui.surfaceTabBar.buttons" + ) ?? ResolvedSurfaceTabBarButtons( + buttons: defaultResolvedButtons, + terminalCommandSourcePaths: [:] + ) + let resolvedWorkspaceButtons = resolvedSurfaceTabBarWorkspaceCommands( + resolvedButtons.buttons, + commands: commands, + sourcePaths: sourcePaths + ) + let resolvedNewWorkspaceCommand = resolvedConfiguredNewWorkspaceCommand( + action: configuredNewWorkspaceAction, + actionSourcePath: configuredNewWorkspaceActionSourcePath, + commandName: configuredNewWorkspaceCommandName, + commandSourcePath: configuredNewWorkspaceCommandSourcePath, + commands: commands, + sourcePaths: sourcePaths + ) + loadedCommands = commands + loadedActions = resolvedActions commandSourcePaths = sourcePaths + actionLookup = resolvedActionLookup + newWorkspaceAction = configuredNewWorkspaceAction + newWorkspaceActionSourcePath = configuredNewWorkspaceActionSourcePath + newWorkspaceCommandName = configuredNewWorkspaceCommandName + surfaceTabBarButtonSourcePath = configuredSurfaceTabBarButtonSourcePath + surfaceTabBarCommandSourcePaths = resolvedButtons.terminalCommandSourcePaths + surfaceTabBarWorkspaceCommands = resolvedWorkspaceButtons.workspaceCommands + surfaceTabBarButtons = resolvedWorkspaceButtons.buttons + resolvedNewWorkspaceCommandCache = resolvedNewWorkspaceCommand.command + if let issue = resolvedNewWorkspaceCommand.issue { + issues.append(issue) + } + configurationIssues = issues + applySurfaceTabBarButtonsToCurrentManager() configRevision &+= 1 } + private func actionEntries( + from actions: [String: CmuxConfigActionDefinition], + sourcePath: String? + ) -> [String: ActionEntry] { + actions.mapValues { ActionEntry(definition: $0, sourcePath: sourcePath) } + } + + private func mergedActionEntries( + primary: [String: ActionEntry], + fallback: [String: ActionEntry] + ) -> [String: ActionEntry] { + fallback.merging(primary) { _, primary in primary } + } + + private func sanitizeConfigText(_ text: String) -> String { + let dangerous: Set = [ + "\u{200B}", "\u{200C}", "\u{200D}", "\u{200E}", "\u{200F}", + "\u{202A}", "\u{202B}", "\u{202C}", "\u{202D}", "\u{202E}", + "\u{2066}", "\u{2067}", "\u{2068}", "\u{2069}", + "\u{FEFF}", + ] + let filtered = String(text.unicodeScalars.filter { !dangerous.contains($0) }) + return filtered.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func resolvedActionRegistry( + globalActions: [String: ActionEntry], + localActions: [String: ActionEntry], + commands: [CmuxCommandDefinition], + commandSourcePaths: [String: String] + ) -> [CmuxResolvedConfigAction] { + var registry = Dictionary( + uniqueKeysWithValues: CmuxSurfaceTabBarBuiltInAction.allCases.map { + ($0.configID, CmuxResolvedConfigAction.builtIn($0)) + } + ) + + func apply(_ entries: [String: ActionEntry]) { + for (id, entry) in entries { + if let existing = registry[id] { + guard let resolved = existing.applying(entry.definition, sourcePath: entry.sourcePath) else { continue } + registry[id] = resolved + } else if let resolved = CmuxResolvedConfigAction.fromDefinition( + id: id, + definition: entry.definition, + sourcePath: entry.sourcePath + ) { + registry[id] = resolved + } else { + NSLog("[CmuxConfig] action '%@' ignored because it does not define a runnable action", id) + } + } + } + + apply(globalActions) + apply(localActions) + + for command in commands where registry[command.id] == nil { + let sourcePath = commandSourcePaths[command.id] + registry[command.id] = CmuxResolvedConfigAction( + id: command.id, + title: String( + localized: "command.cmuxConfig.customTitle", + defaultValue: "Custom: \(sanitizeConfigText(command.name))" + ), + subtitle: command.description.map { sanitizeConfigText($0) } + ?? String(localized: "command.cmuxConfig.subtitle", defaultValue: "cmux.json"), + keywords: command.keywords ?? [], + palette: true, + shortcut: nil, + icon: .symbol(command.workspace == nil ? "terminal" : "rectangle.stack.badge.plus"), + tooltip: command.description, + action: command.workspace == nil + ? .command(command.command ?? "") + : .workspaceCommand(command.name), + confirm: command.confirm, + terminalCommandTarget: command.workspace == nil ? .currentTerminal : nil, + actionSourcePath: sourcePath, + iconSourcePath: nil + ) + } + + return registry.values.sorted { lhs, rhs in + lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending + } + } + + private func resolvedSurfaceTabBarButtons( + _ buttons: [CmuxSurfaceTabBarButton], + actions: [String: CmuxResolvedConfigAction], + settingName: String + ) -> ResolvedSurfaceTabBarButtons? { + var resolvedButtons: [CmuxSurfaceTabBarButton] = [] + var terminalCommandSourcePaths: [String: String] = [:] + resolvedButtons.reserveCapacity(buttons.count) + + for button in buttons { + do { + let resolved = try resolvedSurfaceTabBarButton(button, actions: actions) + resolvedButtons.append(resolved.button) + guard resolved.button.terminalCommand != nil else { continue } + if let commandSourcePath = resolved.terminalCommandSourcePath { + terminalCommandSourcePaths[resolved.button.id] = commandSourcePath + } + } catch { + NSLog("[CmuxConfig] %@ ignored: %@", settingName, String(describing: error)) + return nil + } + } + + return ResolvedSurfaceTabBarButtons( + buttons: resolvedButtons, + terminalCommandSourcePaths: terminalCommandSourcePaths + ) + } + + private func resolvedSurfaceTabBarButton( + _ button: CmuxSurfaceTabBarButton, + actions: [String: CmuxResolvedConfigAction] + ) throws -> ResolvedSurfaceTabBarButtonEntry { + guard case .actionReference(let identifier) = button.action else { + return ResolvedSurfaceTabBarButtonEntry(button: button, terminalCommandSourcePath: nil) + } + + if let entry = actions[identifier] { + let resolvedButton = CmuxSurfaceTabBarButton( + id: button.id, + title: button.title ?? entry.title, + icon: button.icon ?? entry.icon, + tooltip: button.tooltip ?? entry.tooltip ?? entry.title, + action: entry.action, + confirm: button.confirm ?? entry.confirm, + terminalCommandTarget: button.terminalCommandTarget ?? entry.terminalCommandTarget, + actionSourcePath: entry.actionSourcePath, + iconSourcePath: button.icon == nil ? entry.iconSourcePath : button.iconSourcePath + ) + return ResolvedSurfaceTabBarButtonEntry( + button: resolvedButton, + terminalCommandSourcePath: resolvedButton.terminalCommand == nil ? nil : entry.actionSourcePath + ) + } + + if let builtIn = CmuxSurfaceTabBarBuiltInAction(configID: identifier) { + return ResolvedSurfaceTabBarButtonEntry( + button: CmuxSurfaceTabBarButton( + id: button.id, + title: button.title, + icon: button.icon, + tooltip: button.tooltip, + action: .builtIn(builtIn), + confirm: button.confirm, + terminalCommandTarget: button.terminalCommandTarget + ), + terminalCommandSourcePath: nil + ) + } + + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: [], + debugDescription: "Unknown action reference '\(identifier)'" + ) + ) + } + + private func applySurfaceTabBarButtonsToCurrentManager() { + tabManager?.applySurfaceTabBarButtons( + surfaceTabBarButtons, + sourcePath: surfaceTabBarButtonSourcePath, + globalConfigPath: globalConfigPath, + terminalCommandSourcePaths: surfaceTabBarCommandSourcePaths, + workspaceCommands: surfaceTabBarWorkspaceCommands + ) + } + + private func resolvedSurfaceTabBarWorkspaceCommands( + _ buttons: [CmuxSurfaceTabBarButton], + commands: [CmuxCommandDefinition], + sourcePaths: [String: String] + ) -> (buttons: [CmuxSurfaceTabBarButton], workspaceCommands: [String: CmuxResolvedCommand]) { + var visibleButtons: [CmuxSurfaceTabBarButton] = [] + var workspaceCommands: [String: CmuxResolvedCommand] = [:] + visibleButtons.reserveCapacity(buttons.count) + + for button in buttons { + guard let commandName = button.workspaceCommandName else { + visibleButtons.append(button) + continue + } + + guard let command = resolvedWorkspaceCommand( + named: commandName, + settingName: "surfaceTabBarButtons action", + commands: commands, + sourcePaths: sourcePaths + ) else { + NSLog( + "[CmuxConfig] surfaceTabBarButtons action '%@' hidden because workspace command '%@' is unavailable", + button.id, + commandName + ) + continue + } + + visibleButtons.append(button) + workspaceCommands[button.id] = command + } + + return (visibleButtons, workspaceCommands) + } + + func resolvedNewWorkspaceCommand() -> CmuxResolvedCommand? { + resolvedNewWorkspaceCommandCache + } + + func resolvedAction(id: String) -> CmuxResolvedConfigAction? { + actionLookup[id] + } + + func paletteCustomActions() -> [CmuxResolvedConfigAction] { + let builtInIDs = Set(CmuxSurfaceTabBarBuiltInAction.allCases.map(\.configID)) + return loadedActions.filter { action in + action.palette && !builtInIDs.contains(action.id) + } + } + + func shortcutActions() -> [CmuxResolvedConfigAction] { + let builtInIDs = Set(CmuxSurfaceTabBarBuiltInAction.allCases.map(\.configID)) + return loadedActions.filter { action in + action.shortcut != nil && (builtInIDs.contains(action.id) || action.actionSourcePath != nil) + }.sorted { lhs, rhs in + let lhsPriority = builtInIDs.contains(lhs.id) ? 0 : 1 + let rhsPriority = builtInIDs.contains(rhs.id) ? 0 : 1 + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending + } + } + + private func resolvedConfiguredNewWorkspaceCommand( + action: CmuxConfigActionDefinition?, + actionSourcePath: String?, + commandName: String?, + commandSourcePath: String?, + commands: [CmuxCommandDefinition], + sourcePaths: [String: String] + ) -> NewWorkspaceCommandResolution { + if let action { + guard let actionCommandName = action.action?.workspaceCommandName else { + return newWorkspaceResolutionIssue( + kind: .newWorkspaceActionRequiresWorkspaceCommand, + settingName: "ui.newWorkspace.action", + commandName: nil, + sourcePath: actionSourcePath + ) + } + return resolvedConfiguredNewWorkspaceCommand( + named: actionCommandName, + settingName: "ui.newWorkspace.action", + settingSourcePath: actionSourcePath, + commands: commands, + sourcePaths: sourcePaths + ) + } + + guard let commandName else { + return NewWorkspaceCommandResolution(command: nil, issue: nil) + } + return resolvedConfiguredNewWorkspaceCommand( + named: commandName, + settingName: "newWorkspaceCommand", + settingSourcePath: commandSourcePath, + commands: commands, + sourcePaths: sourcePaths + ) + } + + private func resolvedConfiguredNewWorkspaceCommand( + named commandName: String, + settingName: String, + settingSourcePath: String?, + commands: [CmuxCommandDefinition], + sourcePaths: [String: String] + ) -> NewWorkspaceCommandResolution { + guard let command = commands.first(where: { $0.name == commandName }) else { + return newWorkspaceResolutionIssue( + kind: .newWorkspaceCommandNotFound, + settingName: settingName, + commandName: commandName, + sourcePath: settingSourcePath + ) + } + guard command.workspace != nil else { + return newWorkspaceResolutionIssue( + kind: .newWorkspaceCommandRequiresWorkspace, + settingName: settingName, + commandName: commandName, + sourcePath: sourcePaths[command.id] ?? settingSourcePath + ) + } + return NewWorkspaceCommandResolution( + command: CmuxResolvedCommand(command: command, sourcePath: sourcePaths[command.id]), + issue: nil + ) + } + + private func newWorkspaceResolutionIssue( + kind: CmuxConfigIssue.Kind, + settingName: String, + commandName: String?, + sourcePath: String? + ) -> NewWorkspaceCommandResolution { + let issue = CmuxConfigIssue( + kind: kind, + settingName: settingName, + commandName: commandName, + sourcePath: sourcePath + ) + NSLog("[CmuxConfig] %@", issue.logMessage) + return NewWorkspaceCommandResolution(command: nil, issue: issue) + } + + private func resolvedWorkspaceCommand( + named commandName: String, + settingName: String + ) -> CmuxResolvedCommand? { + resolvedWorkspaceCommand( + named: commandName, + settingName: settingName, + commands: loadedCommands, + sourcePaths: commandSourcePaths + ) + } + + private func resolvedWorkspaceCommand( + named commandName: String, + settingName: String, + commands: [CmuxCommandDefinition], + sourcePaths: [String: String] + ) -> CmuxResolvedCommand? { + guard let command = commands.first(where: { $0.name == commandName }) else { + NSLog("[CmuxConfig] %@ '%@' does not match any loaded command", settingName, commandName) + return nil + } + guard command.workspace != nil else { + NSLog("[CmuxConfig] %@ '%@' must reference a workspace command", settingName, commandName) + return nil + } + return CmuxResolvedCommand(command: command, sourcePath: sourcePaths[command.id]) + } + // MARK: - Parsing - private func parseConfig(at path: String) -> CmuxConfigFile? { - guard FileManager.default.fileExists(atPath: path), - let data = FileManager.default.contents(atPath: path), + private func parseConfig(at path: String) -> ParsedConfigResult { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: path) else { + parsedConfigCache.removeValue(forKey: path) + return ParsedConfigResult(config: nil, issue: nil) + } + + let attributes = try? fileManager.attributesOfItem(atPath: path) + let fileSize = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 + let modificationDate = attributes?[.modificationDate] as? Date + + if let cached = parsedConfigCache[path], + cached.fileSize == fileSize, + cached.modificationDate == modificationDate { + return ParsedConfigResult(config: cached.config, issue: cached.issue) + } + + guard let data = fileManager.contents(atPath: path), !data.isEmpty else { - return nil + let issue = schemaIssue(path: path, message: "cmux.json is empty") + parsedConfigCache[path] = ParsedConfigCacheEntry( + fileSize: fileSize, + modificationDate: modificationDate, + config: nil, + issue: issue + ) + return ParsedConfigResult(config: nil, issue: issue) } do { - return try JSONDecoder().decode(CmuxConfigFile.self, from: data) + let config = try JSONDecoder().decode(CmuxConfigFile.self, from: data) + parsedConfigCache[path] = ParsedConfigCacheEntry( + fileSize: fileSize, + modificationDate: modificationDate, + config: config, + issue: nil + ) + return ParsedConfigResult(config: config, issue: nil) } catch { + let issue = schemaIssue(path: path, message: schemaErrorMessage(error)) + parsedConfigCache[path] = ParsedConfigCacheEntry( + fileSize: fileSize, + modificationDate: modificationDate, + config: nil, + issue: issue + ) NSLog("[CmuxConfig] parse error at %@: %@", path, String(describing: error)) - return nil + return ParsedConfigResult(config: nil, issue: issue) + } + } + + private func schemaIssue(path: String, message: String) -> CmuxConfigIssue { + CmuxConfigIssue( + kind: .schemaError, + settingName: (path as NSString).lastPathComponent, + sourcePath: path, + message: message + ) + } + + private func schemaErrorMessage(_ error: Error) -> String { + switch error { + case DecodingError.typeMismatch(_, let context): + return schemaErrorMessage(context) + case DecodingError.valueNotFound(_, let context): + return schemaErrorMessage(context) + case DecodingError.keyNotFound(let key, let context): + let path = schemaCodingPath(context.codingPath + [key]) + let detail = sanitizeConfigText(context.debugDescription) + return "\(path): \(detail)" + case DecodingError.dataCorrupted(let context): + return schemaErrorMessage(context) + default: + let message = sanitizeConfigText(error.localizedDescription) + return message.isEmpty ? String(describing: error) : message } } + private func schemaErrorMessage(_ context: DecodingError.Context) -> String { + let path = schemaCodingPath(context.codingPath) + let detail = sanitizeConfigText(context.debugDescription) + return detail.isEmpty ? path : "\(path): \(detail)" + } + + private func schemaCodingPath(_ codingPath: [CodingKey]) -> String { + let path = codingPath.map(\.stringValue).filter { !$0.isEmpty }.joined(separator: ".") + return path.isEmpty ? "root" : path + } + // MARK: - File watching (local) private func startLocalFileWatcher() { @@ -443,35 +2634,72 @@ final class CmuxConfigStore: ObservableObject { private func startLocalDirectoryWatcher() { guard let path = localConfigPath else { return } - let dirPath = (path as NSString).deletingLastPathComponent - let fd = open(dirPath, O_EVTONLY) - guard fd >= 0 else { return } - localFileDescriptor = fd + let configDirectory = (path as NSString).deletingLastPathComponent + let fs = FileManager.default + let dirPath: String + if fs.fileExists(atPath: configDirectory) { + dirPath = configDirectory + } else if let searchDirectory = localConfigSearchDirectory, + fs.fileExists(atPath: searchDirectory) { + dirPath = searchDirectory + } else { + dirPath = (configDirectory as NSString).deletingLastPathComponent + } + let eventHandler = { [weak self] in + DispatchQueue.main.async { + self?.handleLocalDirectoryWatchEvent() + } + } + guard let primaryWatch = startLocalDirectoryWatchSource(at: dirPath, eventHandler: eventHandler) else { + return + } + localFileWatchSource = primaryWatch.source + localFileDescriptor = primaryWatch.fileDescriptor + + if let searchDirectory = localConfigSearchDirectory, + fs.fileExists(atPath: configDirectory), + searchDirectory != dirPath, + let fallbackWatch = startLocalDirectoryWatchSource(at: searchDirectory, eventHandler: eventHandler) { + localFallbackDirectoryWatchSource = fallbackWatch.source + localFallbackDirectoryDescriptor = fallbackWatch.fileDescriptor + } + } + + private func startLocalDirectoryWatchSource( + at dirPath: String, + eventHandler: @escaping () -> Void + ) -> (source: DispatchSourceFileSystemObject, fileDescriptor: Int32)? { + let fd = open(dirPath, O_EVTONLY) + guard fd >= 0 else { return nil } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, eventMask: [.write, .link, .rename], queue: watchQueue ) - - source.setEventHandler { [weak self] in - guard let self else { return } - DispatchQueue.main.async { - guard let configPath = self.localConfigPath, - FileManager.default.fileExists(atPath: configPath) else { return } - // File appeared — switch to file-level watching - self.stopLocalFileWatcher() - self.loadAll() - self.startLocalFileWatcher() - } - } - + source.setEventHandler(handler: eventHandler) source.setCancelHandler { Darwin.close(fd) } - source.resume() - localFileWatchSource = source + return (source, fd) + } + + private func handleLocalDirectoryWatchEvent() { + if let searchDirectory = localConfigSearchDirectory { + let resolvedPath = resolvedLocalConfigPath(startingFrom: searchDirectory) + if resolvedPath != localConfigPath { + localConfigPath = resolvedPath + } + } + guard let configPath = localConfigPath else { return } + let configDirectory = (configPath as NSString).deletingLastPathComponent + guard FileManager.default.fileExists(atPath: configPath) || + FileManager.default.fileExists(atPath: configDirectory) else { return } + // File or its parent directory appeared — switch to file-level watching. + stopLocalFileWatcher() + loadAll() + startLocalFileWatcher() } private func scheduleLocalReattach(attempt: Int) { @@ -495,7 +2723,12 @@ final class CmuxConfigStore: ObservableObject { source.cancel() localFileWatchSource = nil } + if let source = localFallbackDirectoryWatchSource { + source.cancel() + localFallbackDirectoryWatchSource = nil + } localFileDescriptor = -1 + localFallbackDirectoryDescriptor = -1 } // MARK: - File watching (global) diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index df7276155c2f..6940ecc7f11e 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -4,70 +4,410 @@ import Foundation @MainActor struct CmuxConfigExecutor { + @discardableResult static func execute( command: CmuxCommandDefinition, tabManager: TabManager, baseCwd: String, configSourcePath: String?, - globalConfigPath: String - ) { + globalConfigPath: String, + displayTitle: String? = nil, + actionID: String? = nil, + icon: CmuxButtonIcon? = nil, + iconSourcePath: String? = nil, + presentingWindow: NSWindow? = nil, + onExecuted: (() -> Void)? = nil + ) -> Bool { if let workspace = command.workspace { - executeWorkspaceCommand(command: command, workspace: workspace, tabManager: tabManager, baseCwd: baseCwd) - } else if let rawCommand = command.command { - let shellCommand = sanitizeForDisplay(rawCommand) - let needsConfirm = command.confirm ?? false - if needsConfirm, let sourcePath = configSourcePath { - let trusted = CmuxDirectoryTrust.shared.isTrusted( - configPath: sourcePath, + return authorizeProjectActionIfNeeded( + descriptor: workspaceTrustDescriptor( + command: command, + actionID: actionID ?? command.id, + configSourcePath: configSourcePath, + icon: icon, + iconSourcePath: iconSourcePath, globalConfigPath: globalConfigPath + ), + confirm: command.confirm ?? false, + configSourcePath: configSourcePath, + globalConfigPath: globalConfigPath, + displayCommand: command.name, + displayTitle: displayTitle ?? command.name, + presentingWindow: presentingWindow + ) { + executeWorkspaceCommand( + command: command, + workspace: workspace, + tabManager: tabManager, + baseCwd: baseCwd ) - if !trusted { - guard showConfirmDialog(command: shellCommand, configPath: sourcePath) else { return } + onExecuted?() + } + } else if let rawCommand = command.command { + let targetTerminal = tabManager.selectedWorkspace?.focusedTerminalPanel + guard let targetTerminal else { return false } + return prepareShellInputIfAuthorized( + rawCommand, + confirm: command.confirm ?? false, + actionID: actionID ?? command.id, + target: .currentTerminal, + configSourcePath: configSourcePath, + globalConfigPath: globalConfigPath, + displayTitle: displayTitle ?? command.name, + icon: icon, + iconSourcePath: iconSourcePath, + presentingWindow: presentingWindow + ) { shellInput in + targetTerminal.sendInput(shellInput) + onExecuted?() + } + } + return false + } + + @discardableResult + static func execute( + action: CmuxResolvedConfigAction, + commands: [CmuxCommandDefinition], + commandSourcePaths: [String: String], + tabManager: TabManager, + baseCwd: String, + globalConfigPath: String, + presentingWindow: NSWindow? = nil, + onExecuted: (() -> Void)? = nil + ) -> Bool { + if let commandName = action.workspaceCommandName, + let command = commands.first(where: { $0.name == commandName }) { + guard command.workspace != nil else { return false } + return execute( + command: command, + tabManager: tabManager, + baseCwd: baseCwd, + configSourcePath: commandSourcePaths[command.id] ?? action.actionSourcePath, + globalConfigPath: globalConfigPath, + displayTitle: action.title, + actionID: action.id, + icon: action.icon, + iconSourcePath: action.iconSourcePath, + presentingWindow: presentingWindow, + onExecuted: onExecuted + ) + } + + guard let command = action.terminalCommand else { return false } + let target = action.terminalCommandTarget ?? .newTabInCurrentPane + let targetTerminal = (target == .currentTerminal) ? tabManager.selectedWorkspace?.focusedTerminalPanel : nil + let targetWorkspace = (target == .newTabInCurrentPane) ? tabManager.selectedWorkspace : nil + return prepareShellInputIfAuthorized( + command, + confirm: action.confirm ?? false, + actionID: action.id, + target: target, + configSourcePath: action.actionSourcePath, + globalConfigPath: globalConfigPath, + displayTitle: action.title, + icon: action.icon, + iconSourcePath: action.iconSourcePath, + presentingWindow: presentingWindow + ) { shellInput in + switch target { + case .currentTerminal: + targetTerminal?.sendInput(shellInput) + case .newTabInCurrentPane: + targetWorkspace?.clearSplitZoom() + targetWorkspace?.newTerminalSurfaceInFocusedPane(focus: true, initialInput: shellInput) + } + onExecuted?() + } + } + + @discardableResult + static func prepareShellInputIfAuthorized( + _ rawCommand: String, + confirm: Bool, + actionID: String, + target: CmuxConfigTerminalCommandTarget, + configSourcePath: String?, + globalConfigPath: String, + displayTitle: String? = nil, + icon: CmuxButtonIcon? = nil, + iconSourcePath: String? = nil, + presentingWindow: NSWindow? = nil, + onAuthorized: @escaping (String) -> Void + ) -> Bool { + let shellCommand = sanitizeForDisplay(rawCommand) + guard !shellCommand.isEmpty else { return false } + + let descriptor = terminalTrustDescriptor( + command: shellCommand, + actionID: actionID, + target: target, + configSourcePath: configSourcePath, + icon: icon, + iconSourcePath: iconSourcePath, + globalConfigPath: globalConfigPath + ) + return authorizeProjectActionIfNeeded( + descriptor: descriptor, + confirm: confirm, + configSourcePath: configSourcePath, + globalConfigPath: globalConfigPath, + displayCommand: shellCommand, + displayTitle: displayTitle, + presentingWindow: presentingWindow + ) { + onAuthorized(shellCommand + "\n") + } + } + + @discardableResult + private static func authorizeProjectActionIfNeeded( + descriptor: CmuxActionTrustDescriptor, + confirm: Bool, + configSourcePath: String?, + globalConfigPath: String, + displayCommand: String, + displayTitle: String?, + presentingWindow: NSWindow?, + onAuthorized: @escaping () -> Void + ) -> Bool { + let sourcePath = configSourcePath.map(canonicalPath) + let canonicalGlobalConfigPath = canonicalPath(globalConfigPath) + let isTrusted = CmuxActionTrust.shared.isTrusted(descriptor) + let resolvedPresentingWindow = presentingWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + guard let sourcePath, + sourcePath != canonicalGlobalConfigPath else { + onAuthorized() + return true + } + if !confirm, isTrusted { + onAuthorized() + return true + } + if let resolvedPresentingWindow { + presentConfirmDialog( + command: displayCommand, + displayTitle: displayTitle, + descriptor: descriptor, + configPath: sourcePath, + presentingWindow: resolvedPresentingWindow + ) { allowed in + if allowed { + onAuthorized() } } - guard let terminal = tabManager.selectedWorkspace?.focusedTerminalPanel else { return } - terminal.sendInput(shellCommand + "\n") + return true + } + let allowed = runConfirmDialog( + command: displayCommand, + displayTitle: displayTitle, + descriptor: descriptor, + configPath: sourcePath + ) + if allowed { + onAuthorized() } + return allowed } - /// Show a confirmation dialog with the command text and a "trust this directory" checkbox. - /// Returns true if the user chose to run, false if cancelled. - private static func showConfirmDialog(command: String, configPath: String) -> Bool { - let alert = NSAlert() - alert.messageText = String( - localized: "dialog.cmuxConfig.confirmCommand.title", - defaultValue: "Run Command" + private static func presentConfirmDialog( + command: String, + displayTitle: String?, + descriptor: CmuxActionTrustDescriptor, + configPath: String, + presentingWindow: NSWindow, + completion: @escaping (Bool) -> Void + ) { + let alert = makeConfirmDialog( + command: command, + displayTitle: displayTitle, + configPath: configPath + ) + alert.beginSheetModal(for: presentingWindow) { response in + completion(handleConfirmDialogResponse(response, descriptor: descriptor)) + } + } + + private static func runConfirmDialog( + command: String, + displayTitle: String?, + descriptor: CmuxActionTrustDescriptor, + configPath: String + ) -> Bool { + let alert = makeConfirmDialog( + command: command, + displayTitle: displayTitle, + configPath: configPath ) + return handleConfirmDialogResponse(alert.runModal(), descriptor: descriptor) + } + + private static func makeConfirmDialog( + command: String, + displayTitle: String?, + configPath: String + ) -> NSAlert { + let alert = NSAlert() + let trimmedDisplayTitle = displayTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + alert.messageText = (trimmedDisplayTitle?.isEmpty == false) + ? trimmedDisplayTitle! + : String( + localized: "dialog.cmuxConfig.confirmCommand.title", + defaultValue: "Run Project Action?" + ) let messageFormat = String( localized: "dialog.cmuxConfig.confirmCommand.messageWithCommand", - defaultValue: "This will run the following command:\n\n%@" + defaultValue: "This project action comes from:\n\n%@\n\nIt will run:\n\n%@" + ) + alert.informativeText = String( + format: messageFormat, + sanitizeForDisplay(configPath), + sanitizeForDisplay(command) ) - alert.informativeText = String(format: messageFormat, sanitizeForDisplay(command)) alert.alertStyle = .warning alert.addButton(withTitle: String( localized: "dialog.cmuxConfig.confirmCommand.run", - defaultValue: "Run" + defaultValue: "Run Once" + )) + alert.addButton(withTitle: String( + localized: "dialog.cmuxConfig.confirmCommand.trustAndRun", + defaultValue: "Trust and Run" )) alert.addButton(withTitle: String( localized: "dialog.cmuxConfig.confirmCommand.cancel", defaultValue: "Cancel" )) + return alert + } - let checkbox = NSButton(checkboxWithTitle: String( - localized: "dialog.cmuxConfig.confirmCommand.trustDirectory", - defaultValue: "Always trust commands from this folder" - ), target: nil, action: nil) - checkbox.state = .off - alert.accessoryView = checkbox + private static func handleConfirmDialogResponse( + _ response: NSApplication.ModalResponse, + descriptor: CmuxActionTrustDescriptor + ) -> Bool { + switch response { + case .alertFirstButtonReturn: + return true + case .alertSecondButtonReturn: + CmuxActionTrust.shared.trust(descriptor) + return true + default: + return false + } + } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return false } + private static func terminalTrustDescriptor( + command: String, + actionID: String, + target: CmuxConfigTerminalCommandTarget, + configSourcePath: String?, + icon: CmuxButtonIcon?, + iconSourcePath: String?, + globalConfigPath: String + ) -> CmuxActionTrustDescriptor { + CmuxActionTrustDescriptor( + actionID: actionID, + kind: "terminalCommand", + command: command, + target: target.rawValue, + workspaceCommand: nil, + configPath: configSourcePath.map(canonicalPath), + projectRoot: configSourcePath.map { canonicalPath(CmuxButtonIcon.projectRoot(forConfigPath: $0)) }, + iconFingerprint: icon?.projectLocalImageFingerprint( + configSourcePath: iconSourcePath ?? configSourcePath, + globalConfigPath: globalConfigPath + ) + ) + } - if checkbox.state == .on { - CmuxDirectoryTrust.shared.trust(configPath: configPath) + private static func workspaceTrustDescriptor( + command: CmuxCommandDefinition, + actionID: String, + configSourcePath: String?, + icon: CmuxButtonIcon?, + iconSourcePath: String?, + globalConfigPath: String + ) -> CmuxActionTrustDescriptor { + CmuxActionTrustDescriptor( + actionID: actionID, + kind: "workspaceCommand", + command: nil, + target: nil, + workspaceCommand: command, + configPath: configSourcePath.map(canonicalPath), + projectRoot: configSourcePath.map { canonicalPath(CmuxButtonIcon.projectRoot(forConfigPath: $0)) }, + iconFingerprint: icon?.projectLocalImageFingerprint( + configSourcePath: iconSourcePath ?? configSourcePath, + globalConfigPath: globalConfigPath + ) + ) + } + + static func isTrustedSurfaceButton( + _ button: CmuxSurfaceTabBarButton, + workspaceCommand: CmuxResolvedCommand?, + terminalCommandSourcePath: String?, + surfaceTabBarConfigSourcePath: String?, + globalConfigPath: String + ) -> Bool { + guard let descriptor = surfaceButtonTrustDescriptor( + button, + workspaceCommand: workspaceCommand, + terminalCommandSourcePath: terminalCommandSourcePath, + surfaceTabBarConfigSourcePath: surfaceTabBarConfigSourcePath, + globalConfigPath: globalConfigPath + ) else { + return true + } + guard let configPath = descriptor.configPath, + configPath != canonicalPath(globalConfigPath) else { + return true + } + return CmuxActionTrust.shared.isTrusted(descriptor) + } + + private static func surfaceButtonTrustDescriptor( + _ button: CmuxSurfaceTabBarButton, + workspaceCommand: CmuxResolvedCommand?, + terminalCommandSourcePath: String?, + surfaceTabBarConfigSourcePath: String?, + globalConfigPath: String + ) -> CmuxActionTrustDescriptor? { + let configSourcePath = terminalCommandSourcePath + ?? workspaceCommand?.sourcePath + ?? button.actionSourcePath + ?? surfaceTabBarConfigSourcePath + let iconSourcePath = button.iconSourcePath + ?? (button.icon == nil ? nil : surfaceTabBarConfigSourcePath) + let resolvedIcon = button.icon ?? button.action.defaultButtonIcon + + if let workspaceCommand { + return workspaceTrustDescriptor( + command: workspaceCommand.command, + actionID: button.id, + configSourcePath: configSourcePath, + icon: resolvedIcon, + iconSourcePath: iconSourcePath, + globalConfigPath: globalConfigPath + ) + } + + guard let terminalCommand = button.terminalCommand else { + return nil } - return true + return terminalTrustDescriptor( + command: sanitizeForDisplay(terminalCommand), + actionID: button.id, + target: button.resolvedTerminalCommandTarget, + configSourcePath: configSourcePath, + icon: resolvedIcon, + iconSourcePath: iconSourcePath, + globalConfigPath: globalConfigPath + ) + } + + private static func canonicalPath(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path } private static func sanitizeForDisplay(_ text: String) -> String { diff --git a/Sources/CmuxDirectoryTrust.swift b/Sources/CmuxDirectoryTrust.swift deleted file mode 100644 index 4c0190fb5130..000000000000 --- a/Sources/CmuxDirectoryTrust.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Foundation - -/// Manages trusted directories for cmux.json command execution. -/// When a directory (or its git repo root) is trusted, `confirm: true` commands -/// from that directory's cmux.json skip the confirmation dialog. -/// Global config (~/.config/cmux/cmux.json) is always trusted. -final class CmuxDirectoryTrust { - static let shared = CmuxDirectoryTrust() - static let didChangeNotification = Notification.Name("cmux.directoryTrustDidChange") - - private let storePath: String - private var trustedPaths: Set - - private init() { - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask - ).first!.appendingPathComponent("cmux") - storePath = appSupport.appendingPathComponent("trusted-directories.json").path - - let fm = FileManager.default - if !fm.fileExists(atPath: appSupport.path) { - try? fm.createDirectory(atPath: appSupport.path, withIntermediateDirectories: true) - } - - if let data = fm.contents(atPath: storePath), - let paths = try? JSONDecoder().decode([String].self, from: data) { - trustedPaths = Set(paths) - } else { - trustedPaths = [] - } - } - - /// Check if a cmux.json path is trusted. - /// Global config is always trusted. For local configs, check the git repo root - /// (or the cmux.json parent directory if not in a git repo). - func isTrusted(configPath: String, globalConfigPath: String) -> Bool { - if configPath == globalConfigPath { return true } - let trustKey = Self.trustKey(for: configPath) - return trustedPaths.contains(trustKey) - } - - /// Trust the directory containing a cmux.json. If the cmux.json is inside a git - /// repo, trusts the repo root (covering all subdirectories). - func trust(configPath: String) { - let trustKey = Self.trustKey(for: configPath) - trustedPaths.insert(trustKey) - save() - } - - /// Remove trust for a directory. - func revokeTrust(configPath: String) { - let trustKey = Self.trustKey(for: configPath) - trustedPaths.remove(trustKey) - save() - } - - /// Remove trust by the trust key directly (as stored/displayed in settings). - func revokeTrustByPath(_ path: String) { - trustedPaths.remove(path) - save() - } - - /// All currently trusted paths. - var allTrustedPaths: [String] { - Array(trustedPaths).sorted() - } - - /// Replace all trusted paths (used by Settings textarea save). - func replaceAll(with paths: [String]) { - trustedPaths = Set(paths) - save() - } - - /// Clear all trusted directories. - func clearAll() { - trustedPaths.removeAll() - save() - } - - // MARK: - Private - - /// Resolve the trust key for a cmux.json path: git repo root if inside a repo, - /// otherwise the cmux.json's parent directory. - static func trustKey(for configPath: String) -> String { - let configDir = (configPath as NSString).deletingLastPathComponent - if let gitRoot = findGitRoot(from: configDir) { - return gitRoot - } - return configDir - } - - /// Walk up from `directory` looking for a `.git` directory or file. - private static func findGitRoot(from directory: String) -> String? { - let fm = FileManager.default - var current = directory - while true { - let gitPath = (current as NSString).appendingPathComponent(".git") - if fm.fileExists(atPath: gitPath) { - return current - } - let parent = (current as NSString).deletingLastPathComponent - if parent == current { break } - current = parent - } - return nil - } - - private func save() { - let sorted = trustedPaths.sorted() - guard let data = try? JSONEncoder().encode(sorted) else { return } - FileManager.default.createFile(atPath: storePath, contents: data) - NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) - } -} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fe46975a6056..b14eed92913e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2600,7 +2600,12 @@ struct ContentView: View { anchorView: fullscreenControlsViewModel.notificationsAnchorView ) }, - onNewTab: { tabManager.addTab() }, + onNewTab: { + AppDelegate.shared?.performNewWorkspaceAction( + tabManager: tabManager, + debugSource: "titlebar.fullscreenNewWorkspace" + ) + }, visibilityMode: .alwaysVisible ) } @@ -3520,7 +3525,8 @@ struct ContentView: View { windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState + sidebarSelectionState: sidebarSelectionState, + cmuxConfigStore: cmuxConfigStore ) installFileDropOverlayWhenReady(on: window, tabManager: tabManager) })) @@ -6103,6 +6109,11 @@ struct ContentView: View { var nextRank = 0 for contribution in contributions { + let configuredPaletteAction = commandPaletteConfigActionID(for: contribution.commandId) + .flatMap { cmuxConfigStore.resolvedAction(id: $0) } + if let configuredPaletteAction, !configuredPaletteAction.palette { + continue + } guard contribution.when(context), contribution.enablement(context) else { continue } guard let action = handlerRegistry.handler(for: contribution.commandId) else { assertionFailure("No command palette handler registered for \(contribution.commandId)") @@ -6112,11 +6123,13 @@ struct ContentView: View { CommandPaletteCommand( id: contribution.commandId, rank: nextRank, - title: contribution.title(context), - subtitle: contribution.subtitle(context), + title: configuredPaletteAction?.title ?? contribution.title(context), + subtitle: configuredPaletteAction?.subtitle ?? contribution.subtitle(context), shortcutHint: commandPaletteShortcutHint(for: contribution, context: context), kindLabel: nil, - keywords: contribution.keywords, + keywords: configuredPaletteAction?.keywords.isEmpty == false + ? configuredPaletteAction?.keywords ?? contribution.keywords + : contribution.keywords, dismissOnRun: contribution.dismissOnRun, action: action ) @@ -6127,6 +6140,21 @@ struct ContentView: View { return commands } + private func commandPaletteConfigActionID(for commandId: String) -> String? { + switch commandId { + case "palette.newTerminalTab": + return CmuxSurfaceTabBarBuiltInAction.newTerminal.configID + case "palette.newBrowserTab": + return CmuxSurfaceTabBarBuiltInAction.newBrowser.configID + case "palette.terminalSplitRight": + return CmuxSurfaceTabBarBuiltInAction.splitRight.configID + case "palette.terminalSplitDown": + return CmuxSurfaceTabBarBuiltInAction.splitDown.configID + default: + return nil + } + } + private func commandPaletteShortcutHint( for contribution: CommandPaletteCommandContribution, context: CommandPaletteContextSnapshot @@ -6136,6 +6164,13 @@ struct ContentView: View { context.bool(CommandPaletteContextKeys.panelIsBrowser) { return nil } + if let configuredShortcut = cmuxConfigStore.resolvedAction(id: contribution.commandId)?.shortcut { + return configuredShortcut.displayString + } + if let configuredPaletteAction = commandPaletteConfigActionID(for: contribution.commandId), + let configuredShortcut = cmuxConfigStore.resolvedAction(id: configuredPaletteAction)?.shortcut { + return configuredShortcut.displayString + } if let action = commandPaletteShortcutAction(for: contribution.commandId) { return KeyboardShortcutSettings.shortcut(for: action).displayString } @@ -7088,19 +7123,29 @@ struct ContentView: View { ) ) - let cmuxConfigDefaultSubtitle = constant(String(localized: "command.cmuxConfig.subtitle", defaultValue: "cmux.json")) - for command in cmuxConfigStore.loadedCommands { - let commandName = sanitizeCmuxConfigPaletteText(command.name) - let subtitle = command.description + let cmuxConfigDefaultSubtitle = String(localized: "command.cmuxConfig.subtitle", defaultValue: "cmux.json") + for issue in cmuxConfigStore.configurationIssues { + contributions.append( + CommandPaletteCommandContribution( + commandId: commandPaletteCmuxConfigIssueCommandID(issue), + title: constant(commandPaletteCmuxConfigIssueTitle(issue)), + subtitle: constant(commandPaletteCmuxConfigIssueSubtitle(issue)), + keywords: ["cmux", "config", "json", "schema", "error", "warning"] + ) + ) + } + for action in cmuxConfigStore.paletteCustomActions() { + let actionTitle = sanitizeCmuxConfigPaletteText(action.title) + let subtitleText = action.subtitle .map { sanitizeCmuxConfigPaletteText($0) } - .flatMap { $0.isEmpty ? nil : constant($0) } + .flatMap { $0.isEmpty ? nil : $0 } ?? cmuxConfigDefaultSubtitle contributions.append( CommandPaletteCommandContribution( - commandId: command.id, - title: constant(String(localized: "command.cmuxConfig.customTitle", defaultValue: "Custom: \(commandName)")), - subtitle: subtitle, - keywords: command.keywords ?? [] + commandId: action.id, + title: constant(actionTitle), + subtitle: constant(subtitleText), + keywords: action.keywords ) ) } @@ -7119,9 +7164,83 @@ struct ContentView: View { return filtered.trimmingCharacters(in: .whitespacesAndNewlines) } + private func commandPaletteCmuxConfigIssueCommandID(_ issue: CmuxConfigIssue) -> String { + var hash: UInt64 = 1_469_598_103_934_665_603 + for byte in issue.id.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return "palette.cmuxConfig.issue.\(String(hash, radix: 16))" + } + + private func commandPaletteCmuxConfigIssueTitle(_ issue: CmuxConfigIssue) -> String { + switch issue.kind { + case .schemaError: + return String( + localized: "command.cmuxConfig.issue.schemaError.title", + defaultValue: "cmux.json Schema Error" + ) + default: + return String( + localized: "command.cmuxConfig.issue.warning.title", + defaultValue: "cmux.json Configuration Warning" + ) + } + } + + private func commandPaletteCmuxConfigIssueSubtitle(_ issue: CmuxConfigIssue) -> String { + let rawPath = issue.sourcePath.map { + NSString(string: $0).abbreviatingWithTildeInPath + } ?? issue.settingName + let path = sanitizeCmuxConfigPaletteText(rawPath) + let detail = sanitizeCmuxConfigPaletteText(commandPaletteCmuxConfigIssueDetail(issue)) + guard !detail.isEmpty else { return path } + let format = String( + localized: "command.cmuxConfig.issue.subtitle", + defaultValue: "%@: %@" + ) + return String(format: format, path, detail) + } + + private func commandPaletteCmuxConfigIssueDetail(_ issue: CmuxConfigIssue) -> String { + switch issue.kind { + case .schemaError: + let format = String( + localized: "command.cmuxConfig.issue.schemaError.detail", + defaultValue: "%@" + ) + let fallback = String( + localized: "command.cmuxConfig.issue.schemaError.fallback", + defaultValue: "Invalid cmux.json" + ) + return String(format: format, issue.message ?? fallback) + case .newWorkspaceActionRequiresWorkspaceCommand: + let format = String( + localized: "command.cmuxConfig.issue.newWorkspaceActionRequiresWorkspaceCommand.detail", + defaultValue: "%@ must reference a workspace command action" + ) + return String(format: format, issue.settingName) + case .newWorkspaceCommandNotFound: + let format = String( + localized: "command.cmuxConfig.issue.newWorkspaceCommandNotFound.detail", + defaultValue: "%@ references missing command '%@'" + ) + return String(format: format, issue.settingName, issue.commandName ?? "") + case .newWorkspaceCommandRequiresWorkspace: + let format = String( + localized: "command.cmuxConfig.issue.newWorkspaceCommandRequiresWorkspace.detail", + defaultValue: "%@ '%@' must reference a workspace command" + ) + return String(format: format, issue.settingName, issue.commandName ?? "") + } + } + private func registerCommandPaletteHandlers(_ registry: inout CommandPaletteHandlerRegistry) { registry.register(commandId: "palette.newWorkspace") { - tabManager.addWorkspace() + AppDelegate.shared?.performNewWorkspaceAction( + tabManager: tabManager, + debugSource: "palette.newWorkspace" + ) } registry.register(commandId: "palette.openFolder") { // Defer so the command palette dismisses before the modal sheet appears. @@ -7157,9 +7276,14 @@ struct ContentView: View { AppDelegate.shared?.uninstallCmuxCLIInPath(nil) } registry.register(commandId: "palette.newTerminalTab") { - tabManager.newSurface() + if !executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.newTerminal.configID) { + tabManager.newSurface() + } } registry.register(commandId: "palette.newBrowserTab") { + if executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.newBrowser.configID) { + return + } // Let command-palette dismissal complete first so omnibar focus // is not blocked by the palette visibility guard. DispatchQueue.main.async { @@ -7449,10 +7573,14 @@ struct ContentView: View { tabManager.searchSelection() } registry.register(commandId: "palette.terminalSplitRight") { - tabManager.createSplit(direction: .right) + if !executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.splitRight.configID) { + tabManager.createSplit(direction: .right) + } } registry.register(commandId: "palette.terminalSplitDown") { - tabManager.createSplit(direction: .down) + if !executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.splitDown.configID) { + tabManager.createSplit(direction: .down) + } } registry.register(commandId: "palette.terminalSplitBrowserRight") { _ = tabManager.createBrowserSplit(direction: .right) @@ -7473,23 +7601,67 @@ struct ContentView: View { } } - for command in cmuxConfigStore.loadedCommands { - let captured = command - let sourcePath = cmuxConfigStore.commandSourcePaths[command.id] - let globalPath = cmuxConfigStore.globalConfigPath - registry.register(commandId: command.id) { - let rawCwd = tabManager.selectedWorkspace?.currentDirectory - let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd! - : FileManager.default.homeDirectoryForCurrentUser.path - CmuxConfigExecutor.execute( - command: captured, - tabManager: tabManager, - baseCwd: baseCwd, - configSourcePath: sourcePath, - globalConfigPath: globalPath - ) + for issue in cmuxConfigStore.configurationIssues { + let captured = issue + registry.register(commandId: commandPaletteCmuxConfigIssueCommandID(issue)) { + openCmuxConfigIssue(captured) + } + } + for action in cmuxConfigStore.paletteCustomActions() { + let captured = action + registry.register(commandId: action.id) { + executeConfiguredAction(captured) + } + } + } + + private func openCmuxConfigIssue(_ issue: CmuxConfigIssue) { + guard let sourcePath = issue.sourcePath, + FileManager.default.fileExists(atPath: sourcePath) else { + NSSound.beep() + return + } + PreferredEditorSettings.open(URL(fileURLWithPath: sourcePath)) + } + + @discardableResult + private func executeConfiguredAction(id: String) -> Bool { + guard let action = cmuxConfigStore.resolvedAction(id: id) else { + return false + } + return executeConfiguredAction(action) + } + + @discardableResult + private func executeConfiguredAction(_ action: CmuxResolvedConfigAction) -> Bool { + let baseCwd = configuredActionBaseCwd() + return CmuxConfigExecutor.execute( + action: action, + commands: cmuxConfigStore.loadedCommands, + commandSourcePaths: cmuxConfigStore.commandSourcePaths, + tabManager: tabManager, + baseCwd: baseCwd, + globalConfigPath: cmuxConfigStore.globalConfigPath + ) + } + + private func configuredActionBaseCwd() -> String { + guard let workspace = tabManager.selectedWorkspace else { + return FileManager.default.homeDirectoryForCurrentUser.path + } + let focusedPanelId = workspace.focusedPanelId + let candidates = [ + focusedPanelId.flatMap { workspace.panelDirectories[$0] }, + focusedPanelId.flatMap { workspace.terminalPanel(for: $0)?.requestedWorkingDirectory }, + workspace.currentDirectory + ] + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { + return trimmed } } + return FileManager.default.homeDirectoryForCurrentUser.path } private var focusedPanelContext: (workspace: Workspace, panelId: UUID, panel: any Panel)? { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 9474b922988b..a7f1b14bd24d 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -1080,7 +1080,7 @@ final class SystemWideHotkeyController { } } -struct ShortcutStroke: Equatable { +struct ShortcutStroke: Equatable, Hashable { enum RecordingResult: Equatable { case accepted(ShortcutStroke) case rejected(KeyboardShortcutSettings.ShortcutRecordingRejection) @@ -1742,7 +1742,7 @@ struct ShortcutStroke: Equatable { } /// A keyboard shortcut that can be stored in UserDefaults -struct StoredShortcut: Codable, Equatable { +struct StoredShortcut: Codable, Equatable, Hashable { var key: String var command: Bool var shift: Bool @@ -1946,6 +1946,158 @@ struct StoredShortcut: Codable, Equatable { } } +extension ShortcutStroke { + static func parseConfig(_ 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 = parseConfigKeyToken(lastPart) else { return nil } + return ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + } + + func configString(preserveDigit: Bool = true) -> String { + var parts: [String] = [] + if command { parts.append("cmd") } + if shift { parts.append("shift") } + if option { parts.append("opt") } + if control { parts.append("ctrl") } + parts.append(configKeyString(preserveDigit: preserveDigit)) + return parts.joined(separator: "+") + } + + private func configKeyString(preserveDigit: Bool) -> String { + if preserveDigit { + return key + } + if let digit = Int(key), (1...9).contains(digit) { + return "1" + } + return key + } + + private static func parseConfigKeyToken(_ 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 "]" + case "volumeup", "mediavolumeup", "media.volumeup": + return "media.volumeUp" + case "volumedown", "mediavolumedown", "media.volumedown": + return "media.volumeDown" + case "brightnessup", "mediabrightnessup", "media.brightnessup": + return "media.brightnessUp" + case "brightnessdown", "mediabrightnessdown", "media.brightnessdown": + return "media.brightnessDown" + case "mute", "mediamute", "media.mute": + return "media.mute" + case "playpause", "mediaplaypause", "media.playpause": + return "media.playPause" + case "nexttrack", "medianext", "media.next", "media.nexttrack": + return "media.next" + case "previoustrack", "mediaprevious", "media.previous", "media.previoustrack": + return "media.previous" + default: + if lowered.hasPrefix("f"), + let number = Int(lowered.dropFirst()), + (1...20).contains(number) { + return "f\(number)" + } + guard lowered.count == 1 else { return nil } + return lowered + } + } +} + +extension StoredShortcut { + static func parseConfig(_ rawValue: String) -> StoredShortcut? { + parseConfig(strokes: [rawValue]) + } + + static func parseConfig(strokes: [String]) -> StoredShortcut? { + guard !strokes.isEmpty, strokes.count <= 2 else { return nil } + let parsedStrokes = strokes.compactMap(ShortcutStroke.parseConfig(_:)) + 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) + } + + var configIdentifier: String { + if let secondStroke { + return "\(firstStroke.configString()) \(secondStroke.configString())" + } + return firstStroke.configString() + } +} + enum KeyboardShortcutRecorderActivity { static let didChangeNotification = Notification.Name("cmux.keyboardShortcutRecorderActivityDidChange") static let stopAllNotification = Notification.Name("cmux.keyboardShortcutRecorderActivityStopAll") diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 7c055ffb4b60..0e8b63eefd7f 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -78,7 +78,6 @@ final class CmuxSettingsFileStore { "automation.geminiIntegration", "automation.portBase", "automation.portRange", - "customCommands.trustedDirectories", "browser.defaultSearchEngine", "browser.showSearchSuggestions", "browser.theme", @@ -95,7 +94,6 @@ final class CmuxSettingsFileStore { private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let backupsDefaultsKey = "cmux.settingsFile.backups.v1" - fileprivate static let trustedDirectoriesBackupIdentifier = "customCommands.trustedDirectories" fileprivate static let socketPasswordBackupIdentifier = "automation.socketPassword" static var defaultPrimaryPath: String { @@ -125,7 +123,6 @@ final class CmuxSettingsFileStore { private var primaryWatcher: ShortcutSettingsFileWatcher? private var fallbackWatcher: ShortcutSettingsFileWatcher? private var defaultsCancellable: AnyCancellable? - private var trustObserver: NSObjectProtocol? private var socketPasswordObserver: NSObjectProtocol? private var shortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] @@ -167,13 +164,6 @@ final class CmuxSettingsFileStore { .sink { [weak self] _ in self?.reapplyManagedSettingsIfNeeded() } - trustObserver = notificationCenter.addObserver( - forName: CmuxDirectoryTrust.didChangeNotification, - object: nil, - queue: nil - ) { [weak self] _ in - self?.reapplyManagedSettingsIfNeeded() - } socketPasswordObserver = notificationCenter.addObserver( forName: SocketControlPasswordStore.didChangeNotification, object: nil, @@ -187,9 +177,6 @@ final class CmuxSettingsFileStore { primaryWatcher?.stop() fallbackWatcher?.stop() defaultsCancellable?.cancel() - if let trustObserver { - notificationCenter.removeObserver(trustObserver) - } if let socketPasswordObserver { notificationCenter.removeObserver(socketPasswordObserver) } @@ -370,9 +357,6 @@ final class CmuxSettingsFileStore { if let automationSection = root["automation"] as? [String: Any] { parseAutomationSection(automationSection, sourcePath: sourcePath, snapshot: &snapshot) } - if let customCommandsSection = root["customCommands"] as? [String: Any] { - parseCustomCommandsSection(customCommandsSection, sourcePath: sourcePath, snapshot: &snapshot) - } if let browserSection = root["browser"] as? [String: Any] { parseBrowserSection(browserSection, sourcePath: sourcePath, snapshot: &snapshot) } @@ -740,21 +724,6 @@ final class CmuxSettingsFileStore { } } - private func parseCustomCommandsSection( - _ section: [String: Any], - sourcePath: String, - snapshot: inout ResolvedSettingsSnapshot - ) { - if let values = jsonStringArray(section["trustedDirectories"]) { - let normalized = values - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - snapshot.managedCustomSettings.trustedDirectories = normalized - } else if section.keys.contains("trustedDirectories") { - logInvalid("customCommands.trustedDirectories", sourcePath: sourcePath) - } - } - private func parseBrowserSection( _ section: [String: Any], sourcePath: String, @@ -862,9 +831,9 @@ final class CmuxSettingsFileStore { ) -> StoredShortcut? { let shortcut: StoredShortcut? if let stroke = jsonString(rawValue) { - shortcut = parseStoredShortcut(strokes: [stroke]) + shortcut = StoredShortcut.parseConfig(stroke) } else if let strokes = jsonStringArray(rawValue) { - shortcut = parseStoredShortcut(strokes: strokes) + shortcut = StoredShortcut.parseConfig(strokes: strokes) } else { shortcut = nil } @@ -876,102 +845,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, @@ -1010,10 +883,6 @@ final class CmuxSettingsFileStore { for (defaultsKey, value) in snapshot.managedUserDefaults where backups[defaultsKey] == nil { backups[defaultsKey] = backupValueForUserDefaultsKey(defaultsKey, managedValue: value) } - if snapshot.managedCustomSettings.trustedDirectories != nil, - backups[Self.trustedDirectoriesBackupIdentifier] == nil { - backups[Self.trustedDirectoriesBackupIdentifier] = .stringArray(CmuxDirectoryTrust.shared.allTrustedPaths) - } if snapshot.managedCustomSettings.socketPassword != nil, backups[Self.socketPasswordBackupIdentifier] == nil { backups[Self.socketPasswordBackupIdentifier] = currentSocketPasswordBackupValue() @@ -1037,11 +906,6 @@ final class CmuxSettingsFileStore { } private func applyManagedCustomSettings(_ settings: ManagedCustomSettings) { - if let trustedDirectories = settings.trustedDirectories, - CmuxDirectoryTrust.shared.allTrustedPaths != trustedDirectories { - CmuxDirectoryTrust.shared.replaceAll(with: trustedDirectories) - } - if let socketPassword = settings.socketPassword { switch socketPassword { case .set(let value): @@ -1060,12 +924,6 @@ final class CmuxSettingsFileStore { private func restoreBackup(_ backup: BackupValue, for identifier: String) { switch identifier { - case Self.trustedDirectoriesBackupIdentifier: - if case .stringArray(let values) = backup { - CmuxDirectoryTrust.shared.replaceAll(with: values) - } else { - CmuxDirectoryTrust.shared.replaceAll(with: []) - } case Self.socketPasswordBackupIdentifier: switch backup { case .string(let value): @@ -1433,11 +1291,6 @@ final class CmuxSettingsFileStore { "portRange": 10, ], ], - [ - "customCommands": [ - "trustedDirectories": [String](), - ], - ], [ "browser": [ "defaultSearchEngine": BrowserSearchSettings.defaultSearchEngine.rawValue, @@ -1467,33 +1320,16 @@ final class CmuxSettingsFileStore { usesNumberedDigits: Bool ) -> Any { let defaultShortcut = usesNumberedDigits ? (shortcut.secondStroke ?? shortcut.firstStroke) : nil - let rendered = renderShortcutStroke(shortcut.firstStroke, preserveDigit: !usesNumberedDigits) + let rendered = shortcut.firstStroke.configString(preserveDigit: !usesNumberedDigits) if let secondStroke = shortcut.secondStroke { - return [rendered, renderShortcutStroke(secondStroke, preserveDigit: true)] + return [rendered, secondStroke.configString(preserveDigit: true)] } if let defaultShortcut { - return renderShortcutStroke(defaultShortcut, preserveDigit: true) + return defaultShortcut.configString(preserveDigit: true) } return rendered } - private static func renderShortcutStroke(_ stroke: ShortcutStroke, preserveDigit: Bool) -> String { - var parts: [String] = [] - if stroke.command { parts.append("cmd") } - if stroke.shift { parts.append("shift") } - if stroke.option { parts.append("opt") } - if stroke.control { parts.append("ctrl") } - parts.append(renderShortcutKey(stroke.key, preserveDigit: preserveDigit)) - return parts.joined(separator: "+") - } - - private static func renderShortcutKey(_ key: String, preserveDigit: Bool) -> String { - if preserveDigit { - return key - } - return key - } - private static func prettyJSONString(_ value: Any) -> String { guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), let string = String(data: data, encoding: .utf8) else { @@ -1518,18 +1354,14 @@ private enum ManagedStringOverride: Equatable { } private struct ManagedCustomSettings: Equatable { - var trustedDirectories: [String]? var socketPassword: ManagedStringOverride? var isEmpty: Bool { - trustedDirectories == nil && socketPassword == nil + socketPassword == nil } var managedIdentifiers: Set { var identifiers: Set = [] - if trustedDirectories != nil { - identifiers.insert(CmuxSettingsFileStore.trustedDirectoriesBackupIdentifier) - } if socketPassword != nil { identifiers.insert(CmuxSettingsFileStore.socketPasswordBackupIdentifier) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1416b091ee80..5a6174d199c2 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5238,6 +5238,11 @@ class TabManager: ObservableObject { selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true) } + func newSurface(initialInput: String) { + selectedWorkspace?.clearSplitZoom() + selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true, initialInput: initialInput) + } + // MARK: - Split Creation /// Create a new split in the current tab @@ -5282,6 +5287,24 @@ class TabManager: ObservableObject { } } + func applySurfaceTabBarButtons( + _ buttons: [CmuxSurfaceTabBarButton], + sourcePath: String?, + globalConfigPath: String, + terminalCommandSourcePaths: [String: String], + workspaceCommands: [String: CmuxResolvedCommand] + ) { + for workspace in tabs { + workspace.applySurfaceTabBarButtons( + buttons, + sourcePath: sourcePath, + globalConfigPath: globalConfigPath, + terminalCommandSourcePaths: terminalCommandSourcePaths, + workspaceCommands: workspaceCommands + ) + } + } + // MARK: - Pane Focus Navigation /// Move focus to an adjacent pane in the specified direction diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index cb0a73194e09..77ce0056c23e 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -558,7 +558,9 @@ struct HiddenTitlebarSidebarControlsView: View { anchorView: viewModel.notificationsAnchorView ) }, - onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() }, + onNewTab: { + AppDelegate.shared?.performNewWorkspaceAction(debugSource: "titlebar.hiddenNewWorkspace") + }, visibilityMode: .onHover ) .frame(width: hostWidth, height: hostHeight, alignment: .leading) @@ -791,7 +793,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont self.notificationStore = notificationStore let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() } let toggleNotifications: () -> Void = { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) } - let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() } + let newTab = { _ = AppDelegate.shared?.performNewWorkspaceAction(debugSource: "titlebar.accessoryNewWorkspace") } hostingView = NonDraggableHostingView( rootView: TitlebarControlsView( notificationStore: notificationStore, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 712f6d10f48c..8046faf70653 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6646,6 +6646,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var customColor: String? // hex string, e.g. "#C0392B" @Published private(set) var terminalScrollBarHidden: Bool = false @Published var currentDirectory: String + @Published private(set) var surfaceTabBarDirectory: String? private(set) var preferredBrowserProfileID: UUID? /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) @@ -6653,6 +6654,15 @@ final class Workspace: Identifiable, ObservableObject { /// The bonsplit controller managing the split panes for this workspace let bonsplitController: BonsplitController + private struct SurfaceTabBarExecutableButton { + let button: CmuxSurfaceTabBarButton + let workspaceCommand: CmuxResolvedCommand? + let terminalCommandSourcePath: String? + } + + private var surfaceTabBarCommandButtons: [String: SurfaceTabBarExecutableButton] = [:] + private var surfaceTabBarButtonSourcePath: String? + private var surfaceTabBarButtonGlobalConfigPath: String? /// Mapping from bonsplit TabID to our Panel instances @Published private(set) var panels: [UUID: any Panel] = [:] @@ -7030,9 +7040,13 @@ final class Workspace: Identifiable, ObservableObject { let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty + let initialDirectory = hasWorkingDirectory + ? trimmedWorkingDirectory + : FileManager.default.homeDirectoryForCurrentUser.path self.currentDirectory = hasWorkingDirectory ? trimmedWorkingDirectory : FileManager.default.homeDirectoryForCurrentUser.path + self.surfaceTabBarDirectory = initialDirectory // Configure bonsplit with keepAllAlive to preserve terminal state // and keep split entry instantaneous. @@ -7146,6 +7160,65 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.configuration = configuration } + func applySurfaceTabBarButtons( + _ buttons: [CmuxSurfaceTabBarButton], + sourcePath: String?, + globalConfigPath: String, + terminalCommandSourcePaths: [String: String], + workspaceCommands: [String: CmuxResolvedCommand] + ) { + let executableButtons = Dictionary( + uniqueKeysWithValues: buttons.compactMap { button in + if button.terminalCommand != nil { + return ( + button.id, + SurfaceTabBarExecutableButton( + button: button, + workspaceCommand: nil, + terminalCommandSourcePath: button.actionSourcePath ?? terminalCommandSourcePaths[button.id] + ) + ) + } + if let workspaceCommand = workspaceCommands[button.id] { + return ( + button.id, + SurfaceTabBarExecutableButton( + button: button, + workspaceCommand: workspaceCommand, + terminalCommandSourcePath: nil + ) + ) + } + return nil + } + ) + surfaceTabBarCommandButtons = executableButtons + surfaceTabBarButtonSourcePath = sourcePath + surfaceTabBarButtonGlobalConfigPath = globalConfigPath + + let bonsplitButtons = buttons.map { button in + let executable = executableButtons[button.id] + let allowProjectLocalIcon = executable.map { + CmuxConfigExecutor.isTrustedSurfaceButton( + $0.button, + workspaceCommand: $0.workspaceCommand, + terminalCommandSourcePath: $0.terminalCommandSourcePath, + surfaceTabBarConfigSourcePath: sourcePath, + globalConfigPath: globalConfigPath + ) + } ?? true + return button.bonsplitActionButton( + configSourcePath: sourcePath, + globalConfigPath: globalConfigPath, + allowProjectLocalIcon: allowProjectLocalIcon + ) + } + var configuration = bonsplitController.configuration + guard configuration.appearance.splitButtons != bonsplitButtons else { return } + configuration.appearance.splitButtons = bonsplitButtons + bonsplitController.configuration = configuration + } + // MARK: - Surface ID to Panel ID Mapping /// Mapping from bonsplit TabID (surface ID) to panel UUID @@ -7748,6 +7821,23 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Directory Updates + private func configTrackingDirectory(for panelId: UUID?) -> String? { + if let panelId { + for candidate in [ + panelDirectories[panelId], + terminalPanel(for: panelId)?.requestedWorkingDirectory + ] { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { + return trimmed + } + } + } + + let trimmedCurrentDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedCurrentDirectory.isEmpty ? nil : trimmedCurrentDirectory + } + func updatePanelDirectory(panelId: UUID, directory: String) { let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } @@ -7755,8 +7845,13 @@ final class Workspace: Identifiable, ObservableObject { panelDirectories[panelId] = trimmed } // Update current directory if this is the focused panel - if panelId == focusedPanelId, currentDirectory != trimmed { - currentDirectory = trimmed + if panelId == focusedPanelId { + if surfaceTabBarDirectory != trimmed { + surfaceTabBarDirectory = trimmed + } + if currentDirectory != trimmed { + currentDirectory = trimmed + } } } @@ -10400,9 +10495,9 @@ final class Workspace: Identifiable, ObservableObject { /// Create a new terminal surface in the currently focused pane @discardableResult - func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? { + func newTerminalSurfaceInFocusedPane(focus: Bool? = nil, initialInput: String? = nil) -> TerminalPanel? { guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } - return newTerminalSurface(inPane: focusedPaneId, focus: focus) + return newTerminalSurface(inPane: focusedPaneId, focus: focus, initialInput: initialInput) } @discardableResult @@ -11752,6 +11847,8 @@ extension Workspace: BonsplitDelegate { _ = panel.restoreFocusIntent(activationIntent) } + surfaceTabBarDirectory = configTrackingDirectory(for: panelId) + // Update current directory if this is a terminal if let dir = panelDirectories[panelId] { currentDirectory = dir @@ -12496,6 +12593,85 @@ extension Workspace: BonsplitDelegate { } } + private func selectedTerminalPanel(inPane pane: PaneID) -> TerminalPanel? { + guard let selectedTab = bonsplitController.selectedTab(inPane: pane), + let panelId = panelIdFromSurfaceId(selectedTab.id) else { + return nil + } + return terminalPanel(for: panelId) + } + + private func executeSurfaceTabBarCommandButton(identifier: String, inPane pane: PaneID) { + guard let executable = surfaceTabBarCommandButtons[identifier], + let globalConfigPath = surfaceTabBarButtonGlobalConfigPath else { + return + } + let presentingWindow = selectedTerminalPanel(inPane: pane)?.hostedView.window + ?? NSApp.keyWindow + ?? NSApp.mainWindow + + if let workspaceCommand = executable.workspaceCommand { + bonsplitController.focusPane(pane) + if let selectedTab = bonsplitController.selectedTab(inPane: pane) { + applyTabSelection(tabId: selectedTab.id, inPane: pane) + } + + let paneDirectory = selectedTerminalPanel(inPane: pane).flatMap { terminal -> String? in + for candidate in [panelDirectories[terminal.id], terminal.requestedWorkingDirectory] { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + return trimmed + } + } + return nil + } + let rawCwd = paneDirectory ?? currentDirectory + let trimmedCwd = rawCwd.trimmingCharacters(in: .whitespacesAndNewlines) + let baseCwd = trimmedCwd.isEmpty ? FileManager.default.homeDirectoryForCurrentUser.path : trimmedCwd + guard let tabManager = owningTabManager else { return } + _ = CmuxConfigExecutor.execute( + command: workspaceCommand.command, + tabManager: tabManager, + baseCwd: baseCwd, + configSourcePath: workspaceCommand.sourcePath, + globalConfigPath: globalConfigPath, + displayTitle: executable.button.title ?? executable.button.tooltip ?? workspaceCommand.command.name, + actionID: executable.button.id, + icon: executable.button.icon ?? executable.button.action.defaultButtonIcon, + iconSourcePath: executable.button.iconSourcePath, + presentingWindow: presentingWindow + ) + return + } + + guard let command = executable.button.terminalCommand else { return } + let target = executable.button.resolvedTerminalCommandTarget + let didExecute = CmuxConfigExecutor.prepareShellInputIfAuthorized( + command, + confirm: executable.button.confirm ?? false, + actionID: executable.button.id, + target: target, + configSourcePath: executable.terminalCommandSourcePath ?? surfaceTabBarButtonSourcePath, + globalConfigPath: globalConfigPath, + displayTitle: executable.button.title ?? executable.button.tooltip, + icon: executable.button.icon ?? executable.button.action.defaultButtonIcon, + iconSourcePath: executable.button.iconSourcePath, + presentingWindow: presentingWindow + ) { [weak self] shellInput in + guard let self else { return } + self.bonsplitController.focusPane(pane) + switch target { + case .currentTerminal: + self.selectedTerminalPanel(inPane: pane)?.sendInput(shellInput) + case .newTabInCurrentPane: + _ = self.newTerminalSurface(inPane: pane, focus: true, initialInput: shellInput) + } + } + guard didExecute else { + return + } + } + func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) { switch kind { case "terminal": @@ -12507,6 +12683,16 @@ extension Workspace: BonsplitDelegate { } } + func splitTabBar(_ controller: BonsplitController, didRequestCustomAction identifier: String, inPane pane: PaneID) { +#if DEBUG + dlog( + "split.customAction.request workspace=\(id.uuidString.prefix(5)) " + + "pane=\(pane.id.uuidString.prefix(5)) identifier=\(identifier)" + ) +#endif + executeSurfaceTabBarCommandButton(identifier: identifier, inPane: pane) + } + func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { switch action { case .rename: diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a42706afaac6..5ef9543b8086 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -438,16 +438,12 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: menuShortcut(for: .newTab)) { if let appDelegate = AppDelegate.shared { - if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { -#if DEBUG - FocusLogStore.shared.append( - "cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil" - ) -#endif - appDelegate.openNewMainWindow(nil) - } + appDelegate.performNewWorkspaceAction( + tabManager: activeTabManager, + debugSource: "menu.newWorkspace" + ) } else { - activeTabManager.addTab() + activeTabManager.addWorkspace() } } @@ -4353,7 +4349,6 @@ struct SettingsView: View { @State private var showLanguageRestartAlert = false @State private var isResettingSettings = false @State private var workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() - @State private var trustedDirectoriesDraft: String = CmuxDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -4595,14 +4590,6 @@ struct SettingsView: View { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } - private func saveTrustedDirectories() { - let paths = trustedDirectoriesDraft - .split(separator: "\n", omittingEmptySubsequences: true) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - CmuxDirectoryTrust.shared.replaceAll(with: paths) - } - private var hasCustomNotificationSoundFilePath: Bool { !notificationSoundCustomFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -5823,39 +5810,6 @@ struct SettingsView: View { SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) } - SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands")) - SettingsCard { - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - configurationReview: .json("customCommands.trustedDirectories"), - String(localized: "settings.customCommands.trustedDirectories", defaultValue: "Trusted Directories"), - subtitle: String(localized: "settings.customCommands.trustedDirectories.subtitle", defaultValue: "Commands from cmux.json in these directories run without confirmation. One path per line.") - ) { - EmptyView() - } - - TextEditor(text: $trustedDirectoriesDraft) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - .onChange(of: trustedDirectoriesDraft) { _ in - saveTrustedDirectories() - } - } - - SettingsCardDivider() - SettingsCardNote(String(localized: "settings.customCommands.trustedDirectories.note", defaultValue: "Place a cmux.json in your project root to define custom commands. Trust a directory from the confirmation dialog, or add paths here. For git repos, trusting the root covers all subdirectories.")) - } - SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) .id(SettingsNavigationTarget.browser) .accessibilityIdentifier("SettingsBrowserSection") diff --git a/cmuxTests/CmuxConfigTests.swift b/cmuxTests/CmuxConfigTests.swift index 834030d1034e..736e24e89e03 100644 --- a/cmuxTests/CmuxConfigTests.swift +++ b/cmuxTests/CmuxConfigTests.swift @@ -1,3 +1,4 @@ +import Combine import XCTest #if canImport(cmux_DEV) @@ -15,6 +16,21 @@ final class CmuxConfigDecodingTests: XCTestCase { return try JSONDecoder().decode(CmuxConfigFile.self, from: data) } + private func resolvedActions( + from config: CmuxConfigFile, + sourcePath: String? = nil + ) -> [String: CmuxResolvedConfigAction] { + Dictionary( + uniqueKeysWithValues: config.actions.compactMap { id, definition in + CmuxResolvedConfigAction.fromDefinition( + id: id, + definition: definition, + sourcePath: sourcePath + ).map { (id, $0) } + } + ) + } + // MARK: Simple commands func testDecodeSimpleCommand() throws { @@ -69,6 +85,934 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertEqual(config.commands.map(\.name), ["Build", "Test", "Lint"]) } + func testDecodeNewWorkspaceCommand() throws { + let json = """ + { + "newWorkspaceCommand": "Dev Environment", + "commands": [{ + "name": "Dev Environment", + "workspace": { "name": "Dev" } + }] + } + """ + let config = try decode(json) + XCTAssertEqual(config.newWorkspaceCommand, "Dev Environment") + } + + func testDecodeNewWorkspaceCommandTrimsWhitespace() throws { + let json = """ + { + "newWorkspaceCommand": " Dev Environment ", + "commands": [{ + "name": "Dev Environment", + "workspace": { "name": "Dev" } + }] + } + """ + let config = try decode(json) + XCTAssertEqual(config.newWorkspaceCommand, "Dev Environment") + } + + func testDecodeLegacySurfaceTabBarButtons() throws { + let json = """ + { + "surfaceTabBarButtons": ["newTerminal", "splitRight"], + "commands": [] + } + """ + let config = try decode(json) + XCTAssertEqual(config.surfaceTabBarButtons, [.newTerminal, .splitRight]) + } + + func testDecodeSurfaceTabBarButtonObjects() throws { + let json = """ + { + "surfaceTabBarButtons": [ + { + "id": "newTerminal", + "icon": { "type": "symbol", "name": "terminal.fill" }, + "tooltip": "New shell", + "action": "newTerminal" + }, + { + "id": "run-tests", + "icon": { "type": "symbol", "name": "checkmark.circle" }, + "tooltip": "Run tests", + "command": "npm test", + "confirm": true + } + ], + "commands": [] + } + """ + let config = try decode(json) + XCTAssertEqual(config.surfaceTabBarButtons?.count, 2) + let rawFirstButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) + let firstButton = try rawFirstButton.resolved(actions: [:], codingPath: []) + XCTAssertEqual( + firstButton, + .builtIn(.newTerminal, id: "newTerminal", icon: .symbol("terminal.fill"), tooltip: "New shell") + ) + XCTAssertEqual(config.surfaceTabBarButtons?[1].id, "run-tests") + XCTAssertEqual(config.surfaceTabBarButtons?[1].icon, .symbol("checkmark.circle")) + XCTAssertEqual(config.surfaceTabBarButtons?[1].tooltip, "Run tests") + XCTAssertEqual(config.surfaceTabBarButtons?[1].action, .command("npm test")) + XCTAssertEqual(config.surfaceTabBarButtons?[1].confirm, true) + } + + func testDecodeSurfaceTabBarButtonCanOverrideBuiltInWithCommand() throws { + let json = """ + { + "surfaceTabBarButtons": [ + { + "id": "newTerminal", + "icon": { "type": "symbol", "name": "play.circle" }, + "command": "npm run dev" + } + ] + } + """ + let config = try decode(json) + let button = try XCTUnwrap(config.surfaceTabBarButtons?.first) + XCTAssertEqual(button.id, "newTerminal") + XCTAssertEqual(button.icon, .symbol("play.circle")) + XCTAssertEqual(button.command, "npm run dev") + } + + func testDecodeActionsSurfaceTabBarButtons() throws { + let json = """ + { + "actions": { + "start-codex": { "type": "agent", "agent": "codex" }, + "start-claude": { "type": "agent", "agent": "claude", "args": "--permission-mode acceptEdits" } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + { + "action": "start-codex", + "icon": { "type": "image", "path": "./icons/codex.png" }, + "tooltip": "Start Codex" + }, + { + "action": "start-claude", + "icon": { "type": "emoji", "value": "🤖" }, + "tooltip": "Start Claude Code" + } + ] + } + } + } + """ + let config = try decode(json) + let rawButtons = try XCTUnwrap(config.surfaceTabBarButtons) + let buttons = try rawButtons.map { + try $0.resolved(actions: resolvedActions(from: config), codingPath: []) + } + XCTAssertEqual(buttons.count, 2) + XCTAssertEqual(buttons[0].id, "start-codex") + XCTAssertEqual(buttons[0].icon, .imagePath("./icons/codex.png")) + XCTAssertEqual(buttons[0].terminalCommand, "codex") + XCTAssertEqual(buttons[1].id, "start-claude") + XCTAssertEqual(buttons[1].icon, .emoji("🤖")) + XCTAssertEqual(buttons[1].terminalCommand, "claude --permission-mode acceptEdits") + } + + func testDecodeSurfaceTabBarButtonsDefersUnknownActionReferences() throws { + let json = """ + { + "ui": { + "surfaceTabBar": { + "buttons": [ + { "action": "global-codex", "icon": { "type": "symbol", "name": "sparkles" } } + ] + } + } + } + """ + let config = try decode(json) + let button = try XCTUnwrap(config.surfaceTabBarButtons?.first) + XCTAssertEqual(button.id, "global-codex") + XCTAssertEqual(button.action, .actionReference("global-codex")) + } + + func testResolveSurfaceTabBarActionReferenceUsesActionTitle() throws { + let json = """ + { + "actions": { + "start-codex": { + "type": "command", + "title": "Start Codex", + "tooltip": "Open Codex in a new tab", + "command": "codex", + "icon": { "type": "symbol", "name": "sparkles" } + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + { "action": "start-codex" } + ] + } + } + } + """ + let config = try decode(json) + let rawButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) + let button = try rawButton.resolved(actions: resolvedActions(from: config), codingPath: []) + XCTAssertEqual(button.title, "Start Codex") + XCTAssertEqual(button.tooltip, "Open Codex in a new tab") + XCTAssertEqual(button.icon, .symbol("sparkles")) + XCTAssertEqual(button.action, .command("codex")) + } + + func testResolveSurfaceTabBarActionReferenceCanOverrideTitleAndIcon() throws { + let json = """ + { + "actions": { + "start-codex": { + "type": "command", + "title": "Start Codex", + "tooltip": "Open Codex in a new tab", + "command": "codex", + "icon": { "type": "symbol", "name": "sparkles" } + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + { + "action": "start-codex", + "title": "Codex Here", + "icon": { "type": "emoji", "value": "🤖" } + } + ] + } + } + } + """ + let config = try decode(json) + let rawButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) + let button = try rawButton.resolved(actions: resolvedActions(from: config), codingPath: []) + XCTAssertEqual(button.title, "Codex Here") + XCTAssertEqual(button.tooltip, "Open Codex in a new tab") + XCTAssertEqual(button.icon, .emoji("🤖")) + XCTAssertEqual(button.action, .command("codex")) + } + + @MainActor + func testSurfaceTabBarActionReferenceUsesActionSourcePath() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + let globalDirectory = root.appendingPathComponent("global", isDirectory: true) + let localDirectory = root.appendingPathComponent("project", isDirectory: true) + try FileManager.default.createDirectory(at: globalDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: localDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let globalConfigURL = globalDirectory.appendingPathComponent("cmux.json") + let localConfigURL = localDirectory.appendingPathComponent("cmux.json") + let globalJSON = """ + { + "actions": { + "start-codex": { "type": "command", "command": "codex --yolo", "confirm": true } + } + } + """ + let localJSON = """ + { + "ui": { + "surfaceTabBar": { + "buttons": [ + { "action": "start-codex", "icon": { "type": "symbol", "name": "sparkles" } } + ] + } + } + } + """ + try globalJSON.write(to: globalConfigURL, atomically: true, encoding: .utf8) + try localJSON.write(to: localConfigURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: globalConfigURL.path, + localConfigPath: localConfigURL.path, + startFileWatchers: false + ) + store.loadAll() + + XCTAssertEqual(store.surfaceTabBarButtonSourcePath, localConfigURL.path) + XCTAssertEqual(store.surfaceTabBarButtons.first?.terminalCommand, "codex --yolo") + XCTAssertEqual(store.surfaceTabBarCommandSourcePaths["start-codex"], globalConfigURL.path) + } + + func testDecodeActionIconObjectsSupportAllFormats() throws { + let json = """ + { + "ui": { + "surfaceTabBar": { + "buttons": [ + { "id": "emoji", "icon": { "type": "emoji", "value": "🤖", "scale": 0.85 }, "command": "codex" }, + { "id": "svg", "icon": { "type": "image", "path": "./icons/codex.svg" }, "command": "codex" }, + { "id": "jpeg", "icon": { "type": "image", "path": "./icons/claude.jpg" }, "command": "claude" }, + { "id": "pdf", "icon": { "type": "image", "path": "./icons/logo.pdf" }, "command": "open ." }, + { "id": "bmp", "icon": { "type": "image", "path": "./icons/logo.bmp" }, "command": "open ." }, + { "id": "heif", "icon": { "type": "image", "path": "./icons/logo.heif" }, "command": "open ." }, + { "id": "avif", "icon": { "type": "image", "path": "./icons/logo.avif" }, "command": "open ." }, + { "id": "ico", "icon": { "type": "image", "path": "./icons/logo.ico" }, "command": "open ." } + ] + } + } + } + """ + let config = try decode(json) + XCTAssertEqual(config.surfaceTabBarButtons?[0].icon, .emoji("🤖", scale: 0.85)) + XCTAssertEqual(config.surfaceTabBarButtons?[1].icon, .imagePath("./icons/codex.svg")) + XCTAssertEqual(config.surfaceTabBarButtons?[2].icon, .imagePath("./icons/claude.jpg")) + XCTAssertEqual(config.surfaceTabBarButtons?[3].icon, .imagePath("./icons/logo.pdf")) + XCTAssertEqual(config.surfaceTabBarButtons?[4].icon, .imagePath("./icons/logo.bmp")) + XCTAssertEqual(config.surfaceTabBarButtons?[5].icon, .imagePath("./icons/logo.heif")) + XCTAssertEqual(config.surfaceTabBarButtons?[6].icon, .imagePath("./icons/logo.avif")) + XCTAssertEqual(config.surfaceTabBarButtons?[7].icon, .imagePath("./icons/logo.ico")) + } + + func testDecodeStringIconThrows() { + let json = """ + { + "actions": { + "start-codex": { + "type": "command", + "command": "codex", + "icon": "sparkles" + } + } + } + """ + XCTAssertThrowsError(try decode(json)) + } + + func testGlobalSVGIconAllowsNamespaceAndInternalGradient() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-svg-\(UUID().uuidString)", + isDirectory: true + ) + let iconsDirectory = root.appendingPathComponent("icons", isDirectory: true) + try FileManager.default.createDirectory(at: iconsDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let configPath = root.appendingPathComponent("cmux.json").path + let iconPath = iconsDirectory.appendingPathComponent("codex.svg") + let svg = """ + + + + + + + + + + """ + let data = Data(svg.utf8) + try data.write(to: iconPath) + + let icon = CmuxButtonIcon.imagePath("icons/codex.svg") + XCTAssertEqual( + icon.bonsplitIcon( + configSourcePath: configPath, + globalConfigPath: configPath + ), + .imageData(data) + ) + } + + func testProjectLocalSVGIconRejectsExternalReferences() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-svg-\(UUID().uuidString)", + isDirectory: true + ) + let globalDirectory = root.appendingPathComponent("global", isDirectory: true) + let projectDirectory = root.appendingPathComponent("project", isDirectory: true) + let iconsDirectory = projectDirectory.appendingPathComponent("icons", isDirectory: true) + try FileManager.default.createDirectory(at: globalDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: iconsDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let globalConfigPath = globalDirectory.appendingPathComponent("cmux.json").path + let projectConfigPath = projectDirectory.appendingPathComponent("cmux.json").path + let iconPath = iconsDirectory.appendingPathComponent("bad.svg") + let svg = """ + + + + """ + try Data(svg.utf8).write(to: iconPath) + + let icon = CmuxButtonIcon.imagePath("icons/bad.svg") + XCTAssertEqual( + icon.bonsplitIcon( + configSourcePath: projectConfigPath, + globalConfigPath: globalConfigPath + ), + .systemImage("questionmark.circle") + ) + } + + func testUntrustedProjectLocalIconUsesLockPlaceholder() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-svg-\(UUID().uuidString)", + isDirectory: true + ) + let globalDirectory = root.appendingPathComponent("global", isDirectory: true) + let projectDirectory = root.appendingPathComponent("project", isDirectory: true) + let iconsDirectory = projectDirectory.appendingPathComponent("icons", isDirectory: true) + try FileManager.default.createDirectory(at: globalDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: iconsDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let globalConfigPath = globalDirectory.appendingPathComponent("cmux.json").path + let projectConfigPath = projectDirectory.appendingPathComponent("cmux.json").path + let iconPath = iconsDirectory.appendingPathComponent("safe.svg") + let svg = """ + + + + """ + try Data(svg.utf8).write(to: iconPath) + + let icon = CmuxButtonIcon.imagePath("icons/safe.svg") + XCTAssertEqual( + icon.bonsplitIcon( + configSourcePath: projectConfigPath, + globalConfigPath: globalConfigPath, + allowProjectLocalImage: false + ), + .systemImage("lock.fill") + ) + } + + @MainActor + func testInlineSurfaceButtonIconUsesTabBarConfigSourceForTrust() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-svg-\(UUID().uuidString)", + isDirectory: true + ) + let globalDirectory = root.appendingPathComponent("global", isDirectory: true) + let projectDirectory = root.appendingPathComponent("project", isDirectory: true) + let iconsDirectory = projectDirectory.appendingPathComponent("icons", isDirectory: true) + try FileManager.default.createDirectory(at: globalDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: iconsDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let globalConfigPath = globalDirectory.appendingPathComponent("cmux.json").path + let projectConfigPath = projectDirectory.appendingPathComponent("cmux.json").path + let iconPath = iconsDirectory.appendingPathComponent("safe.svg") + let svg = """ + + + + """ + try Data(svg.utf8).write(to: iconPath) + + let button = CmuxSurfaceTabBarButton( + id: "inline-local", + icon: .imagePath("icons/safe.svg"), + action: .command("echo inline") + ) + XCTAssertFalse( + CmuxConfigExecutor.isTrustedSurfaceButton( + button, + workspaceCommand: nil, + terminalCommandSourcePath: nil, + surfaceTabBarConfigSourcePath: projectConfigPath, + globalConfigPath: globalConfigPath + ) + ) + } + + func testDecodeNewWorkspaceAction() throws { + let json = """ + { + "actions": { + "new-dev": { "type": "workspaceCommand", "commandName": "Dev Environment" } + }, + "ui": { + "newWorkspace": { "action": "new-dev" } + }, + "commands": [{ + "name": "Dev Environment", + "workspace": { "name": "Dev" } + }] + } + """ + let config = try decode(json) + XCTAssertEqual(config.ui?.newWorkspace?.action, "new-dev") + XCTAssertEqual(config.actions["new-dev"]?.action?.workspaceCommandName, "Dev Environment") + } + + func testDecodeActionShortcutString() throws { + let json = """ + { + "actions": { + "start-codex": { + "type": "command", + "command": "codex --dangerously-bypass-approvals-and-sandbox", + "shortcut": "cmd+shift+c" + } + } + } + """ + let config = try decode(json) + XCTAssertEqual( + config.actions["start-codex"]?.shortcut, + StoredShortcut.parseConfig("cmd+shift+c") + ) + } + + func testDecodeActionShortcutChord() throws { + let json = """ + { + "actions": { + "start-claude": { + "type": "command", + "command": "claude --dangerously-skip-permissions", + "shortcut": ["cmd+k", "cmd+c"] + } + } + } + """ + let config = try decode(json) + XCTAssertEqual( + config.actions["start-claude"]?.shortcut, + StoredShortcut.parseConfig(strokes: ["cmd+k", "cmd+c"]) + ) + } + + @MainActor + func testInvalidConfigExposesSchemaIssueAndClearsAfterFix() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let configURL = root.appendingPathComponent("cmux.json") + let invalidJSON = """ + { + "actions": { + "bad": { + "type": "command", + "command": "echo bad", + "icon": "sparkles" + } + } + } + """ + try invalidJSON.write(to: configURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: root.appendingPathComponent("missing-global.json").path, + localConfigPath: configURL.path, + startFileWatchers: false + ) + store.loadAll() + + let issue = try XCTUnwrap(store.configurationIssues.first) + XCTAssertEqual(issue.kind, .schemaError) + XCTAssertEqual(issue.sourcePath, configURL.path) + XCTAssertTrue(issue.message?.contains("actions.bad.icon") ?? false) + XCTAssertNil(store.resolvedAction(id: "bad")) + + let validJSON = """ + { + "actions": { + "bad": { + "type": "command", + "command": "echo bad", + "icon": { "type": "symbol", "name": "sparkles" } + } + } + } + """ + try validJSON.write(to: configURL, atomically: true, encoding: .utf8) + store.loadAll() + + XCTAssertTrue(store.configurationIssues.isEmpty) + XCTAssertNotNil(store.resolvedAction(id: "bad")) + } + + @MainActor + func testLocalWatcherDetectsFirstCanonicalConfigAfterDirectoryCreation() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + let projectDirectory = root.appendingPathComponent("project", isDirectory: true) + try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let configDirectory = projectDirectory.appendingPathComponent(".cmux", isDirectory: true) + let configURL = configDirectory.appendingPathComponent("cmux.json", isDirectory: false) + let store = CmuxConfigStore( + globalConfigPath: root.appendingPathComponent("missing-global.json").path, + localConfigPath: configURL.path, + startFileWatchers: true + ) + store.loadAll() + XCTAssertNil(store.resolvedAction(id: "created")) + + let loaded = expectation(description: "created local cmux config is loaded") + loaded.assertForOverFulfill = false + var cancellable: AnyCancellable? + cancellable = store.$loadedActions.dropFirst().sink { actions in + if actions.contains(where: { $0.id == "created" }) { + loaded.fulfill() + } + } + + try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) + try """ + { + "actions": { + "created": { "type": "command", "command": "echo created" } + } + } + """.write(to: configURL, atomically: true, encoding: .utf8) + + await fulfillment(of: [loaded], timeout: 3) + cancellable?.cancel() + } + + @MainActor + func testLocalWatcherDetectsFirstLegacyConfigWhenCmuxDirectoryExists() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + let projectDirectory = root.appendingPathComponent("project", isDirectory: true) + let configDirectory = projectDirectory.appendingPathComponent(".cmux", isDirectory: true) + try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let canonicalConfigURL = configDirectory.appendingPathComponent("cmux.json", isDirectory: false) + let legacyConfigURL = projectDirectory.appendingPathComponent("cmux.json", isDirectory: false) + let store = CmuxConfigStore( + globalConfigPath: root.appendingPathComponent("missing-global.json").path, + localConfigPath: canonicalConfigURL.path, + startFileWatchers: true + ) + store.loadAll() + XCTAssertNil(store.resolvedAction(id: "legacy-created")) + + let loaded = expectation(description: "created legacy cmux config is loaded") + loaded.assertForOverFulfill = false + var cancellable: AnyCancellable? + cancellable = store.$loadedActions.dropFirst().sink { actions in + if actions.contains(where: { $0.id == "legacy-created" }) { + loaded.fulfill() + } + } + + try """ + { + "actions": { + "legacy-created": { "type": "command", "command": "echo created" } + } + } + """.write(to: legacyConfigURL, atomically: true, encoding: .utf8) + + await fulfillment(of: [loaded], timeout: 3) + cancellable?.cancel() + } + + @MainActor + func testResolvedNewWorkspaceCommandReturnsConfiguredWorkspaceCommand() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let configURL = root.appendingPathComponent("cmux.json") + let json = """ + { + "newWorkspaceCommand": "Dev Environment", + "commands": [{ + "name": "Dev Environment", + "workspace": { "name": "Dev" } + }] + } + """ + try json.write(to: configURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: root.appendingPathComponent("missing-global.json").path, + localConfigPath: configURL.path, + startFileWatchers: false + ) + store.loadAll() + + let resolved = try XCTUnwrap(store.resolvedNewWorkspaceCommand()) + XCTAssertEqual(resolved.command.name, "Dev Environment") + XCTAssertTrue(store.configurationIssues.isEmpty) + } + + @MainActor + func testGlobalNewWorkspaceActionUsesLocalActionOverride() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let globalConfigURL = root.appendingPathComponent("global-cmux.json") + let localConfigURL = root.appendingPathComponent("local-cmux.json") + try """ + { + "actions": { + "open-dev": { "type": "workspaceCommand", "commandName": "Global Dev" } + }, + "ui": { + "newWorkspace": { "action": "open-dev" } + }, + "commands": [{ + "name": "Global Dev", + "workspace": { "name": "Global" } + }] + } + """.write(to: globalConfigURL, atomically: true, encoding: .utf8) + try """ + { + "actions": { + "open-dev": { "type": "workspaceCommand", "commandName": "Local Dev" } + }, + "commands": [{ + "name": "Local Dev", + "workspace": { "name": "Local" } + }] + } + """.write(to: localConfigURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: globalConfigURL.path, + localConfigPath: localConfigURL.path, + startFileWatchers: false + ) + store.loadAll() + + let resolved = try XCTUnwrap(store.resolvedNewWorkspaceCommand()) + XCTAssertEqual(resolved.command.name, "Local Dev") + XCTAssertEqual(resolved.sourcePath, localConfigURL.path) + XCTAssertTrue(store.configurationIssues.isEmpty) + } + + @MainActor + func testResolvedNewWorkspaceCommandExposesMissingCommandIssue() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let configURL = root.appendingPathComponent("cmux.json") + let json = """ + { + "newWorkspaceCommand": "Missing", + "commands": [{ + "name": "Dev Environment", + "workspace": { "name": "Dev" } + }] + } + """ + try json.write(to: configURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: root.appendingPathComponent("missing-global.json").path, + localConfigPath: configURL.path, + startFileWatchers: false + ) + store.loadAll() + + XCTAssertNil(store.resolvedNewWorkspaceCommand()) + XCTAssertEqual(store.configurationIssues.first?.kind, .newWorkspaceCommandNotFound) + XCTAssertEqual(store.configurationIssues.first?.commandName, "Missing") + } + + @MainActor + func testResolvedNewWorkspaceCommandExposesNonWorkspaceIssue() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let configURL = root.appendingPathComponent("cmux.json") + let json = """ + { + "newWorkspaceCommand": "Run Tests", + "commands": [{ + "name": "Run Tests", + "command": "npm test" + }] + } + """ + try json.write(to: configURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: root.appendingPathComponent("missing-global.json").path, + localConfigPath: configURL.path, + startFileWatchers: false + ) + store.loadAll() + + XCTAssertNil(store.resolvedNewWorkspaceCommand()) + XCTAssertEqual(store.configurationIssues.first?.kind, .newWorkspaceCommandRequiresWorkspace) + XCTAssertEqual(store.configurationIssues.first?.commandName, "Run Tests") + XCTAssertEqual(store.configurationIssues.first?.sourcePath, configURL.path) + } + + @MainActor + func testResolvedNewWorkspaceActionExposesNonWorkspaceActionIssue() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let configURL = root.appendingPathComponent("cmux.json") + let json = """ + { + "actions": { + "start-codex": { "type": "command", "command": "codex" } + }, + "ui": { + "newWorkspace": { "action": "start-codex" } + } + } + """ + try json.write(to: configURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: root.appendingPathComponent("missing-global.json").path, + localConfigPath: configURL.path, + startFileWatchers: false + ) + store.loadAll() + + XCTAssertNil(store.resolvedNewWorkspaceCommand()) + XCTAssertEqual(store.configurationIssues.first?.kind, .newWorkspaceActionRequiresWorkspaceCommand) + XCTAssertEqual(store.configurationIssues.first?.sourcePath, configURL.path) + } + + func testDecodeActionsSurfaceTabBarButtonSupportsWorkspaceCommand() throws { + let json = """ + { + "actions": { + "new-dev": { "type": "workspaceCommand", "commandName": "Dev Environment" } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + { + "action": "new-dev", + "icon": { "type": "symbol", "name": "rectangle.stack.badge.plus" } + } + ] + } + }, + "commands": [{ + "name": "Dev Environment", + "workspace": { "name": "Dev" } + }] + } + """ + let config = try decode(json) + let rawButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) + let button = try rawButton.resolved(actions: resolvedActions(from: config), codingPath: []) + XCTAssertEqual(button.workspaceCommandName, "Dev Environment") + XCTAssertNil(button.terminalCommand) + } + + func testSurfaceTabBarWorkspaceCommandButtonRoundTrips() throws { + let original = CmuxSurfaceTabBarButton( + id: "new-dev", + icon: .symbol("rectangle.stack.badge.plus"), + tooltip: "New dev workspace", + action: .workspaceCommand("Dev Environment"), + confirm: true + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(CmuxSurfaceTabBarButton.self, from: data) + + XCTAssertEqual(decoded, original) + } + + @MainActor + func testSurfaceTabBarDropsUnresolvedWorkspaceCommandButtons() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let configURL = root.appendingPathComponent("cmux.json") + let json = """ + { + "ui": { + "surfaceTabBar": { + "buttons": [ + { "action": "newTerminal" }, + { "id": "dev", "type": "workspaceCommand", "commandName": "Dev Environment" }, + { "id": "typo", "type": "workspaceCommand", "commandName": "Typo" }, + { "id": "simple", "type": "workspaceCommand", "commandName": "Run Tests" } + ] + } + }, + "commands": [ + { + "name": "Dev Environment", + "workspace": { "name": "Dev" } + }, + { + "name": "Run Tests", + "command": "npm test" + } + ] + } + """ + try json.write(to: configURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: root.appendingPathComponent("missing-global.json").path, + localConfigPath: configURL.path, + startFileWatchers: false + ) + store.loadAll() + + XCTAssertEqual(store.surfaceTabBarButtons.map(\.id), ["newTerminal", "dev"]) + XCTAssertEqual(store.surfaceTabBarButtons.last?.workspaceCommandName, "Dev Environment") + } + + func testDecodeEmptySurfaceTabBarButtons() throws { + let json = """ + { + "surfaceTabBarButtons": [] + } + """ + let config = try decode(json) + XCTAssertEqual(config.surfaceTabBarButtons, []) + XCTAssertTrue(config.commands.isEmpty) + } + func testDecodeEmptyCommandsArray() throws { let json = """ { "commands": [] } @@ -331,11 +1275,26 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertThrowsError(try decode(json)) } - func testDecodeMissingCommandsKeyThrows() { + func testDecodeMissingCommandsKeyAllowsActionOnlyConfig() throws { let json = """ - { "notCommands": [] } + { + "actions": { + "start-codex": { "type": "agent", "agent": "codex" } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + { "action": "start-codex", "icon": { "type": "symbol", "name": "sparkles" } } + ] + } + } + } """ - XCTAssertThrowsError(try decode(json)) + let config = try decode(json) + XCTAssertTrue(config.commands.isEmpty) + let rawButton = try XCTUnwrap(config.surfaceTabBarButtons?.first) + let button = try rawButton.resolved(actions: resolvedActions(from: config), codingPath: []) + XCTAssertEqual(button.terminalCommand, "codex") } func testDecodeInvalidSurfaceTypeThrows() { @@ -508,6 +1467,47 @@ final class CmuxConfigDecodingTests: XCTestCase { """ XCTAssertThrowsError(try decode(json)) } + + func testDecodeBlankNewWorkspaceCommandThrows() { + let json = """ + { + "newWorkspaceCommand": " ", + "commands": [] + } + """ + XCTAssertThrowsError(try decode(json)) + } + + func testDecodeDuplicateSurfaceTabBarButtonsThrows() { + let json = """ + { + "surfaceTabBarButtons": ["newTerminal", "newTerminal"], + "commands": [] + } + """ + XCTAssertThrowsError(try decode(json)) + } + + func testDecodeDuplicateSurfaceTabBarButtonIdsThrows() { + let json = """ + { + "surfaceTabBarButtons": [ + { + "id": "run", + "icon": { "type": "symbol", "name": "play" }, + "command": "npm run dev" + }, + { + "id": "run", + "icon": { "type": "symbol", "name": "checkmark" }, + "command": "npm test" + } + ], + "commands": [] + } + """ + XCTAssertThrowsError(try decode(json)) + } } // MARK: - Command identity diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index f6c3708f4f0c..2672f7bce856 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -633,6 +633,26 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! private let settingsFileBackupsDefaultsKey = "cmux.settingsFile.backups.v1" + func testShortcutConfigStringCanonicalizesNumberedDigitsWhenRequested() { + let stroke = ShortcutStroke( + key: "7", + command: true, + shift: false, + option: false, + control: false + ) + + XCTAssertEqual(stroke.configString(), "cmd+7") + XCTAssertEqual(stroke.configString(preserveDigit: false), "cmd+1") + } + + func testShortcutConfigParsingRoundTripsFunctionAndMediaKeys() { + XCTAssertEqual(ShortcutStroke.parseConfig("cmd+f5")?.key, "f5") + XCTAssertEqual(ShortcutStroke.parseConfig("cmd+media.playPause")?.key, "media.playPause") + XCTAssertEqual(ShortcutStroke.parseConfig("cmd+playPause")?.key, "media.playPause") + XCTAssertNil(ShortcutStroke.parseConfig("cmd+f21")) + } + override func setUp() { super.setUp() originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore diff --git a/dogfood/directory-actions/.cmux/cmux.json b/dogfood/directory-actions/.cmux/cmux.json new file mode 100644 index 000000000000..68057c5e76bf --- /dev/null +++ b/dogfood/directory-actions/.cmux/cmux.json @@ -0,0 +1,31 @@ +{ + "actions": { + "cmux.newTerminal": { + "type": "command", + "title": "Root New Terminal", + "subtitle": "Loaded from directory-actions/.cmux/cmux.json", + "command": "echo 'ROOT NEW TERMINAL'; echo 'SOURCE=directory-actions/.cmux/cmux.json'; pwd", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+1", + "icon": { "type": "image", "path": "./icons/root-terminal.svg" } + }, + "root.echo": { + "type": "command", + "title": "Root Echo", + "subtitle": "Ancestor action should still merge into nested directories", + "command": "echo 'ROOT ECHO'; echo 'SOURCE=directory-actions/.cmux/cmux.json'; pwd", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+r", + "icon": { "type": "emoji", "value": "🌲" } + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + "cmux.newTerminal", + "root.echo", + "cmux.newBrowser" + ] + } + } +} diff --git a/dogfood/directory-actions/.cmux/icons/root-terminal.svg b/dogfood/directory-actions/.cmux/icons/root-terminal.svg new file mode 100644 index 000000000000..7ccff7bcfb4e --- /dev/null +++ b/dogfood/directory-actions/.cmux/icons/root-terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dogfood/directory-actions/README.md b/dogfood/directory-actions/README.md new file mode 100644 index 000000000000..2063347b3b47 --- /dev/null +++ b/dogfood/directory-actions/README.md @@ -0,0 +1,34 @@ +# Directory Actions Dogfood + +This tree is for dogfooding per-directory `cmux.json` resolution. + +Use a terminal pane in cmux and `cd` into these directories: + +- `dogfood/directory-actions/alpha` +- `dogfood/directory-actions/alpha/nested` +- `dogfood/directory-actions/legacy` +- `dogfood/directory-actions/legacy/prefer-dot-cmux` + +What each one demonstrates: + +- `alpha` + - Inherits the ancestor `./.cmux/cmux.json` + - Shows ancestor lookup from the active pane cwd +- `alpha/nested` + - Has its own `./.cmux/cmux.json` + - Overrides `cmux.newTerminal` + - Replaces the surface tab bar button list + - Still inherits parent actions into Command Palette +- `legacy` + - Uses fallback `./cmux.json` + - Demonstrates backward-compatible local config loading +- `legacy/prefer-dot-cmux` + - Contains both `./cmux.json` and `./.cmux/cmux.json` + - The `./.cmux/cmux.json` file should win + +General expectations: + +- Image-backed project-local icons start as a lock until that exact action is trusted. +- Emoji-backed project-local actions show their emoji immediately, but still prompt on first run. +- Running a trusted action opens a new terminal tab in the current pane and sends the configured shell input. +- Command Palette should update as the active pane cwd changes. diff --git a/dogfood/directory-actions/alpha/nested/.cmux/cmux.json b/dogfood/directory-actions/alpha/nested/.cmux/cmux.json new file mode 100644 index 000000000000..f43dcd70ac4e --- /dev/null +++ b/dogfood/directory-actions/alpha/nested/.cmux/cmux.json @@ -0,0 +1,31 @@ +{ + "actions": { + "cmux.newTerminal": { + "type": "command", + "title": "Nested New Terminal", + "subtitle": "Nearest .cmux/cmux.json should win here", + "command": "echo 'NESTED NEW TERMINAL'; echo 'SOURCE=alpha/nested/.cmux/cmux.json'; pwd", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+2", + "icon": { "type": "image", "path": "./icons/nested-terminal.svg" } + }, + "nested.echo": { + "type": "command", + "title": "Nested Echo", + "subtitle": "Added by alpha/nested/.cmux/cmux.json", + "command": "echo 'NESTED ECHO'; echo 'SOURCE=alpha/nested/.cmux/cmux.json'; pwd", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+n", + "icon": { "type": "emoji", "value": "🪺" } + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + "cmux.newTerminal", + "nested.echo", + "cmux.splitRight" + ] + } + } +} diff --git a/dogfood/directory-actions/alpha/nested/.cmux/icons/nested-terminal.svg b/dogfood/directory-actions/alpha/nested/.cmux/icons/nested-terminal.svg new file mode 100644 index 000000000000..21e99e59b9fe --- /dev/null +++ b/dogfood/directory-actions/alpha/nested/.cmux/icons/nested-terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dogfood/directory-actions/legacy/cmux.json b/dogfood/directory-actions/legacy/cmux.json new file mode 100644 index 000000000000..3e753bf8f1df --- /dev/null +++ b/dogfood/directory-actions/legacy/cmux.json @@ -0,0 +1,31 @@ +{ + "actions": { + "cmux.newTerminal": { + "type": "command", + "title": "Legacy File New Terminal", + "subtitle": "Loaded from legacy/cmux.json", + "command": "echo 'LEGACY NEW TERMINAL'; echo 'SOURCE=legacy/cmux.json'; pwd", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+3", + "icon": { "type": "image", "path": "./icons/legacy-terminal.svg" } + }, + "legacy.echo": { + "type": "command", + "title": "Legacy Echo", + "subtitle": "Fallback cmux.json demo", + "command": "echo 'LEGACY ECHO'; echo 'SOURCE=legacy/cmux.json'; pwd", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+l", + "icon": { "type": "emoji", "value": "📄", "scale": 0.8 } + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + "cmux.newTerminal", + "legacy.echo", + "cmux.splitDown" + ] + } + } +} diff --git a/dogfood/directory-actions/legacy/icons/legacy-terminal.svg b/dogfood/directory-actions/legacy/icons/legacy-terminal.svg new file mode 100644 index 000000000000..97f70a2ddd10 --- /dev/null +++ b/dogfood/directory-actions/legacy/icons/legacy-terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dogfood/directory-actions/legacy/prefer-dot-cmux/.cmux/cmux.json b/dogfood/directory-actions/legacy/prefer-dot-cmux/.cmux/cmux.json new file mode 100644 index 000000000000..5c08cc9a9f25 --- /dev/null +++ b/dogfood/directory-actions/legacy/prefer-dot-cmux/.cmux/cmux.json @@ -0,0 +1,31 @@ +{ + "actions": { + "cmux.newTerminal": { + "type": "command", + "title": "Dot CMUX Wins", + "subtitle": "Same-directory .cmux/cmux.json should beat cmux.json", + "command": "echo 'DOT CMUX WINS'; echo 'SOURCE=legacy/prefer-dot-cmux/.cmux/cmux.json'; pwd", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+4", + "icon": { "type": "image", "path": "./icons/winner-terminal.svg" } + }, + "winner.echo": { + "type": "command", + "title": "Winner Echo", + "subtitle": "If you see this, .cmux precedence is working", + "command": "echo 'WINNER ECHO'; echo 'SOURCE=legacy/prefer-dot-cmux/.cmux/cmux.json'; pwd", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+p", + "icon": { "type": "emoji", "value": "🏆" } + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + "cmux.newTerminal", + "winner.echo", + "cmux.newBrowser" + ] + } + } +} diff --git a/dogfood/directory-actions/legacy/prefer-dot-cmux/.cmux/icons/winner-terminal.svg b/dogfood/directory-actions/legacy/prefer-dot-cmux/.cmux/icons/winner-terminal.svg new file mode 100644 index 000000000000..3b734ea89a12 --- /dev/null +++ b/dogfood/directory-actions/legacy/prefer-dot-cmux/.cmux/icons/winner-terminal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dogfood/directory-actions/legacy/prefer-dot-cmux/cmux.json b/dogfood/directory-actions/legacy/prefer-dot-cmux/cmux.json new file mode 100644 index 000000000000..912550ff2310 --- /dev/null +++ b/dogfood/directory-actions/legacy/prefer-dot-cmux/cmux.json @@ -0,0 +1,27 @@ +{ + "actions": { + "cmux.newTerminal": { + "type": "command", + "title": "Losing cmux.json", + "subtitle": "You should not see this in prefer-dot-cmux", + "command": "echo 'LOSING CMUX.JSON'; echo 'THIS SHOULD NOT WIN'; pwd", + "target": "newTabInCurrentPane", + "icon": { "type": "emoji", "value": "❌" } + }, + "legacy.loser": { + "type": "command", + "title": "Losing Action", + "command": "echo 'LOSING ACTION'; pwd", + "target": "newTabInCurrentPane", + "icon": { "type": "emoji", "value": "❌" } + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + "cmux.newTerminal", + "legacy.loser" + ] + } + } +} diff --git a/vendor/bonsplit b/vendor/bonsplit index cffd9a66e9ed..fc54dcc37357 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit cffd9a66e9ede9aaedefcfd0c391f0ad49652d72 +Subproject commit fc54dcc3735741279274169f8c30a7897e5d55d3 diff --git a/web/app/[locale]/docs/custom-commands/page.tsx b/web/app/[locale]/docs/custom-commands/page.tsx index aedb01f8f65c..614357d65440 100644 --- a/web/app/[locale]/docs/custom-commands/page.tsx +++ b/web/app/[locale]/docs/custom-commands/page.tsx @@ -26,31 +26,130 @@ export default function CustomCommandsPage() {

{t("fileLocationsDesc")}

  • - {t("localConfig")} ./cmux.json — {t("localConfigDesc")} + {t("localConfig")} ./.cmux/cmux.json - {t("localConfigDesc")}
  • - {t("globalConfig")} ~/.config/cmux/cmux.json — {t("globalConfigDesc")} + {t("fallbackLocal")} ./cmux.json - {t("fallbackLocalDesc")} +
  • +
  • + {t("globalConfig")} ~/.config/cmux/cmux.json - {t("globalConfigDesc")}
{t("precedenceNote")} + + {t.rich("nightlyFeatureCallout", { + actions: (chunks) => {chunks}, + shortcut: (chunks) => {chunks}, + buttons: (chunks) => {chunks}, + })} + + + {t("trustCallout")} + + + {t.rich("schemaErrorCallout", { + title: (chunks) => {chunks}, + })} +

{t("liveReload")}

{t("schema")}

-

{t("schemaDesc")}

+

+ {t.rich("schemaIntro", { + commands: (chunks) => {chunks}, + actions: (chunks) => {chunks}, + })} +

{`{ + "actions": { + "cmux.newTerminal": { + "type": "command", + "title": "Codex", + "subtitle": "Open Codex in a new terminal tab", + "command": "codex --dangerously-bypass-approvals-and-sandbox", + "target": "newTabInCurrentPane", + "shortcut": "cmd+t", + "icon": { "type": "image", "path": "./icons/codex.svg" } + }, + "claude": { + "type": "command", + "title": "Claude Code", + "command": "claude --dangerously-skip-permissions", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+c", + "icon": { "type": "image", "path": "./icons/claude.svg" } + }, + "opencode": { + "type": "command", + "title": "OpenCode", + "command": "opencode", + "target": "newTabInCurrentPane", + "palette": false, + "icon": { "type": "emoji", "value": "🧪", "scale": 0.9 } + }, + "web-dev": { + "type": "workspaceCommand", + "title": "Web Dev", + "commandName": "Web Dev" + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + "cmux.newTerminal", + "cmux.newBrowser", + "cmux.splitRight", + "cmux.splitDown", + "claude" + ] + } + }, "commands": [ { - "name": "Start Dev", + "name": "Web Dev", "keywords": ["dev", "start"], "workspace": { ... } - }, - { - "name": "Run Tests", - "command": "npm test", - "confirm": true } ] }`} +

{t("nightlyActionRegistry")}

+

+ {t.rich("nightlyActionRegistryDesc", { + actions: (chunks) => {chunks}, + newTerminal: (chunks) => {chunks}, + newBrowser: (chunks) => {chunks}, + splitRight: (chunks) => {chunks}, + splitDown: (chunks) => {chunks}, + })} +

+

+ {t.rich("paletteDesc", { + palette: (chunks) => {chunks}, + trueValue: (chunks) => {chunks}, + falseValue: (chunks) => {chunks}, + shortcut: (chunks) => {chunks}, + singleShortcut: (chunks) => {chunks}, + chordShortcut: (chunks) => {chunks}, + })} +

+

+ {t.rich("iconsDesc", { + buttons: (chunks) => {chunks}, + symbolIcon: (chunks) => {chunks}, + emojiIcon: (chunks) => {chunks}, + imageIcon: (chunks) => {chunks}, + scale: (chunks) => {chunks}, + defaultScale: (chunks) => {chunks}, + })} +

+

+ {t("buttonEntriesDesc")} +

+

+ {t.rich("permissionFlagsDesc", { + target: (chunks) => {chunks}, + })} +

{t("simpleCommands")}

{t("simpleCommandsDesc")}

@@ -174,6 +273,46 @@ export default function CustomCommandsPage() {

{t("fullExample")}

{`{ + "actions": { + "web-dev": { "type": "workspaceCommand", "commandName": "Web Dev" }, + "cmux.newTerminal": { + "type": "command", + "title": "Codex", + "command": "codex --dangerously-bypass-approvals-and-sandbox", + "target": "newTabInCurrentPane", + "shortcut": "cmd+t", + "icon": { "type": "image", "path": "./icons/codex.svg" } + }, + "claude": { + "type": "command", + "title": "Claude Code", + "command": "claude --dangerously-skip-permissions", + "target": "newTabInCurrentPane", + "shortcut": "cmd+shift+c", + "icon": { "type": "image", "path": "./icons/claude.svg" } + }, + "start-dev": { + "type": "command", + "command": "npm run dev", + "target": "newTabInCurrentPane", + "icon": { "type": "symbol", "name": "play.circle" } + } + }, + "ui": { + "surfaceTabBar": { + "buttons": [ + "cmux.newTerminal", + "cmux.newBrowser", + "cmux.splitRight", + "cmux.splitDown", + { + "action": "claude", + "title": "Claude Here" + }, + "start-dev" + ] + } + }, "commands": [ { "name": "Web Dev", diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index feb4de8e2302..a15374cc7f2e 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -416,22 +416,6 @@ } } }, - "customCommands": { - "title": "customCommands", - "description": "Custom command trust settings from Settings > Custom Commands.", - "type": "object", - "additionalProperties": false, - "properties": { - "trustedDirectories": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "Directories whose cmux.json commands can run without confirmation." - } - } - }, "browser": { "title": "browser", "description": "Embedded browser settings from Settings > Browser.", diff --git a/web/messages/ar.json b/web/messages/ar.json index c3f23c52e0aa..3c375b9f2818 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "يبحث cmux عن التكوين في مكانين:", "localConfig": "لكل مشروع:", "localConfigDesc": "يقع في دليل مشروعك، له الأولوية", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "عالمي:", "globalConfigDesc": "ينطبق على جميع المشاريع، يملأ الأوامر غير المعرفة محلياً", "precedenceNote": "الأوامر المحلية تتجاوز الأوامر العالمية بنفس الاسم.", "liveReload": "يتم اكتشاف التغييرات تلقائياً — لا حاجة لإعادة التشغيل.", "schema": "المخطط", "schemaDesc": "يحتوي ملف cmux.json على مصفوفة commands. كل أمر إما أمر shell بسيط أو تعريف كامل لمساحة عمل:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "أوامر بسيطة", "simpleCommandsDesc": "الأمر البسيط ينفذ أمر shell في الطرفية المحددة حالياً:", "simpleCommandFields": "الحقول", diff --git a/web/messages/bs.json b/web/messages/bs.json index 294833c0e671..d8e908a258e1 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux traži konfiguraciju na dva mjesta:", "localConfig": "Po projektu:", "localConfigDesc": "nalazi se u vašem projektnom direktoriju, ima prednost", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Globalno:", "globalConfigDesc": "primjenjuje se na sve projekte, dopunjava komande koje nisu definirane lokalno", "precedenceNote": "Lokalne komande nadjačavaju globalne komande istog naziva.", "liveReload": "Promjene se automatski preuzimaju — nije potrebno ponovno pokretanje.", "schema": "Shema", "schemaDesc": "Datoteka cmux.json sadrži niz commands. Svaka komanda je ili jednostavna shell komanda ili potpuna definicija radnog prostora:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Jednostavne komande", "simpleCommandsDesc": "Jednostavna komanda pokreće shell komandu u trenutno fokusiranom terminalu:", "simpleCommandFields": "Polja", diff --git a/web/messages/da.json b/web/messages/da.json index 3d23c314d47d..a201b97dda0e 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux leder efter konfiguration to steder:", "localConfig": "Per projekt:", "localConfigDesc": "befinder sig i din projektmappe, har forrang", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Global:", "globalConfigDesc": "gælder for alle projekter, udfylder kommandoer, der ikke er defineret lokalt", "precedenceNote": "Lokale kommandoer tilsidesætter globale kommandoer med samme navn.", "liveReload": "Ændringer hentes automatisk — ingen genstart nødvendig.", "schema": "Skema", "schemaDesc": "En cmux.json-fil indeholder et commands-array. Hver kommando er enten en simpel shell-kommando eller en fuld workspace-definition:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Simple kommandoer", "simpleCommandsDesc": "En simpel kommando kører en shell-kommando i den aktuelt fokuserede terminal:", "simpleCommandFields": "Felter", diff --git a/web/messages/de.json b/web/messages/de.json index d418d38fb782..1dec5168e35f 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux sucht an zwei Stellen nach Konfiguration:", "localConfig": "Projektspezifisch:", "localConfigDesc": "liegt in Ihrem Projektverzeichnis, hat Vorrang", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Global:", "globalConfigDesc": "gilt für alle Projekte, ergänzt lokal nicht definierte Befehle", "precedenceNote": "Lokale Befehle überschreiben globale Befehle mit demselben Namen.", "liveReload": "Änderungen werden automatisch übernommen — kein Neustart erforderlich.", "schema": "Schema", "schemaDesc": "Eine cmux.json-Datei enthält ein commands-Array. Jeder Befehl ist entweder ein einfacher Shell-Befehl oder eine vollständige Workspace-Definition:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Einfache Befehle", "simpleCommandsDesc": "Ein einfacher Befehl führt einen Shell-Befehl im aktuell fokussierten Terminal aus:", "simpleCommandFields": "Felder", diff --git a/web/messages/en.json b/web/messages/en.json index a855af482c8d..e556416082da 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -353,18 +353,30 @@ "customCommands": { "title": "Custom Commands", "metaTitle": "Custom Commands", - "metaDescription": "Define custom commands and workspace layouts in cmux.json. Per-project and global configuration with live file watching.", - "intro": "Define custom commands and workspace layouts by adding a cmux.json file to your project root or ~/.config/cmux/. Commands appear in the command palette.", + "metaDescription": "Define actions, custom commands, and workspace layouts in cmux.json. Supports per-project and global configuration with live file watching.", + "intro": "Define actions, custom commands, and workspace layouts by adding .cmux/cmux.json to your project or ~/.config/cmux/cmux.json globally. Actions can appear in the surface tab bar and Command Palette.", "fileLocations": "File locations", "fileLocationsDesc": "cmux looks for configuration in two places:", "localConfig": "Per-project:", "localConfigDesc": "lives in your project directory, takes precedence", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Global:", "globalConfigDesc": "applies to all projects, fills in commands not defined locally", "precedenceNote": "Local commands override global commands with the same name.", "liveReload": "Changes are picked up automatically — no restart needed.", "schema": "Schema", - "schemaDesc": "A cmux.json file contains a commands array. Each command is either a simple shell command or a full workspace definition:", + "schemaDesc": "A cmux.json file can contain an actions registry plus a commands array. Commands are either simple shell commands or full workspace definitions:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Simple commands", "simpleCommandsDesc": "A simple command runs a shell command in the currently focused terminal:", "simpleCommandFields": "Fields", diff --git a/web/messages/es.json b/web/messages/es.json index 340f4d846181..58921780b8bb 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux busca configuración en dos lugares:", "localConfig": "Por proyecto:", "localConfigDesc": "se encuentra en tu directorio de proyecto, tiene prioridad", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Global:", "globalConfigDesc": "se aplica a todos los proyectos, completa los comandos no definidos localmente", "precedenceNote": "Los comandos locales anulan los comandos globales con el mismo nombre.", "liveReload": "Los cambios se recogen automáticamente — no se necesita reinicio.", "schema": "Esquema", "schemaDesc": "Un archivo cmux.json contiene un array commands. Cada comando es un comando de shell simple o una definición completa de workspace:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Comandos simples", "simpleCommandsDesc": "Un comando simple ejecuta un comando de shell en el terminal actualmente enfocado:", "simpleCommandFields": "Campos", diff --git a/web/messages/fr.json b/web/messages/fr.json index a7db88f7d562..db628da655b5 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux recherche la configuration à deux endroits :", "localConfig": "Par projet :", "localConfigDesc": "se trouve dans votre répertoire de projet, a la priorité", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Global :", "globalConfigDesc": "s'applique à tous les projets, complète les commandes non définies localement", "precedenceNote": "Les commandes locales remplacent les commandes globales du même nom.", "liveReload": "Les modifications sont prises en compte automatiquement — aucun redémarrage nécessaire.", "schema": "Schéma", "schemaDesc": "Un fichier cmux.json contient un tableau commands. Chaque commande est soit une commande shell simple, soit une définition complète d'espace de travail :", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Commandes simples", "simpleCommandsDesc": "Une commande simple exécute une commande shell dans le terminal actuellement focalisé :", "simpleCommandFields": "Champs", diff --git a/web/messages/it.json b/web/messages/it.json index ee50e40fdf5f..f69537921407 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux cerca la configurazione in due posti:", "localConfig": "Per progetto:", "localConfigDesc": "si trova nella directory del progetto, ha la precedenza", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Globale:", "globalConfigDesc": "si applica a tutti i progetti, integra i comandi non definiti localmente", "precedenceNote": "I comandi locali sovrascrivono i comandi globali con lo stesso nome.", "liveReload": "Le modifiche vengono rilevate automaticamente — nessun riavvio necessario.", "schema": "Schema", "schemaDesc": "Un file cmux.json contiene un array commands. Ogni comando è un semplice comando shell o una definizione completa di workspace:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Comandi semplici", "simpleCommandsDesc": "Un comando semplice esegue un comando shell nel terminale attualmente attivo:", "simpleCommandFields": "Campi", diff --git a/web/messages/ja.json b/web/messages/ja.json index 6385b677bd71..975091ed5e3c 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -309,18 +309,30 @@ "customCommands": { "title": "カスタムコマンド", "metaTitle": "カスタムコマンド", - "metaDescription": "cmux.jsonでカスタムコマンドとワークスペースレイアウトを定義します。プロジェクトごとおよびグローバル設定とライブファイル監視に対応。", - "intro": "プロジェクトルートまたは ~/.config/cmux/ に cmux.json ファイルを追加してカスタムコマンドとワークスペースレイアウトを定義します。コマンドはコマンドパレットに表示されます。", + "metaDescription": "cmux.json でアクション、カスタムコマンド、ワークスペースレイアウトを定義します。プロジェクトごととグローバル設定、ライブファイル監視に対応します。", + "intro": "プロジェクトでは .cmux/cmux.json、グローバルでは ~/.config/cmux/cmux.json を使ってアクション、カスタムコマンド、ワークスペースレイアウトを定義します。アクションはサーフェスタブバーやコマンドパレットに表示できます。", "fileLocations": "ファイルの場所", "fileLocationsDesc": "cmux は2か所で設定を検索します:", "localConfig": "プロジェクトごと:", "localConfigDesc": "プロジェクトディレクトリに置かれ、優先されます", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "グローバル:", "globalConfigDesc": "すべてのプロジェクトに適用され、ローカルで未定義のコマンドを補完します", "precedenceNote": "ローカルコマンドは同名のグローバルコマンドを上書きします。", "liveReload": "変更は自動的に反映されます — 再起動は不要です。", "schema": "スキーマ", - "schemaDesc": "cmux.json ファイルには commands 配列が含まれます。各コマンドはシンプルなシェルコマンドまたは完全なワークスペース定義です:", + "schemaDesc": "cmux.json ファイルには actions レジストリと commands 配列を含められます。commands の各項目はシンプルなシェルコマンドまたは完全なワークスペース定義です:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "シンプルコマンド", "simpleCommandsDesc": "シンプルコマンドは現在フォーカスされているターミナルでシェルコマンドを実行します:", "simpleCommandFields": "フィールド", diff --git a/web/messages/km.json b/web/messages/km.json index 0c058673c3bd..7cc63ce52631 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux រកការកំណត់រចនាសម្ព័ន្ធនៅ២កន្លែង:", "localConfig": "តាមគម្រោង:", "localConfigDesc": "ស្ថិតក្នុងថតគម្រោងរបស់អ្នក, មានអាទិភាព", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "សាកល:", "globalConfigDesc": "អនុវត្តចំពោះគម្រោងទាំងអស់, បំពេញពាក្យបញ្ជាដែលមិនបានកំណត់ក្នុងតំបន់", "precedenceNote": "ពាក្យបញ្ជាក្នុងតំបន់បដិសេធពាក្យបញ្ជាសាកលដែលមានឈ្មោះដូចគ្នា។", "liveReload": "ការផ្លាស់ប្ដូរត្រូវបានទទួលស្គាល់ដោយស្វ័យប្រវត្តិ — មិនចាំបាច់ចាប់ផ្ដើមឡើងវិញ។", "schema": "ស្គីម៉ា", "schemaDesc": "ឯកសារ cmux.json មានអារ៉េ commands។ ពាក្យបញ្ជានីមួយៗគឺជាពាក្យបញ្ជា shell សាមញ្ញ ឬនិយាមព workspace ពេញលេញ:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "ពាក្យបញ្ជាសាមញ្ញ", "simpleCommandsDesc": "ពាក្យបញ្ជាសាមញ្ញដំណើរការពាក្យបញ្ជា shell ក្នុងទែមីណាល​ដែលបានផ្ដោតបច្ចុប្បន្ន:", "simpleCommandFields": "វាល", diff --git a/web/messages/ko.json b/web/messages/ko.json index 980433a9b61f..efebb56c7996 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux는 두 곳에서 설정을 찾습니다:", "localConfig": "프로젝트별:", "localConfigDesc": "프로젝트 디렉터리에 위치하며 우선순위를 가집니다", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "전역:", "globalConfigDesc": "모든 프로젝트에 적용되며 로컬에서 정의되지 않은 명령어를 보완합니다", "precedenceNote": "로컬 명령어는 동일한 이름의 전역 명령어를 덮어씁니다.", "liveReload": "변경 사항은 자동으로 반영됩니다 — 재시작이 필요 없습니다.", "schema": "스키마", "schemaDesc": "cmux.json 파일에는 commands 배열이 포함됩니다. 각 명령어는 단순한 셸 명령어이거나 완전한 워크스페이스 정의입니다:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "단순 명령어", "simpleCommandsDesc": "단순 명령어는 현재 포커스된 터미널에서 셸 명령어를 실행합니다:", "simpleCommandFields": "필드", diff --git a/web/messages/no.json b/web/messages/no.json index 3b0c1e934e1d..3fbb1f72c5f7 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux søker etter konfigurasjon to steder:", "localConfig": "Per prosjekt:", "localConfigDesc": "ligger i prosjektmappen din, har forrang", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Global:", "globalConfigDesc": "gjelder alle prosjekter, fyller inn kommandoer som ikke er definert lokalt", "precedenceNote": "Lokale kommandoer overstyrer globale kommandoer med samme navn.", "liveReload": "Endringer hentes automatisk — ingen omstart nødvendig.", "schema": "Skjema", "schemaDesc": "En cmux.json-fil inneholder en commands-array. Hver kommando er enten en enkel shell-kommando eller en fullstendig workspace-definisjon:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Enkle kommandoer", "simpleCommandsDesc": "En enkel kommando kjører en shell-kommando i den for øyeblikket fokuserte terminalen:", "simpleCommandFields": "Felter", diff --git a/web/messages/pl.json b/web/messages/pl.json index 14ec33f8fd40..b074d909dec8 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux szuka konfiguracji w dwóch miejscach:", "localConfig": "Per projekt:", "localConfigDesc": "znajduje się w katalogu projektu, ma pierwszeństwo", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Globalnie:", "globalConfigDesc": "stosuje się do wszystkich projektów, uzupełnia polecenia niezdefiniowane lokalnie", "precedenceNote": "Lokalne polecenia zastępują globalne polecenia o tej samej nazwie.", "liveReload": "Zmiany są pobierane automatycznie — nie trzeba restartować.", "schema": "Schemat", "schemaDesc": "Plik cmux.json zawiera tablicę commands. Każde polecenie to albo proste polecenie shell, albo pełna definicja workspace:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Proste polecenia", "simpleCommandsDesc": "Proste polecenie uruchamia polecenie shell w aktualnie sfokusowanym terminalu:", "simpleCommandFields": "Pola", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index d284718ff79c..5b7eeec54cdf 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "O cmux procura configuração em dois lugares:", "localConfig": "Por projeto:", "localConfigDesc": "fica no diretório do seu projeto, tem precedência", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Global:", "globalConfigDesc": "aplica-se a todos os projetos, preenche comandos não definidos localmente", "precedenceNote": "Comandos locais substituem comandos globais com o mesmo nome.", "liveReload": "As alterações são capturadas automaticamente — nenhum reinício necessário.", "schema": "Esquema", "schemaDesc": "Um arquivo cmux.json contém um array commands. Cada comando é um comando shell simples ou uma definição completa de workspace:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Comandos simples", "simpleCommandsDesc": "Um comando simples executa um comando shell no terminal atualmente focado:", "simpleCommandFields": "Campos", diff --git a/web/messages/ru.json b/web/messages/ru.json index f87ac0ee8497..705289299c1c 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux ищет конфигурацию в двух местах:", "localConfig": "Для проекта:", "localConfigDesc": "находится в каталоге проекта, имеет приоритет", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Глобально:", "globalConfigDesc": "применяется ко всем проектам, дополняет команды, не определённые локально", "precedenceNote": "Локальные команды переопределяют глобальные команды с тем же именем.", "liveReload": "Изменения применяются автоматически — перезапуск не требуется.", "schema": "Схема", "schemaDesc": "Файл cmux.json содержит массив commands. Каждая команда — это либо простая команда shell, либо полное определение рабочего пространства:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Простые команды", "simpleCommandsDesc": "Простая команда выполняет команду shell в текущем сфокусированном терминале:", "simpleCommandFields": "Поля", diff --git a/web/messages/th.json b/web/messages/th.json index cd06c1e4206d..b7483c094067 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux ค้นหาการตั้งค่าจากสองที่:", "localConfig": "ต่อโปรเจกต์:", "localConfigDesc": "อยู่ในไดเรกทอรีโปรเจกต์ของคุณ มีความสำคัญสูงกว่า", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "ทั่วไป:", "globalConfigDesc": "ใช้กับทุกโปรเจกต์ เติมคำสั่งที่ยังไม่ได้กำหนดในท้องถิ่น", "precedenceNote": "คำสั่งท้องถิ่นจะแทนที่คำสั่งทั่วไปที่มีชื่อเดียวกัน", "liveReload": "การเปลี่ยนแปลงจะถูกรับโดยอัตโนมัติ — ไม่จำเป็นต้องรีสตาร์ท", "schema": "สคีมา", "schemaDesc": "ไฟล์ cmux.json มีอาร์เรย์ commands แต่ละคำสั่งเป็นคำสั่ง shell แบบง่ายหรือนิยาม workspace แบบเต็ม:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "คำสั่งแบบง่าย", "simpleCommandsDesc": "คำสั่งแบบง่ายรันคำสั่ง shell ในเทอร์มินัลที่กำลังโฟกัสอยู่:", "simpleCommandFields": "ฟิลด์", diff --git a/web/messages/tr.json b/web/messages/tr.json index 518f8383ee60..1514b64f5d6d 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux yapılandırmayı iki yerde arar:", "localConfig": "Proje başına:", "localConfigDesc": "proje dizininde bulunur, önceliğe sahiptir", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Global:", "globalConfigDesc": "tüm projeler için geçerlidir, yerel olarak tanımlanmayan komutları tamamlar", "precedenceNote": "Yerel komutlar, aynı adlı global komutları geçersiz kılar.", "liveReload": "Değişiklikler otomatik olarak algılanır — yeniden başlatma gerekmez.", "schema": "Şema", "schemaDesc": "Bir cmux.json dosyası bir commands dizisi içerir. Her komut ya basit bir shell komutu ya da tam bir workspace tanımıdır:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Basit komutlar", "simpleCommandsDesc": "Basit bir komut, o an odaklanılan terminalde bir shell komutu çalıştırır:", "simpleCommandFields": "Alanlar", diff --git a/web/messages/uk.json b/web/messages/uk.json index 8a75d8c361a8..c8e57db430e8 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux шукає конфігурацію у двох місцях:", "localConfig": "Для проєкту:", "localConfigDesc": "знаходиться в директорії проєкту, має пріоритет", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "Глобальний:", "globalConfigDesc": "застосовується до всіх проєктів, доповнює локально не визначені команди", "precedenceNote": "Локальні команди перевизначають глобальні команди з тим самим ім'ям.", "liveReload": "Зміни підхоплюються автоматично — перезапуск не потрібен.", "schema": "Схема", "schemaDesc": "Файл cmux.json містить масив commands. Кожна команда — це або проста shell-команда, або повне визначення робочого простору:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "Прості команди", "simpleCommandsDesc": "Проста команда виконує shell-команду в поточному активному терміналі:", "simpleCommandFields": "Поля", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 4bac08dfd277..70725c1de9dd 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux 在两个地方查找配置:", "localConfig": "按项目:", "localConfigDesc": "位于项目目录中,优先级更高", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "全局:", "globalConfigDesc": "适用于所有项目,补充本地未定义的命令", "precedenceNote": "本地命令会覆盖同名的全局命令。", "liveReload": "更改会自动生效 — 无需重启。", "schema": "架构", "schemaDesc": "cmux.json 文件包含一个 commands 数组。每个命令要么是简单的 shell 命令,要么是完整的工作区定义:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "简单命令", "simpleCommandsDesc": "简单命令在当前聚焦的终端中运行 shell 命令:", "simpleCommandFields": "字段", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 3562de542e6d..5948b78192b9 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -311,12 +311,24 @@ "fileLocationsDesc": "cmux 在兩個地方尋找設定:", "localConfig": "依專案:", "localConfigDesc": "位於專案目錄中,優先採用", + "fallbackLocal": "Fallback local:", + "fallbackLocalDesc": "still supported for existing repos", + "nightlyFeatureCallout": "The action registry is a nightly feature. Install the latest nightly build before using actions, shortcut, or ui.surfaceTabBar.buttons.", + "trustCallout": "Project-local actions show up in the surface tab bar and Command Palette immediately. The first run still prompts for trust. Trust is per exact action fingerprint, not per repo. Project-local image icons stay locked until that action is trusted.", + "schemaErrorCallout": "If a project or global config has a schema error, cmux falls back to the next valid config and shows a cmux.json Schema Error row in Command Palette. Select it to open the config file.", "globalConfig": "全域:", "globalConfigDesc": "適用於所有專案,補充本地未定義的指令", "precedenceNote": "本地指令會覆蓋同名的全域指令。", "liveReload": "變更會自動生效 — 無需重新啟動。", "schema": "結構描述", "schemaDesc": "cmux.json 檔案包含一個 commands 陣列。每個指令可以是簡單的 shell 指令或完整的工作區定義:", + "schemaIntro": "commands still define reusable shell commands and workspace layouts. Nightly builds add an actions registry. Actions are the public IDs shared by the surface tab bar, the Command Palette, and action-level shortcuts.", + "nightlyActionRegistry": "Nightly action registry", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use the built-in IDs cmux.newTerminal, cmux.newBrowser, cmux.splitRight, and cmux.splitDown to override the defaults. Use your own IDs for project-specific tools.", + "paletteDesc": "palette defaults to true. Set it to false to keep an action out of Command Palette while still making it available to the surface tab bar or a shortcut. shortcut uses the same syntax as settings shortcuts, for example cmd+shift+c or [\"cmd+k\", \"cmd+c\"].", + "iconsDesc": "ui.surfaceTabBar.buttons replaces the default button list when present. Leave out a built-in ID to hide it. Icons always use an object shape: { \"type\": \"symbol\", \"name\": \"play.circle\" }, { \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }, or { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }. Image paths are relative to the config file. Emoji scale is optional and defaults to 1. SVG, PDF, PNG, JPEG, GIF, TIFF, BMP, HEIC, HEIF, WebP, AVIF, ICO, and ICNS are supported.", + "buttonEntriesDesc": "Each button entry can be either an action ID string or a button object. Use a button object when you want the same action with a different surface label, icon, or tooltip. The resolved button title is also used as the trust prompt title.", + "permissionFlagsDesc": "Put any approval or permission flags directly in the command string you actually want to run. The default action target is newTabInCurrentPane, so the common pattern is to open a new terminal tab in the current pane and start Codex, Claude Code, or OpenCode there.", "simpleCommands": "簡單指令", "simpleCommandsDesc": "簡單指令會在目前聚焦的終端機中執行 shell 指令:", "simpleCommandFields": "欄位",