Skip to content
Open
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
60 changes: 60 additions & 0 deletions apps/server/src/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"]);
}),
);
});
28 changes: 23 additions & 5 deletions apps/server/src/open.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

export function resolveAvailableEditors(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): ReadonlyArray<EditorId> {
const available: EditorId[] = [];
for (const editor of EDITORS) {
const command =
editor.commands === null
? fileManagerCommandForPlatform(platform)
: resolveAvailableCommand(editor.commands, { platform, env });
if (command !== null) {
available.push(editor.id);
}
}
return available;

For the file-manager editor, fileManagerCommandForPlatform always returns a command string (open, explorer, or xdg-open) without checking if it actually exists on the system. On Linux without xdg-open, isCommandAvailable would have returned false, but the new command !== null check always passes, causing file-manager to appear available when it isn't.

  for (const editor of EDITORS) {
-    const command =
-      editor.commands === null
-        ? fileManagerCommandForPlatform(platform)
-        : resolveAvailableCommand(editor.commands, { platform, env });
-    if (command !== null) {
-      available.push(editor.id);
+    if (editor.commands === null) {
+      const command = fileManagerCommandForPlatform(platform);
+      if (isCommandAvailable(command, { platform, env })) {
+        available.push(editor.id);
+      }
+    } else {
+      const command = resolveAvailableCommand(editor.commands, { platform, env });
+      if (command !== null) {
+        available.push(editor.id);
+      }
     }
   }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/open.ts around lines 176-192:

For the `file-manager` editor, `fileManagerCommandForPlatform` always returns a command string (`open`, `explorer`, or `xdg-open`) without checking if it actually exists on the system. On Linux without `xdg-open`, `isCommandAvailable` would have returned `false`, but the new `command !== null` check always passes, causing `file-manager` to appear available when it isn't.

Evidence trail:
apps/server/src/open.ts lines 60-69 (fileManagerCommandForPlatform always returns string), lines 176-193 (resolveAvailableEditors logic), lines 144-174 (isCommandAvailable implementation); packages/contracts/src/editor.ts line 9 (file-manager has commands: null); apps/server/src/open.test.ts lines 271 and 289 (tests expect file-manager without validating underlying command)

Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
);
}

function resolveAvailableCommand(
commands: ReadonlyArray<string>,
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":
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -206,16 +221,19 @@ export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}
export const resolveEditorLaunch = Effect.fnUntraced(function* (
input: OpenInEditorInput,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): Effect.fn.Return<EditorLaunch, OpenError> {
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") {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 5 additions & 5 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down