Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 80 additions & 74 deletions apps/desktop/src/lib/trpc/routers/external/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
]);
});
});

Expand Down
32 changes: 26 additions & 6 deletions apps/desktop/src/lib/trpc/routers/external/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ const APP_NAMES: Record<ExternalApp, string | null> = {
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",
Expand All @@ -30,16 +30,36 @@ const APP_NAMES: Record<ExternalApp, string | null> = {
};

/**
* 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 <bundleId>` 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<Record<ExternalApp, string[]>> = {
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] }];
}

/**
Expand Down
21 changes: 17 additions & 4 deletions apps/desktop/src/lib/trpc/routers/external/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading