Skip to content
Closed
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
49 changes: 44 additions & 5 deletions apps/desktop/src/lib/trpc/routers/external/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import os from "node:os";
import path from "node:path";
import { getAppCommand, resolvePath, stripPathWrappers } from "./helpers";
import {
getAppCandidates,
getAppCommand,
resolvePath,
stripPathWrappers,
} from "./helpers";

describe("getAppCommand", () => {
test("returns null for finder (handled specially)", () => {
Expand Down Expand Up @@ -73,11 +78,11 @@ describe("getAppCommand", () => {
});

describe("JetBrains IDEs", () => {
test("returns correct command for intellij", () => {
test("returns first candidate for intellij (Ultimate edition)", () => {
const result = getAppCommand("intellij", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "IntelliJ IDEA", "/path/to/file"],
args: ["-a", "IntelliJ IDEA Ultimate", "/path/to/file"],
});
});

Expand All @@ -89,11 +94,11 @@ describe("getAppCommand", () => {
});
});

test("returns correct command for pycharm", () => {
test("returns first candidate for pycharm (Professional edition)", () => {
const result = getAppCommand("pycharm", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "PyCharm", "/path/to/file"],
args: ["-a", "PyCharm Professional", "/path/to/file"],
});
});

Expand Down Expand Up @@ -123,6 +128,40 @@ describe("getAppCommand", () => {
});
});

describe("getAppCandidates", () => {
test("returns empty array for finder", () => {
expect(getAppCandidates("finder", "/path/to/file")).toEqual([]);
});

test("returns single candidate for single-edition app", () => {
expect(getAppCandidates("webstorm", "/path/to/file")).toEqual([
{ command: "open", args: ["-a", "WebStorm", "/path/to/file"] },
]);
});

test("returns multiple candidates for intellij (edition variants)", () => {
expect(getAppCandidates("intellij", "/path/to/file")).toEqual([
{
command: "open",
args: ["-a", "IntelliJ IDEA Ultimate", "/path/to/file"],
},
{ command: "open", args: ["-a", "IntelliJ IDEA CE", "/path/to/file"] },
{ command: "open", args: ["-a", "IntelliJ IDEA", "/path/to/file"] },
]);
});

test("returns multiple candidates for pycharm (edition variants)", () => {
expect(getAppCandidates("pycharm", "/path/to/file")).toEqual([
{
command: "open",
args: ["-a", "PyCharm Professional", "/path/to/file"],
},
{ command: "open", args: ["-a", "PyCharm CE", "/path/to/file"] },
{ command: "open", args: ["-a", "PyCharm", "/path/to/file"] },
]);
});
});

describe("resolvePath", () => {
const homedir = os.homedir();
const originalHome = process.env.HOME;
Expand Down
36 changes: 29 additions & 7 deletions apps/desktop/src/lib/trpc/routers/external/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { spawn } from "node:child_process";
import nodePath from "node:path";
import { EXTERNAL_APPS, type ExternalApp } from "@superset/local-db";

/** Map of app IDs to their macOS application names */
const APP_NAMES: Record<ExternalApp, string | null> = {
/**
* Map of app IDs to their macOS application names.
* Arrays indicate edition variants to try in order (preferred first).
*/
const APP_NAMES: Record<ExternalApp, string | string[] | null> = {
finder: null, // Handled specially with shell.showItemInFolder
vscode: "Visual Studio Code",
"vscode-insiders": "Visual Studio Code - Insiders",
Expand All @@ -15,9 +18,9 @@ const APP_NAMES: Record<ExternalApp, string | null> = {
terminal: "Terminal",
ghostty: "Ghostty",
sublime: "Sublime Text",
intellij: "IntelliJ IDEA",
intellij: ["IntelliJ IDEA Ultimate", "IntelliJ IDEA CE", "IntelliJ IDEA"],
webstorm: "WebStorm",
pycharm: "PyCharm",
pycharm: ["PyCharm Professional", "PyCharm CE", "PyCharm"],
phpstorm: "PhpStorm",
rubymine: "RubyMine",
goland: "GoLand",
Expand All @@ -29,17 +32,36 @@ const APP_NAMES: Record<ExternalApp, string | null> = {
rustrover: "RustRover",
};

/**
* Get all candidate commands for opening a path in the specified app.
* Returns multiple candidates for apps with edition variants (e.g. JetBrains IDEs).
* Returns empty array for apps handled specially (e.g. Finder).
*/
export function getAppCandidates(
app: ExternalApp,
targetPath: string,
): { command: string; args: string[] }[] {
const appName = APP_NAMES[app];
if (!appName) return [];

const names = Array.isArray(appName) ? appName : [appName];
return names.map((name) => ({
command: "open",
args: ["-a", name, targetPath],
}));
}

/**
* Get the command and args to open a path in the specified app.
* For apps with edition variants, returns the first (preferred) candidate.
* Uses `open -a` for macOS apps to avoid PATH issues in production builds.
*/
export function getAppCommand(
app: ExternalApp,
targetPath: string,
): { command: string; args: string[] } | null {
const appName = APP_NAMES[app];
if (!appName) return null;
return { command: "open", args: ["-a", appName, targetPath] };
const candidates = getAppCandidates(app, targetPath);
return candidates[0] ?? null;
}

/**
Expand Down
29 changes: 24 additions & 5 deletions apps/desktop/src/lib/trpc/routers/external/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { publicProcedure, router } from "../..";
import {
EXTERNAL_APPS,
type ExternalApp,
getAppCommand,
getAppCandidates,
resolvePath,
spawnAsync,
} from "./helpers";
Expand All @@ -23,13 +23,32 @@ async function openPathInApp(
return;
}

const cmd = getAppCommand(app, filePath);
if (cmd) {
await spawnAsync(cmd.command, cmd.args);
const candidates = getAppCandidates(app, filePath);
if (candidates.length === 0) {
await shell.openPath(filePath);
return;
}

await shell.openPath(filePath);
// Try each candidate in order, falling back only when the app is not found.
// Re-throw immediately for other errors (bad path, permissions, etc.)
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));
const msg = lastError.message.toLowerCase();
const isAppNotFound = msg.includes("unable to find application");
if (!isAppNotFound) {
throw lastError;
}
}
}

if (lastError) {
throw lastError;
}
}

/**
Expand Down