authorgravatar for 96244394+AdemBenAbdallah@users.noreply.github.comAdem Ben Abdallah <96244394+AdemBenAbdallah@users.noreply.github.com> 2026-04-07 00:46:17+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-06 16:46:17-07:00
logafc392439345ef4f3056661cb21669da12efc233
treeb4cde8c6d4ab0427dc5d6c66e70ac3ebfba09da9
parent11d456f6c8404be6617b7299a9db9ad5538ee061
signature Signed by PGP key B5690EEEBB952194

Add Zed support to Open actions via editor command aliases (#1303)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 105 insertions(+), 14 deletions(-)

apps/server/src/open.test.ts+68
...@@ -16,6 +16,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -16,6 +16,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
16 const antigravityLaunch = yield* resolveEditorLaunch(16 const antigravityLaunch = yield* resolveEditorLaunch(
17 { cwd: "/tmp/workspace", editor: "antigravity" },17 { cwd: "/tmp/workspace", editor: "antigravity" },
18 "darwin",18 "darwin",
19 { PATH: "" },
19 );20 );
20 assert.deepEqual(antigravityLaunch, {21 assert.deepEqual(antigravityLaunch, {
21 command: "agy",22 command: "agy",
...@@ -25,6 +26,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -25,6 +26,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
25 const cursorLaunch = yield* resolveEditorLaunch(26 const cursorLaunch = yield* resolveEditorLaunch(
26 { cwd: "/tmp/workspace", editor: "cursor" },27 { cwd: "/tmp/workspace", editor: "cursor" },
27 "darwin",28 "darwin",
29 { PATH: "" },
28 );30 );
29 assert.deepEqual(cursorLaunch, {31 assert.deepEqual(cursorLaunch, {
30 command: "cursor",32 command: "cursor",
...@@ -43,6 +45,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -43,6 +45,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
43 const vscodeLaunch = yield* resolveEditorLaunch(45 const vscodeLaunch = yield* resolveEditorLaunch(
44 { cwd: "/tmp/workspace", editor: "vscode" },46 { cwd: "/tmp/workspace", editor: "vscode" },
45 "darwin",47 "darwin",
48 { PATH: "" },
46 );49 );
47 assert.deepEqual(vscodeLaunch, {50 assert.deepEqual(vscodeLaunch, {
48 command: "code",51 command: "code",
...@@ -70,6 +73,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -70,6 +73,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
70 const zedLaunch = yield* resolveEditorLaunch(73 const zedLaunch = yield* resolveEditorLaunch(
71 { cwd: "/tmp/workspace", editor: "zed" },74 { cwd: "/tmp/workspace", editor: "zed" },
72 "darwin",75 "darwin",
76 { PATH: "" },
73 );77 );
74 assert.deepEqual(zedLaunch, {78 assert.deepEqual(zedLaunch, {
75 command: "zed",79 command: "zed",
...@@ -92,6 +96,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -92,6 +96,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
92 const lineOnly = yield* resolveEditorLaunch(96 const lineOnly = yield* resolveEditorLaunch(
93 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },97 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
94 "darwin",98 "darwin",
99 { PATH: "" },
95 );100 );
96 assert.deepEqual(lineOnly, {101 assert.deepEqual(lineOnly, {
97 command: "cursor",102 command: "cursor",
...@@ -101,6 +106,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -101,6 +106,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
101 const lineAndColumn = yield* resolveEditorLaunch(106 const lineAndColumn = yield* resolveEditorLaunch(
102 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" },107 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" },
103 "darwin",108 "darwin",
109 { PATH: "" },
104 );110 );
105 assert.deepEqual(lineAndColumn, {111 assert.deepEqual(lineAndColumn, {
106 command: "cursor",112 command: "cursor",
...@@ -119,6 +125,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -119,6 +125,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
119 const vscodeLineAndColumn = yield* resolveEditorLaunch(125 const vscodeLineAndColumn = yield* resolveEditorLaunch(
120 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },126 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },
121 "darwin",127 "darwin",
128 { PATH: "" },
122 );129 );
123 assert.deepEqual(vscodeLineAndColumn, {130 assert.deepEqual(vscodeLineAndColumn, {
124 command: "code",131 command: "code",
...@@ -146,6 +153,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -146,6 +153,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
146 const zedLineAndColumn = yield* resolveEditorLaunch(153 const zedLineAndColumn = yield* resolveEditorLaunch(
147 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },154 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },
148 "darwin",155 "darwin",
156 { PATH: "" },
149 );157 );
150 assert.deepEqual(zedLineAndColumn, {158 assert.deepEqual(zedLineAndColumn, {
151 command: "zed",159 command: "zed",
...@@ -155,6 +163,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -155,6 +163,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
155 const zedLineOnly = yield* resolveEditorLaunch(163 const zedLineOnly = yield* resolveEditorLaunch(
156 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" },164 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" },
157 "darwin",165 "darwin",
166 { PATH: "" },
158 );167 );
159 assert.deepEqual(zedLineOnly, {168 assert.deepEqual(zedLineOnly, {
160 command: "zed",169 command: "zed",
...@@ -181,11 +190,43 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -181,11 +190,43 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
181 }),190 }),
182 );191 );
183192
193 it.effect("falls back to zeditor when zed is not installed", () =>
194 Effect.gen(function* () {
195 const fs = yield* FileSystem.FileSystem;
196 const path = yield* Path.Path;
197 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
198 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
199 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
200
201 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
202 PATH: dir,
203 });
204
205 assert.deepEqual(result, {
206 command: "zeditor",
207 args: ["/tmp/workspace"],
208 });
209 }),
210 );
211
212 it.effect("falls back to the primary command when no alias is installed", () =>
213 Effect.gen(function* () {
214 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
215 PATH: "",
216 });
217 assert.deepEqual(result, {
218 command: "zed",
219 args: ["/tmp/workspace"],
220 });
221 }),
222 );
223
184 it.effect("maps file-manager editor to OS open commands", () =>224 it.effect("maps file-manager editor to OS open commands", () =>
185 Effect.gen(function* () {225 Effect.gen(function* () {
186 const launch1 = yield* resolveEditorLaunch(226 const launch1 = yield* resolveEditorLaunch(
187 { cwd: "/tmp/workspace", editor: "file-manager" },227 { cwd: "/tmp/workspace", editor: "file-manager" },
188 "darwin",228 "darwin",
229 { PATH: "" },
189 );230 );
190 assert.deepEqual(launch1, {231 assert.deepEqual(launch1, {
191 command: "open",232 command: "open",
...@@ -195,6 +236,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -195,6 +236,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
195 const launch2 = yield* resolveEditorLaunch(236 const launch2 = yield* resolveEditorLaunch(
196 { cwd: "C:\\workspace", editor: "file-manager" },237 { cwd: "C:\\workspace", editor: "file-manager" },
197 "win32",238 "win32",
239 { PATH: "" },
198 );240 );
199 assert.deepEqual(launch2, {241 assert.deepEqual(launch2, {
200 command: "explorer",242 command: "explorer",
...@@ -204,6 +246,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -204,6 +246,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
204 const launch3 = yield* resolveEditorLaunch(246 const launch3 = yield* resolveEditorLaunch(
205 { cwd: "/tmp/workspace", editor: "file-manager" },247 { cwd: "/tmp/workspace", editor: "file-manager" },
206 "linux",248 "linux",
249 { PATH: "" },
207 );250 );
208 assert.deepEqual(launch3, {251 assert.deepEqual(launch3, {
209 command: "xdg-open",252 command: "xdg-open",
...@@ -321,4 +364,29 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {...@@ -321,4 +364,29 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
321 assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]);364 assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]);
322 }),365 }),
323 );366 );
367
368 it.effect("includes zed when only the zeditor command is installed", () =>
369 Effect.gen(function* () {
370 const fs = yield* FileSystem.FileSystem;
371 const path = yield* Path.Path;
372 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
373
374 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
375 yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n");
376 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
377 yield* fs.chmod(path.join(dir, "xdg-open"), 0o755);
378
379 const editors = resolveAvailableEditors("linux", {
380 PATH: dir,
381 });
382 assert.deepEqual(editors, ["zed", "file-manager"]);
383 }),
384 );
385
386 it("omits file-manager when the platform opener is unavailable", () => {
387 const editors = resolveAvailableEditors("linux", {
388 PATH: "",
389 });
390 assert.deepEqual(editors, []);
391 });
324});392});
apps/server/src/open.ts+27-4
...@@ -75,6 +75,18 @@ function resolveCommandEditorArgs(...@@ -75,6 +75,18 @@ function resolveCommandEditorArgs(
75 }75 }
76}76}
7777
78function resolveAvailableCommand(
79 commands: ReadonlyArray<string>,
80 options: CommandAvailabilityOptions = {},
81): string | null {
82 for (const command of commands) {
83 if (isCommandAvailable(command, options)) {
84 return command;
85 }
86 }
87 return null;
88}
89
78function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {90function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
79 switch (platform) {91 switch (platform) {
80 case "darwin":92 case "darwin":
...@@ -198,8 +210,16 @@ export function resolveAvailableEditors(...@@ -198,8 +210,16 @@ export function resolveAvailableEditors(
198 const available: EditorId[] = [];210 const available: EditorId[] = [];
199211
200 for (const editor of EDITORS) {212 for (const editor of EDITORS) {
201 const command = editor.command ?? fileManagerCommandForPlatform(platform);213 if (editor.commands === null) {
202 if (isCommandAvailable(command, { platform, env })) {214 const command = fileManagerCommandForPlatform(platform);
215 if (isCommandAvailable(command, { platform, env })) {
216 available.push(editor.id);
217 }
218 continue;
219 }
220
221 const command = resolveAvailableCommand(editor.commands, { platform, env });
222 if (command !== null) {
203 available.push(editor.id);223 available.push(editor.id);
204 }224 }
205 }225 }
...@@ -236,6 +256,7 @@ export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}...@@ -236,6 +256,7 @@ export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}
236export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (256export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
237 input: OpenInEditorInput,257 input: OpenInEditorInput,
238 platform: NodeJS.Platform = process.platform,258 platform: NodeJS.Platform = process.platform,
259 env: NodeJS.ProcessEnv = process.env,
239): Effect.fn.Return<EditorLaunch, OpenError> {260): Effect.fn.Return<EditorLaunch, OpenError> {
240 yield* Effect.annotateCurrentSpan({261 yield* Effect.annotateCurrentSpan({
241 "open.editor": input.editor,262 "open.editor": input.editor,
...@@ -247,9 +268,11 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (...@@ -247,9 +268,11 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
247 return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });268 return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });
248 }269 }
249270
250 if (editorDef.command) {271 if (editorDef.commands) {
272 const command =
273 resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0];
251 return {274 return {
252 command: editorDef.command,275 command,
253 args: resolveCommandEditorArgs(editorDef, input.cwd),276 args: resolveCommandEditorArgs(editorDef, input.cwd),
254 };277 };
255 }278 }
packages/contracts/src/editor.ts+10-10
...@@ -7,25 +7,25 @@ export type EditorLaunchStyle = typeof EditorLaunchStyle.Type;...@@ -7,25 +7,25 @@ export type EditorLaunchStyle = typeof EditorLaunchStyle.Type;
7type EditorDefinition = {7type EditorDefinition = {
8 readonly id: string;8 readonly id: string;
9 readonly label: string;9 readonly label: string;
10 readonly command: string | null;10 readonly commands: readonly [string, ...string[]] | null;
11 readonly launchStyle: EditorLaunchStyle;11 readonly launchStyle: EditorLaunchStyle;
12};12};
1313
14export const EDITORS = [14export const EDITORS = [
15 { id: "cursor", label: "Cursor", command: "cursor", launchStyle: "goto" },15 { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" },
16 { id: "trae", label: "Trae", command: "trae", launchStyle: "goto" },16 { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" },
17 { id: "vscode", label: "VS Code", command: "code", launchStyle: "goto" },17 { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" },
18 {18 {
19 id: "vscode-insiders",19 id: "vscode-insiders",
20 label: "VS Code Insiders",20 label: "VS Code Insiders",
21 command: "code-insiders",21 commands: ["code-insiders"],
22 launchStyle: "goto",22 launchStyle: "goto",
23 },23 },
24 { id: "vscodium", label: "VSCodium", command: "codium", launchStyle: "goto" },24 { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" },
25 { id: "zed", label: "Zed", command: "zed", launchStyle: "direct-path" },25 { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" },
26 { id: "antigravity", label: "Antigravity", command: "agy", launchStyle: "goto" },26 { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" },
27 { id: "idea", label: "IntelliJ IDEA", command: "idea", launchStyle: "line-column" },27 { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },
28 { id: "file-manager", label: "File Manager", command: null, launchStyle: "direct-path" },28 { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" },
29] as const satisfies ReadonlyArray<EditorDefinition>;29] as const satisfies ReadonlyArray<EditorDefinition>;
3030
31export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));31export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));