diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts new file mode 100644 index 00000000000..c7d96550b61 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts @@ -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")); + }); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.ts new file mode 100644 index 00000000000..9be94a7ee2d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.ts @@ -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 = { + 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 { + 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 }; diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 80c2afca7a4..0551c57d7df 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -1,107 +1,38 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; import { clipboard, shell } from "electron"; import { db } from "main/lib/db"; -import { EXTERNAL_APPS, type ExternalApp } from "main/lib/db/schemas"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + EXTERNAL_APPS, + type ExternalApp, + getAppCommand, + resolvePath, + spawnAsync, +} from "./helpers"; const ExternalAppSchema = z.enum(EXTERNAL_APPS); -/** - * 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. - */ -const getAppCommand = ( +async function openPathInApp( + filePath: string, app: ExternalApp, - targetPath: string, -): { command: string; args: string[] } | null => { - switch (app) { - case "finder": - return null; // Handled specially with shell.showItemInFolder - case "vscode": - return { - command: "open", - args: ["-a", "Visual Studio Code", targetPath], - }; - case "cursor": - return { command: "open", args: ["-a", "Cursor", targetPath] }; - case "xcode": - return { command: "open", args: ["-a", "Xcode", targetPath] }; - case "iterm": - return { command: "open", args: ["-a", "iTerm", targetPath] }; - case "warp": - return { command: "open", args: ["-a", "Warp", targetPath] }; - case "terminal": - return { command: "open", args: ["-a", "Terminal", targetPath] }; - case "sublime": - return { command: "open", args: ["-a", "Sublime Text", targetPath] }; - // JetBrains IDEs - case "intellij": - return { command: "open", args: ["-a", "IntelliJ IDEA", targetPath] }; - case "webstorm": - return { command: "open", args: ["-a", "WebStorm", targetPath] }; - case "pycharm": - return { command: "open", args: ["-a", "PyCharm", targetPath] }; - case "phpstorm": - return { command: "open", args: ["-a", "PhpStorm", targetPath] }; - case "rubymine": - return { command: "open", args: ["-a", "RubyMine", targetPath] }; - case "goland": - return { command: "open", args: ["-a", "GoLand", targetPath] }; - case "clion": - return { command: "open", args: ["-a", "CLion", targetPath] }; - case "rider": - return { command: "open", args: ["-a", "Rider", targetPath] }; - case "datagrip": - return { command: "open", args: ["-a", "DataGrip", targetPath] }; - case "appcode": - return { command: "open", args: ["-a", "AppCode", targetPath] }; - case "fleet": - return { command: "open", args: ["-a", "Fleet", targetPath] }; - case "rustrover": - return { command: "open", args: ["-a", "RustRover", targetPath] }; - default: - return null; +): Promise { + if (app === "finder") { + shell.showItemInFolder(filePath); + return; } -}; -/** - * Spawns a process and waits for it to complete - * @throws Error if the process exits with non-zero code or fails to spawn - */ -const spawnAsync = (command: string, args: string[]): Promise => { - 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.`, - ), - ); - }); + const cmd = getAppCommand(app, filePath); + if (cmd) { + await spawnAsync(cmd.command, cmd.args); + return; + } - child.on("exit", (code) => { - if (code === 0) { - resolve(); - } else { - reject( - new Error( - `'${command}' exited with code ${code}. The application may not be installed.`, - ), - ); - } - }); - }); -}; + await shell.openPath(filePath); +} /** - * External operations router - * Handles opening URLs and files in external applications + * External operations router. + * Handles opening URLs and files in external applications. */ export const createExternalRouter = () => { return router({ @@ -123,22 +54,10 @@ export const createExternalRouter = () => { }), ) .mutation(async ({ input }) => { - // Save last used app to DB await db.update((data) => { data.settings.lastUsedApp = input.app; }); - - if (input.app === "finder") { - shell.showItemInFolder(input.path); - return; - } - - const cmd = getAppCommand(input.app, input.path); - if (!cmd) { - throw new Error(`Unknown app: ${input.app}`); - } - - await spawnAsync(cmd.command, cmd.args); + await openPathInApp(input.path, input.app); }), copyPath: publicProcedure.input(z.string()).mutation(async ({ input }) => { @@ -155,48 +74,9 @@ export const createExternalRouter = () => { }), ) .mutation(async ({ input }) => { - let filePath = input.path; - - // Expand home directory - needed because editors expect absolute paths - if (filePath.startsWith("~")) { - const home = process.env.HOME || process.env.USERPROFILE; - if (home) { - filePath = filePath.replace(/^~/, home); - } - } - - // Convert to absolute path - required for editor commands to work reliably - if (!path.isAbsolute(filePath)) { - filePath = input.cwd - ? path.resolve(input.cwd, filePath) - : path.resolve(filePath); - } - - // Build the file location string (file:line:column format for URL schemes) - let location = filePath; - if (input.line) { - location += `:${input.line}`; - if (input.column) { - location += `:${input.column}`; - } - } - - // Try editor URL schemes - these work reliably without PATH issues - // Format: cursor://file/path:line:column or vscode://file/path:line:column - const editorSchemes = ["cursor", "vscode"]; - - for (const scheme of editorSchemes) { - try { - const url = `${scheme}://file${location}`; - await shell.openExternal(url); - return; - } catch { - // Editor not installed or URL scheme not registered, try next - } - } - - // Fall back to system default - await shell.openPath(filePath); + const filePath = resolvePath(input.path, input.cwd); + const app = db.data.settings.lastUsedApp ?? "cursor"; + await openPathInApp(filePath, app); }), }); };