From 446d488edf140ac4e7f1da25e3fdd6d2f112470f Mon Sep 17 00:00:00 2001 From: Cody Stevens Date: Wed, 14 Oct 2026 12:48:34 -0500 Subject: [PATCH 1/2] fix(server): reveal opens the default Windows file manager Reveal on Windows launched explorer.exe directly, which bypasses a replacement file manager registered as the default folder handler. When HKCR\Directory\shell has a default verb, open the containing folder through the shell instead; stock Windows keeps the Explorer /select reveal. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/process/externalLauncher.test.ts | 9 +++++---- apps/server/src/process/externalLauncher.ts | 14 +++++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 98a867d4591a..a3985f7d0726 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -342,11 +342,12 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () => ]); const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); - // explorer.exe expects `/select,""` with only the path quoted; - // PowerShell 5.1's Start-Process passes the argument string verbatim. + // A replacement default file manager gets the containing folder through + // the shell. Otherwise explorer.exe expects `/select,""` with only + // the path quoted; PowerShell 5.1's Start-Process passes it verbatim. assert.equal( decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", + "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ([IO.Path]::GetDirectoryName('C:\\workspace with spaces\\media\\author''s clip.mp4')) } else { $ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"') }", ); assert.equal(spawned.options.shell, false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), @@ -492,7 +493,7 @@ it.effect.skipIf(windowsHost)( const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); assert.equal( decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", + "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ([IO.Path]::GetDirectoryName('\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4')) } else { $ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"') }", ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 8ab130b01e38..939b9a30d144 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -599,6 +599,18 @@ export function buildFileExplorerRevealPowerShellSource( return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; } +/** + * Keeps reveals in a replacement file manager (File Pilot, Directory Opus, + * XYplorer) set as the Windows default. Those register a default verb on + * `Directory\shell`, which stock Windows leaves as `none`, and launching + * Explorer directly bypasses it. Opening the containing folder through the + * shell runs that verb; only Explorer has a selection switch, so the file + * itself is not selected there. + */ +function buildDefaultFileManagerRevealPowerShellSource(target: string): string { + return `$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ([IO.Path]::GetDirectoryName(${escapePowerShellStringLiteral(target)})) } else { ${buildFileExplorerRevealPowerShellSource("explorer.exe", target)} }`; +} + function fileExplorerRevealLaunch( target: string, explorerTarget: string, @@ -610,7 +622,7 @@ function fileExplorerRevealLaunch( command: powershellCommand, args: [ ...POWERSHELL_ARGUMENTS_PREFIX, - encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), + encodeUtf16LeBase64(buildDefaultFileManagerRevealPowerShellSource(explorerTarget)), ], }; } From 3bc6e4689b9f1cef557cd75df84b8b92f9d82e9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 09:12:21 +0000 Subject: [PATCH 2/2] fix(server): preserve default file manager reveals from WSL --- .../src/process/externalLauncher.test.ts | 18 +++-- apps/server/src/process/externalLauncher.ts | 66 +++++++------------ 2 files changed, 35 insertions(+), 49 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 0a7c55e7ba14..dbd7808f4694 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -460,7 +460,7 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () => const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); assert.equal( decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ([IO.Path]::GetDirectoryName('C:\\workspace with spaces\\media\\author''s clip.mp4')) } else { $ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"') }", + "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath 'C:\\workspace with spaces\\media' } else { Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"') }", ); assert.equal(spawned.options.shell, false); assert.equal(spawned.options.detached, false); @@ -616,7 +616,7 @@ it.effect.skipIf(windowsHost)( const editors = yield* launcher.resolveAvailableEditors(); yield* launcher.launchEditor({ editor: "file-manager", - cwd: "/home/t3/workspace [draft]/author's $media/clip.mp4", + cwd: "/home/t3/workspace [draft]/author's $media/clip|draft.mp4", reveal: true, }); return { kind, editors }; @@ -645,7 +645,7 @@ it.effect.skipIf(windowsHost)( const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); assert.equal( decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ([IO.Path]::GetDirectoryName('\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media\\clip.mp4')) } else { $ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media\\clip.mp4' + '\"') }", + "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media' } else { Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media\\clip|draft.mp4' + '\"') }", ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -808,7 +808,7 @@ it.effect.skipIf(windowsHost)( const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchEditor({ editor: "file-manager", - cwd: '/home/t3/work "quoted"/clip.mp4', + cwd: '/home/t3/work/clip "quoted".mp4', reveal: true, }); }).pipe( @@ -828,8 +828,14 @@ it.effect.skipIf(windowsHost)( ); assert.ok(spawned); - assert.equal(spawned.command, "explorer.exe"); - assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']); + assert.equal(spawned.command, "powershell.exe"); + assert.equal(spawned.options.detached, true); + assert.equal(spawned.options.shell, false); + const source = Buffer.from(spawned.args.at(-1) ?? "", "base64").toString("utf16le"); + assert.equal( + source, + `Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work'`, + ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index e5f310fb8bfd..c9477129e646 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import { EDITORS, ExternalLauncherError, @@ -561,42 +562,24 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }; }); -/** - * Explorer's contract is the single argument `/select,""` with only the - * path quoted; Node's default spawn quoting wraps the whole argument when the - * path has spaces and Explorer misparses it, silently opening a fallback - * folder. A single `-ArgumentList` string in Windows PowerShell 5.1 reaches the - * child's command line verbatim, preserving the raw switch. - */ -function buildFileExplorerRevealPowerShellSource(explorerCommand: string, target: string): string { - return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; -} - -/** - * Keeps reveals in a replacement file manager (File Pilot, Directory Opus, - * XYplorer) set as the Windows default. Those register a default verb on - * `Directory\shell`, which stock Windows leaves as `none`, and launching - * Explorer directly bypasses it. Opening the containing folder through the - * shell runs that verb; only Explorer has a selection switch, so the file - * itself is not selected there. - */ -function buildDefaultFileManagerRevealPowerShellSource(target: string): string { - return `$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ([IO.Path]::GetDirectoryName(${escapePowerShellStringLiteral(target)})) } else { ${buildFileExplorerRevealPowerShellSource("explorer.exe", target)} }`; +// A replacement manager registers a default Directory shell verb; stock Windows +// uses "none". Explorer needs its /select argument passed verbatim through +// PowerShell because Node's argument quoting changes how Explorer parses it. +function buildFileManagerRevealPowerShellSource(target: string, parent: string): string { + const literal = escapePowerShellStringLiteral(target); + return `$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ${escapePowerShellStringLiteral(parent)} } else { Start-Process 'explorer.exe' -ArgumentList ('/select,"' + ${literal} + '"') }`; } -function fileExplorerRevealLaunch( +function powerShellFileManagerLaunch( target: string, - explorerTarget: string, + source: string, powershellCommand: string, ): EditorLaunch { return { editor: "file-manager", target, command: powershellCommand, - args: [ - ...POWERSHELL_ARGUMENTS_PREFIX, - encodeUtf16LeBase64(buildDefaultFileManagerRevealPowerShellSource(explorerTarget)), - ], + args: [...POWERSHELL_ARGUMENTS_PREFIX, encodeUtf16LeBase64(source)], }; } @@ -619,10 +602,12 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch } if (platform === "win32") { + const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)); + const windowsTarget = normalizeWindowsFileManagerPath(target); return { - ...fileExplorerRevealLaunch( + ...powerShellFileManagerLaunch( target, - normalizeWindowsFileManagerPath(target), + buildFileManagerRevealPowerShellSource(windowsTarget, path.dirname(windowsTarget)), resolvePowerShellPath(env), ), detached: WINDOWS_POWERSHELL_DETACHED, @@ -636,20 +621,15 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch ) { const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME); if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) { - // Explorer's raw switch cannot express a double quote, and unlike - // Windows paths a WSL path may legally contain one: open the containing - // directory in File Explorer instead, matching the advertised - // "file-explorer" kind. - if (explorerTarget.includes('"')) { - const path = yield* Path.Path; - return { - editor: "file-manager", - target, - command: "explorer.exe", - args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], - }; - } - return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND); + // WSL names may contain quotes that Explorer's /select cannot encode. + // Open the parent through the shell so replacement managers still apply. + const path = yield* Path.Path; + // .NET path parsing rejects characters that are legal in WSL filenames. + const parent = resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME); + const source = explorerTarget.includes('"') + ? `Invoke-Item -LiteralPath ${escapePowerShellStringLiteral(parent)}` + : buildFileManagerRevealPowerShellSource(explorerTarget, parent); + return powerShellFileManagerLaunch(target, source, WSL_POWERSHELL_COMMAND); } if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) { const path = yield* Path.Path;