Files
orca/src/shared/windows-path-access.ts
T
Neil 962c338b67 feat(wsl): Windows path actions for WSL hosts through one owner-routed capability (WSL-via-orcad C8) (#27955)
* feat(wsl): open WSL host paths from Windows through one owner-routed capability (WSL-via-orcad C8)

* test(wsl): typed shell handler lookup for the WSL owner tests

* fix(wsl): read known WSL hosts through a registered accessor; e2e for the Windows folder picker

* fix(wsl): open the host-path entry filled in after a Windows pick; e2e screenshots

* test(wsl): read the e2e picker's default path through the main process env

* refactor(wsl): read a WSL host's distro from its record instead of a new accessor

* fix(wsl): skip the host browser only for a path Windows' picker or an Explorer drop filled in

A path left filled from an earlier pick on an SSH or paired host opens the host browser again, as it did before.
2026-10-13 10:00:55 -07:00

97 lines
3.8 KiB
TypeScript

/**
* How Windows reaches the paths of a resource's OWNER host (WSL-via-orcad D5). Only a WSL host
* qualifies: its files are on `\\wsl.localhost\<distro>\...`. Every other host gets null, so SSH,
* paired servers and this computer keep their behavior. Translation is pure string work on the
* desktop side; nothing runs inside the distro.
*/
import { parseRoutableExecutionHostId } from './execution-host'
import { parseWslUncPath, toWindowsWslUncPath } from './wsl-paths'
export type WindowsPathAccess = {
/** The distro as registered, which is the casing every emitted path uses. */
readonly wslDistro: string
/** The Windows spelling of an absolute Linux path on the host, or null when it has none. */
toWindows(linuxPath: string): string | null
/** The host's Linux path for a `\\wsl.localhost` / `\\wsl$` path into its distro, else null. */
fromWindows(windowsPath: string): string | null
}
export type WindowsPathAccessContext = {
platform: NodeJS.Platform | null
/** The distro behind a WSL host's runtime environment; null for every other environment. */
wslDistroForEnvironment: (environmentId: string) => string | null | undefined
}
/** The one capability every Windows path guard asks, keyed on the owner host, never on focus. */
export function windowsPathAccess(
ownerHostId: string | null | undefined,
context: WindowsPathAccessContext
): WindowsPathAccess | null {
if (context.platform !== 'win32') {
return null
}
const host = parseRoutableExecutionHostId(ownerHostId)
if (host?.kind !== 'runtime') {
return null
}
const distro = context.wslDistroForEnvironment(host.environmentId)?.trim()
return distro ? wslDistroWindowsPathAccess(distro) : null
}
export function wslDistroWindowsPathAccess(distro: string): WindowsPathAccess {
return {
wslDistro: distro,
toWindows: (linuxPath) => {
const normalized = normalizeLinuxPath(linuxPath)
return normalized === null ? null : toWindowsWslUncPath(normalized, distro)
},
fromWindows: (windowsPath) => {
const parsed = parseWslUncPath(stripWin32LongPathPrefix(windowsPath.trim()))
// Why case-insensitive: Windows folds the distro in UNC paths (`\\wsl.localhost\ubuntu`).
if (!parsed || !sameWslDistroName(parsed.distro, distro)) {
return null
}
return normalizeLinuxPath(parsed.linuxPath)
}
}
}
export function sameWslDistroName(left: string, right: string): boolean {
return left.trim().toLowerCase() === right.trim().toLowerCase()
}
export type WindowsPathRejection =
| { kind: 'windows-drive' }
| { kind: 'other-distro'; distro: string }
| { kind: 'not-wsl' }
/** Why `fromWindows` refused a path, for copy that tells the user what to pick instead. */
export function classifyRejectedWindowsPath(windowsPath: string): WindowsPathRejection {
const trimmed = stripWin32LongPathPrefix(windowsPath.trim())
if (/^[A-Za-z]:([\\/]|$)/.test(trimmed)) {
return { kind: 'windows-drive' }
}
const parsed = parseWslUncPath(trimmed)
return parsed ? { kind: 'other-distro', distro: parsed.distro } : { kind: 'not-wsl' }
}
/** `\\?\UNC\wsl.localhost\...` and `\\?\C:\...` are the same paths Explorer may hand over. */
function stripWin32LongPathPrefix(path: string): string {
if (/^\\\\\?\\UNC\\/i.test(path)) {
return `\\\\${path.slice('\\\\?\\UNC\\'.length)}`
}
return /^\\\\\?\\[A-Za-z]:/.test(path) ? path.slice('\\\\?\\'.length) : path
}
/** Absolute, no `..`/backslash/NUL (Windows would read those differently); trailing slash dropped. */
function normalizeLinuxPath(linuxPath: string): string | null {
if (!linuxPath.startsWith('/') || linuxPath.startsWith('//') || /[\\\0]/.test(linuxPath)) {
return null
}
const segments = linuxPath.split('/').filter((segment) => segment !== '' && segment !== '.')
if (segments.includes('..')) {
return null
}
return `/${segments.join('/')}`
}