diff --git a/Sources/App/KeyboardShortcutSettings+TerminalDirectoryOpen.swift b/Sources/App/KeyboardShortcutSettings+TerminalDirectoryOpen.swift new file mode 100644 index 000000000000..172b1bcde395 --- /dev/null +++ b/Sources/App/KeyboardShortcutSettings+TerminalDirectoryOpen.swift @@ -0,0 +1,58 @@ +enum TerminalDirectoryOpenShortcutBindings { + typealias Binding = (target: TerminalDirectoryOpenTarget, action: KeyboardShortcutSettings.Action) + + static let all: [Binding] = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets.map { target in + (target, KeyboardShortcutSettings.Action.terminalDirectoryOpenAction(for: target)) + } + + static let actions: [KeyboardShortcutSettings.Action] = all.map { $0.action } +} + +extension KeyboardShortcutSettings.Action { + static var terminalDirectoryOpenActions: [Self] { + TerminalDirectoryOpenShortcutBindings.actions + } + + static var terminalDirectoryOpenShortcutBindings: [TerminalDirectoryOpenShortcutBindings.Binding] { + TerminalDirectoryOpenShortcutBindings.all + } + + static func terminalDirectoryOpenAction(for target: TerminalDirectoryOpenTarget) -> Self { + switch target { + case .androidStudio: + return .terminalOpenDirectoryAndroidStudio + case .antigravity: + return .terminalOpenDirectoryAntigravity + case .cursor: + return .terminalOpenDirectoryCursor + case .finder: + return .terminalOpenDirectoryFinder + case .ghostty: + return .terminalOpenDirectoryGhostty + case .intellij: + return .terminalOpenDirectoryIntelliJ + case .iterm2: + return .terminalOpenDirectoryITerm2 + case .terminal: + return .terminalOpenDirectoryTerminal + case .tower: + return .terminalOpenDirectoryTower + case .vscode: + return .terminalOpenDirectoryVSCode + case .vscodeInline: + return .terminalOpenDirectoryVSCodeInline + case .warp: + return .terminalOpenDirectoryWarp + case .windsurf: + return .terminalOpenDirectoryWindsurf + case .xcode: + return .terminalOpenDirectoryXcode + case .zed: + return .terminalOpenDirectoryZed + } + } + + var terminalDirectoryOpenTarget: TerminalDirectoryOpenTarget? { + TerminalDirectoryOpenShortcutBindings.all.first { $0.action == self }?.target + } +} diff --git a/Sources/App/TerminalDirectoryOpenLauncher.swift b/Sources/App/TerminalDirectoryOpenLauncher.swift new file mode 100644 index 000000000000..038f8000fdc2 --- /dev/null +++ b/Sources/App/TerminalDirectoryOpenLauncher.swift @@ -0,0 +1,85 @@ +import AppKit +import Foundation + +@MainActor +enum TerminalDirectoryOpenLauncher { + typealias ApplicationOpenCompletion = @MainActor (Error?) -> Void + typealias ApplicationOpenHandler = ( + _ urls: [URL], + _ applicationURL: URL, + _ configuration: NSWorkspace.OpenConfiguration, + _ completion: @escaping ApplicationOpenCompletion + ) -> Void + + static func currentDirectoryURL(in tabManager: TabManager?) -> URL? { + guard let workspace = tabManager?.selectedWorkspace else { return nil } + let focusedPanelDirectory = workspace.focusedPanelId.flatMap { workspace.panelDirectories[$0] } + return firstValidDirectoryURL(in: [focusedPanelDirectory, workspace.currentDirectory]) + } + + static func firstValidDirectoryURL(in rawDirectories: [String?]) -> URL? { + for rawDirectory in rawDirectories { + let trimmed = rawDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { continue } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDirectory), + isDirectory.boolValue else { + continue + } + return URL(fileURLWithPath: trimmed, isDirectory: true) + } + return nil + } + + static func openCurrentDirectory( + in target: TerminalDirectoryOpenTarget, + tabManager: TabManager?, + onOpenFailure: ApplicationOpenCompletion? = nil + ) -> Bool { + guard let directoryURL = currentDirectoryURL(in: tabManager) else { return false } + return openDirectory( + directoryURL, + in: target, + tabManager: tabManager, + onOpenFailure: onOpenFailure + ) + } + + static func openDirectory( + _ directoryURL: URL, + in target: TerminalDirectoryOpenTarget, + tabManager: TabManager?, + onOpenFailure: ApplicationOpenCompletion? = nil, + applicationURLProvider: (TerminalDirectoryOpenTarget) -> URL? = { $0.applicationURL() }, + openWithApplication: ApplicationOpenHandler? = nil + ) -> Bool { + switch target { + case .finder: + return NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) + case .vscodeInline: + return AppDelegate.shared?.openDirectoryInInlineVSCode(directoryURL, tabManager: tabManager) ?? false + default: + guard let applicationURL = applicationURLProvider(target) else { return false } + let configuration = NSWorkspace.OpenConfiguration() + let openWithApplication = openWithApplication ?? openURLsWithApplication + openWithApplication([directoryURL], applicationURL, configuration) { error in + guard error != nil else { return } + onOpenFailure?(error) + } + return true + } + } + + private static func openURLsWithApplication( + _ urls: [URL], + applicationURL: URL, + configuration: NSWorkspace.OpenConfiguration, + completion: @escaping ApplicationOpenCompletion + ) { + NSWorkspace.shared.open(urls, withApplicationAt: applicationURL, configuration: configuration) { _, error in + Task { @MainActor in + completion(error) + } + } + } +} diff --git a/Sources/App/TerminalDirectoryOpenSupport.swift b/Sources/App/TerminalDirectoryOpenSupport.swift index 81ad90d19248..57116299e30c 100644 --- a/Sources/App/TerminalDirectoryOpenSupport.swift +++ b/Sources/App/TerminalDirectoryOpenSupport.swift @@ -96,6 +96,12 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { Array(allCases) } + init?(commandPaletteCommandId: String) { + let prefix = "palette.terminalOpenDirectory." + guard commandPaletteCommandId.hasPrefix(prefix) else { return nil } + self.init(rawValue: String(commandPaletteCommandId.dropFirst(prefix.count))) + } + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 438df7b3d609..9df21fcc7f93 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11940,6 +11940,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if handleTerminalDirectoryOpenShortcut(event: event) { + return true + } + if handleConfiguredCmuxShortcut( event: event, actions: configuredCmuxShortcutActions, @@ -13632,6 +13636,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + private func handleTerminalDirectoryOpenShortcut(event: NSEvent) -> Bool { + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded( + event: event, + actions: KeyboardShortcutSettings.Action.terminalDirectoryOpenActions + ) { + return true + } + + for binding in KeyboardShortcutSettings.Action.terminalDirectoryOpenShortcutBindings { + guard matchConfiguredShortcut(event: event, action: binding.action) else { + continue + } + let targetTabManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager + if !TerminalDirectoryOpenLauncher.openCurrentDirectory( + in: binding.target, + tabManager: targetTabManager, + onOpenFailure: { _ in + NSSound.beep() + } + ) { + NSSound.beep() + } + return true + } + return false + } + @discardableResult private func executeConfiguredCmuxAction( _ action: CmuxResolvedConfigAction, diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 8876c7e4c6b3..e02339b9dcc5 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -2,6 +2,10 @@ import AppKit extension ContentView { static func commandPaletteShortcutAction(forCommandID commandId: String) -> KeyboardShortcutSettings.Action? { + if let target = TerminalDirectoryOpenTarget(commandPaletteCommandId: commandId) { + return KeyboardShortcutSettings.Action.terminalDirectoryOpenAction(for: target) + } + if let rightSidebarModeAction = commandPaletteRightSidebarModeShortcutAction(forCommandID: commandId) { return rightSidebarModeAction } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e7e58e8dc44f..45e01a5c4046 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9157,27 +9157,13 @@ struct ContentView: View { } private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool { - guard let directoryURL = focusedTerminalDirectoryURL() else { return false } - return openFocusedDirectory(directoryURL, in: target) - } - - private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool { - switch target { - case .finder: - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) - return true - case .vscodeInline: - return openFocusedDirectoryInInlineVSCode(directoryURL) - default: - guard let applicationURL = target.applicationURL() else { return false } - let configuration = NSWorkspace.OpenConfiguration() - NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration) - return true - } - } - - private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool { - AppDelegate.shared?.openDirectoryInInlineVSCode(directoryURL, tabManager: tabManager) ?? false + TerminalDirectoryOpenLauncher.openCurrentDirectory( + in: target, + tabManager: tabManager, + onOpenFailure: { _ in + NSSound.beep() + } + ) } private func stopInlineVSCodeServeWeb() { @@ -9196,21 +9182,6 @@ struct ContentView: View { return true } - private func focusedTerminalDirectoryURL() -> URL? { - guard let workspace = tabManager.selectedWorkspace else { return nil } - let rawDirectory: String = { - if let focusedPanelId = workspace.focusedPanelId, - let directory = workspace.panelDirectories[focusedPanelId] { - return directory - } - return workspace.currentDirectory - }() - let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard FileManager.default.fileExists(atPath: trimmed) else { return nil } - return URL(fileURLWithPath: trimmed, isDirectory: true) - } - #if DEBUG private func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 6686e0a82521..eb437e0b9108 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -72,6 +72,21 @@ enum KeyboardShortcutSettings { case toggleSidebar case newTab case openFolder + case terminalOpenDirectoryAndroidStudio = "palette.terminalOpenDirectory.androidStudio" + case terminalOpenDirectoryAntigravity = "palette.terminalOpenDirectory.antigravity" + case terminalOpenDirectoryCursor = "palette.terminalOpenDirectory.cursor" + case terminalOpenDirectoryFinder = "palette.terminalOpenDirectory.finder" + case terminalOpenDirectoryGhostty = "palette.terminalOpenDirectory.ghostty" + case terminalOpenDirectoryIntelliJ = "palette.terminalOpenDirectory.intellij" + case terminalOpenDirectoryITerm2 = "palette.terminalOpenDirectory.iterm2" + case terminalOpenDirectoryTerminal = "palette.terminalOpenDirectory.terminal" + case terminalOpenDirectoryTower = "palette.terminalOpenDirectory.tower" + case terminalOpenDirectoryVSCode = "palette.terminalOpenDirectory.vscode" + case terminalOpenDirectoryVSCodeInline = "palette.terminalOpenDirectory.vscodeInline" + case terminalOpenDirectoryWarp = "palette.terminalOpenDirectory.warp" + case terminalOpenDirectoryWindsurf = "palette.terminalOpenDirectory.windsurf" + case terminalOpenDirectoryXcode = "palette.terminalOpenDirectory.xcode" + case terminalOpenDirectoryZed = "palette.terminalOpenDirectory.zed" case reopenPreviousSession case goToWorkspace case commandPalette @@ -156,6 +171,21 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return String(localized: "shortcut.toggleLeftSidebar.label", defaultValue: "Toggle Left Sidebar") case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace") case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder") + case .terminalOpenDirectoryAndroidStudio: return TerminalDirectoryOpenTarget.androidStudio.commandPaletteTitle + case .terminalOpenDirectoryAntigravity: return TerminalDirectoryOpenTarget.antigravity.commandPaletteTitle + case .terminalOpenDirectoryCursor: return TerminalDirectoryOpenTarget.cursor.commandPaletteTitle + case .terminalOpenDirectoryFinder: return TerminalDirectoryOpenTarget.finder.commandPaletteTitle + case .terminalOpenDirectoryGhostty: return TerminalDirectoryOpenTarget.ghostty.commandPaletteTitle + case .terminalOpenDirectoryIntelliJ: return TerminalDirectoryOpenTarget.intellij.commandPaletteTitle + case .terminalOpenDirectoryITerm2: return TerminalDirectoryOpenTarget.iterm2.commandPaletteTitle + case .terminalOpenDirectoryTerminal: return TerminalDirectoryOpenTarget.terminal.commandPaletteTitle + case .terminalOpenDirectoryTower: return TerminalDirectoryOpenTarget.tower.commandPaletteTitle + case .terminalOpenDirectoryVSCode: return TerminalDirectoryOpenTarget.vscode.commandPaletteTitle + case .terminalOpenDirectoryVSCodeInline: return TerminalDirectoryOpenTarget.vscodeInline.commandPaletteTitle + case .terminalOpenDirectoryWarp: return TerminalDirectoryOpenTarget.warp.commandPaletteTitle + case .terminalOpenDirectoryWindsurf: return TerminalDirectoryOpenTarget.windsurf.commandPaletteTitle + case .terminalOpenDirectoryXcode: return TerminalDirectoryOpenTarget.xcode.commandPaletteTitle + case .terminalOpenDirectoryZed: return TerminalDirectoryOpenTarget.zed.commandPaletteTitle case .reopenPreviousSession: return String(localized: "shortcut.reopenPreviousSession.label", defaultValue: "Reopen Previous Session") case .goToWorkspace: return String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…") case .commandPalette: return String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…") @@ -264,6 +294,22 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .openFolder: return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false) + case .terminalOpenDirectoryAndroidStudio, + .terminalOpenDirectoryAntigravity, + .terminalOpenDirectoryCursor, + .terminalOpenDirectoryFinder, + .terminalOpenDirectoryGhostty, + .terminalOpenDirectoryIntelliJ, + .terminalOpenDirectoryITerm2, + .terminalOpenDirectoryTerminal, + .terminalOpenDirectoryTower, + .terminalOpenDirectoryVSCode, + .terminalOpenDirectoryVSCodeInline, + .terminalOpenDirectoryWarp, + .terminalOpenDirectoryWindsurf, + .terminalOpenDirectoryXcode, + .terminalOpenDirectoryZed: + return .unbound case .reopenPreviousSession: return StoredShortcut(key: "o", command: true, shift: true, option: false, control: false) case .goToWorkspace: diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fe7cac4b2187..975fdeb9f80f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -172,6 +172,8 @@ B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */; }; C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */; }; 0D56BE882EAD4B67AC44F96D /* TerminalDirectoryOpenSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */; }; + C41110010000000000000001 /* TerminalDirectoryOpenLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41110010000000000000002 /* TerminalDirectoryOpenLauncher.swift */; }; + C41110020000000000000001 /* KeyboardShortcutSettings+TerminalDirectoryOpen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41110020000000000000002 /* KeyboardShortcutSettings+TerminalDirectoryOpen.swift */; }; A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */; }; E8BA79E246754A8B99A0B823 /* ScreenIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D5AA7D29C94F5CA865B2BF /* ScreenIdentity.swift */; }; C1713002C1713002C1713002 /* CommandPaletteShortcutRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1713001C1713001C1713001 /* CommandPaletteShortcutRouting.swift */; }; @@ -706,6 +708,8 @@ C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSettingsToggle.swift; sourceTree = ""; }; 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/GhosttySurfaceConfigurationRefresh.swift; sourceTree = ""; }; 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalDirectoryOpenSupport.swift; sourceTree = ""; }; + C41110010000000000000002 /* TerminalDirectoryOpenLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalDirectoryOpenLauncher.swift; sourceTree = ""; }; + C41110020000000000000002 /* KeyboardShortcutSettings+TerminalDirectoryOpen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/KeyboardShortcutSettings+TerminalDirectoryOpen.swift"; sourceTree = ""; }; 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxCLIPathInstaller.swift; sourceTree = ""; }; 47D5AA7D29C94F5CA865B2BF /* ScreenIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ScreenIdentity.swift; sourceTree = ""; }; C1713001C1713001C1713001 /* CommandPaletteShortcutRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CommandPaletteShortcutRouting.swift; sourceTree = ""; }; @@ -1269,6 +1273,8 @@ C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */, 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */, 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */, + C41110010000000000000002 /* TerminalDirectoryOpenLauncher.swift */, + C41110020000000000000002 /* KeyboardShortcutSettings+TerminalDirectoryOpen.swift */, 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */, 47D5AA7D29C94F5CA865B2BF /* ScreenIdentity.swift */, C1713001C1713001C1713001 /* CommandPaletteShortcutRouting.swift */, @@ -2014,6 +2020,8 @@ C0DEF4110000000000000001 /* CommandPaletteSettingsToggle.swift in Sources */, B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */, 0D56BE882EAD4B67AC44F96D /* TerminalDirectoryOpenSupport.swift in Sources */, + C41110010000000000000001 /* TerminalDirectoryOpenLauncher.swift in Sources */, + C41110020000000000000001 /* KeyboardShortcutSettings+TerminalDirectoryOpen.swift in Sources */, A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */, E8BA79E246754A8B99A0B823 /* ScreenIdentity.swift in Sources */, C1713002C1713002C1713002 /* CommandPaletteShortcutRouting.swift in Sources */, diff --git a/cmuxTests/CommandPaletteShortcutCustomizationTests.swift b/cmuxTests/CommandPaletteShortcutCustomizationTests.swift index c7364517404b..123b4ae321e0 100644 --- a/cmuxTests/CommandPaletteShortcutCustomizationTests.swift +++ b/cmuxTests/CommandPaletteShortcutCustomizationTests.swift @@ -53,6 +53,99 @@ final class CommandPaletteShortcutCustomizationTests: XCTestCase { } } + func testTerminalDirectoryOpenPaletteCommandsExposeBindableShortcutActions() { + let actions = KeyboardShortcutSettings.Action.terminalDirectoryOpenActions + let bindings = KeyboardShortcutSettings.Action.terminalDirectoryOpenShortcutBindings + + XCTAssertEqual(actions.count, TerminalDirectoryOpenTarget.commandPaletteShortcutTargets.count) + XCTAssertEqual(bindings.map { $0.action }, actions) + XCTAssertEqual(bindings.map { $0.target }, TerminalDirectoryOpenTarget.commandPaletteShortcutTargets) + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + let action = KeyboardShortcutSettings.Action.terminalDirectoryOpenAction(for: target) + XCTAssertEqual(action.rawValue, target.commandPaletteCommandId) + XCTAssertEqual(ContentView.commandPaletteShortcutAction(forCommandID: target.commandPaletteCommandId), action) + XCTAssertEqual(action.terminalDirectoryOpenTarget, target) + XCTAssertEqual(action.defaultShortcut, .unbound) + XCTAssertTrue(action.isPublicShortcutAction) + } + } + + func testSettingsFileParsesTerminalDirectoryOpenPaletteShortcutBinding() throws { + let settingsFileURL = settingsDirectoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try """ + { + "shortcuts": { + "bindings": { + "palette.terminalOpenDirectory.vscode": "cmd+shift+v" + } + } + } + """.write(to: settingsFileURL, atomically: true, encoding: .utf8) + + KeyboardShortcutSettings.settingsFileStore.reload() + + XCTAssertEqual( + KeyboardShortcutSettings.shortcut(for: .terminalOpenDirectoryVSCode), + StoredShortcut(key: "v", command: true, shift: true, option: false, control: false) + ) + } + + func testTerminalDirectoryOpenLauncherFallsBackToWorkspaceDirectoryWhenFocusedDirectoryIsStale() throws { + let workspaceDirectory = settingsDirectoryURL.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: workspaceDirectory, withIntermediateDirectories: true) + + let resolvedURL = TerminalDirectoryOpenLauncher.firstValidDirectoryURL( + in: [ + settingsDirectoryURL.appendingPathComponent("missing", isDirectory: true).path, + workspaceDirectory.path, + ] + ) + + XCTAssertEqual(resolvedURL?.standardizedFileURL, workspaceDirectory.standardizedFileURL) + } + + func testTerminalDirectoryOpenLauncherReportsApplicationLaunchCompletionFailures() throws { + let directoryURL = settingsDirectoryURL.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let applicationURL = URL(fileURLWithPath: "/Applications/Example.app", isDirectory: true) + let expectedError = NSError(domain: "cmuxTests", code: 42) + var capturedURLs: [URL] = [] + var capturedApplicationURL: URL? + var launchCompletion: TerminalDirectoryOpenLauncher.ApplicationOpenCompletion? + var reportedErrors: [NSError] = [] + + let opened = TerminalDirectoryOpenLauncher.openDirectory( + directoryURL, + in: .vscode, + tabManager: nil, + onOpenFailure: { error in + guard let error else { return } + reportedErrors.append(error as NSError) + }, + applicationURLProvider: { target in + XCTAssertEqual(target, .vscode) + return applicationURL + }, + openWithApplication: { urls, applicationURL, _, completion in + capturedURLs = urls + capturedApplicationURL = applicationURL + launchCompletion = completion + } + ) + + XCTAssertTrue(opened) + XCTAssertEqual(capturedURLs, [directoryURL]) + XCTAssertEqual(capturedApplicationURL, applicationURL) + XCTAssertTrue(reportedErrors.isEmpty) + + launchCompletion?(nil) + XCTAssertTrue(reportedErrors.isEmpty) + + launchCompletion?(expectedError) + XCTAssertEqual(reportedErrors.map(\.domain), [expectedError.domain]) + XCTAssertEqual(reportedErrors.map(\.code), [expectedError.code]) + } + func testFieldEditorMoveCommandHonorsClearedCommandPalettePreviousShortcut() { guard let controlPEvent = makeKeyDownEvent( key: "\u{10}", @@ -377,6 +470,50 @@ final class CommandPaletteShortcutCustomizationTests: XCTestCase { } } + func testChordedTerminalDirectoryOpenShortcutConsumesPrefix() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + withTemporaryCommandPaletteShortcut(.terminalOpenDirectoryFinder) { + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "o"), + for: .terminalOpenDirectoryFinder + ) + + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct terminal-open chord prefix event") + return + } + + #if DEBUG + XCTAssertTrue( + appDelegate.debugHandleCustomShortcut(event: prefixEvent), + "Terminal-directory open shortcut prefixes must be consumed and armed" + ) + #else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + #endif + } + } + func testWindowPerformKeyEquivalentRoutesHorizontalArrowsToCommandPaletteFieldEditor() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 4dff87f634b3..319314095b25 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -17,6 +17,17 @@ export type ShortcutCategory = { shortcuts: Shortcut[]; }; +const unboundByDefaultNote: LocalizedText = { en: "unbound by default", ja: "デフォルトでは未割り当て" }; + +function openWithShortcut(id: string, en: string, ja: string): Shortcut { + return { + id, + combos: [], + description: { en, ja }, + note: unboundByDefaultNote, + }; +} + export const shortcutCategories: ShortcutCategory[] = [ { id: "app", @@ -180,6 +191,27 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "useSelectionForFind", combos: [["⌘", "E"]], description: { en: "Use selection for find", ja: "選択範囲で検索" } }, ], }, + { + id: "open-with", + titleKey: "openWith", + shortcuts: [ + openWithShortcut("palette.terminalOpenDirectory.androidStudio", "Open current directory in Android Studio", "現在のディレクトリをAndroid Studioで開く"), + openWithShortcut("palette.terminalOpenDirectory.antigravity", "Open current directory in Antigravity", "現在のディレクトリをAntigravityで開く"), + openWithShortcut("palette.terminalOpenDirectory.cursor", "Open current directory in Cursor", "現在のディレクトリをCursorで開く"), + openWithShortcut("palette.terminalOpenDirectory.finder", "Open current directory in Finder", "現在のディレクトリをFinderで開く"), + openWithShortcut("palette.terminalOpenDirectory.ghostty", "Open current directory in Ghostty", "現在のディレクトリをGhosttyで開く"), + openWithShortcut("palette.terminalOpenDirectory.intellij", "Open current directory in IntelliJ IDEA", "現在のディレクトリをIntelliJ IDEAで開く"), + openWithShortcut("palette.terminalOpenDirectory.iterm2", "Open current directory in iTerm2", "現在のディレクトリをiTerm2で開く"), + openWithShortcut("palette.terminalOpenDirectory.terminal", "Open current directory in Terminal", "現在のディレクトリをTerminalで開く"), + openWithShortcut("palette.terminalOpenDirectory.tower", "Open current directory in Tower", "現在のディレクトリをTowerで開く"), + openWithShortcut("palette.terminalOpenDirectory.vscode", "Open current directory in VS Code", "現在のディレクトリをVS Codeで開く"), + openWithShortcut("palette.terminalOpenDirectory.vscodeInline", "Open current directory in VS Code (Inline)", "現在のディレクトリをVS Code (Inline)で開く"), + openWithShortcut("palette.terminalOpenDirectory.warp", "Open current directory in Warp", "現在のディレクトリをWarpで開く"), + openWithShortcut("palette.terminalOpenDirectory.windsurf", "Open current directory in Windsurf", "現在のディレクトリをWindsurfで開く"), + openWithShortcut("palette.terminalOpenDirectory.xcode", "Open current directory in Xcode", "現在のディレクトリをXcodeで開く"), + openWithShortcut("palette.terminalOpenDirectory.zed", "Open current directory in Zed", "現在のディレクトリをZedで開く"), + ], + }, { id: "notifications", titleKey: "notifications", diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 934556cbda0a..d8691500f545 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -874,6 +874,21 @@ "toggleSidebar", "newTab", "openFolder", + "palette.terminalOpenDirectory.androidStudio", + "palette.terminalOpenDirectory.antigravity", + "palette.terminalOpenDirectory.cursor", + "palette.terminalOpenDirectory.finder", + "palette.terminalOpenDirectory.ghostty", + "palette.terminalOpenDirectory.intellij", + "palette.terminalOpenDirectory.iterm2", + "palette.terminalOpenDirectory.terminal", + "palette.terminalOpenDirectory.tower", + "palette.terminalOpenDirectory.vscode", + "palette.terminalOpenDirectory.vscodeInline", + "palette.terminalOpenDirectory.warp", + "palette.terminalOpenDirectory.windsurf", + "palette.terminalOpenDirectory.xcode", + "palette.terminalOpenDirectory.zed", "reopenPreviousSession", "goToWorkspace", "commandPalette", diff --git a/web/messages/ar.json b/web/messages/ar.json index d938170f6792..95e417eb07fb 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -612,6 +612,7 @@ "notifications": "الإشعارات", "find": "البحث", "terminal": "الطرفية", + "openWith": "فتح باستخدام", "window": "النافذة" }, "sc": { diff --git a/web/messages/bs.json b/web/messages/bs.json index f13996f20e95..0ed94baac661 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -612,6 +612,7 @@ "notifications": "Notifikacije", "find": "Pretraga", "terminal": "Terminal", + "openWith": "Otvori pomoću", "window": "Prozor" }, "sc": { diff --git a/web/messages/da.json b/web/messages/da.json index aad77a7a6950..87efe0ba8c9b 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -612,6 +612,7 @@ "notifications": "Notifikationer", "find": "Find", "terminal": "Terminal", + "openWith": "Åbn med", "window": "Vindue" }, "sc": { diff --git a/web/messages/de.json b/web/messages/de.json index 1b2b51483232..fdb65100d72a 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -612,6 +612,7 @@ "notifications": "Benachrichtigungen", "find": "Suche", "terminal": "Terminal", + "openWith": "Öffnen mit", "window": "Fenster" }, "sc": { diff --git a/web/messages/en.json b/web/messages/en.json index 8742134d9b5a..a863e4914dcb 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -722,6 +722,7 @@ "notifications": "Notifications", "find": "Find", "terminal": "Terminal", + "openWith": "Open With", "window": "Window" }, "sc": { diff --git a/web/messages/es.json b/web/messages/es.json index 81f9f6d4720d..3b32addc516d 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -612,6 +612,7 @@ "notifications": "Notificaciones", "find": "Búsqueda", "terminal": "Terminal", + "openWith": "Abrir con", "window": "Ventana" }, "sc": { diff --git a/web/messages/fr.json b/web/messages/fr.json index 22fcd24bfd63..729e02f0398c 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -612,6 +612,7 @@ "notifications": "Notifications", "find": "Recherche", "terminal": "Terminal", + "openWith": "Ouvrir avec", "window": "Fenêtre" }, "sc": { diff --git a/web/messages/it.json b/web/messages/it.json index eafd528e8906..a869bd4928c0 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -612,6 +612,7 @@ "notifications": "Notifiche", "find": "Ricerca", "terminal": "Terminale", + "openWith": "Apri con", "window": "Finestra" }, "sc": { diff --git a/web/messages/ja.json b/web/messages/ja.json index 8298953062f6..8177d5d691a4 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -680,6 +680,7 @@ "notifications": "通知", "find": "検索", "terminal": "ターミナル", + "openWith": "アプリで開く", "window": "ウィンドウ" }, "sc": { diff --git a/web/messages/km.json b/web/messages/km.json index 4cfbc029e04e..98d1f4d9dda9 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -612,6 +612,7 @@ "notifications": "ជូនដំណឹង", "find": "ស្វែងរក", "terminal": "ទែមីណល", + "openWith": "បើកជាមួយ", "window": "បង្អួច" }, "sc": { diff --git a/web/messages/ko.json b/web/messages/ko.json index 30e860a4073e..63adae602cb0 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -612,6 +612,7 @@ "notifications": "알림", "find": "찾기", "terminal": "터미널", + "openWith": "다음으로 열기", "window": "창" }, "sc": { diff --git a/web/messages/no.json b/web/messages/no.json index 367cec85dc1b..782708bf3ed4 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -612,6 +612,7 @@ "notifications": "Varsler", "find": "Finn", "terminal": "Terminal", + "openWith": "Åpne med", "window": "Vindu" }, "sc": { diff --git a/web/messages/pl.json b/web/messages/pl.json index 7f1b9203d408..ba8eede9b0b5 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -612,6 +612,7 @@ "notifications": "Powiadomienia", "find": "Znajdź", "terminal": "Terminal", + "openWith": "Otwórz za pomocą", "window": "Okno" }, "sc": { diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 08a552ea52f2..51f70134567a 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -612,6 +612,7 @@ "notifications": "Notificações", "find": "Buscar", "terminal": "Terminal", + "openWith": "Abrir com", "window": "Janela" }, "sc": { diff --git a/web/messages/ru.json b/web/messages/ru.json index b4b7f6a21268..ff39ea5dcd11 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -612,6 +612,7 @@ "notifications": "Уведомления", "find": "Поиск", "terminal": "Терминал", + "openWith": "Открыть с помощью", "window": "Окно" }, "sc": { diff --git a/web/messages/th.json b/web/messages/th.json index 7e9d4fa2d6f7..f9596280c375 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -612,6 +612,7 @@ "notifications": "การแจ้งเตือน", "find": "ค้นหา", "terminal": "เทอร์มินัล", + "openWith": "เปิดด้วย", "window": "หน้าต่าง" }, "sc": { diff --git a/web/messages/tr.json b/web/messages/tr.json index 99c20c2e5819..1c33c18f6fc7 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -612,6 +612,7 @@ "notifications": "Bildirimler", "find": "Bul", "terminal": "Terminal", + "openWith": "Birlikte Aç", "window": "Pencere" }, "sc": { diff --git a/web/messages/uk.json b/web/messages/uk.json index 6440e717ee5a..4709b4979982 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -613,6 +613,7 @@ "notifications": "Сповіщення", "find": "Пошук", "terminal": "Термінал", + "openWith": "Відкрити за допомогою", "window": "Вікно" }, "sc": { diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index e9137910088b..7525c09fae01 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -612,6 +612,7 @@ "notifications": "通知", "find": "查找", "terminal": "终端", + "openWith": "打开方式", "window": "窗口" }, "sc": { diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 5fd057c15799..a631acec19fd 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -612,6 +612,7 @@ "notifications": "通知", "find": "搜尋", "terminal": "終端機", + "openWith": "開啟方式", "window": "視窗" }, "sc": {