diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 649e88462c..f0a5461768 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -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) => { @@ -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"]); + }), + ); }); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index e7238c04b2..13f5b14737 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -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 ( @@ -64,6 +71,65 @@ function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { return env.PATH ?? env.Path ?? env.path ?? ""; } +function tokenizeCommand(value: string): ReadonlyArray { + 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 { const rawValue = env.PATHEXT; const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; @@ -166,14 +232,25 @@ export function resolveAvailableEditors( env: NodeJS.ProcessEnv = process.env, ): ReadonlyArray { 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; } @@ -206,12 +283,32 @@ 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}` }); } + 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] } diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 9f62f7121e..7776e34b3e 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -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"; @@ -32,6 +32,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray