diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 76b14c8597..59b0239c96 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -16,6 +16,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const antigravityLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "antigravity" }, "darwin", + { PATH: "" }, ); assert.deepEqual(antigravityLaunch, { command: "agy", @@ -25,6 +26,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const cursorLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "cursor" }, "darwin", + { PATH: "" }, ); assert.deepEqual(cursorLaunch, { command: "cursor", @@ -43,6 +45,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const vscodeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "vscode" }, "darwin", + { PATH: "" }, ); assert.deepEqual(vscodeLaunch, { command: "code", @@ -70,6 +73,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const zedLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "zed" }, "darwin", + { PATH: "" }, ); assert.deepEqual(zedLaunch, { command: "zed", @@ -92,6 +96,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const lineOnly = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, "darwin", + { PATH: "" }, ); assert.deepEqual(lineOnly, { command: "cursor", @@ -101,6 +106,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const lineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" }, "darwin", + { PATH: "" }, ); assert.deepEqual(lineAndColumn, { command: "cursor", @@ -119,6 +125,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const vscodeLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, "darwin", + { PATH: "" }, ); assert.deepEqual(vscodeLineAndColumn, { command: "code", @@ -146,6 +153,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const zedLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, "darwin", + { PATH: "" }, ); assert.deepEqual(zedLineAndColumn, { command: "zed", @@ -155,6 +163,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const zedLineOnly = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, "darwin", + { PATH: "" }, ); assert.deepEqual(zedLineOnly, { command: "zed", @@ -181,11 +190,43 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }), ); + it.effect("falls back to zeditor when zed is not installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); + yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(path.join(dir, "zeditor"), 0o755); + + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { + PATH: dir, + }); + + assert.deepEqual(result, { + command: "zeditor", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("falls back to the primary command when no alias is installed", () => + Effect.gen(function* () { + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { + PATH: "", + }); + assert.deepEqual(result, { + command: "zed", + args: ["/tmp/workspace"], + }); + }), + ); + it.effect("maps file-manager editor to OS open commands", () => Effect.gen(function* () { const launch1 = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "file-manager" }, "darwin", + { PATH: "" }, ); assert.deepEqual(launch1, { command: "open", @@ -195,6 +236,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const launch2 = yield* resolveEditorLaunch( { cwd: "C:\\workspace", editor: "file-manager" }, "win32", + { PATH: "" }, ); assert.deepEqual(launch2, { command: "explorer", @@ -204,6 +246,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const launch3 = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "file-manager" }, "linux", + { PATH: "" }, ); assert.deepEqual(launch3, { command: "xdg-open", @@ -321,4 +364,29 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]); }), ); + + it.effect("includes zed when only the zeditor command is installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + + yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); + yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(path.join(dir, "zeditor"), 0o755); + yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); + + const editors = resolveAvailableEditors("linux", { + PATH: dir, + }); + assert.deepEqual(editors, ["zed", "file-manager"]); + }), + ); + + it("omits file-manager when the platform opener is unavailable", () => { + const editors = resolveAvailableEditors("linux", { + PATH: "", + }); + assert.deepEqual(editors, []); + }); }); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 58074ceef2..ef50d3a5b8 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -75,6 +75,18 @@ function resolveCommandEditorArgs( } } +function resolveAvailableCommand( + commands: ReadonlyArray, + options: CommandAvailabilityOptions = {}, +): string | null { + for (const command of commands) { + if (isCommandAvailable(command, options)) { + return command; + } + } + return null; +} + function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { switch (platform) { case "darwin": @@ -198,8 +210,16 @@ export function resolveAvailableEditors( const available: EditorId[] = []; for (const editor of EDITORS) { - const command = editor.command ?? fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { + if (editor.commands === null) { + const command = fileManagerCommandForPlatform(platform); + if (isCommandAvailable(command, { platform, env })) { + available.push(editor.id); + } + continue; + } + + const command = resolveAvailableCommand(editor.commands, { platform, env }); + if (command !== null) { available.push(editor.id); } } @@ -236,6 +256,7 @@ export class Open extends ServiceMap.Service()("t3/open") {} export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "open.editor": input.editor, @@ -247,9 +268,11 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); } - if (editorDef.command) { + if (editorDef.commands) { + const command = + resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0]; return { - command: editorDef.command, + command, args: resolveCommandEditorArgs(editorDef, input.cwd), }; } diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 4ecaa72c49..2ffdf4c6db 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -7,25 +7,25 @@ export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; type EditorDefinition = { readonly id: string; readonly label: string; - readonly command: string | null; + readonly commands: readonly [string, ...string[]] | null; readonly launchStyle: EditorLaunchStyle; }; export const EDITORS = [ - { id: "cursor", label: "Cursor", command: "cursor", launchStyle: "goto" }, - { id: "trae", label: "Trae", command: "trae", launchStyle: "goto" }, - { id: "vscode", label: "VS Code", command: "code", launchStyle: "goto" }, + { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" }, + { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, + { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" }, { id: "vscode-insiders", label: "VS Code Insiders", - command: "code-insiders", + commands: ["code-insiders"], launchStyle: "goto", }, - { id: "vscodium", label: "VSCodium", command: "codium", launchStyle: "goto" }, - { id: "zed", label: "Zed", command: "zed", launchStyle: "direct-path" }, - { id: "antigravity", label: "Antigravity", command: "agy", launchStyle: "goto" }, - { id: "idea", label: "IntelliJ IDEA", command: "idea", launchStyle: "line-column" }, - { id: "file-manager", label: "File Manager", command: null, launchStyle: "direct-path" }, + { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" }, + { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, + { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, + { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, + { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" }, ] as const satisfies ReadonlyArray; export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));