Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>.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 `<name>.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: "/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 [
Expand Down
22 changes: 20 additions & 2 deletions packages/shared/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const installNames: Partial<Record<Editor["id"], ReadonlyArray<string>>> = {
rider: ["Rider", "JetBrains Rider"],
};

/** True for `<name> <version>.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,
Expand All @@ -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
// `<name> <version>.app` as well as the exact `<name>.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)]
Expand Down
Loading