- Launch `explorer.exe` with a `vscode://file/...` target for Windows editor opens - Improve Windows spawn handling with `windowsHide` and protocol URI coverage
Showing the changes to apps/server/src/process (2 of 4 files). Show all
Unified · Split
| @@ -29,6 +29,7 @@ interface SpawnCall { · 28 unmodified lines | |||
| 29 | readonly detached?: boolean | undefined; | 29 | readonly detached?: boolean | undefined; |
| 30 | readonly shell?: boolean | string | undefined; | 30 | readonly shell?: boolean | string | undefined; |
| 31 | readonly windowsVerbatimArguments?: boolean | undefined; | 31 | readonly windowsVerbatimArguments?: boolean | undefined; |
| 32 | readonly windowsHide?: boolean | undefined; | ||
| 32 | readonly stdin?: unknown; | 33 | readonly stdin?: unknown; |
| 33 | readonly stdout?: unknown; | 34 | readonly stdout?: unknown; |
| 34 | readonly stderr?: unknown; | 35 | readonly stderr?: unknown; |
| @@ -154,6 +155,7 @@ function spawnHarness( · 119 unmodified lines | |||
| 154 | detached: input.detached, | 155 | detached: input.detached, |
| 155 | shell: input.shell, | 156 | shell: input.shell, |
| 156 | windowsVerbatimArguments: input.windowsVerbatimArguments, | 157 | windowsVerbatimArguments: input.windowsVerbatimArguments, |
| 158 | windowsHide: input.windowsHide, | ||
| 157 | stdin: input.stdin, | 159 | stdin: input.stdin, |
| 158 | stdout: input.stdout, | 160 | stdout: input.stdout, |
| 159 | stderr: input.stderr, | 161 | stderr: input.stderr, |
| @@ -313,11 +315,11 @@ it.effect("openInEditor uses --goto for editors that support it", () => · 153 unmodified lines | |||
| 313 | }).pipe(Effect.provide(NodeFileSystem.layer)), | 315 | }).pipe(Effect.provide(NodeFileSystem.layer)), |
| 314 | ); | 316 | ); |
| 315 | 317 | ||
| 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", () => |
| 317 | Effect.gen(function* () { | 319 | Effect.gen(function* () { |
| 318 | const fs = yield* FileSystem.FileSystem; | 320 | const fs = yield* FileSystem.FileSystem; |
| 319 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" }); | 321 | 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"); |
| 321 | 323 | ||
| 322 | const calls: Array<SpawnCall> = []; | 324 | const calls: Array<SpawnCall> = []; |
| 323 | const open = yield* runOpen( | 325 | const open = yield* runOpen( |
| @@ -337,28 +339,17 @@ it.effect("openInEditor launches Windows batch shims through cmd.exe without she · 13 unmodified lines | |||
| 337 | editor: "vscode", | 339 | editor: "vscode", |
| 338 | }); | 340 | }); |
| 339 | 341 | ||
| 340 | assert.equal(calls.length, 1); | 342 | assert.deepEqual(calls, [ |
| 341 | assert.equal(calls[0]?.command.toLowerCase().includes("powershell"), true); | 343 | { |
| 342 | assert.deepEqual(calls[0]?.args.slice(0, 5), [ | 344 | command: `${dir}/explorer.exe`, |
| 343 | "-NoProfile", | 345 | args: ["vscode://file/C:/work/100%25%20real/file.ts:12:4"], |
| 344 | "-NonInteractive", | 346 | detached: true, |
| 345 | "-ExecutionPolicy", | 347 | shell: false, |
| 346 | "Bypass", | 348 | stdin: "ignore", |
| 347 | "-EncodedCommand", | 349 | stdout: "ignore", |
| 350 | stderr: "ignore", | ||
| 351 | }, | ||
| 348 | ]); | 352 | ]); |
| 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"); | ||
| 362 | }).pipe(Effect.provide(NodeFileSystem.layer)), | 353 | }).pipe(Effect.provide(NodeFileSystem.layer)), |
| 363 | ); | 354 | ); |
| 364 | 355 | ||
| @@ -9,6 +9,7 @@ · 8 unmodified lines | |||
| 9 | */ | 9 | */ |
| 10 | import OS from "node:os"; | 10 | import OS from "node:os"; |
| 11 | import { spawn as spawnNodeChildProcess } from "node:child_process"; | 11 | import { spawn as spawnNodeChildProcess } from "node:child_process"; |
| 12 | import { pathToFileURL } from "node:url"; | ||
| 12 | 13 | ||
| 13 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | 14 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 14 | import { Array, Effect, FileSystem, Layer, Option, Path, Scope } from "effect"; | 15 | import { Array, Effect, FileSystem, Layer, Option, Path, Scope } from "effect"; |
| @@ -38,6 +39,7 @@ export interface DetachedSpawnInput { · 23 unmodified lines | |||
| 38 | readonly detached?: boolean; | 39 | readonly detached?: boolean; |
| 39 | readonly shell?: boolean; | 40 | readonly shell?: boolean; |
| 40 | readonly windowsVerbatimArguments?: boolean; | 41 | readonly windowsVerbatimArguments?: boolean; |
| 42 | readonly windowsHide?: boolean; | ||
| 41 | readonly stdin?: "ignore"; | 43 | readonly stdin?: "ignore"; |
| 42 | readonly stdout?: "ignore"; | 44 | readonly stdout?: "ignore"; |
| 43 | readonly stderr?: "ignore"; | 45 | readonly stderr?: "ignore"; |
| @@ -119,6 +121,11 @@ const WSL_POWERSHELL_CANDIDATES = [ · 75 unmodified lines | |||
| 119 | "powershell.exe", | 121 | "powershell.exe", |
| 120 | "pwsh.exe", | 122 | "pwsh.exe", |
| 121 | ] as const; | 123 | ] as const; |
| 124 | const WINDOWS_EDITOR_URI_SCHEMES: Partial<Record<EditorId, string>> = { | ||
| 125 | vscode: "vscode", | ||
| 126 | "vscode-insiders": "vscode-insiders", | ||
| 127 | vscodium: "vscodium", | ||
| 128 | }; | ||
| 122 | 129 | ||
| 123 | function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { | 130 | function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { |
| 124 | return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); | 131 | return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); |
| @@ -128,6 +135,24 @@ function stripWrappingQuotes(value: string): string { · 3 unmodified lines | |||
| 128 | return value.replace(/^"+|"+$/g, ""); | 135 | return value.replace(/^"+|"+$/g, ""); |
| 129 | } | 136 | } |
| 130 | 137 | ||
| 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 | |||
| 131 | function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { | 156 | function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { |
| 132 | return env.PATH ?? env.Path ?? env.path ?? ""; | 157 | return env.PATH ?? env.Path ?? env.path ?? ""; |
| 133 | } | 158 | } |
| @@ -188,6 +213,10 @@ function quotePowerShellValue(value: string): string { · 54 unmodified lines | |||
| 188 | return `'${value.replaceAll("'", "''")}'`; | 213 | return `'${value.replaceAll("'", "''")}'`; |
| 189 | } | 214 | } |
| 190 | 215 | ||
| 216 | function quotePowerShellArgument(value: string): string { | ||
| 217 | return `"\`"${value.replaceAll("`", "``").replaceAll('"', '`"')}\`""`; | ||
| 218 | } | ||
| 219 | |||
| 191 | function encodePowerShellCommand(command: string): string { | 220 | function encodePowerShellCommand(command: string): string { |
| 192 | return Buffer.from(command, "utf16le").toString("base64"); | 221 | return Buffer.from(command, "utf16le").toString("base64"); |
| 193 | } | 222 | } |
| @@ -218,6 +247,19 @@ function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: Launc · 24 unmodified lines | |||
| 218 | return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target); | 247 | return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target); |
| 219 | } | 248 | } |
| 220 | 249 | ||
| 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 | |||
| 221 | function makeLaunchPlan( | 263 | function makeLaunchPlan( |
| 222 | command: string, | 264 | command: string, |
| 223 | args: ReadonlyArray<string>, | 265 | args: ReadonlyArray<string>, |
| @@ -309,8 +351,8 @@ function makePowerShellStartProcessArgs( · 85 unmodified lines | |||
| 309 | args: ReadonlyArray<string>, | 351 | args: ReadonlyArray<string>, |
| 310 | ): ReadonlyArray<string> { | 352 | ): ReadonlyArray<string> { |
| 311 | const argumentList = | 353 | const argumentList = |
| 312 | args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellValue).join(", ")}` : ""; | 354 | args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellArgument).join(",")}` : ""; |
| 313 | const command = `Start-Process -FilePath ${quotePowerShellValue(commandPath)}${argumentList} -WindowStyle Hidden`; | 355 | const command = `Start ${quotePowerShellArgument(commandPath)}${argumentList} -WindowStyle Hidden`; |
| 314 | return [ | 356 | return [ |
| 315 | "-NoProfile", | 357 | "-NoProfile", |
| 316 | "-NonInteractive", | 358 | "-NonInteractive", |
| @@ -336,6 +378,8 @@ function makeWindowsExplorerPlan(input: OpenExternalInput): LaunchPlan { · 19 unmodified lines | |||
| 336 | return makeLaunchPlan("explorer", [input.target], { | 378 | return makeLaunchPlan("explorer", [input.target], { |
| 337 | wait: false, | 379 | wait: false, |
| 338 | allowNonzeroExitCode: false, | 380 | allowNonzeroExitCode: false, |
| 381 | detached: true, | ||
| 382 | stdio: "ignore", | ||
| 339 | shell: false, | 383 | shell: false, |
| 340 | }); | 384 | }); |
| 341 | } | 385 | } |
| @@ -408,6 +452,9 @@ function resolveSpawnInput( · 66 unmodified lines | |||
| 408 | args: makePowerShellStartProcessArgs(resolvedCommand.path, plan.args), | 452 | args: makePowerShellStartProcessArgs(resolvedCommand.path, plan.args), |
| 409 | ...(plan.detached !== undefined ? { detached: plan.detached } : {}), | 453 | ...(plan.detached !== undefined ? { detached: plan.detached } : {}), |
| 410 | ...(plan.shell !== undefined ? { shell: plan.shell } : {}), | 454 | ...(plan.shell !== undefined ? { shell: plan.shell } : {}), |
| 455 | ...(runtime.platform === "win32" | ||
| 456 | ? { windowsVerbatimArguments: true, windowsHide: true } | ||
| 457 | : {}), | ||
| 411 | ...(plan.stdio === "ignore" | 458 | ...(plan.stdio === "ignore" |
| 412 | ? { | 459 | ? { |
| 413 | stdin: "ignore" as const, | 460 | stdin: "ignore" as const, |
| @@ -417,6 +464,7 @@ function resolveSpawnInput( · 3 unmodified lines | |||
| 417 | : {}), | 464 | : {}), |
| 418 | }; | 465 | }; |
| 419 | } | 466 | } |
| 467 | const windowsHide = runtime.platform === "win32" && plan.detached ? true : undefined; | ||
| 420 | return { | 468 | return { |
| 421 | command: resolvedCommand.usesCmdWrapper | 469 | command: resolvedCommand.usesCmdWrapper |
| 422 | ? resolveWindowsCommandShell(runtime.env) | 470 | ? resolveWindowsCommandShell(runtime.env) |
| @@ -426,6 +474,7 @@ function resolveSpawnInput( · 3 unmodified lines | |||
| 426 | : [...plan.args], | 474 | : [...plan.args], |
| 427 | ...(plan.detached !== undefined ? { detached: plan.detached } : {}), | 475 | ...(plan.detached !== undefined ? { detached: plan.detached } : {}), |
| 428 | ...(plan.shell !== undefined ? { shell: plan.shell } : {}), | 476 | ...(plan.shell !== undefined ? { shell: plan.shell } : {}), |
| 477 | ...(windowsHide ? { windowsHide } : {}), | ||
| 429 | ...(resolvedCommand.usesCmdWrapper ? { windowsVerbatimArguments: true } : {}), | 478 | ...(resolvedCommand.usesCmdWrapper ? { windowsVerbatimArguments: true } : {}), |
| 430 | ...(plan.stdio === "ignore" | 479 | ...(plan.stdio === "ignore" |
| 431 | ? { | 480 | ? { |
| @@ -525,6 +574,7 @@ function defaultSpawnDetached( · 93 unmodified lines | |||
| 525 | spawnNodeChildProcess(input.command, [...input.args], { | 574 | spawnNodeChildProcess(input.command, [...input.args], { |
| 526 | ...(input.detached !== undefined ? { detached: input.detached } : {}), | 575 | ...(input.detached !== undefined ? { detached: input.detached } : {}), |
| 527 | ...(input.shell !== undefined ? { shell: input.shell } : {}), | 576 | ...(input.shell !== undefined ? { shell: input.shell } : {}), |
| 577 | ...(input.windowsHide !== undefined ? { windowsHide: input.windowsHide } : {}), | ||
| 528 | ...(input.windowsVerbatimArguments !== undefined | 578 | ...(input.windowsVerbatimArguments !== undefined |
| 529 | ? { windowsVerbatimArguments: input.windowsVerbatimArguments } | 579 | ? { windowsVerbatimArguments: input.windowsVerbatimArguments } |
| 530 | : {}), | 580 | : {}), |
| @@ -891,6 +941,21 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( · 360 unmodified lines | |||
| 891 | return yield* new DesktopLauncherUnknownEditorError({ editor: input.editor }); | 941 | return yield* new DesktopLauncherUnknownEditorError({ editor: input.editor }); |
| 892 | } | 942 | } |
| 893 | 943 | ||
| 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 | |||
| 894 | if (editor.command) { | 959 | if (editor.command) { |
| 895 | return yield* runFirstAvailablePlan( | 960 | return yield* runFirstAvailablePlan( |
| 896 | [ | 961 | [ |