diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 649e88462c..9df4c82441 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", @@ -34,6 +36,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const vscodeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "vscode" }, "darwin", + { PATH: "" }, ); assert.deepEqual(vscodeLaunch, { command: "code", @@ -43,6 +46,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const zedLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "zed" }, "darwin", + { PATH: "" }, ); assert.deepEqual(zedLaunch, { command: "zed", @@ -56,6 +60,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", @@ -65,6 +70,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", @@ -74,6 +80,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", @@ -83,6 +90,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", @@ -91,11 +99,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", @@ -105,6 +145,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { const launch2 = yield* resolveEditorLaunch( { cwd: "C:\\workspace", editor: "file-manager" }, "win32", + { PATH: "" }, ); assert.deepEqual(launch2, { command: "explorer", @@ -114,6 +155,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", @@ -229,4 +271,22 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { assert.deepEqual(editors, ["cursor", "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"]); + }), + ); }); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index e7238c04b2..008e370e0b 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -45,6 +45,18 @@ function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { ); } +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": @@ -168,8 +180,11 @@ export function resolveAvailableEditors( const available: EditorId[] = []; for (const editor of EDITORS) { - const command = editor.command ?? fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { + const command = + editor.commands === null + ? fileManagerCommandForPlatform(platform) + : resolveAvailableCommand(editor.commands, { platform, env }); + if (command !== null) { available.push(editor.id); } } @@ -206,16 +221,19 @@ export class Open extends ServiceMap.Service()("t3/open") {} export const resolveEditorLaunch = Effect.fnUntraced(function* ( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { 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 shouldUseGotoFlag(editorDef.id, input.cwd) - ? { command: editorDef.command, args: ["--goto", input.cwd] } - : { command: editorDef.command, args: [input.cwd] }; + ? { command, args: ["--goto", input.cwd] } + : { command, args: [input.cwd] }; } if (editorDef.id !== "file-manager") { diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index daa58d0f12..8fa9dca80e 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.10' +const PACKAGE_VERSION = '2.12.11' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 0ebd4fe5ae..f665478617 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -2,11 +2,11 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; export const EDITORS = [ - { id: "cursor", label: "Cursor", command: "cursor" }, - { id: "vscode", label: "VS Code", command: "code" }, - { id: "zed", label: "Zed", command: "zed" }, - { id: "antigravity", label: "Antigravity", command: "agy" }, - { id: "file-manager", label: "File Manager", command: null }, + { id: "cursor", label: "Cursor", commands: ["cursor"] }, + { id: "vscode", label: "VS Code", commands: ["code"] }, + { id: "zed", label: "Zed", commands: ["zed", "zeditor"] }, + { id: "antigravity", label: "Antigravity", commands: ["agy"] }, + { id: "file-manager", label: "File Manager", commands: null }, ] as const; export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));