Skip to content
Source
Sign inSign up

editor.ts

185 lines7.1 KB

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";
6import * as Stream from "effect/Stream";
7import { ChildProcess, ChildProcessSpawner } from "effect/process";
8​
9import * as HostProcess from "./HostProcess.ts";
10import { isCommandAvailable, resolveCommandPath } from "./shell.ts";
11​
12type Editor = (typeof EDITORS)[number];
13​
14const installNames: Partial<Record<Editor["id"], ReadonlyArray<string>>> = {
15 vscode: ["Visual Studio Code"],
16 "vscode-insiders": ["Visual Studio Code - Insiders"],
17 // `Antigravity.app` is the separate Hub, not the IDE.
18 antigravity: ["Antigravity IDE"],
19 idea: ["IntelliJ IDEA", "IntelliJ IDEA CE", "IntelliJ IDEA Ultimate"],
20 pycharm: ["PyCharm", "PyCharm CE"],
21 rider: ["Rider", "JetBrains Rider"],
22};
23​
24// Prefix-only matches could select Insiders when resolving stable VS Code.
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​
30// OpenZFS's event daemon is also named zed; it rejects `--version` instead of starting.
31const isZedEditor = Effect.fn("editor.isZedEditor")(
32 function* (command: string) {
33 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
34 const handle = yield* spawner.spawn(
35 ChildProcess.make(command, ["--version"], {
36 stdin: "ignore",
37 stderr: "ignore",
38 forceKillAfter: "1 second",
39 }),
40 );
41 const [stdout, exitCode] = yield* Effect.all(
42 [handle.stdout.pipe(Stream.decodeText(), Stream.runHead), handle.exitCode],
43 { concurrency: "unbounded" },
44 );
45 return exitCode === 0 && Option.getOrElse(stdout, () => "").startsWith("Zed");
46 },
47 Effect.scoped,
48 Effect.timeout("2 seconds"),
49 Effect.catchCause(() => Effect.succeed(false)),
50);
51​
52export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(function* (
53 editor: Editor,
54 env: NodeJS.ProcessEnv,
55) {
56 if (editor.commands === null) return Option.none();
57 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
58 const platform = yield* HostProcess.Platform;
59 // Prefer Zed's macOS bundle because OpenZFS also installs a zed executable.
60 const preferBundle = platform === "darwin" && editor.id === "zed";
61 const preferZeditor = platform === "linux" && editor.id === "zed";
62 if (!preferBundle && !preferZeditor) {
63 for (const command of editor.commands) {
64 if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs });
65 }
66 }
67​
68 const path = yield* Path.Path;
69 const fs = yield* FileSystem.FileSystem;
70 const home = env.HOME;
71 const names = installNames[editor.id] ?? [editor.label];
72 const command = editor.commands[0];
73 const jetbrains = editor.launchStyle === "line-column";
74 const candidates: string[] = [];
75​
76 if (platform === "darwin") {
77 const roots = [...(home ? [path.join(home, "Applications")] : []), "/Applications"];
78 for (const root of roots) {
79 // JetBrains Toolbox appends versions to bundle names; accept exact and versioned names.
80 const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []));
81 const bundles = new Set(names.map((name) => `${name}.app`));
82 for (const entry of entries) {
83 if (names.some((name) => entry === `${name}.app` || isVersionedBundle(entry, name))) {
84 bundles.add(entry);
85 }
86 }
87 for (const bundle of bundles) {
88 const contents = path.join(root, bundle, "Contents");
89 candidates.push(
90 ...(jetbrains || editor.id === "zed"
91 ? [path.join(contents, "MacOS", editor.id === "zed" ? "cli" : command)]
92 : [
93 path.join(contents, "Resources/app/bin", command),
94 path.join(contents, "Resources/app/bin/code"),
95 ]),
96 );
97 }
98 }
99 if (home && jetbrains) {
100 candidates.push(
101 path.join(home, "Library/Application Support/JetBrains/Toolbox/scripts", command),
102 );
103 }
104 } else if (platform === "win32") {
105 const roots = [
106 ...(env.LOCALAPPDATA ? [path.join(env.LOCALAPPDATA, "Programs")] : []),
107 ...[env.ProgramFiles, env["ProgramFiles(x86)"], env.ProgramW6432].filter(
108 (root): root is string => !!root,
109 ),
110 ];
111 if (jetbrains) {
112 if (env.LOCALAPPDATA) {
113 candidates.push(path.join(env.LOCALAPPDATA, "JetBrains/Toolbox/scripts", `${command}.cmd`));
114 }
115 for (const directory of roots.flatMap((root) => [root, path.join(root, "JetBrains")])) {
116 const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
117 for (const entry of entries) {
118 if (names.some((name) => entry === name || entry.startsWith(`${name} `))) {
119 candidates.push(path.join(directory, entry, "bin", `${command}64.exe`));
120 candidates.push(path.join(directory, entry, "bin", `${command}.exe`));
121 }
122 }
123 }
124 } else {
125 const name =
126 editor.id === "vscode"
127 ? "Microsoft VS Code"
128 : editor.id === "vscode-insiders"
129 ? "Microsoft VS Code Insiders"
130 : editor.label;
131 for (const root of roots) {
132 candidates.push(
133 path.join(root, name, "resources/app/bin", `${command}.cmd`),
134 path.join(root, name, "resources/app/bin/code.cmd"),
135 path.join(root, name, "bin", `${command}.cmd`),
136 path.join(root, name, "bin/code.cmd"),
137 );
138 if (editor.id === "zed") {
139 candidates.push(
140 path.join(root, name, "bin", "zed.exe"),
141 path.join(root, name, "zed.exe"),
142 );
143 }
144 }
145 }
146 } else if (platform === "linux") {
147 const dirs = [
148 ...(home ? [path.join(home, ".local/bin")] : []),
149 "/usr/local/bin",
150 "/usr/bin",
151 "/snap/bin",
152 ];
153 if (jetbrains) {
154 const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local/share") : undefined);
155 if (dataHome) dirs.push(path.join(dataHome, "JetBrains/Toolbox/scripts"));
156 }
157 if (preferZeditor) {
158 // Linux packages use zeditor because OpenZFS owns the zed command.
159 for (const name of ["zeditor", "zed"]) {
160 candidates.push(name, ...dirs.map((dir) => path.join(dir, name)));
161 }
162 } else {
163 for (const dir of dirs) {
164 for (const name of editor.commands) candidates.push(path.join(dir, name));
165 }
166 }
167 }
168​
169 if (preferBundle) candidates.push(...editor.commands);
170 const rejected = new Set<string>();
171 for (const candidate of candidates) {
172 const resolved = yield* resolveCommandPath(candidate, { env });
173 if (resolved === null || rejected.has(resolved)) continue;
174 if (preferZeditor && path.basename(candidate) === "zed" && !(yield* isZedEditor(resolved))) {
175 rejected.add(resolved);
176 continue;
177 }
178 return Option.some({
179 command: candidate,
180 baseArgs:
181 editor.id === "kiro" && (platform === "darwin" || platform === "win32") ? [] : baseArgs,
182 });
183 }
184 return Option.none();
185});