authorgravatar for sayffadil@gmail.comSypher760-gif <sayffadil@gmail.com> 2026-10-06 04:34:43-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 04:34:43-05:00
loge6abc01788eef557c214a086999b3fd635b69f71
treeedb0f7547c38af456530d32d817dbc5dfa11e329
parent4fb04fe7fae566cc91ab810888b3232f274a00cf
signature Signed by PGP key B5690EEEBB952194

fix(shared): find versioned JetBrains macOS app bundles (#16246)


2 files changed, 150 insertions(+), 2 deletions(-)

apps/server/src/process/externalLauncher.test.ts+130
......@@ -1041,6 +1041,136 @@ for (const { platform, installPath, editor, args } of [
10411041 );
10421042}
10431043
1044// JetBrains Toolbox names its bundles after the app and the installed version
1045// (`IntelliJ IDEA 2026.1.4.app`), so the darwin branch has to list the
1046// Applications directories the way the win32 branch does instead of probing
1047// exact `<name>.app` paths. The fixtures only use the mocked platform and a
1048// temp directory, so this runs on a Windows host too.
1049it.effect.each([
1050 { editor: "webstorm", installPath: "Applications/WebStorm 2026.2.app/Contents/MacOS/webstorm" },
1051 { editor: "idea", installPath: "Applications/IntelliJ IDEA 2026.1.4.app/Contents/MacOS/idea" },
1052] as const)(
1053 "discovers and launches $editor from a versioned macOS bundle",
1054 ({ editor, installPath }) =>
1055 Effect.gen(function* () {
1056 const fs = yield* FileSystem.FileSystem;
1057 const path = yield* Path.Path;
1058 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-versioned-" });
1059 const executable = path.join(home, installPath);
1060 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
1061 yield* fs.writeFileString(executable, "#!/bin/sh\n");
1062 yield* fs.chmod(executable, 0o755);
1063
1064 let spawned: ChildProcess.StandardCommand | undefined;
1065 yield* Effect.gen(function* () {
1066 const launcher = yield* ExternalLauncher.ExternalLauncher;
1067 assert.include(yield* launcher.resolveAvailableEditors(), editor);
1068 yield* launcher.launchEditor({ editor, cwd: "/workspace with spaces/file.ts:12:4" });
1069 }).pipe(
1070 Effect.provide(
1071 testLayer({
1072 platform: "darwin",
1073 env: { HOME: home, PATH: path.join(home, "empty") },
1074 onSpawn: (command) => {
1075 spawned = command;
1076 },
1077 }),
1078 ),
1079 );
1080
1081 assert.ok(spawned);
1082 assert.equal(spawned.command, executable);
1083 assert.deepEqual(spawned.args, [
1084 "--line",
1085 "12",
1086 "--column",
1087 "4",
1088 "/workspace with spaces/file.ts",
1089 ]);
1090 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1091);
1092
1093// An app whose name merely starts with the same letters is a different IDE, and
1094// an exact `<name>.app` bundle keeps resolving the way it did before.
1095it.effect("matches macOS bundles on the name boundary only", () =>
1096 Effect.gen(function* () {
1097 const fs = yield* FileSystem.FileSystem;
1098 const path = yield* Path.Path;
1099 const unrelatedHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-unrelated-" });
1100 const unrelated = path.join(
1101 unrelatedHome,
1102 "Applications/WebStormHelper.app/Contents/MacOS/webstorm",
1103 );
1104 yield* fs.makeDirectory(path.dirname(unrelated), { recursive: true });
1105 yield* fs.writeFileString(unrelated, "#!/bin/sh\n");
1106 yield* fs.chmod(unrelated, 0o755);
1107
1108 const editorsFor = (home: string) =>
1109 Effect.gen(function* () {
1110 const launcher = yield* ExternalLauncher.ExternalLauncher;
1111 return yield* launcher.resolveAvailableEditors();
1112 }).pipe(
1113 Effect.provide(
1114 testLayer({ platform: "darwin", env: { HOME: home, PATH: path.join(home, "bin") } }),
1115 ),
1116 );
1117
1118 assert.notInclude(yield* editorsFor(unrelatedHome), "webstorm");
1119
1120 const exactHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-exact-" });
1121 const exact = path.join(exactHome, "Applications/WebStorm.app/Contents/MacOS/webstorm");
1122 yield* fs.makeDirectory(path.dirname(exact), { recursive: true });
1123 yield* fs.writeFileString(exact, "#!/bin/sh\n");
1124 yield* fs.chmod(exact, 0o755);
1125
1126 assert.include(yield* editorsFor(exactHome), "webstorm");
1127 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1128);
1129
1130// `Visual Studio Code - Insiders.app` starts with the stable VS Code install
1131// name, so a plain prefix match let the stable editor resolve to the Insiders
1132// bundle. Insiders has to stay reachable under its own name, stable VS Code has
1133// to stay unresolved.
1134it.effect("keeps the macOS VS Code Insiders bundle out of stable VS Code", () =>
1135 Effect.gen(function* () {
1136 const fs = yield* FileSystem.FileSystem;
1137 const path = yield* Path.Path;
1138 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-insiders-" });
1139 // The real Insiders bundle ships both `code-insiders` and `code`.
1140 const bin = path.join(
1141 home,
1142 "Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin",
1143 );
1144 yield* fs.makeDirectory(bin, { recursive: true });
1145 for (const name of ["code", "code-insiders"]) {
1146 yield* fs.writeFileString(path.join(bin, name), "#!/bin/sh\n");
1147 yield* fs.chmod(path.join(bin, name), 0o755);
1148 }
1149
1150 let spawned: ChildProcess.StandardCommand | undefined;
1151 const editors = yield* Effect.gen(function* () {
1152 const launcher = yield* ExternalLauncher.ExternalLauncher;
1153 const available = yield* launcher.resolveAvailableEditors();
1154 yield* launcher.launchEditor({ editor: "vscode-insiders", cwd: "/workspace/file.ts" });
1155 return available;
1156 }).pipe(
1157 Effect.provide(
1158 testLayer({
1159 platform: "darwin",
1160 env: { HOME: home, PATH: path.join(home, "empty") },
1161 onSpawn: (command) => {
1162 spawned = command;
1163 },
1164 }),
1165 ),
1166 );
1167
1168 assert.notInclude(editors, "vscode");
1169 assert.include(editors, "vscode-insiders");
1170 assert.equal(spawned?.command, path.join(bin, "code-insiders"));
1171 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1172);
1173
10441174// `agy` is the standalone Antigravity CLI, which installs to ~/.local/bin on
10451175// macOS and Linux and to its own bin folder on Windows. It is not the IDE.
10461176for (const { platform, installPath, onPath } of [
packages/shared/src/editor.ts+20-2
......@@ -19,6 +19,14 @@ const installNames: Partial<Record<Editor["id"], ReadonlyArray<string>>> = {
1919 rider: ["Rider", "JetBrains Rider"],
2020};
2121
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
2230export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(function* (
2331 editor: Editor,
2432 env: NodeJS.ProcessEnv,
......@@ -41,8 +49,18 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun
4149 if (platform === "darwin") {
4250 const roots = [...(home ? [path.join(home, "Applications")] : []), "/Applications"];
4351 for (const root of roots) {
44 for (const name of names) {
45 const contents = path.join(root, `${name}.app`, "Contents");
52 // JetBrains Toolbox installs bundles named after the app and its version
53 // (`IntelliJ IDEA 2026.1.4.app`), so list the directory and accept
54 // `<name> <version>.app` as well as the exact `<name>.app`.
55 const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []));
56 const bundles = new Set(names.map((name) => `${name}.app`));
57 for (const entry of entries) {
58 if (names.some((name) => entry === `${name}.app` || isVersionedBundle(entry, name))) {
59 bundles.add(entry);
60 }
61 }
62 for (const bundle of bundles) {
63 const contents = path.join(root, bundle, "Contents");
4664 candidates.push(
4765 ...(jetbrains || editor.id === "zed"
4866 ? [path.join(contents, "MacOS", editor.id === "zed" ? "cli" : command)]