From f59fa0d636052f1c8b3783b9ba5cb12c25df7763 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 19 Dec 2025 17:33:08 -0800 Subject: [PATCH 1/2] fix(desktop): use user's preferred app for terminal click-to-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal file path clicks now open files in the user's preferred app (from OpenInButton selection) instead of hardcoding Cursor/VS Code. Refactored external router to eliminate duplication: - Extract helpers.ts with pure functions (getAppCommand, resolvePath) - Share openPathInApp logic between openInApp and openFileInEditor - Replace switch statement with map lookup - Add 24 tests for helper functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/trpc/routers/external/helpers.test.ts | 187 ++++++++++++++++++ .../src/lib/trpc/routers/external/helpers.ts | 100 ++++++++++ .../src/lib/trpc/routers/external/index.ts | 180 ++++------------- 3 files changed, 322 insertions(+), 145 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/external/helpers.test.ts create mode 100644 apps/desktop/src/lib/trpc/routers/external/helpers.ts 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..6a8fb732c7b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.ts @@ -0,0 +1,100 @@ +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; + + // Expand home directory + if (resolved.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE; + if (home) { + resolved = resolved.replace(/^~/, home); + } + } + + // Convert to absolute path + 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.`, + ), + ); + } + }); + }); +} + +/** All supported external apps (re-exported for convenience) */ +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..71e2b4b7d02 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -1,107 +1,39 @@ -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. + * Open a path in the specified app. + * Handles finder specially and falls back to system default if app command not found. */ -const getAppCommand = ( - 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; +async function openPathInApp(filePath: string, app: ExternalApp): 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({ @@ -115,6 +47,10 @@ export const createExternalRouter = () => { shell.showItemInFolder(input); }), + /** + * Open a path in a specific app and save it as the user's preference. + * Used by OpenInButton when user explicitly selects an app. + */ openInApp: publicProcedure .input( z.object({ @@ -123,28 +59,21 @@ 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 }) => { clipboard.writeText(input); }), + /** + * Open a file in the user's preferred editor. + * Resolves relative paths and ~ before opening. + * Used by terminal click-to-open. + */ openFileInEditor: publicProcedure .input( z.object({ @@ -155,48 +84,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); }), }); }; From 5520d15c58449e84dbab25ae02c3921b121ab1d8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 19 Dec 2025 17:39:04 -0800 Subject: [PATCH 2/2] remove slop --- .../src/lib/trpc/routers/external/helpers.ts | 3 -- .../src/lib/trpc/routers/external/index.ts | 18 +++------- apps/desktop/src/main/lib/agent-setup.ts | 34 ------------------- 3 files changed, 4 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.ts index 6a8fb732c7b..9be94a7ee2d 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.ts @@ -45,7 +45,6 @@ export function getAppCommand( export function resolvePath(filePath: string, cwd?: string): string { let resolved = filePath; - // Expand home directory if (resolved.startsWith("~")) { const home = process.env.HOME || process.env.USERPROFILE; if (home) { @@ -53,7 +52,6 @@ export function resolvePath(filePath: string, cwd?: string): string { } } - // Convert to absolute path if (!nodePath.isAbsolute(resolved)) { resolved = cwd ? nodePath.resolve(cwd, resolved) @@ -96,5 +94,4 @@ export function spawnAsync(command: string, args: string[]): Promise { }); } -/** All supported external apps (re-exported for convenience) */ 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 71e2b4b7d02..0551c57d7df 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -12,11 +12,10 @@ import { const ExternalAppSchema = z.enum(EXTERNAL_APPS); -/** - * Open a path in the specified app. - * Handles finder specially and falls back to system default if app command not found. - */ -async function openPathInApp(filePath: string, app: ExternalApp): Promise { +async function openPathInApp( + filePath: string, + app: ExternalApp, +): Promise { if (app === "finder") { shell.showItemInFolder(filePath); return; @@ -47,10 +46,6 @@ export const createExternalRouter = () => { shell.showItemInFolder(input); }), - /** - * Open a path in a specific app and save it as the user's preference. - * Used by OpenInButton when user explicitly selects an app. - */ openInApp: publicProcedure .input( z.object({ @@ -69,11 +64,6 @@ export const createExternalRouter = () => { clipboard.writeText(input); }), - /** - * Open a file in the user's preferred editor. - * Resolves relative paths and ~ before opening. - * Used by terminal click-to-open. - */ openFileInEditor: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/main/lib/agent-setup.ts b/apps/desktop/src/main/lib/agent-setup.ts index 2e22ec83e40..db94f0a0797 100644 --- a/apps/desktop/src/main/lib/agent-setup.ts +++ b/apps/desktop/src/main/lib/agent-setup.ts @@ -17,7 +17,6 @@ const BASH_DIR = path.join(SUPERSET_HOME_DIR, "bash"); */ function findRealBinary(name: string): string | null { try { - // Get all paths, filter out both dev and prod superset bin dirs const result = execSync(`which -a ${name} 2>/dev/null || true`, { encoding: "utf-8", }); @@ -36,9 +35,6 @@ function findRealBinary(name: string): string | null { } } -/** - * Creates the notify.sh script - */ function createNotifyScript(): void { const notifyPath = path.join(HOOKS_DIR, "notify.sh"); const script = `#!/bin/bash @@ -78,9 +74,6 @@ curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${PORTS.NOTIFICATIONS}}/hook/comple fs.writeFileSync(notifyPath, script, { mode: 0o755 }); } -/** - * Creates wrapper script for Claude Code - */ function createClaudeWrapper(): void { const wrapperPath = path.join(BIN_DIR, "claude"); const realClaude = findRealBinary("claude"); @@ -105,9 +98,6 @@ exec "${realClaude}" --settings "$SUPERSET_CLAUDE_SETTINGS" "$@" console.log(`[agent-setup] Created Claude wrapper -> ${realClaude}`); } -/** - * Creates wrapper script for Codex - */ function createCodexWrapper(): void { const wrapperPath = path.join(BIN_DIR, "codex"); const realCodex = findRealBinary("codex"); @@ -155,10 +145,6 @@ export PATH="$HOME/${SUPERSET_DIR_NAME}/bin:$PATH" console.log("[agent-setup] Created zsh wrapper"); } -/** - * Creates bash initialization wrapper that intercepts shell startup - * Sources user's real bashrc/profile then prepends our bin to PATH - */ function createBashWrapper(): void { const rcfilePath = path.join(BASH_DIR, "rcfile"); const script = `# Superset bash rcfile wrapper @@ -187,34 +173,23 @@ export PS1=$'\\[\\e[1;38;2;52;211;153m\\]❯\\[\\e[0m\\] ' console.log("[agent-setup] Created bash wrapper"); } -/** - * Sets up the ~/.superset directory structure and agent wrappers - * Called on app startup - */ export function setupAgentHooks(): void { console.log("[agent-setup] Initializing agent hooks..."); - // Create directories fs.mkdirSync(BIN_DIR, { recursive: true }); fs.mkdirSync(HOOKS_DIR, { recursive: true }); fs.mkdirSync(ZSH_DIR, { recursive: true }); fs.mkdirSync(BASH_DIR, { recursive: true }); - // Create scripts createNotifyScript(); createClaudeWrapper(); createCodexWrapper(); - - // Create shell initialization wrappers createZshWrapper(); createBashWrapper(); console.log("[agent-setup] Agent hooks initialized"); } -/** - * Returns shell-specific environment variables for intercepting shell initialization - */ export function getShellEnv(shell: string): Record { if (shell.includes("zsh")) { return { @@ -226,25 +201,16 @@ export function getShellEnv(shell: string): Record { return {}; } -/** - * Returns shell-specific arguments for intercepting shell initialization - */ export function getShellArgs(shell: string): string[] { if (shell.includes("zsh")) { - // Zsh uses ZDOTDIR env var, no special args needed - // -l for login shell behavior return ["-l"]; } if (shell.includes("bash")) { - // Use our custom rcfile that sources user's files then fixes PATH return ["--rcfile", path.join(BASH_DIR, "rcfile")]; } return []; } -/** - * Returns the bin directory path - */ export function getSupersetBinDir(): string { return BIN_DIR; }