diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts index a7b4197132c..a30fed56441 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts @@ -8,118 +8,124 @@ describe("getAppCommand", () => { expect(getAppCommand("finder", "/path/to/file")).toBeNull(); }); - test("returns correct command for cursor", () => { + test("returns single-element array for cursor", () => { const result = getAppCommand("cursor", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "Cursor", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "Cursor", "/path/to/file"] }, + ]); }); - test("returns correct command for vscode", () => { + test("returns single-element array for vscode", () => { const result = getAppCommand("vscode", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "Visual Studio Code", "/path/to/file"], - }); + expect(result).toEqual([ + { + command: "open", + args: ["-a", "Visual Studio Code", "/path/to/file"], + }, + ]); }); - test("returns correct command for sublime", () => { + test("returns single-element array for sublime", () => { const result = getAppCommand("sublime", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "Sublime Text", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "Sublime Text", "/path/to/file"] }, + ]); }); - test("returns correct command for xcode", () => { + test("returns single-element array for xcode", () => { const result = getAppCommand("xcode", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "Xcode", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "Xcode", "/path/to/file"] }, + ]); }); - test("returns correct command for iterm", () => { + test("returns single-element array for iterm", () => { const result = getAppCommand("iterm", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "iTerm", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "iTerm", "/path/to/file"] }, + ]); }); - test("returns correct command for warp", () => { + test("returns single-element array for warp", () => { const result = getAppCommand("warp", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "Warp", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "Warp", "/path/to/file"] }, + ]); }); - test("returns correct command for terminal", () => { + test("returns single-element array for terminal", () => { const result = getAppCommand("terminal", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "Terminal", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "Terminal", "/path/to/file"] }, + ]); }); - test("returns correct command for ghostty", () => { + test("returns single-element array for ghostty", () => { const result = getAppCommand("ghostty", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "Ghostty", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "Ghostty", "/path/to/file"] }, + ]); }); describe("JetBrains IDEs", () => { - test("returns correct command for intellij", () => { + test("returns bundle ID candidates for intellij (multi-edition)", () => { const result = getAppCommand("intellij", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "IntelliJ IDEA", "/path/to/file"], - }); - }); - - test("returns correct command for webstorm", () => { - const result = getAppCommand("webstorm", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "WebStorm", "/path/to/file"], - }); - }); - - test("returns correct command for pycharm", () => { + expect(result).toEqual([ + { + command: "open", + args: ["-b", "com.jetbrains.intellij", "/path/to/file"], + }, + { + command: "open", + args: ["-b", "com.jetbrains.intellij.ce", "/path/to/file"], + }, + ]); + }); + + test("returns bundle ID candidates for pycharm (multi-edition)", () => { const result = getAppCommand("pycharm", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "PyCharm", "/path/to/file"], - }); + expect(result).toEqual([ + { + command: "open", + args: ["-b", "com.jetbrains.pycharm", "/path/to/file"], + }, + { + command: "open", + args: ["-b", "com.jetbrains.pycharm.ce", "/path/to/file"], + }, + ]); + }); + + test("returns single-element array for webstorm (single-edition)", () => { + const result = getAppCommand("webstorm", "/path/to/file"); + expect(result).toEqual([ + { command: "open", args: ["-a", "WebStorm", "/path/to/file"] }, + ]); }); - test("returns correct command for goland", () => { + test("returns single-element array for goland (single-edition)", () => { const result = getAppCommand("goland", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "GoLand", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "GoLand", "/path/to/file"] }, + ]); }); - test("returns correct command for rustrover", () => { + test("returns single-element array for rustrover (single-edition)", () => { const result = getAppCommand("rustrover", "/path/to/file"); - expect(result).toEqual({ - command: "open", - args: ["-a", "RustRover", "/path/to/file"], - }); + expect(result).toEqual([ + { command: "open", args: ["-a", "RustRover", "/path/to/file"] }, + ]); }); }); test("preserves paths with spaces", () => { const result = getAppCommand("cursor", "/path/with spaces/file.ts"); - expect(result).toEqual({ - command: "open", - args: ["-a", "Cursor", "/path/with spaces/file.ts"], - }); + expect(result).toEqual([ + { + command: "open", + args: ["-a", "Cursor", "/path/with spaces/file.ts"], + }, + ]); }); }); diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.ts index 6e8a09d5c0a..a887e977de7 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.ts @@ -15,9 +15,9 @@ const APP_NAMES: Record = { terminal: "Terminal", ghostty: "Ghostty", sublime: "Sublime Text", - intellij: "IntelliJ IDEA", + intellij: null, // Multi-edition, uses bundle IDs webstorm: "WebStorm", - pycharm: "PyCharm", + pycharm: null, // Multi-edition, uses bundle IDs phpstorm: "PhpStorm", rubymine: "RubyMine", goland: "GoLand", @@ -30,16 +30,36 @@ const APP_NAMES: Record = { }; /** - * Get the command and args to open a path in the specified app. - * Uses `open -a` for macOS apps to avoid PATH issues in production builds. + * Bundle ID candidates for JetBrains IDEs with multiple editions. + * `open -b ` works regardless of the .app display name, + * so "IntelliJ IDEA Ultimate.app" and "IntelliJ IDEA CE.app" both resolve correctly. + */ +const BUNDLE_ID_CANDIDATES: Partial> = { + intellij: ["com.jetbrains.intellij", "com.jetbrains.intellij.ce"], + pycharm: ["com.jetbrains.pycharm", "com.jetbrains.pycharm.ce"], +}; + +/** + * Get candidate commands to open a path in the specified app. + * Returns an array of commands to try in order — for multi-edition apps (IntelliJ, PyCharm), + * multiple bundle IDs are returned so the caller can fall back if one isn't installed. + * Uses `open -b` (bundle ID) for multi-edition apps and `open -a` (app name) for others. */ export function getAppCommand( app: ExternalApp, targetPath: string, -): { command: string; args: string[] } | null { +): { command: string; args: string[] }[] | null { + const bundleIds = BUNDLE_ID_CANDIDATES[app]; + if (bundleIds) { + return bundleIds.map((id) => ({ + command: "open", + args: ["-b", id, targetPath], + })); + } + const appName = APP_NAMES[app]; if (!appName) return null; - return { command: "open", args: ["-a", appName, targetPath] }; + return [{ command: "open", args: ["-a", appName, targetPath] }]; } /** diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 13d4450e672..459fd5f391e 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -23,10 +23,23 @@ async function openPathInApp( return; } - const cmd = getAppCommand(app, filePath); - if (cmd) { - await spawnAsync(cmd.command, cmd.args); - return; + const candidates = getAppCommand(app, filePath); + if (candidates) { + let lastError: Error | undefined; + for (const cmd of candidates) { + try { + await spawnAsync(cmd.command, cmd.args); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (candidates.length > 1) { + console.warn( + `[external/openInApp] ${cmd.args[1]} not found, trying next candidate`, + ); + } + } + } + throw lastError; } await shell.openPath(filePath);