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
80 changes: 80 additions & 0 deletions apps/server/src/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,54 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
});
}),
);

it.effect("uses the configured system editor from VISUAL or EDITOR", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-configured-editor-" });
const editorPath = path.join(dir, "custom editor.CMD");
yield* fs.writeFileString(editorPath, "@echo off\r\n");
const launch = yield* resolveEditorLaunch(
{ cwd: "C:\\workspace", editor: "system-editor" },
"win32",
{
PATH: dir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
VISUAL: `"${editorPath}" --reuse-window`,
},
);
assert.deepEqual(launch, {
command: editorPath,
args: ["--reuse-window", "C:\\workspace"],
});
}),
);

it.effect(
"uses configured known editor commands even when the default binary is unavailable",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-configured-vscode-" });
const editorPath = path.join(dir, "code.CMD");
yield* fs.writeFileString(editorPath, "@echo off\r\n");
const launch = yield* resolveEditorLaunch(
{ cwd: "C:\\workspace\\src\\open.ts:71:5", editor: "vscode" },
"win32",
{
PATH: "",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
VISUAL: `"${editorPath}" --reuse-window`,
},
);
assert.deepEqual(launch, {
command: editorPath,
args: ["--reuse-window", "--goto", "C:\\workspace\\src\\open.ts:71:5"],
});
}),
);
});

it.layer(NodeServices.layer)("launchDetached", (it) => {
Expand Down Expand Up @@ -229,4 +277,36 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
assert.deepEqual(editors, ["cursor", "file-manager"]);
}),
);

it.effect("adds the configured system editor when it is available but unknown", () =>
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, "custom-editor"), "#!/bin/sh\n");
yield* fs.writeFileString(path.join(dir, "cursor"), "#!/bin/sh\n");
const editors = resolveAvailableEditors("linux", {
PATH: dir,
VISUAL: "custom-editor --reuse-window",
});
assert.deepEqual(editors, ["cursor", "system-editor"]);
}),
);

it.effect("reuses built-in editor ids when VISUAL points at a known editor path", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-known-" });
const editorPath = path.join(dir, "code.CMD");
yield* fs.writeFileString(editorPath, "@echo off\r\n");
const editors = resolveAvailableEditors("win32", {
PATH: "",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
VISUAL: `"${editorPath}" --reuse-window`,
});
assert.deepEqual(editors, ["vscode"]);
}),
);
});
101 changes: 99 additions & 2 deletions apps/server/src/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ interface CommandAvailabilityOptions {
}

const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const CONFIGURED_EDITOR_ENV_KEYS = ["VISUAL", "EDITOR"] as const;
const SHELL_WORD_PATTERN = /"([^"]*)"|'([^']*)'|([^\s]+)/g;

interface ResolvedConfiguredEditor {
readonly editorId: EditorId;
readonly launch: EditorLaunch;
}

function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
return (
Expand All @@ -64,6 +71,65 @@ function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
return env.PATH ?? env.Path ?? env.path ?? "";
}

function tokenizeCommand(value: string): ReadonlyArray<string> {
const tokens: string[] = [];
for (const match of value.matchAll(SHELL_WORD_PATTERN)) {
const token = match[1] ?? match[2] ?? match[3];
if (!token) {
continue;
}
tokens.push(token);
}
return tokens;
}

function resolveCommandIdentity(command: string): string {
const trimmed = command.trim();
if (trimmed.length === 0) {
return "";
}
const parts = trimmed.split(/[\\/]/);
const lastSegment = parts[parts.length - 1] ?? trimmed;
const extension = extname(lastSegment);
const commandName = extension.length > 0 ? lastSegment.slice(0, -extension.length) : lastSegment;
return commandName.toLowerCase();
}

function resolveConfiguredEditor(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): ResolvedConfiguredEditor | null {
for (const envKey of CONFIGURED_EDITOR_ENV_KEYS) {
const rawValue = env[envKey]?.trim();
if (!rawValue) {
continue;
}

const [command, ...args] = tokenizeCommand(rawValue);
if (!command) {
continue;
}
if (!isCommandAvailable(command, { platform, env })) {
continue;
}

const builtInEditor = EDITORS.find(
(editor) =>
editor.command &&
resolveCommandIdentity(editor.command) === resolveCommandIdentity(command),
);
return {
editorId: builtInEditor?.id ?? "system-editor",
launch: {
command,
args,
},
};
}

return null;
}

function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
const rawValue = env.PATHEXT;
const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
Expand Down Expand Up @@ -166,14 +232,25 @@ export function resolveAvailableEditors(
env: NodeJS.ProcessEnv = process.env,
): ReadonlyArray<EditorId> {
const available: EditorId[] = [];
const configuredEditor = resolveConfiguredEditor(env, platform);

for (const editor of EDITORS) {
const command = editor.command ?? fileManagerCommandForPlatform(platform);
if (isCommandAvailable(command, { platform, env })) {
if (editor.id === "system-editor") {
continue;
}

const command =
editor.id === "file-manager" ? fileManagerCommandForPlatform(platform) : editor.command;
const isConfiguredEditor = configuredEditor?.editorId === editor.id;
if ((command && isCommandAvailable(command, { platform, env })) || isConfiguredEditor) {
available.push(editor.id);
}
}

if (configuredEditor?.editorId === "system-editor") {
available.push("system-editor");
}

return available;
}

Expand Down Expand Up @@ -206,12 +283,32 @@ 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}` });
}

const configuredEditor = resolveConfiguredEditor(env, platform);
if (configuredEditor && configuredEditor.editorId === input.editor) {
return shouldUseGotoFlag(input.editor, input.cwd)
? {
command: configuredEditor.launch.command,
args: [...configuredEditor.launch.args, "--goto", input.cwd],
}
: {
command: configuredEditor.launch.command,
args: [...configuredEditor.launch.args, input.cwd],
};
}

if (editorDef.id === "system-editor") {
return yield* new OpenError({
message: "System editor is not configured. Set VISUAL or EDITOR to use it.",
});
}

if (editorDef.command) {
return shouldUseGotoFlag(editorDef.id, input.cwd)
? { command: editorDef.command, args: ["--goto", input.cwd] }
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ChevronDownIcon, FolderClosedIcon } from "lucide-react";
import { Button } from "../ui/button";
import { Group, GroupSeparator } from "../ui/group";
import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu";
import { AntigravityIcon, CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons";
import { AntigravityIcon, CursorIcon, Icon, OpenCodeIcon, VisualStudioCode, Zed } from "../Icons";
import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
import { readNativeApi } from "~/nativeApi";

Expand All @@ -32,6 +32,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor
Icon: AntigravityIcon,
value: "antigravity",
},
{
label: "System Editor",
Icon: OpenCodeIcon,
value: "system-editor",
},
{
label: isMacPlatform(platform)
? "Finder"
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const EDITORS = [
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "antigravity", label: "Antigravity", command: "agy" },
{ id: "system-editor", label: "System Editor", command: null },
{ id: "file-manager", label: "File Manager", command: null },
] as const;

Expand Down