authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-19 10:50:22+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-19 13:20:22+05:30
log408ff8ae9bd7eb2e7e90cbfd8b3fcfe63641bf23
tree3ad0e12c3c287f83cb02df7050f3a46333777e04
parent7a326c244f72d6421c2f1cbf004d55806c631526
signature Signed by PGP key B5690EEEBB952194

fix: detect installed editors outside PATH (#12439)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

6 files changed, 341 insertions(+), 37 deletions(-)

apps/desktop/src/ipc/methods/window.test.ts+36
......@@ -1,4 +1,8 @@
11import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
26import * as Effect from "effect/Effect";
37import * as Layer from "effect/Layer";
48import * as Option from "effect/Option";
......@@ -26,6 +30,7 @@ import {
2630 getWindowFullscreenState,
2731 pasteAsText,
2832 pickProjectFavicon,
33 probeRemoteEditors,
2934} from "./window.ts";
3035
3136const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
......@@ -262,3 +267,34 @@ describe("pickProjectFavicon", () => {
262267 }),
263268 );
264269});
270
271it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
272 "finds remote editors installed without PATH launchers",
273 () =>
274 Effect.gen(function* () {
275 const fs = yield* FileSystem.FileSystem;
276 const path = yield* Path.Path;
277 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-remote-editors-" });
278 for (const app of ["Cursor", "Visual Studio Code", "WebStorm"]) {
279 const executable = path.join(
280 home,
281 "Applications",
282 `${app}.app`,
283 app === "WebStorm" ? "Contents/MacOS/webstorm" : "Contents/Resources/app/bin/code",
284 );
285 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
286 yield* fs.writeFileString(executable, "#!/bin/sh\n");
287 yield* fs.chmod(executable, 0o755);
288 }
289 const editors = yield* probeRemoteEditors.handler(undefined).pipe(
290 Effect.provideService(HostProcessEnvironment, {
291 HOME: home,
292 PATH: path.join(home, "empty"),
293 }),
294 Effect.provideService(HostProcessPlatform, "darwin"),
295 );
296 assert.include(editors, "cursor");
297 assert.include(editors, "vscode");
298 assert.notInclude(editors, "webstorm");
299 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
300);
apps/desktop/src/ipc/methods/window.ts+6-12
......@@ -14,7 +14,8 @@ import {
1414 type PickedThemeFile,
1515} from "@t3tools/contracts";
1616import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview";
17import { isCommandAvailable } from "@t3tools/shared/shell";
17import { resolveEditorCommand } from "@t3tools/shared/editor";
18import * as HostProcess from "@t3tools/shared/hostProcess";
1819import * as NodeOS from "node:os";
1920import * as FileSystem from "effect/FileSystem";
2021import * as Path from "effect/Path";
......@@ -333,20 +334,13 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
333334 channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL,
334335 payload: Schema.Undefined,
335336 result: Schema.Array(EditorId),
336 // Probes THIS machine (where the renderer runs) for remote-capable editor
337 // CLIs, unlike the server's probe which walks the environment host's PATH.
338 // A Finder-launched app can miss PATH entries; an empty result makes the
339 // renderer fall back to VS Code only, so that fails soft.
340337 handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () {
341338 const available: Array<EditorId> = [];
339 const env = yield* HostProcess.HostProcessEnvironment;
342340 for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) {
343 const commands = EDITORS.find((editor) => editor.id === editorId)?.commands;
344 if (!commands) continue;
345 for (const command of commands) {
346 if (yield* isCommandAvailable(command, { env: process.env })) {
347 available.push(editorId);
348 break;
349 }
341 const editor = EDITORS.find((editor) => editor.id === editorId);
342 if (editor && Option.isSome(yield* resolveEditorCommand(editor, env))) {
343 available.push(editorId);
350344 }
351345 }
352346 return available;
apps/server/src/process/externalLauncher.test.ts+153
......@@ -922,6 +922,159 @@ it.effect("discovers editors through the service API", () =>
922922 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
923923);
924924
925for (const { platform, installPath, editor, args } of [
926 {
927 platform: "darwin",
928 installPath: "Applications/Cursor.app/Contents/Resources/app/bin/code",
929 editor: "cursor",
930 args: ["--classic", "--goto", "/workspace with spaces/file.ts:12:4"],
931 },
932 {
933 platform: "darwin",
934 installPath: "Applications/Kiro.app/Contents/Resources/app/bin/code",
935 editor: "kiro",
936 args: ["--goto", "/workspace with spaces/file.ts:12:4"],
937 },
938 {
939 platform: "darwin",
940 installPath: "Applications/WebStorm.app/Contents/MacOS/webstorm",
941 editor: "webstorm",
942 args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"],
943 },
944 {
945 platform: "darwin",
946 installPath: "Applications/Zed.app/Contents/MacOS/cli",
947 editor: "zed",
948 args: ["/workspace with spaces/file.ts:12:4"],
949 },
950 {
951 platform: "win32",
952 installPath: "Programs/Cursor/resources/app/bin/cursor.cmd",
953 editor: "cursor",
954 args: ['^"--classic^"', '^"--goto^"', '^"/workspace^ with^ spaces/file.ts:12:4^"'],
955 },
956 {
957 platform: "win32",
958 installPath: "Programs/Microsoft VS Code/bin/code.cmd",
959 editor: "vscode",
960 args: ['^"--goto^"', '^"/workspace^ with^ spaces/file.ts:12:4^"'],
961 },
962 {
963 platform: "win32",
964 installPath: "Programs/JetBrains/WebStorm 2026.2/bin/webstorm64.exe",
965 editor: "webstorm",
966 args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"],
967 },
968 {
969 platform: "win32",
970 installPath: "Programs/WebStorm/bin/webstorm64.exe",
971 editor: "webstorm",
972 args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"],
973 },
974 {
975 platform: "win32",
976 installPath: "Programs/Zed/bin/zed.exe",
977 editor: "zed",
978 args: ["/workspace with spaces/file.ts:12:4"],
979 },
980 {
981 platform: "linux",
982 installPath: ".local/share/JetBrains/Toolbox/scripts/idea",
983 editor: "idea",
984 args: ["--line", "12", "--column", "4", "/workspace with spaces/file.ts"],
985 },
986 {
987 platform: "linux",
988 installPath: ".local/bin/zed",
989 editor: "zed",
990 args: ["/workspace with spaces/file.ts:12:4"],
991 },
992] as const) {
993 it.effect.skipIf(windowsHost && platform !== "win32")(
994 `discovers and launches ${editor} outside PATH on ${platform}`,
995 () =>
996 Effect.gen(function* () {
997 const fs = yield* FileSystem.FileSystem;
998 const path = yield* Path.Path;
999 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor installs-" });
1000 const executable = path.join(home, installPath);
1001 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
1002 yield* fs.writeFileString(executable, "#!/bin/sh\n");
1003 yield* fs.chmod(executable, 0o755);
1004 let spawned: ChildProcess.StandardCommand | undefined;
1005 yield* Effect.gen(function* () {
1006 const launcher = yield* ExternalLauncher.ExternalLauncher;
1007 assert.include(yield* launcher.resolveAvailableEditors(), editor);
1008 yield* launcher.launchEditor({ editor, cwd: "/workspace with spaces/file.ts:12:4" });
1009 }).pipe(
1010 Effect.provide(
1011 testLayer({
1012 platform,
1013 env: { HOME: home, LOCALAPPDATA: home, PATH: path.join(home, "empty") },
1014 onSpawn: (command) => {
1015 spawned = command;
1016 },
1017 }),
1018 ),
1019 );
1020 assert.ok(spawned);
1021 assert.equal(
1022 spawned.command,
1023 executable.endsWith(".cmd") ? `^"${executable.replaceAll(" ", "^ ")}^"` : executable,
1024 );
1025 assert.deepEqual(spawned.args, args);
1026 assert.equal(spawned.options.shell, executable.endsWith(".cmd"));
1027 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1028 );
1029}
1030
1031it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launchers first", () =>
1032 Effect.gen(function* () {
1033 const fs = yield* FileSystem.FileSystem;
1034 const path = yield* Path.Path;
1035 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor-priority-" });
1036 const executable = path.join(home, "Applications/Cursor.app/Contents/Resources/app/bin/code");
1037 const env = { HOME: home, PATH: path.join(home, "bin") };
1038 const discover = Effect.gen(function* () {
1039 const launcher = yield* ExternalLauncher.ExternalLauncher;
1040 return yield* launcher.resolveAvailableEditors();
1041 });
1042 const before = yield* discover.pipe(Effect.provide(testLayer({ platform: "darwin", env })));
1043 yield* fs.makeDirectory(executable, { recursive: true });
1044 assert.deepEqual(
1045 yield* discover.pipe(Effect.provide(testLayer({ platform: "darwin", env }))),
1046 before,
1047 );
1048 yield* fs.remove(executable, { recursive: true });
1049 yield* fs.writeFileString(executable, "#!/bin/sh\n");
1050 yield* fs.chmod(executable, 0o644);
1051 assert.deepEqual(
1052 yield* discover.pipe(Effect.provide(testLayer({ platform: "darwin", env }))),
1053 before,
1054 );
1055 yield* fs.chmod(executable, 0o755);
1056 yield* fs.makeDirectory(env.PATH);
1057 yield* fs.writeFileString(path.join(env.PATH, "cursor"), "#!/bin/sh\n");
1058 yield* fs.chmod(path.join(env.PATH, "cursor"), 0o755);
1059 let spawned: ChildProcess.StandardCommand | undefined;
1060 yield* Effect.gen(function* () {
1061 const launcher = yield* ExternalLauncher.ExternalLauncher;
1062 yield* launcher.launchEditor({ editor: "cursor", cwd: "/workspace" });
1063 }).pipe(
1064 Effect.provide(
1065 testLayer({
1066 platform: "darwin",
1067 env: { ...env, PATH: `${env.PATH}:/usr/bin` },
1068 onSpawn: (command) => {
1069 spawned = command;
1070 },
1071 }),
1072 ),
1073 );
1074 assert.equal(spawned?.command, "cursor");
1075 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1076);
1077
9251078it.effect("memoizes editor discovery and refreshes after the cache window", () => {
9261079 let statCalls = 0;
9271080 const fileInfo = { type: "File" } as FileSystem.File.Info;
apps/server/src/process/externalLauncher.ts+15-25
......@@ -18,6 +18,7 @@ import {
1818 type FileManagerRevealKind,
1919 type LaunchEditorInput,
2020} from "@t3tools/contracts";
21import { resolveEditorCommand } from "@t3tools/shared/editor";
2122import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2223import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
2324import * as Clock from "effect/Clock";
......@@ -109,6 +110,12 @@ const CommandLookupEnvConfig = Config.all({
109110 Path: Config.String("Path").pipe(Config.option),
110111 path: Config.String("path").pipe(Config.option),
111112 PATHEXT: Config.String("PATHEXT").pipe(Config.option),
113 HOME: Config.String("HOME").pipe(Config.option),
114 LOCALAPPDATA: Config.String("LOCALAPPDATA").pipe(Config.option),
115 ProgramFiles: Config.String("ProgramFiles").pipe(Config.option),
116 ProgramW6432: Config.String("ProgramW6432").pipe(Config.option),
117 XDG_DATA_HOME: Config.String("XDG_DATA_HOME").pipe(Config.option),
118 "ProgramFiles(x86)": Config.String("ProgramFiles(x86)").pipe(Config.option),
112119}).pipe(Config.map(compactEnv));
113120
114121const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
......@@ -154,26 +161,6 @@ function resolveCommandEditorArgs(
154161 }
155162}
156163
157function resolveEditorArgs(
158 editor: (typeof EDITORS)[number],
159 target: string,
160): ReadonlyArray<string> {
161 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
162 return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];
163}
164
165const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* (
166 commands: ReadonlyArray<string>,
167 env: NodeJS.ProcessEnv,
168): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> {
169 for (const command of commands) {
170 if (yield* isCommandAvailable(command, { env })) {
171 return Option.some(command);
172 }
173 }
174 return Option.none();
175});
176
177164function encodeUtf16LeBase64(input: string): string {
178165 const bytes = new Uint8Array(input.length * 2);
179166 for (let index = 0; index < input.length; index += 1) {
......@@ -435,7 +422,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors"
435422 continue;
436423 }
437424
438 const command = yield* resolveAvailableCommand(editor.commands, env);
425 const command = yield* resolveEditorCommand(editor, env);
439426 if (Option.isSome(command)) {
440427 available.push(editor.id);
441428 }
......@@ -538,15 +525,18 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
538525 }
539526
540527 if (editorDef.commands) {
541 const command = Option.getOrElse(
542 yield* resolveAvailableCommand(editorDef.commands, env),
543 () => editorDef.commands[0],
528 const { command, baseArgs } = Option.getOrElse(
529 yield* resolveEditorCommand(editorDef, env),
530 () => ({
531 command: editorDef.commands[0],
532 baseArgs: "baseArgs" in editorDef ? editorDef.baseArgs : [],
533 }),
544534 );
545535 return {
546536 editor: editorDef.id,
547537 target: input.cwd,
548538 command,
549 args: resolveEditorArgs(editorDef, input.cwd),
539 args: [...baseArgs, ...resolveCommandEditorArgs(editorDef, input.cwd)],
550540 };
551541 }
552542
packages/shared/package.json+4
......@@ -63,6 +63,10 @@
6363 "types": "./src/httpObservability.ts",
6464 "import": "./src/httpObservability.ts"
6565 },
66 "./editor": {
67 "types": "./src/editor.ts",
68 "import": "./src/editor.ts"
69 },
6670 "./shell": {
6771 "types": "./src/shell.ts",
6872 "import": "./src/shell.ts"
packages/shared/src/editor.ts created+127
......@@ -0,0 +1,127 @@
1import type { EDITORS } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Path from "effect/Path";
6
7import * as HostProcess from "./hostProcess.ts";
8import { isCommandAvailable } from "./shell.ts";
9
10type Editor = (typeof EDITORS)[number];
11
12const installNames: Partial<Record<Editor["id"], ReadonlyArray<string>>> = {
13 vscode: ["Visual Studio Code"],
14 "vscode-insiders": ["Visual Studio Code - Insiders"],
15 idea: ["IntelliJ IDEA", "IntelliJ IDEA CE", "IntelliJ IDEA Ultimate"],
16 pycharm: ["PyCharm", "PyCharm CE"],
17 rider: ["Rider", "JetBrains Rider"],
18};
19
20export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(function* (
21 editor: Editor,
22 env: NodeJS.ProcessEnv,
23) {
24 if (editor.commands === null) return Option.none();
25 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
26 for (const command of editor.commands) {
27 if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs });
28 }
29
30 const platform = yield* HostProcess.HostProcessPlatform;
31 const path = yield* Path.Path;
32 const fs = yield* FileSystem.FileSystem;
33 const home = env.HOME;
34 const names = installNames[editor.id] ?? [editor.label];
35 const command = editor.commands[0];
36 const jetbrains = editor.launchStyle === "line-column";
37 const candidates: string[] = [];
38
39 if (platform === "darwin") {
40 const roots = [...(home ? [path.join(home, "Applications")] : []), "/Applications"];
41 for (const root of roots) {
42 for (const name of names) {
43 const contents = path.join(root, `${name}.app`, "Contents");
44 candidates.push(
45 ...(jetbrains || editor.id === "zed"
46 ? [path.join(contents, "MacOS", editor.id === "zed" ? "cli" : command)]
47 : [
48 path.join(contents, "Resources/app/bin", command),
49 path.join(contents, "Resources/app/bin/code"),
50 ]),
51 );
52 }
53 }
54 if (home && jetbrains) {
55 candidates.push(
56 path.join(home, "Library/Application Support/JetBrains/Toolbox/scripts", command),
57 );
58 }
59 } else if (platform === "win32") {
60 const roots = [
61 ...(env.LOCALAPPDATA ? [path.join(env.LOCALAPPDATA, "Programs")] : []),
62 ...[env.ProgramFiles, env["ProgramFiles(x86)"], env.ProgramW6432].filter(
63 (root): root is string => !!root,
64 ),
65 ];
66 if (jetbrains) {
67 if (env.LOCALAPPDATA) {
68 candidates.push(path.join(env.LOCALAPPDATA, "JetBrains/Toolbox/scripts", `${command}.cmd`));
69 }
70 for (const directory of roots.flatMap((root) => [root, path.join(root, "JetBrains")])) {
71 const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
72 for (const entry of entries) {
73 if (names.some((name) => entry === name || entry.startsWith(`${name} `))) {
74 candidates.push(path.join(directory, entry, "bin", `${command}64.exe`));
75 candidates.push(path.join(directory, entry, "bin", `${command}.exe`));
76 }
77 }
78 }
79 } else {
80 const name =
81 editor.id === "vscode"
82 ? "Microsoft VS Code"
83 : editor.id === "vscode-insiders"
84 ? "Microsoft VS Code Insiders"
85 : editor.label;
86 for (const root of roots) {
87 candidates.push(
88 path.join(root, name, "resources/app/bin", `${command}.cmd`),
89 path.join(root, name, "resources/app/bin/code.cmd"),
90 path.join(root, name, "bin", `${command}.cmd`),
91 path.join(root, name, "bin/code.cmd"),
92 );
93 if (editor.id === "zed") {
94 candidates.push(
95 path.join(root, name, "bin", "zed.exe"),
96 path.join(root, name, "zed.exe"),
97 );
98 }
99 }
100 }
101 } else if (platform === "linux") {
102 const dirs = [
103 ...(home ? [path.join(home, ".local/bin")] : []),
104 "/usr/local/bin",
105 "/usr/bin",
106 "/snap/bin",
107 ];
108 if (jetbrains) {
109 const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local/share") : undefined);
110 if (dataHome) dirs.push(path.join(dataHome, "JetBrains/Toolbox/scripts"));
111 }
112 for (const dir of dirs) {
113 for (const name of editor.commands) candidates.push(path.join(dir, name));
114 }
115 }
116
117 for (const candidate of candidates) {
118 if (yield* isCommandAvailable(candidate, { env })) {
119 return Option.some({
120 command: candidate,
121 baseArgs:
122 editor.id === "kiro" && (platform === "darwin" || platform === "win32") ? [] : baseArgs,
123 });
124 }
125 }
126 return Option.none();
127});