diff --git a/supacode/Clients/Workspace/WorkspaceClient.swift b/supacode/Clients/Workspace/WorkspaceClient.swift index 317c1e310..a7ec15e3d 100644 --- a/supacode/Clients/Workspace/WorkspaceClient.swift +++ b/supacode/Clients/Workspace/WorkspaceClient.swift @@ -31,7 +31,6 @@ private func performOpenWorktreeAction( worktree: Worktree, onError: @escaping @MainActor @Sendable (OpenActionError) -> Void ) { - let actionTitle = action.title switch action { case .editor: return @@ -39,12 +38,7 @@ private func performOpenWorktreeAction( NSWorkspace.shared.activateFileViewerSelecting([worktree.workingDirectory]) case .androidStudio, .intellij, .webstorm, .pycharm, .rubymine, .rustrover: guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: action.bundleIdentifier) else { - onError( - OpenActionError( - title: "\(action.title) not found", - message: "Install \(action.title) to open this worktree." - ) - ) + onError(.appNotFound(action)) return } let configuration = NSWorkspace.OpenConfiguration() @@ -55,42 +49,119 @@ private func performOpenWorktreeAction( return } Task { @MainActor in - onError( - OpenActionError( - title: "Unable to open in \(actionTitle)", - message: error.localizedDescription - ) - ) + onError(.openFailed(action, error)) } } case .alacritty, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, .ghostty, .kitty, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, .wezterm, .windsurf, .xcode, .zed: + AppLauncher.open(action: action, worktree: worktree, onError: onError) + } +} + +/// Opens a worktree directory in an arbitrary macOS app, preferring the app's bundled +/// command-line helper when it advertises one via `OpenWorktreeAction.cliLauncher`. +/// +/// A CLI launch is preferred because it lets us pass flags/arguments the plain +/// "open document" event (`NSWorkspace.open`) cannot — e.g. Zed honoring +/// `cli_default_open_behavior` to give one window per worktree. Apps without a launcher, +/// and any app whose helper is missing, fall back to `NSWorkspace.open`. +enum AppLauncher { + enum Plan: Equatable { + case cli(executable: URL, arguments: [String]) + case workspaceOpen(url: URL) + } + + static func plan( + launcher: OpenWorktreeAction.CLILauncher?, + appURL: URL, + cliExists: Bool, + workingDirectory: URL + ) -> Plan { + guard let launcher, cliExists else { + return .workspaceOpen(url: workingDirectory) + } + return .cli( + executable: appURL.appending(path: launcher.relativeExecutablePath), + arguments: launcher.openArguments + [workingDirectory.path] + ) + } + + @MainActor static func open( + action: OpenWorktreeAction, + worktree: Worktree, + onError: @escaping @MainActor @Sendable (OpenActionError) -> Void + ) { guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: action.bundleIdentifier) else { - onError( - OpenActionError( - title: "\(action.title) not found", - message: "Install \(action.title) to open this worktree." - ) - ) + onError(.appNotFound(action)) return } - NSWorkspace.shared.open( - [worktree.workingDirectory], - withApplicationAt: appURL, - configuration: .init() - ) { _, error in - guard let error else { - return + let launcher = action.cliLauncher + let cliExists = + launcher.map { FileManager.default.fileExists(atPath: appURL.appending(path: $0.relativeExecutablePath).path) } + ?? false + switch plan( + launcher: launcher, + appURL: appURL, + cliExists: cliExists, + workingDirectory: worktree.workingDirectory + ) { + case .cli(let executable, let arguments): + let process = Process() + process.executableURL = executable + process.arguments = arguments + do { + try process.run() + } catch { + onError(.openFailed(action, error)) } - Task { @MainActor in - onError( - OpenActionError( - title: "Unable to open in \(actionTitle)", - message: error.localizedDescription - ) - ) + case .workspaceOpen(let url): + NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: .init()) { _, error in + guard let error else { + return + } + Task { @MainActor in + onError(.openFailed(action, error)) + } } } } } + +extension OpenWorktreeAction { + /// Describes how to open a worktree through an app's bundled command-line helper. + struct CLILauncher: Equatable { + /// Path to the helper executable, relative to the app bundle root. + let relativeExecutablePath: String + /// Flags placed before the worktree path; the path is always appended last. + let openArguments: [String] + } + + /// The bundled CLI used to open a worktree, or `nil` to fall back to `NSWorkspace.open`. + var cliLauncher: CLILauncher? { + switch self { + case .zed: + // The bare path (no `-n`) gives one window per worktree when Zed's + // `cli_default_open_behavior` is `new_window`, focusing an already-open worktree. + CLILauncher(relativeExecutablePath: "Contents/MacOS/cli", openArguments: []) + default: + nil + } + } +} + +extension OpenActionError { + static func appNotFound(_ action: OpenWorktreeAction) -> OpenActionError { + OpenActionError( + title: "\(action.title) not found", + message: "Install \(action.title) to open this worktree." + ) + } + + static func openFailed(_ action: OpenWorktreeAction, _ error: Error) -> OpenActionError { + OpenActionError( + title: "Unable to open in \(action.title)", + message: error.localizedDescription + ) + } +} diff --git a/supacodeTests/AppLauncherTests.swift b/supacodeTests/AppLauncherTests.swift new file mode 100644 index 000000000..34278a55e --- /dev/null +++ b/supacodeTests/AppLauncherTests.swift @@ -0,0 +1,57 @@ +import Foundation +import SupacodeSettingsShared +import Testing + +@testable import supacode + +struct AppLauncherTests { + private let appURL = URL(filePath: "/Applications/Zed.app") + private let workingDirectory = URL(filePath: "/Users/me/code/worktree") + + @Test func zedExposesBundledCLILauncher() { + #expect( + OpenWorktreeAction.zed.cliLauncher + == .init(relativeExecutablePath: "Contents/MacOS/cli", openArguments: []) + ) + } + + @Test func appsWithoutABundledCLIHaveNoLauncher() { + #expect(OpenWorktreeAction.cursor.cliLauncher == nil) + #expect(OpenWorktreeAction.finder.cliLauncher == nil) + } + + @Test func planLaunchesCLIWithWorktreePathWhenAvailable() { + let plan = AppLauncher.plan( + launcher: OpenWorktreeAction.zed.cliLauncher, + appURL: appURL, + cliExists: true, + workingDirectory: workingDirectory + ) + #expect( + plan == .cli( + executable: appURL.appending(path: "Contents/MacOS/cli"), + arguments: ["/Users/me/code/worktree"] + ) + ) + } + + @Test func planFallsBackToWorkspaceOpenWhenCLIMissing() { + let plan = AppLauncher.plan( + launcher: OpenWorktreeAction.zed.cliLauncher, + appURL: appURL, + cliExists: false, + workingDirectory: workingDirectory + ) + #expect(plan == .workspaceOpen(url: workingDirectory)) + } + + @Test func planFallsBackToWorkspaceOpenWhenNoLauncher() { + let plan = AppLauncher.plan( + launcher: nil, + appURL: appURL, + cliExists: true, + workingDirectory: workingDirectory + ) + #expect(plan == .workspaceOpen(url: workingDirectory)) + } +}