History

Open VS Code on Windows via protocol URI

- Launch `explorer.exe` with a `vscode://file/...` target for Windows editor opens - Improve Windows spawn handling with `windowsHide` and protocol URI coverage

julius committed 7 months ago

Commit 8d257e21 parent 1218eff4 files · +86 −25Browse files

Unified · Split

4 files
  1. apps/server/spawned2.txt +2 −0
  2. apps/server/src/process/Layers/DesktopLauncher.test.ts +14 −23
  3. apps/server/src/process/Layers/DesktopLauncher.ts +67 −2
  4. apps/server/tmp-test.cmd +3 −0
apps/server/spawned2.txt+2−0
@@ -0,0 +1,2 @@
1+started
2+started
apps/server/src/process/Layers/DesktopLauncher.test.ts+14−23
@@ -29,6 +29,7 @@ interface SpawnCall { · 28 unmodified lines
2929 readonly detached?: boolean | undefined;
3030 readonly shell?: boolean | string | undefined;
3131 readonly windowsVerbatimArguments?: boolean | undefined;
32+ readonly windowsHide?: boolean | undefined;
3233 readonly stdin?: unknown;
3334 readonly stdout?: unknown;
3435 readonly stderr?: unknown;
@@ -154,6 +155,7 @@ function spawnHarness( · 119 unmodified lines
154155 detached: input.detached,
155156 shell: input.shell,
156157 windowsVerbatimArguments: input.windowsVerbatimArguments,
158+ windowsHide: input.windowsHide,
157159 stdin: input.stdin,
158160 stdout: input.stdout,
159161 stderr: input.stderr,
@@ -313,11 +315,11 @@ it.effect("openInEditor uses --goto for editors that support it", () => · 153 unmodified lines
313315 }).pipe(Effect.provide(NodeFileSystem.layer)),
314316 );
315317
316−it.effect("openInEditor launches Windows batch shims through cmd.exe without shell mode", () =>
318+it.effect("openInEditor uses explorer with a VS Code protocol target on Windows", () =>
317319 Effect.gen(function* () {
318320 const fs = yield* FileSystem.FileSystem;
319321 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" });
320− yield* fs.writeFileString(`${dir}/code.cmd`, "@echo off\r\n");
322+ yield* fs.writeFileString(`${dir}/explorer.exe`, "MZ");
321323
322324 const calls: Array<SpawnCall> = [];
323325 const open = yield* runOpen(
@@ -337,28 +339,17 @@ it.effect("openInEditor launches Windows batch shims through cmd.exe without she · 13 unmodified lines
337339 editor: "vscode",
338340 });
339341
340− assert.equal(calls.length, 1);
341− assert.equal(calls[0]?.command.toLowerCase().includes("powershell"), true);
342− assert.deepEqual(calls[0]?.args.slice(0, 5), [
343− "-NoProfile",
344− "-NonInteractive",
345− "-ExecutionPolicy",
346− "Bypass",
347− "-EncodedCommand",
342+ assert.deepEqual(calls, [
343+ {
344+ command: `${dir}/explorer.exe`,
345+ args: ["vscode://file/C:/work/100%25%20real/file.ts:12:4"],
346+ detached: true,
347+ shell: false,
348+ stdin: "ignore",
349+ stdout: "ignore",
350+ stderr: "ignore",
351+ },
348352 ]);
349− const encoded = calls[0]?.args[5];
350− assert.isString(encoded);
351− const decoded = decodePowerShellCommand(encoded!);
352− assert.equal(decoded.includes("Start-Process"), true);
353− assert.equal(decoded.toLowerCase().includes(`${dir}/code.cmd`.toLowerCase()), true);
354− assert.equal(decoded.includes("--goto"), true);
355− assert.equal(decoded.includes("100% real"), true);
356− assert.equal(calls[0]?.detached, true);
357− assert.equal(calls[0]?.windowsVerbatimArguments, undefined);
358− assert.equal(calls[0]?.shell, false);
359− assert.equal(calls[0]?.stdin, "ignore");
360− assert.equal(calls[0]?.stdout, "ignore");
361− assert.equal(calls[0]?.stderr, "ignore");
362353 }).pipe(Effect.provide(NodeFileSystem.layer)),
363354 );
364355
apps/server/src/process/Layers/DesktopLauncher.ts+67−2
@@ -9,6 +9,7 @@ · 8 unmodified lines
99 */
1010 import OS from "node:os";
1111 import { spawn as spawnNodeChildProcess } from "node:child_process";
12+import { pathToFileURL } from "node:url";
1213
1314 import { EDITORS, type EditorId } from "@t3tools/contracts";
1415 import { Array, Effect, FileSystem, Layer, Option, Path, Scope } from "effect";
@@ -38,6 +39,7 @@ export interface DetachedSpawnInput { · 23 unmodified lines
3839 readonly detached?: boolean;
3940 readonly shell?: boolean;
4041 readonly windowsVerbatimArguments?: boolean;
42+ readonly windowsHide?: boolean;
4143 readonly stdin?: "ignore";
4244 readonly stdout?: "ignore";
4345 readonly stderr?: "ignore";
@@ -119,6 +121,11 @@ const WSL_POWERSHELL_CANDIDATES = [ · 75 unmodified lines
119121 "powershell.exe",
120122 "pwsh.exe",
121123 ] as const;
124+const WINDOWS_EDITOR_URI_SCHEMES: Partial<Record<EditorId, string>> = {
125+ vscode: "vscode",
126+ "vscode-insiders": "vscode-insiders",
127+ vscodium: "vscodium",
128+};
122129
123130 function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean {
124131 return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target);
@@ -128,6 +135,24 @@ function stripWrappingQuotes(value: string): string { · 3 unmodified lines
128135 return value.replace(/^"+|"+$/g, "");
129136 }
130137
138+function splitLineColumnSuffix(target: string): {
139+ readonly filePath: string;
140+ readonly suffix: string;
141+} {
142+ const match = target.match(LINE_COLUMN_SUFFIX_PATTERN);
143+ if (!match) {
144+ return {
145+ filePath: target,
146+ suffix: "",
147+ };
148+ }
149+
150+ return {
151+ filePath: target.slice(0, -match[0].length),
152+ suffix: match[0],
153+ };
154+}
155+
131156 function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
132157 return env.PATH ?? env.Path ?? env.path ?? "";
133158 }
@@ -188,6 +213,10 @@ function quotePowerShellValue(value: string): string { · 54 unmodified lines
188213 return `'${value.replaceAll("'", "''")}'`;
189214 }
190215
216+function quotePowerShellArgument(value: string): string {
217+ return `"\`"${value.replaceAll("`", "``").replaceAll('"', '`"')}\`""`;
218+}
219+
191220 function encodePowerShellCommand(command: string): string {
192221 return Buffer.from(command, "utf16le").toString("base64");
193222 }
@@ -218,6 +247,19 @@ function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: Launc · 24 unmodified lines
218247 return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target);
219248 }
220249
250+function makeWindowsEditorProtocolTarget(editor: EditorId, target: string): string | undefined {
251+ const scheme = WINDOWS_EDITOR_URI_SCHEMES[editor];
252+ if (!scheme) return undefined;
253+
254+ const { filePath, suffix } = splitLineColumnSuffix(target);
255+ const fileUrl = pathToFileURL(filePath).href;
256+ const fileTarget = fileUrl.startsWith("file:///")
257+ ? fileUrl.slice("file:///".length)
258+ : fileUrl.replace(/^file:\/\//, "");
259+
260+ return `${scheme}://file/${fileTarget}${suffix}`;
261+}
262+
221263 function makeLaunchPlan(
222264 command: string,
223265 args: ReadonlyArray<string>,
@@ -309,8 +351,8 @@ function makePowerShellStartProcessArgs( · 85 unmodified lines
309351 args: ReadonlyArray<string>,
310352 ): ReadonlyArray<string> {
311353 const argumentList =
312− args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellValue).join(", ")}` : "";
313− const command = `Start-Process -FilePath ${quotePowerShellValue(commandPath)}${argumentList} -WindowStyle Hidden`;
354+ args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellArgument).join(",")}` : "";
355+ const command = `Start ${quotePowerShellArgument(commandPath)}${argumentList} -WindowStyle Hidden`;
314356 return [
315357 "-NoProfile",
316358 "-NonInteractive",
@@ -336,6 +378,8 @@ function makeWindowsExplorerPlan(input: OpenExternalInput): LaunchPlan { · 19 unmodified lines
336378 return makeLaunchPlan("explorer", [input.target], {
337379 wait: false,
338380 allowNonzeroExitCode: false,
381+ detached: true,
382+ stdio: "ignore",
339383 shell: false,
340384 });
341385 }
@@ -408,6 +452,9 @@ function resolveSpawnInput( · 66 unmodified lines
408452 args: makePowerShellStartProcessArgs(resolvedCommand.path, plan.args),
409453 ...(plan.detached !== undefined ? { detached: plan.detached } : {}),
410454 ...(plan.shell !== undefined ? { shell: plan.shell } : {}),
455+ ...(runtime.platform === "win32"
456+ ? { windowsVerbatimArguments: true, windowsHide: true }
457+ : {}),
411458 ...(plan.stdio === "ignore"
412459 ? {
413460 stdin: "ignore" as const,
@@ -417,6 +464,7 @@ function resolveSpawnInput( · 3 unmodified lines
417464 : {}),
418465 };
419466 }
467+ const windowsHide = runtime.platform === "win32" && plan.detached ? true : undefined;
420468 return {
421469 command: resolvedCommand.usesCmdWrapper
422470 ? resolveWindowsCommandShell(runtime.env)
@@ -426,6 +474,7 @@ function resolveSpawnInput( · 3 unmodified lines
426474 : [...plan.args],
427475 ...(plan.detached !== undefined ? { detached: plan.detached } : {}),
428476 ...(plan.shell !== undefined ? { shell: plan.shell } : {}),
477+ ...(windowsHide ? { windowsHide } : {}),
429478 ...(resolvedCommand.usesCmdWrapper ? { windowsVerbatimArguments: true } : {}),
430479 ...(plan.stdio === "ignore"
431480 ? {
@@ -525,6 +574,7 @@ function defaultSpawnDetached( · 93 unmodified lines
525574 spawnNodeChildProcess(input.command, [...input.args], {
526575 ...(input.detached !== undefined ? { detached: input.detached } : {}),
527576 ...(input.shell !== undefined ? { shell: input.shell } : {}),
577+ ...(input.windowsHide !== undefined ? { windowsHide: input.windowsHide } : {}),
528578 ...(input.windowsVerbatimArguments !== undefined
529579 ? { windowsVerbatimArguments: input.windowsVerbatimArguments }
530580 : {}),
@@ -891,6 +941,21 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( · 360 unmodified lines
891941 return yield* new DesktopLauncherUnknownEditorError({ editor: input.editor });
892942 }
893943
944+ const windowsEditorProtocolTarget =
945+ runtime.platform === "win32"
946+ ? makeWindowsEditorProtocolTarget(input.editor, input.cwd)
947+ : undefined;
948+ if (windowsEditorProtocolTarget) {
949+ return yield* runFirstAvailablePlan(
950+ [makeWindowsExplorerPlan({ target: windowsEditorProtocolTarget })],
951+ {
952+ operation: "openInEditor",
953+ target: input.cwd,
954+ editor: input.editor,
955+ },
956+ );
957+ }
958+
894959 if (editor.command) {
895960 return yield* runFirstAvailablePlan(
896961 [
apps/server/tmp-test.cmd+3−0
@@ -0,0 +1,3 @@
1+@echo off
2+echo started >> spawned2.txt
3+