Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions src/open-external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* The command builders are pure — the platform is injectable — so every
* per-platform branch is unit-testable without spawning anything.
*/
import { spawn } from 'node:child_process'
import { execFileSync, spawn } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { parentOf, requireAbsolute } from './fs-tree.ts'
import { SidebarError } from './wire.ts'

Expand All @@ -22,9 +23,42 @@ export interface ExternalCommand {
args: string[]
}

/** Reveal/select a path in the OS file manager. On Linux there is no common
* select protocol — the containing directory is opened instead (KISS). */
export function revealCommand(path: string, platform: NodeJS.Platform = process.platform): ExternalCommand {
/** True when the host runs inside WSL: a Linux kernel with Windows interop.
* "Reveal in the OS file manager" must then go to the Windows side — there
* is no Linux desktop file manager here (and usually no xdg-open). */
export function isWslRuntime(): boolean {
if (process.env.WSL_DISTRO_NAME) return true
try {
return /microsoft/i.test(readFileSync('/proc/version', 'utf8'))
} catch {
return false
}
}

/** Translate a WSL absolute path to the Windows form Explorer understands:
* `\\wsl.localhost\<distro>\…` for the Linux filesystem, `C:\…` for /mnt/c. */
function windowsPathOf(path: string): string {
const out = execFileSync('wslpath', ['-w', path], { encoding: 'utf8' }).trim()
if (out === '') throw new Error(`wslpath returned no Windows path for ${path}`)
return out
}

/** A Linux-path → Windows-path translator (injectable so the builder below
* stays pure — the real translator shells out to `wslpath`). */
export type WindowsPathTranslator = (path: string) => string

/** Reveal/select a path in the OS file manager. On plain Linux there is no
* common select protocol — the containing directory is opened instead
* (KISS). Under WSL the opener is the Windows Explorer, fed a translated
* path so the entry is selected exactly like the win32 branch. `wsl` and
* `toWindows` default to non-WSL so the builder stays pure; the launch
* route injects `isWslRuntime()` and the real translator. */
export function revealCommand(
path: string,
platform: NodeJS.Platform = process.platform,
wsl: boolean = false,
toWindows: WindowsPathTranslator = windowsPathOf,
): ExternalCommand {
switch (platform) {
case 'darwin':
return { command: 'open', args: ['-R', path] }
Expand All @@ -33,6 +67,9 @@ export function revealCommand(path: string, platform: NodeJS.Platform = process.
case 'win32':
return { command: 'explorer.exe', args: [`/select,${path}`] }
default: {
if (wsl) {
return { command: 'explorer.exe', args: [`/select,${toWindows(path)}`] }
}
const parent = parentOf(path)
return { command: 'xdg-open', args: [parent ?? path] }
}
Expand Down Expand Up @@ -79,10 +116,16 @@ export function validateExternalUrl(raw: string): string {
*/
export function launchExternal(action: OpenExternalAction, value: string): { started: true } {
const platform = process.platform
const wsl = platform === 'linux' && isWslRuntime()
const spec = action === 'reveal'
? revealCommand(requireAbsolute(value), platform)
? revealCommand(requireAbsolute(value), platform, wsl)
: urlCommand(validateExternalUrl(value), platform)
const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore' })
// WSL: the .exe opener runs through Windows interop — keep the Windows
// system dirs on PATH even when the host was started with a slim PATH.
const env = wsl && spec.command.endsWith('.exe')
? { ...process.env, PATH: `${process.env.PATH ?? ''}:/mnt/c/WINDOWS:/mnt/c/WINDOWS/System32` }
: process.env
const child = spawn(spec.command, spec.args, { env, detached: true, stdio: 'ignore' })
child.on('error', () => { /* opener missing/denied: handled by the OS */ })
child.unref()
return { started: true }
Expand Down
10 changes: 10 additions & 0 deletions tests/open-external.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ describe('revealCommand', () => {
expect(revealCommand('/a/b.txt', 'linux')).toEqual({ command: 'xdg-open', args: ['/a'] })
expect(revealCommand('/', 'linux')).toEqual({ command: 'xdg-open', args: ['/'] })
})

it('wsl (linux host with Windows interop): selects the file in the Windows Explorer', () => {
// Translator injected — the real one shells out to `wslpath` (see
// windowsPathOf), which does not exist on non-WSL CI hosts.
const toWindows = (p: string): string => p === '/a/b.txt' ? '\\\\wsl.localhost\\Ubuntu\\a\\b.txt' : p
expect(revealCommand('/a/b.txt', 'linux', true, toWindows)).toEqual({
command: 'explorer.exe',
args: ['/select,\\\\wsl.localhost\\Ubuntu\\a\\b.txt'],
})
})
})

describe('urlCommand', () => {
Expand Down