diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index db674105e604..4030c1062b22 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -10037,6 +10037,74 @@ } } }, + "command.rightSidebarShow.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Right Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右サイドバーを表示" + } + } + } + }, + "command.rightSidebarHide.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Right Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右サイドバーを非表示" + } + } + } + }, + "command.rightSidebarFocus.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Right Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右サイドバーにフォーカス" + } + } + } + }, + "command.rightSidebarSessions.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Sidebar Sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのセッションを表示" + } + } + } + }, "command.openRightSidebarToolAsPane.subtitle": { "extractionState": "manual", "localizations": { @@ -108280,6 +108348,23 @@ } } }, + "shortcut.switchRightSidebarToVault.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Sidebar Vault" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのボールトを表示" + } + } + } + }, "shortcut.switchRightSidebarToSessions.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 438df7b3d609..feb10b5d65dd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5873,9 +5873,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applyRightSidebarRemoteCommand( _ command: RightSidebarRemoteCommand, - target: RightSidebarRemoteTarget = RightSidebarRemoteTarget() + target: RightSidebarRemoteTarget = RightSidebarRemoteTarget(), + preferredWindow: NSWindow? = nil ) -> RightSidebarRemoteApplyResult { - let context = rightSidebarRemoteContext(target: target) + let context = rightSidebarRemoteContext(target: target, preferredWindow: preferredWindow) if !target.isActiveTarget, context == nil { return .failure(String(localized: "rightSidebar.remote.error.targetNotFound", defaultValue: "ERROR: Right sidebar target not found")) } @@ -5961,7 +5962,156 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func rightSidebarRemoteContext(target: RightSidebarRemoteTarget) -> MainWindowContext? { + @discardableResult + func executeBuiltInRightSidebarAction( + _ action: CmuxSurfaceTabBarBuiltInAction, + preferredWindow: NSWindow? = nil, + onExecuted: (() -> Void)? = nil + ) -> Bool { + executeBuiltInAction( + action, + scope: .rightSidebarOnly, + preferredWindow: preferredWindow, + onExecuted: onExecuted + ) + } + + @discardableResult + func executeSurfaceTabBarBuiltInAction( + _ action: CmuxSurfaceTabBarBuiltInAction, + tabManager: TabManager?, + preferredWindow: NSWindow? = nil + ) -> Bool { + executeBuiltInAction( + action, + scope: .surfaceTabBar(tabManager), + preferredWindow: preferredWindow + ) + } + + private enum BuiltInActionExecutionScope { + case configured(MainWindowContext) + case surfaceTabBar(TabManager?) + case rightSidebarOnly + } + + @discardableResult + private func executeBuiltInAction( + _ action: CmuxSurfaceTabBarBuiltInAction, + scope: BuiltInActionExecutionScope, + preferredWindow: NSWindow? = nil, + onExecuted: (() -> Void)? = nil + ) -> Bool { + switch action { + case .rightSidebarToggle, .rightSidebarShow, .rightSidebarHide, .rightSidebarFocus, + .rightSidebarFiles, .rightSidebarFind, .rightSidebarVault, .rightSidebarSessions, + .rightSidebarFeed, .rightSidebarDock: + guard let command = action.rightSidebarRemoteCommand else { + return false + } + switch applyRightSidebarRemoteCommand(command, preferredWindow: preferredWindow) { + case .ok: + onExecuted?() + return true + case .state, .failure: + return false + } + + case .newWorkspace: + switch scope { + case .configured(let context): + context.tabManager.addWorkspace() + onExecuted?() + return true + case .surfaceTabBar(let tabManager): + guard let tabManager else { return false } + tabManager.addWorkspace() + return true + case .rightSidebarOnly: + return false + } + + case .cloudVM: + switch scope { + case .configured(let context): + let didStart = performCloudVMAction( + tabManager: context.tabManager, + preferredWindow: resolvedWindow(for: context) ?? preferredWindow, + debugSource: "configured.cmux.cloudvm" + ) + if didStart { onExecuted?() } + return didStart + case .surfaceTabBar(let tabManager): + return performCloudVMAction( + tabManager: tabManager, + preferredWindow: preferredWindow, + debugSource: "surfaceTabBar.cloudVM" + ) + case .rightSidebarOnly: + return false + } + + case .newTerminal: + guard case .configured(let context) = scope else { + return false + } + context.tabManager.newSurface() + onExecuted?() + return true + + case .newBrowser: + guard case .configured(let context) = scope else { + return false + } + let previousTabManager = tabManager + tabManager = context.tabManager + defer { tabManager = previousTabManager } + guard openBrowserAndFocusAddressBar(insertAtEnd: true) != nil else { + return false + } + onExecuted?() + return true + + case .splitRight: + guard case .configured(let context) = scope else { + return false + } + if shouldSuppressSplitShortcutForTransientTerminalFocusState( + direction: .right, + tabManager: context.tabManager + ) { + return true + } + let didSplit = performSplitShortcut( + direction: .right, + preferredWindow: preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + if didSplit { onExecuted?() } + return didSplit + + case .splitDown: + guard case .configured(let context) = scope else { + return false + } + if shouldSuppressSplitShortcutForTransientTerminalFocusState( + direction: .down, + tabManager: context.tabManager + ) { + return true + } + let didSplit = performSplitShortcut( + direction: .down, + preferredWindow: preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + if didSplit { onExecuted?() } + return didSplit + } + } + + private func rightSidebarRemoteContext( + target: RightSidebarRemoteTarget, + preferredWindow: NSWindow? = nil + ) -> MainWindowContext? { if let windowId = target.windowId { return mainWindowContexts.values.first(where: { $0.windowId == windowId }) } @@ -5970,7 +6120,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent context.tabManager.tabs.contains(where: { $0.id == workspaceId }) } } - return preferredRegisteredMainWindowContext() + return preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) } @discardableResult @@ -13641,59 +13791,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) -> Bool { switch action.action { case .builtIn(let builtIn): - switch builtIn { - case .newWorkspace: - context.tabManager.addWorkspace() - onExecuted?() - return true - case .cloudVM: - let didStart = performCloudVMAction( - tabManager: context.tabManager, - preferredWindow: resolvedWindow(for: context) ?? preferredWindow, - debugSource: "configured.cmux.cloudvm" - ) - if didStart { onExecuted?() } - return didStart - case .newTerminal: - context.tabManager.newSurface() - onExecuted?() - return true - case .newBrowser: - let previousTabManager = tabManager - tabManager = context.tabManager - defer { tabManager = previousTabManager } - guard openBrowserAndFocusAddressBar(insertAtEnd: true) != nil else { - return false - } - onExecuted?() - return true - case .splitRight: - if shouldSuppressSplitShortcutForTransientTerminalFocusState( - direction: .right, - tabManager: context.tabManager - ) { - return true - } - let didSplit = performSplitShortcut( - direction: .right, - preferredWindow: preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - if didSplit { onExecuted?() } - return didSplit - case .splitDown: - if shouldSuppressSplitShortcutForTransientTerminalFocusState( - direction: .down, - tabManager: context.tabManager - ) { - return true - } - let didSplit = performSplitShortcut( - direction: .down, - preferredWindow: preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - if didSplit { onExecuted?() } - return didSplit - } + return executeBuiltInAction( + builtIn, + scope: .configured(context), + preferredWindow: preferredWindow, + onExecuted: onExecuted + ) case .command, .agent, .workspaceCommand: guard let cmuxConfigStore = context.cmuxConfigStore else { return false diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 4d47710ae764..c195c1f1af76 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -1499,34 +1499,11 @@ struct CmuxResolvedConfigAction: Identifiable, Sendable, Hashable { } static func builtIn(_ builtIn: CmuxSurfaceTabBarBuiltInAction) -> CmuxResolvedConfigAction { - let title: String - let keywords: [String] - switch builtIn { - case .newWorkspace: - title = String(localized: "command.newWorkspace.title", defaultValue: "New Workspace") - keywords = ["create", "new", "workspace"] - case .cloudVM: - title = String(localized: "command.cloudVM.title", defaultValue: "Start Cloud VM") - keywords = ["cloud", "vm", "virtual", "machine", "remote"] - 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, + title: builtIn.defaultTitle, subtitle: String(localized: "command.cmuxConfig.builtInSubtitle", defaultValue: "cmux"), - keywords: keywords, + keywords: builtIn.defaultKeywords, palette: true, shortcut: nil, icon: .symbol(builtIn.defaultIcon), diff --git a/Sources/CmuxSurfaceTabBarBuiltInAction.swift b/Sources/CmuxSurfaceTabBarBuiltInAction.swift index cc7586e14ea8..aa471310cb94 100644 --- a/Sources/CmuxSurfaceTabBarBuiltInAction.swift +++ b/Sources/CmuxSurfaceTabBarBuiltInAction.swift @@ -8,6 +8,20 @@ enum CmuxSurfaceTabBarBuiltInAction: String, Codable, Sendable, CaseIterable, Ha case newBrowser = "cmux.newBrowser" case splitRight = "cmux.splitRight" case splitDown = "cmux.splitDown" + case rightSidebarToggle = "cmux.rightSidebar.toggle" + case rightSidebarShow = "cmux.rightSidebar.show" + case rightSidebarHide = "cmux.rightSidebar.hide" + case rightSidebarFocus = "cmux.rightSidebar.focus" + case rightSidebarFiles = "cmux.rightSidebar.files" + case rightSidebarFind = "cmux.rightSidebar.find" + case rightSidebarVault = "cmux.rightSidebar.vault" + case rightSidebarSessions = "cmux.rightSidebar.sessions" + case rightSidebarFeed = "cmux.rightSidebar.feed" + case rightSidebarDock = "cmux.rightSidebar.dock" + + static let rightSidebarActions: [CmuxSurfaceTabBarBuiltInAction] = allCases.filter { + $0.rightSidebarRemoteCommand != nil + } init?(configID: String) { switch configID { @@ -25,6 +39,26 @@ enum CmuxSurfaceTabBarBuiltInAction: String, Codable, Sendable, CaseIterable, Ha self = .splitRight case "cmux.splitDown", "splitDown": self = .splitDown + case "cmux.rightSidebar.toggle", "rightSidebar.toggle": + self = .rightSidebarToggle + case "cmux.rightSidebar.show", "rightSidebar.show": + self = .rightSidebarShow + case "cmux.rightSidebar.hide", "rightSidebar.hide": + self = .rightSidebarHide + case "cmux.rightSidebar.focus", "rightSidebar.focus": + self = .rightSidebarFocus + case "cmux.rightSidebar.files", "rightSidebar.files": + self = .rightSidebarFiles + case "cmux.rightSidebar.find", "rightSidebar.find": + self = .rightSidebarFind + case "cmux.rightSidebar.vault", "rightSidebar.vault": + self = .rightSidebarVault + case "cmux.rightSidebar.sessions", "rightSidebar.sessions": + self = .rightSidebarSessions + case "cmux.rightSidebar.feed", "rightSidebar.feed": + self = .rightSidebarFeed + case "cmux.rightSidebar.dock", "rightSidebar.dock": + self = .rightSidebarDock default: return nil } @@ -48,12 +82,128 @@ enum CmuxSurfaceTabBarBuiltInAction: String, Codable, Sendable, CaseIterable, Ha return "square.split.2x1" case .splitDown: return "square.split.1x2" + case .rightSidebarToggle, .rightSidebarShow, .rightSidebarHide: + return "rectangle.righthalf.inset.filled" + case .rightSidebarFocus: + return "scope" + case .rightSidebarFiles: + return RightSidebarMode.files.symbolName + case .rightSidebarFind: + return RightSidebarMode.find.symbolName + case .rightSidebarVault, .rightSidebarSessions: + return RightSidebarMode.sessions.symbolName + case .rightSidebarFeed: + return RightSidebarMode.feed.symbolName + case .rightSidebarDock: + return RightSidebarMode.dock.symbolName + } + } + + var defaultTitle: String { + switch self { + case .newWorkspace: + return String(localized: "command.newWorkspace.title", defaultValue: "New Workspace") + case .cloudVM: + return String(localized: "command.cloudVM.title", defaultValue: "Start Cloud VM") + case .newTerminal: + return String(localized: "command.newTerminalTab.title", defaultValue: "New Terminal Tab") + case .newBrowser: + return String(localized: "command.newBrowserTab.title", defaultValue: "New Browser Tab") + case .splitRight: + return String(localized: "command.terminalSplitRight.title", defaultValue: "Split Right") + case .splitDown: + return String(localized: "command.terminalSplitDown.title", defaultValue: "Split Down") + case .rightSidebarToggle: + return String(localized: "shortcut.toggleRightSidebar.label", defaultValue: "Toggle Right Sidebar") + case .rightSidebarShow: + return String(localized: "command.rightSidebarShow.title", defaultValue: "Show Right Sidebar") + case .rightSidebarHide: + return String(localized: "command.rightSidebarHide.title", defaultValue: "Hide Right Sidebar") + case .rightSidebarFocus: + return String(localized: "command.rightSidebarFocus.title", defaultValue: "Focus Right Sidebar") + case .rightSidebarFiles: + return String(localized: "shortcut.switchRightSidebarToFiles.label", defaultValue: "Show Sidebar Files") + case .rightSidebarFind: + return String(localized: "shortcut.switchRightSidebarToFind.label", defaultValue: "Show Sidebar Find") + case .rightSidebarVault: + return String(localized: "shortcut.switchRightSidebarToVault.label", defaultValue: "Show Sidebar Vault") + case .rightSidebarSessions: + return String(localized: "command.rightSidebarSessions.title", defaultValue: "Show Sidebar Sessions") + case .rightSidebarFeed: + return String(localized: "shortcut.switchRightSidebarToFeed.label", defaultValue: "Show Sidebar Feed") + case .rightSidebarDock: + return String(localized: "shortcut.switchRightSidebarToDock.label", defaultValue: "Show Sidebar Dock") + } + } + + var defaultKeywords: [String] { + switch self { + case .newWorkspace: + return ["create", "new", "workspace"] + case .cloudVM: + return ["cloud", "vm", "virtual", "machine", "remote"] + case .newTerminal: + return ["new", "terminal", "tab", "surface"] + case .newBrowser: + return ["new", "browser", "tab", "surface"] + case .splitRight: + return ["terminal", "split", "right"] + case .splitDown: + return ["terminal", "split", "down"] + case .rightSidebarToggle: + return ["right", "sidebar", "toggle"] + case .rightSidebarShow: + return ["right", "sidebar", "show"] + case .rightSidebarHide: + return ["right", "sidebar", "hide"] + case .rightSidebarFocus: + return ["right", "sidebar", "focus"] + case .rightSidebarFiles: + return ["right", "sidebar", "files"] + case .rightSidebarFind: + return ["right", "sidebar", "find", "search"] + case .rightSidebarVault: + return ["right", "sidebar", "vault", "sessions"] + case .rightSidebarSessions: + return ["right", "sidebar", "sessions", "vault"] + case .rightSidebarFeed: + return ["right", "sidebar", "feed"] + case .rightSidebarDock: + return ["right", "sidebar", "dock"] + } + } + + var rightSidebarRemoteCommand: RightSidebarRemoteCommand? { + switch self { + case .rightSidebarToggle: + return .toggle + case .rightSidebarShow: + return .show + case .rightSidebarHide: + return .hide + case .rightSidebarFocus: + return .focus + case .rightSidebarFiles: + return .setMode(.files, focus: true) + case .rightSidebarFind: + return .setMode(.find, focus: true) + case .rightSidebarVault, .rightSidebarSessions: + return .setMode(.sessions, focus: true) + case .rightSidebarFeed: + return .setMode(.feed, focus: true) + case .rightSidebarDock: + return .setMode(.dock, focus: true) + case .newWorkspace, .cloudVM, .newTerminal, .newBrowser, .splitRight, .splitDown: + return nil } } var bonsplitAction: BonsplitConfiguration.SplitActionButton.Action? { switch self { - case .newWorkspace, .cloudVM: + case .newWorkspace, .cloudVM, + .rightSidebarToggle, .rightSidebarShow, .rightSidebarHide, .rightSidebarFocus, + .rightSidebarFiles, .rightSidebarFind, .rightSidebarVault, .rightSidebarSessions, + .rightSidebarFeed, .rightSidebarDock: return nil case .newTerminal: return .newTerminal diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 8876c7e4c6b3..62d8e1e7f110 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -187,8 +187,12 @@ extension ContentView { private static func commandPaletteRightSidebarModeShortcutAction( forCommandID commandID: String ) -> KeyboardShortcutSettings.Action? { + commandPaletteRightSidebarMode(forCommandID: commandID)?.shortcutAction + } + + static func commandPaletteRightSidebarMode(forCommandID commandID: String) -> RightSidebarMode? { RightSidebarMode.availableModes().first { mode in Self.commandPaletteRightSidebarModeCommandID(mode) == commandID - }?.shortcutAction + } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e7e58e8dc44f..0aa6b80e592d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6122,6 +6122,10 @@ struct ContentView: View { } private func commandPaletteConfigActionID(for commandId: String) -> String? { + if let rightSidebarMode = Self.commandPaletteRightSidebarMode(forCommandID: commandId) { + return rightSidebarMode.builtInAction.configID + } + switch commandId { case "palette.newTerminalTab": return CmuxSurfaceTabBarBuiltInAction.newTerminal.configID diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index b724ef2fcf00..4d6dcf6c061f 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -45,6 +45,16 @@ nonisolated enum RightSidebarMode: String, CaseIterable, Codable, Sendable { case .dock: return .switchRightSidebarToDock } } + + var builtInAction: CmuxSurfaceTabBarBuiltInAction { + switch self { + case .files: return .rightSidebarFiles + case .find: return .rightSidebarFind + case .sessions: return .rightSidebarSessions + case .feed: return .rightSidebarFeed + case .dock: return .rightSidebarDock + } + } } extension RightSidebarMode { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 0959bc7a89f1..f7d20029af4c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14842,18 +14842,11 @@ extension Workspace: BonsplitDelegate { ?? NSApp.mainWindow if let builtInAction = executable.builtInAction { - switch builtInAction { - case .newWorkspace: - owningTabManager?.addWorkspace() - case .cloudVM: - _ = AppDelegate.shared?.performCloudVMAction( - tabManager: owningTabManager, - preferredWindow: presentingWindow, - debugSource: "surfaceTabBar.cloudVM" - ) - case .newTerminal, .newBrowser, .splitRight, .splitDown: - break - } + _ = AppDelegate.shared?.executeSurfaceTabBarBuiltInAction( + builtInAction, + tabManager: owningTabManager, + preferredWindow: presentingWindow + ) return } diff --git a/cmuxTests/CmuxConfigTests.swift b/cmuxTests/CmuxConfigTests.swift index e61550d8b6e1..89537ee0fdf8 100644 --- a/cmuxTests/CmuxConfigTests.swift +++ b/cmuxTests/CmuxConfigTests.swift @@ -334,6 +334,90 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertEqual(button.action, .command("codex")) } + @MainActor + func testRightSidebarBuiltInActionsResolveFromRegistry() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-config-right-sidebar-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let globalConfigURL = root.appendingPathComponent("cmux.json") + try "{}".write(to: globalConfigURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: globalConfigURL.path, + startFileWatchers: false + ) + store.loadAll() + + for action in CmuxSurfaceTabBarBuiltInAction.rightSidebarActions { + let resolved = try XCTUnwrap( + store.resolvedAction(id: action.configID), + "Expected action registry entry for \(action.configID)" + ) + XCTAssertEqual(resolved.id, action.configID) + XCTAssertEqual(resolved.action, .builtIn(action)) + XCTAssertNil(resolved.terminalCommand) + XCTAssertEqual(resolved.icon, .symbol(action.defaultIcon)) + } + } + + @MainActor + func testRightSidebarBuiltInActionMetadataStaysCompleteAndDistinct() { + let expectedActions = CmuxSurfaceTabBarBuiltInAction.allCases.filter { + $0.rightSidebarRemoteCommand != nil + } + let actions = CmuxSurfaceTabBarBuiltInAction.rightSidebarActions + + XCTAssertEqual(actions, expectedActions) + XCTAssertEqual(Set(actions.map(\.configID)).count, actions.count) + XCTAssertEqual(Set(actions.map(\.defaultTitle)).count, actions.count) + + for action in actions { + XCTAssertFalse(action.defaultIcon.isEmpty) + XCTAssertFalse(action.defaultKeywords.isEmpty) + XCTAssertNotNil(action.rightSidebarRemoteCommand) + } + } + + @MainActor + func testSurfaceTabBarCanResolveRightSidebarToggleBuiltInAction() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-config-right-sidebar-button-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let globalConfigURL = root.appendingPathComponent("cmux.json") + let json = """ + { + "ui": { + "surfaceTabBar": { + "buttons": [ + "cmux.newTerminal", + "cmux.splitRight", + "cmux.splitDown", + "cmux.rightSidebar.toggle" + ] + } + } + } + """ + try json.write(to: globalConfigURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore( + globalConfigPath: globalConfigURL.path, + startFileWatchers: false + ) + store.loadAll() + + XCTAssertEqual(store.surfaceTabBarButtons.count, 4) + let button = store.surfaceTabBarButtons[3] + XCTAssertEqual(button.id, CmuxSurfaceTabBarBuiltInAction.rightSidebarToggle.configID) + XCTAssertEqual(button.action, .builtIn(.rightSidebarToggle)) + XCTAssertNil(button.terminalCommand) + XCTAssertEqual(button.icon, .symbol(CmuxSurfaceTabBarBuiltInAction.rightSidebarToggle.defaultIcon)) + } + @MainActor func testSurfaceTabBarActionReferenceUsesActionSourcePath() throws { let root = FileManager.default.temporaryDirectory diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index f5d00e6fa7fd..7dd6468003a4 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -451,6 +451,138 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { } } + func testRightSidebarBuiltInActionsMatchCLISubcommandEffects() throws { + try withSavedRightSidebarDockAvailability { + UserDefaults.standard.set(true, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + + let cases: [(CmuxSurfaceTabBarBuiltInAction, [String], Bool, RightSidebarMode)] = [ + (.rightSidebarToggle, ["toggle"], false, .files), + (.rightSidebarShow, ["show"], false, .files), + (.rightSidebarHide, ["hide"], true, .files), + (.rightSidebarFocus, ["focus"], false, .find), + (.rightSidebarFiles, ["files"], false, .find), + (.rightSidebarFind, ["find"], false, .files), + (.rightSidebarVault, ["vault"], false, .files), + (.rightSidebarSessions, ["sessions"], false, .files), + (.rightSidebarFeed, ["feed"], false, .files), + (.rightSidebarDock, ["dock"], false, .files) + ] + + for (action, cliTokens, initialVisible, initialMode) in cases { + let cliState = try rightSidebarStateAfterCLI( + tokens: cliTokens, + initialVisible: initialVisible, + initialMode: initialMode + ) + let actionState = try rightSidebarStateAfterBuiltInAction( + action, + initialVisible: initialVisible, + initialMode: initialMode + ) + + XCTAssertEqual(actionState.succeeded, cliState.succeeded, action.configID) + XCTAssertEqual(actionState.visible, cliState.visible, action.configID) + XCTAssertEqual(actionState.mode, cliState.mode, action.configID) + } + } + } + + func testRightSidebarToggleBuiltInMatchesKeyboardShortcutToggleEffect() throws { + let shortcutState = try rightSidebarStateAfter( + initialVisible: false, + initialMode: .files + ) { appDelegate in + appDelegate.toggleRightSidebarInActiveMainWindow() + } + let actionState = try rightSidebarStateAfterBuiltInAction( + .rightSidebarToggle, + initialVisible: false, + initialMode: .files + ) + + XCTAssertEqual(actionState.succeeded, shortcutState.succeeded) + XCTAssertEqual(actionState.visible, shortcutState.visible) + XCTAssertEqual(actionState.mode, shortcutState.mode) + } + + private func rightSidebarStateAfterCLI( + tokens: [String], + initialVisible: Bool, + initialMode: RightSidebarMode + ) throws -> (succeeded: Bool, visible: Bool, mode: RightSidebarMode) { + let request: RightSidebarRemoteRequest + switch RightSidebarRemoteRequest.parse(tokens: tokens) { + case .success(let parsed): + request = parsed + case .failure(let error): + XCTFail("Expected CLI tokens to parse: \(tokens.joined(separator: " ")) \(error.message)") + return (false, initialVisible, initialMode) + } + + return try rightSidebarStateAfter( + initialVisible: initialVisible, + initialMode: initialMode + ) { appDelegate in + switch appDelegate.applyRightSidebarRemoteCommand(request.command, target: request.target) { + case .ok: + return true + case .state, .failure: + return false + } + } + } + + private func rightSidebarStateAfterBuiltInAction( + _ action: CmuxSurfaceTabBarBuiltInAction, + initialVisible: Bool, + initialMode: RightSidebarMode + ) throws -> (succeeded: Bool, visible: Bool, mode: RightSidebarMode) { + try rightSidebarStateAfter( + initialVisible: initialVisible, + initialMode: initialMode + ) { appDelegate in + appDelegate.executeBuiltInRightSidebarAction(action) + } + } + + private func rightSidebarStateAfter( + initialVisible: Bool, + initialMode: RightSidebarMode, + apply: (AppDelegate) -> Bool + ) throws -> (succeeded: Bool, visible: Bool, mode: RightSidebarMode) { + let previousAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + defer { AppDelegate.shared = previousAppDelegate } + + let manager = TabManager() + _ = manager.addWorkspace(select: false, eagerLoadTerminal: false) + let state = FileExplorerState() + state.setVisible(initialVisible) + state.mode = initialMode + + let windowId = appDelegate.registerMainWindowContextForTesting( + tabManager: manager, + fileExplorerState: state + ) + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) } + + let succeeded = apply(appDelegate) + return (succeeded, state.isVisible, state.mode) + } + + private func withSavedRightSidebarDockAvailability(_ body: () throws -> Void) throws { + let defaults = UserDefaults.standard + let previousDockEnabled = defaults.object(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + defer { + if let previousDockEnabled { + defaults.set(previousDockEnabled, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + } else { + defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + } + } + try body() + } + func testNotificationCreateUsesExplicitSurfaceIDWhenProvided() async throws { let socketPath = makeSocketPath("notify-surface") let store = TerminalNotificationStore.shared diff --git a/web/app/[locale]/docs/custom-commands/page.tsx b/web/app/[locale]/docs/custom-commands/page.tsx index e7c6fb99c8d2..374e8dfada95 100644 --- a/web/app/[locale]/docs/custom-commands/page.tsx +++ b/web/app/[locale]/docs/custom-commands/page.tsx @@ -134,6 +134,7 @@ export default function CustomCommandsPage() { "cmux.newBrowser", "cmux.splitRight", "cmux.splitDown", + "cmux.rightSidebar.toggle", "claude" ] } @@ -154,6 +155,7 @@ export default function CustomCommandsPage() { newBrowser: (chunks) => {chunks}, splitRight: (chunks) => {chunks}, splitDown: (chunks) => {chunks}, + rightSidebarToggle: (chunks) => {chunks}, })}

@@ -468,6 +470,7 @@ export default function CustomCommandsPage() { "cmux.newBrowser", "cmux.splitRight", "cmux.splitDown", + "cmux.rightSidebar.toggle", { "action": "claude", "title": "Claude Here" diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 934556cbda0a..a68fd70d3bd1 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -20,8 +20,16 @@ }, "actions": { "title": "actions", - "description": "Action registry used by the surface tab bar, Command Palette, shortcuts, and plus-button menu.", + "description": "Action registry used by the surface tab bar, Command Palette, shortcuts, and plus-button menu. Built-in IDs include cmux.newTerminal, cmux.newBrowser, cmux.splitRight, cmux.splitDown, cmux.rightSidebar.toggle, cmux.rightSidebar.show, cmux.rightSidebar.hide, cmux.rightSidebar.focus, cmux.rightSidebar.files, cmux.rightSidebar.find, cmux.rightSidebar.vault, cmux.rightSidebar.sessions, cmux.rightSidebar.feed, and cmux.rightSidebar.dock.", "type": "object", + "examples": [ + { + "cmux.rightSidebar.toggle": { + "type": "builtin", + "builtin": "cmux.rightSidebar.toggle" + } + } + ], "additionalProperties": true }, "ui": { @@ -161,7 +169,7 @@ }, "surfaceTabBarButtons": { "title": "surfaceTabBarButtons", - "description": "Legacy root-level surface tab bar buttons. Prefer ui.surfaceTabBar.buttons for new configs.", + "description": "Legacy root-level surface tab bar buttons. Prefer ui.surfaceTabBar.buttons for new configs. Button strings may reference built-in action IDs such as cmux.newTerminal, cmux.splitRight, cmux.splitDown, and cmux.rightSidebar.toggle.", "type": "array", "items": { "oneOf": [ diff --git a/web/messages/en.json b/web/messages/en.json index 8742134d9b5a..9da5687046cb 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -616,7 +616,7 @@ "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.", + "nightlyActionRegistryDesc": "actions maps stable IDs to runnable behavior. Use built-in IDs such as cmux.newTerminal, cmux.newBrowser, cmux.splitRight, cmux.splitDown, and cmux.rightSidebar.toggle to wire common app actions without shell commands. Right-sidebar built-ins mirror the CLI: cmux.rightSidebar.toggle, cmux.rightSidebar.show, cmux.rightSidebar.hide, cmux.rightSidebar.focus, cmux.rightSidebar.files, cmux.rightSidebar.find, cmux.rightSidebar.vault, cmux.rightSidebar.sessions, cmux.rightSidebar.feed, and cmux.rightSidebar.dock. 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.", @@ -624,7 +624,7 @@ "customActions": "Custom actions and Command Palette", "customActionsDesc": "An actions entry is the reusable thing cmux runs. Use actions when the same behavior should be available from the Command Palette, surface tab bar, shortcuts, or the plus-button menu. Keep commands for reusable shell commands and workspace layouts. Set palette to false when an action should stay out of Command Palette.", "actionTypes": "Action types", - "actionTypeBuiltin": "Alias a built-in cmux action such as cmux.newTerminal, cmux.newBrowser, cmux.splitRight, or cmux.splitDown.", + "actionTypeBuiltin": "Alias a built-in cmux action such as cmux.newTerminal, cmux.newBrowser, cmux.splitRight, cmux.splitDown, or cmux.rightSidebar.toggle.", "actionTypeCommand": "Run shell text in a terminal. Use target to choose the current terminal or a new tab in the current pane.", "actionTypeAgent": "Start a supported coding agent. Today this supports codex and claude, with optional args.", "actionTypeWorkspaceCommand": "Run a named workspace definition from commands. Use this for multi-pane layouts, custom working directories, and startup commands.", diff --git a/web/messages/ja.json b/web/messages/ja.json index 8298953062f6..1c9ef3095d73 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -575,7 +575,7 @@ "schemaDesc": "cmux.json ファイルには actions レジストリと commands 配列を含められます。commands の各項目はシンプルなシェルコマンドまたは完全なワークスペース定義です:", "schemaIntro": "commands は引き続き、再利用可能なシェルコマンドとワークスペースレイアウトを定義します。nightly ビルドでは actions レジストリが追加されます。アクションは、サーフェスタブバー、Command Palette、アクション単位のショートカットで共有される公開 ID です。", "nightlyActionRegistry": "nightly アクションレジストリ", - "nightlyActionRegistryDesc": "actions は安定した ID を実行可能な動作に対応付けます。既定値を上書きするには、組み込み ID の cmux.newTerminal、cmux.newBrowser、cmux.splitRight、cmux.splitDown を使います。プロジェクト固有のツールには独自の ID を使います。", + "nightlyActionRegistryDesc": "actions は安定した ID を実行可能な動作に対応付けます。cmux.newTerminal、cmux.newBrowser、cmux.splitRight、cmux.splitDown、cmux.rightSidebar.toggle などの組み込み ID を使うと、シェルコマンドなしで一般的なアプリ操作を割り当てられます。右サイドバーの組み込み ID は CLI と対応しており、cmux.rightSidebar.toggle、cmux.rightSidebar.show、cmux.rightSidebar.hide、cmux.rightSidebar.focus、cmux.rightSidebar.files、cmux.rightSidebar.find、cmux.rightSidebar.vault、cmux.rightSidebar.sessions、cmux.rightSidebar.feed、cmux.rightSidebar.dock があります。プロジェクト固有のツールには独自の ID を使います。", "paletteDesc": "palette は既定で true です。false にすると、サーフェスタブバーやショートカットからは使えるまま、Command Palette には表示しません。shortcut は設定ショートカットと同じ構文を使います。たとえば cmd+shift+c や [\"cmd+k\", \"cmd+c\"] です。", "iconsDesc": "ui.surfaceTabBar.buttons がある場合、既定のボタン一覧を置き換えます。組み込み ID を省略すると非表示になります。アイコンは常にオブジェクト形式です: { \"type\": \"symbol\", \"name\": \"play.circle\" }、{ \"type\": \"emoji\", \"value\": \"🧪\", \"scale\": 0.9 }、または { \"type\": \"image\", \"path\": \"./icons/codex.svg\" }。画像パスは設定ファイルからの相対パスです。絵文字の scale は省略可能で、既定値は 1 です。SVG、PDF、PNG、JPEG、GIF、TIFF、BMP、HEIC、HEIF、WebP、AVIF、ICO、ICNS に対応しています。", "buttonEntriesDesc": "各ボタン項目は、アクション ID 文字列またはボタンオブジェクトにできます。同じアクションに別のサーフェスラベル、アイコン、ツールチップを付けたい場合は、ボタンオブジェクトを使います。解決後のボタンタイトルは、信頼確認のタイトルにも使われます。", @@ -583,7 +583,7 @@ "customActions": "カスタムアクションと Command Palette", "customActionsDesc": "actions の項目は、cmux が実行する再利用可能な処理です。同じ動作を Command Palette、サーフェスタブバー、ショートカット、またはプラスボタンのメニューから使えるようにしたい場合にアクションを使います。再利用可能なシェルコマンドやワークスペースレイアウトには commands を使い続けます。Command Palette に出したくないアクションは palette を false に設定します。", "actionTypes": "アクションの種類", - "actionTypeBuiltin": "cmux.newTerminal、cmux.newBrowser、cmux.splitRight、cmux.splitDown などの組み込み cmux アクションに別名を付けます。", + "actionTypeBuiltin": "cmux.newTerminal、cmux.newBrowser、cmux.splitRight、cmux.splitDown、cmux.rightSidebar.toggle などの組み込み cmux アクションに別名を付けます。", "actionTypeCommand": "ターミナルでシェルテキストを実行します。target で現在のターミナルまたは現在のペイン内の新しいタブを選びます。", "actionTypeAgent": "対応しているコーディングエージェントを起動します。現在は codex と claude を、任意の引数付きでサポートしています。", "actionTypeWorkspaceCommand": "commands から名前付きのワークスペース定義を実行します。複数ペインのレイアウト、カスタム作業ディレクトリ、起動コマンドに使います。",