| 1 | import type { EDITORS } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | |
| 7 | import * as HostProcess from "./HostProcess.ts"; |
| 8 | import { isCommandAvailable } from "./shell.ts"; |
| 9 | |
| 10 | type Editor = (typeof EDITORS)[number]; |
| 11 | |
| 12 | const installNames: Partial<Record<Editor["id"], ReadonlyArray<string>>> = { |
| 13 | vscode: ["Visual Studio Code"], |
| 14 | "vscode-insiders": ["Visual Studio Code - Insiders"], |
| 15 | // `Antigravity.app` is the separate Hub, not the IDE. |
| 16 | antigravity: ["Antigravity IDE"], |
| 17 | idea: ["IntelliJ IDEA", "IntelliJ IDEA CE", "IntelliJ IDEA Ultimate"], |
| 18 | pycharm: ["PyCharm", "PyCharm CE"], |
| 19 | rider: ["Rider", "JetBrains Rider"], |
| 20 | }; |
| 21 | |
| 22 | /** True for `<name> <version>.app`, where the version is digits and dots (`IntelliJ IDEA 2026.1.4.app`). |
| 23 | * A plain prefix match would let one editor steal another's bundle, e.g. stable VS Code resolving to |
| 24 | * `Visual Studio Code - Insiders.app`. */ |
| 25 | const isVersionedBundle = (entry: string, name: string) => { |
| 26 | if (!entry.startsWith(`${name} `) || !entry.endsWith(".app")) return false; |
| 27 | return /^\d[\d.]*$/.test(entry.slice(name.length + 1, -".app".length)); |
| 28 | }; |
| 29 | |
| 30 | export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(function* ( |
| 31 | editor: Editor, |
| 32 | env: NodeJS.ProcessEnv, |
| 33 | ) { |
| 34 | if (editor.commands === null) return Option.none(); |
| 35 | const baseArgs = "baseArgs" in editor ? editor.baseArgs : []; |
| 36 | const platform = yield* HostProcess.Platform; |
| 37 | // OpenZFS also installs a `zed` executable. Prefer the macOS editor bundle |
| 38 | // so a PATH entry for the ZFS Event Daemon cannot take over editor opens. |
| 39 | const preferBundle = platform === "darwin" && editor.id === "zed"; |
| 40 | if (!preferBundle) { |
| 41 | for (const command of editor.commands) { |
| 42 | if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs }); |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | const path = yield* Path.Path; |
| 47 | const fs = yield* FileSystem.FileSystem; |
| 48 | const home = env.HOME; |
| 49 | const names = installNames[editor.id] ?? [editor.label]; |
| 50 | const command = editor.commands[0]; |
| 51 | const jetbrains = editor.launchStyle === "line-column"; |
| 52 | const candidates: string[] = []; |
| 53 | |
| 54 | if (platform === "darwin") { |
| 55 | const roots = [...(home ? [path.join(home, "Applications")] : []), "/Applications"]; |
| 56 | for (const root of roots) { |
| 57 | // JetBrains Toolbox installs bundles named after the app and its version |
| 58 | // (`IntelliJ IDEA 2026.1.4.app`), so list the directory and accept |
| 59 | // `<name> <version>.app` as well as the exact `<name>.app`. |
| 60 | const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); |
| 61 | const bundles = new Set(names.map((name) => `${name}.app`)); |
| 62 | for (const entry of entries) { |
| 63 | if (names.some((name) => entry === `${name}.app` || isVersionedBundle(entry, name))) { |
| 64 | bundles.add(entry); |
| 65 | } |
| 66 | } |
| 67 | for (const bundle of bundles) { |
| 68 | const contents = path.join(root, bundle, "Contents"); |
| 69 | candidates.push( |
| 70 | ...(jetbrains || editor.id === "zed" |
| 71 | ? [path.join(contents, "MacOS", editor.id === "zed" ? "cli" : command)] |
| 72 | : [ |
| 73 | path.join(contents, "Resources/app/bin", command), |
| 74 | path.join(contents, "Resources/app/bin/code"), |
| 75 | ]), |
| 76 | ); |
| 77 | } |
| 78 | } |
| 79 | if (home && jetbrains) { |
| 80 | candidates.push( |
| 81 | path.join(home, "Library/Application Support/JetBrains/Toolbox/scripts", command), |
| 82 | ); |
| 83 | } |
| 84 | } else if (platform === "win32") { |
| 85 | const roots = [ |
| 86 | ...(env.LOCALAPPDATA ? [path.join(env.LOCALAPPDATA, "Programs")] : []), |
| 87 | ...[env.ProgramFiles, env["ProgramFiles(x86)"], env.ProgramW6432].filter( |
| 88 | (root): root is string => !!root, |
| 89 | ), |
| 90 | ]; |
| 91 | if (jetbrains) { |
| 92 | if (env.LOCALAPPDATA) { |
| 93 | candidates.push(path.join(env.LOCALAPPDATA, "JetBrains/Toolbox/scripts", `${command}.cmd`)); |
| 94 | } |
| 95 | for (const directory of roots.flatMap((root) => [root, path.join(root, "JetBrains")])) { |
| 96 | const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); |
| 97 | for (const entry of entries) { |
| 98 | if (names.some((name) => entry === name || entry.startsWith(`${name} `))) { |
| 99 | candidates.push(path.join(directory, entry, "bin", `${command}64.exe`)); |
| 100 | candidates.push(path.join(directory, entry, "bin", `${command}.exe`)); |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | } else { |
| 105 | const name = |
| 106 | editor.id === "vscode" |
| 107 | ? "Microsoft VS Code" |
| 108 | : editor.id === "vscode-insiders" |
| 109 | ? "Microsoft VS Code Insiders" |
| 110 | : editor.label; |
| 111 | for (const root of roots) { |
| 112 | candidates.push( |
| 113 | path.join(root, name, "resources/app/bin", `${command}.cmd`), |
| 114 | path.join(root, name, "resources/app/bin/code.cmd"), |
| 115 | path.join(root, name, "bin", `${command}.cmd`), |
| 116 | path.join(root, name, "bin/code.cmd"), |
| 117 | ); |
| 118 | if (editor.id === "zed") { |
| 119 | candidates.push( |
| 120 | path.join(root, name, "bin", "zed.exe"), |
| 121 | path.join(root, name, "zed.exe"), |
| 122 | ); |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | } else if (platform === "linux") { |
| 127 | const dirs = [ |
| 128 | ...(home ? [path.join(home, ".local/bin")] : []), |
| 129 | "/usr/local/bin", |
| 130 | "/usr/bin", |
| 131 | "/snap/bin", |
| 132 | ]; |
| 133 | if (jetbrains) { |
| 134 | const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local/share") : undefined); |
| 135 | if (dataHome) dirs.push(path.join(dataHome, "JetBrains/Toolbox/scripts")); |
| 136 | } |
| 137 | for (const dir of dirs) { |
| 138 | for (const name of editor.commands) candidates.push(path.join(dir, name)); |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | if (preferBundle) candidates.push(...editor.commands); |
| 143 | for (const candidate of candidates) { |
| 144 | if (yield* isCommandAvailable(candidate, { env })) { |
| 145 | return Option.some({ |
| 146 | command: candidate, |
| 147 | baseArgs: |
| 148 | editor.id === "kiro" && (platform === "darwin" || platform === "win32") ? [] : baseArgs, |
| 149 | }); |
| 150 | } |
| 151 | } |
| 152 | return Option.none(); |
| 153 | }); |