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

describe("getAppCommand", () => {
test("returns null for finder (handled specially)", () => {
expect(getAppCommand("finder", "/path/to/file")).toBeNull();
});

test("returns correct command for cursor", () => {
const result = getAppCommand("cursor", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "Cursor", "/path/to/file"],
});
});

test("returns correct command for vscode", () => {
const result = getAppCommand("vscode", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "Visual Studio Code", "/path/to/file"],
});
});

test("returns correct command for sublime", () => {
const result = getAppCommand("sublime", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "Sublime Text", "/path/to/file"],
});
});

test("returns correct command for xcode", () => {
const result = getAppCommand("xcode", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "Xcode", "/path/to/file"],
});
});

test("returns correct command for iterm", () => {
const result = getAppCommand("iterm", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "iTerm", "/path/to/file"],
});
});

test("returns correct command for warp", () => {
const result = getAppCommand("warp", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "Warp", "/path/to/file"],
});
});

test("returns correct command for terminal", () => {
const result = getAppCommand("terminal", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "Terminal", "/path/to/file"],
});
});

describe("JetBrains IDEs", () => {
test("returns correct command for intellij", () => {
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", () => {
const result = getAppCommand("pycharm", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "PyCharm", "/path/to/file"],
});
});

test("returns correct command for goland", () => {
const result = getAppCommand("goland", "/path/to/file");
expect(result).toEqual({
command: "open",
args: ["-a", "GoLand", "/path/to/file"],
});
});

test("returns correct command for rustrover", () => {
const result = getAppCommand("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"],
});
});
});

describe("resolvePath", () => {
const homedir = os.homedir();
const originalHome = process.env.HOME;

beforeEach(() => {
process.env.HOME = homedir;
});

afterEach(() => {
process.env.HOME = originalHome;
});

describe("home directory expansion", () => {
test("expands ~ to home directory", () => {
const result = resolvePath("~/Documents/file.ts");
expect(result).toBe(path.join(homedir, "Documents/file.ts"));
});

test("expands ~ alone to home directory", () => {
const result = resolvePath("~");
expect(result).toBe(homedir);
});

test("does not expand ~ in middle of path", () => {
const result = resolvePath("/path/~/file.ts");
expect(result).toBe("/path/~/file.ts");
});
});

describe("absolute paths", () => {
test("returns absolute path unchanged", () => {
const result = resolvePath("/absolute/path/file.ts");
expect(result).toBe("/absolute/path/file.ts");
});

test("returns absolute path unchanged even with cwd", () => {
const result = resolvePath("/absolute/path/file.ts", "/some/cwd");
expect(result).toBe("/absolute/path/file.ts");
});
});

describe("relative paths", () => {
test("resolves relative path against cwd", () => {
const result = resolvePath("src/file.ts", "/project");
expect(result).toBe("/project/src/file.ts");
});

test("resolves ./prefixed path against cwd", () => {
const result = resolvePath("./src/file.ts", "/project");
expect(result).toBe("/project/src/file.ts");
});

test("resolves ../prefixed path against cwd", () => {
const result = resolvePath("../sibling/file.ts", "/project/subdir");
expect(result).toBe("/project/sibling/file.ts");
});

test("resolves relative path against process.cwd() when no cwd provided", () => {
const result = resolvePath("file.ts");
expect(result).toBe(path.resolve("file.ts"));
});
});

describe("combined expansion", () => {
test("expands ~ then resolves (already absolute after expansion)", () => {
const result = resolvePath("~/file.ts", "/ignored/cwd");
expect(result).toBe(path.join(homedir, "file.ts"));
});
});
});
97 changes: 97 additions & 0 deletions apps/desktop/src/lib/trpc/routers/external/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { spawn } from "node:child_process";
import nodePath from "node:path";
import { EXTERNAL_APPS, type ExternalApp } from "main/lib/db/schemas";

/** Map of app IDs to their macOS application names */
const APP_NAMES: Record<ExternalApp, string | null> = {
finder: null, // Handled specially with shell.showItemInFolder
vscode: "Visual Studio Code",
cursor: "Cursor",
xcode: "Xcode",
iterm: "iTerm",
warp: "Warp",
terminal: "Terminal",
sublime: "Sublime Text",
intellij: "IntelliJ IDEA",
webstorm: "WebStorm",
pycharm: "PyCharm",
phpstorm: "PhpStorm",
rubymine: "RubyMine",
goland: "GoLand",
clion: "CLion",
rider: "Rider",
datagrip: "DataGrip",
appcode: "AppCode",
fleet: "Fleet",
rustrover: "RustRover",
};

/**
* 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.
*/
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] };
}

/**
* Resolve a path by expanding ~ and converting relative paths to absolute.
*/
export function resolvePath(filePath: string, cwd?: string): string {
let resolved = filePath;

if (resolved.startsWith("~")) {
const home = process.env.HOME || process.env.USERPROFILE;
if (home) {
resolved = resolved.replace(/^~/, home);
}
}

if (!nodePath.isAbsolute(resolved)) {
resolved = cwd
? nodePath.resolve(cwd, resolved)
: nodePath.resolve(resolved);
}

return resolved;
}

/**
* Spawns a process and waits for it to complete.
* @throws Error if the process exits with non-zero code or fails to spawn
*/
export function spawnAsync(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: "ignore",
detached: false,
});

child.on("error", (error) => {
reject(
new Error(
`Failed to spawn '${command}': ${error.message}. Ensure the application is installed.`,
),
);
});

child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(
`'${command}' exited with code ${code}. The application may not be installed.`,
),
);
}
});
});
}

export { EXTERNAL_APPS, type ExternalApp };
Loading