From 76bbd127fa39204aca6b363b8cdfce8c6325170a Mon Sep 17 00:00:00 2001 From: Sayf Fadil <313136901+Sypher760-gif@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:03:02 -0500 Subject: [PATCH] fix(shared): find versioned JetBrains macOS app bundles --- .../src/process/externalLauncher.test.ts | 130 ++++++++++++++++++ packages/shared/src/editor.ts | 22 ++- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 2c16c51de280..197ab3bf3b06 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1041,6 +1041,136 @@ for (const { platform, installPath, editor, args } of [ ); } +// JetBrains Toolbox names its bundles after the app and the installed version +// (`IntelliJ IDEA 2026.1.4.app`), so the darwin branch has to list the +// Applications directories the way the win32 branch does instead of probing +// exact `.app` paths. The fixtures only use the mocked platform and a +// temp directory, so this runs on a Windows host too. +it.effect.each([ + { editor: "webstorm", installPath: "Applications/WebStorm 2026.2.app/Contents/MacOS/webstorm" }, + { editor: "idea", installPath: "Applications/IntelliJ IDEA 2026.1.4.app/Contents/MacOS/idea" }, +] as const)( + "discovers and launches $editor from a versioned macOS bundle", + ({ editor, installPath }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-versioned-" }); + const executable = path.join(home, installPath); + yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + assert.include(yield* launcher.resolveAvailableEditors(), editor); + yield* launcher.launchEditor({ editor, cwd: "/workspace with spaces/file.ts:12:4" }); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { HOME: home, PATH: path.join(home, "empty") }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, executable); + assert.deepEqual(spawned.args, [ + "--line", + "12", + "--column", + "4", + "/workspace with spaces/file.ts", + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// An app whose name merely starts with the same letters is a different IDE, and +// an exact `.app` bundle keeps resolving the way it did before. +it.effect("matches macOS bundles on the name boundary only", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const unrelatedHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-unrelated-" }); + const unrelated = path.join( + unrelatedHome, + "Applications/WebStormHelper.app/Contents/MacOS/webstorm", + ); + yield* fs.makeDirectory(path.dirname(unrelated), { recursive: true }); + yield* fs.writeFileString(unrelated, "#!/bin/sh\n"); + yield* fs.chmod(unrelated, 0o755); + + const editorsFor = (home: string) => + Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ platform: "darwin", env: { HOME: home, PATH: path.join(home, "bin") } }), + ), + ); + + assert.notInclude(yield* editorsFor(unrelatedHome), "webstorm"); + + const exactHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-exact-" }); + const exact = path.join(exactHome, "Applications/WebStorm.app/Contents/MacOS/webstorm"); + yield* fs.makeDirectory(path.dirname(exact), { recursive: true }); + yield* fs.writeFileString(exact, "#!/bin/sh\n"); + yield* fs.chmod(exact, 0o755); + + assert.include(yield* editorsFor(exactHome), "webstorm"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// `Visual Studio Code - Insiders.app` starts with the stable VS Code install +// name, so a plain prefix match let the stable editor resolve to the Insiders +// bundle. Insiders has to stay reachable under its own name, stable VS Code has +// to stay unresolved. +it.effect("keeps the macOS VS Code Insiders bundle out of stable VS Code", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-insiders-" }); + // The real Insiders bundle ships both `code-insiders` and `code`. + const bin = path.join( + home, + "Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin", + ); + yield* fs.makeDirectory(bin, { recursive: true }); + for (const name of ["code", "code-insiders"]) { + yield* fs.writeFileString(path.join(bin, name), "#!/bin/sh\n"); + yield* fs.chmod(path.join(bin, name), 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const available = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ editor: "vscode-insiders", cwd: "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/workspace/file.ts" }); + return available; + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { HOME: home, PATH: path.join(home, "empty") }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.notInclude(editors, "vscode"); + assert.include(editors, "vscode-insiders"); + assert.equal(spawned?.command, path.join(bin, "code-insiders")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + // `agy` is the standalone Antigravity CLI, which installs to ~/.local/bin on // macOS and Linux and to its own bin folder on Windows. It is not the IDE. for (const { platform, installPath, onPath } of [ diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index 1584f3a201d9..b996b6d12c14 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -19,6 +19,14 @@ const installNames: Partial>> = { rider: ["Rider", "JetBrains Rider"], }; +/** True for ` .app`, where the version is digits and dots (`IntelliJ IDEA 2026.1.4.app`). + * A plain prefix match would let one editor steal another's bundle, e.g. stable VS Code resolving to + * `Visual Studio Code - Insiders.app`. */ +const isVersionedBundle = (entry: string, name: string) => { + if (!entry.startsWith(`${name} `) || !entry.endsWith(".app")) return false; + return /^\d[\d.]*$/.test(entry.slice(name.length + 1, -".app".length)); +}; + export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(function* ( editor: Editor, env: NodeJS.ProcessEnv, @@ -41,8 +49,18 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun if (platform === "darwin") { const roots = [...(home ? [path.join(home, "Applications")] : []), "/Applications"]; for (const root of roots) { - for (const name of names) { - const contents = path.join(root, `${name}.app`, "Contents"); + // JetBrains Toolbox installs bundles named after the app and its version + // (`IntelliJ IDEA 2026.1.4.app`), so list the directory and accept + // ` .app` as well as the exact `.app`. + const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); + const bundles = new Set(names.map((name) => `${name}.app`)); + for (const entry of entries) { + if (names.some((name) => entry === `${name}.app` || isVersionedBundle(entry, name))) { + bundles.add(entry); + } + } + for (const bundle of bundles) { + const contents = path.join(root, bundle, "Contents"); candidates.push( ...(jetbrains || editor.id === "zed" ? [path.join(contents, "MacOS", editor.id === "zed" ? "cli" : command)]