1import type { EDITORS } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Path from "effect/Path";
6
7import * as HostProcess from "./HostProcess.ts";
8import { isCommandAvailable } from "./shell.ts";
9
10type Editor = (typeof EDITORS)[number];
11
12const 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`. */
25const 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
30export 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});