From 375b8391e54e470de12d2575882b486f74c16f5d Mon Sep 17 00:00:00 2001 From: MatthewFeroz Date: Fri, 18 Sep 2026 00:41:15 -0400 Subject: [PATCH] fix(server): restore Windows file-manager actions --- .../src/process/externalLauncher.test.ts | 71 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 26 ++++--- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index b7092d70f0e2..650ead7d7f98 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -338,9 +338,80 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () => "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", ); assert.equal(spawned.options.shell, false); + + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "C:/workspace with spaces/media/author's clip.mp4", + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.deepEqual(spawned.args, ["C:\\workspace with spaces\\media\\author's clip.mp4"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect.skipIf(!windowsHost)( + "executes the Windows reveal helper with the launcher's process options", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-reveal-launch-" }); + const recorderPath = path.join(tempDir, "recorder.cmd"); + const outputPath = path.join(tempDir, "argv.txt"); + yield* fileSystem.writeFileString(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); + let helper: ChildProcessSpawner.ChildProcessHandle | undefined; + const recordingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + assert.ok(ChildProcess.isStandardCommand(command)); + if (!ChildProcess.isStandardCommand(command)) + throw new Error("Expected a standard command"); + const source = Buffer.from(command.args.at(-1) ?? "", "base64").toString("utf16le"); + // Substitute only Explorer, preserving the production launch options. + // Wait for the recorder so its output is complete when the helper exits. + const recordingSource = + source.replace("'explorer.exe'", `'${recorderPath.replaceAll("'", "''")}'`) + " -Wait"; + helper = yield* spawner.spawn( + ChildProcess.make( + command.command, + [ + ...command.args.slice(0, -1), + Buffer.from(recordingSource, "utf16le").toString("base64"), + ], + command.options, + ), + ); + return helper; + }), + ); + const launcher = yield* ExternalLauncher.make.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner), + ); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "C:/workspace with spaces/author's file.md", + reveal: true, + }); + assert.ok(helper); + assert.equal(yield* helper.exitCode, 0); + assert.equal( + (yield* fileSystem.readFileString(outputPath)).trim(), + `/select,"C:\\workspace with spaces\\author's file.md"`, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + // Real-chain smoke check for the Explorer selection contract: runs the exact // PowerShell source the reveal launch encodes, against a stub that records // the raw argument tail it receives, and asserts a spaced path arrives as the diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 2b7310051b89..e467a4066f48 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -52,6 +52,7 @@ interface EditorLaunch { readonly target: string; readonly command: string; readonly args: ReadonlyArray; + readonly detached?: boolean; } interface ProcessLaunch { @@ -568,9 +569,11 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( target: input.cwd, command, args: - command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined - ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] - : [input.cwd], + platform === "win32" + ? [normalizeWindowsFileManagerPath(input.cwd)] + : command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined + ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] + : [input.cwd], }; }); @@ -629,11 +632,16 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch } if (platform === "win32") { - return fileExplorerRevealLaunch( - target, - normalizeWindowsFileManagerPath(target), - resolvePowerShellPath(env), - ); + return { + ...fileExplorerRevealLaunch( + target, + normalizeWindowsFileManagerPath(target), + resolvePowerShellPath(env), + ), + // Windows PowerShell can exit successfully without running its command + // when detached with ignored stdio. The helper is still unref'ed below. + detached: false, + }; } if ( @@ -734,7 +742,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu command: spawnCommand.command, args: spawnCommand.args, options: { - detached: true, + detached: launch.detached ?? true, shell: spawnCommand.shell, stdin: "ignore", stdout: "ignore",