Support Windows drives in the remote host filesystem picker (#7439)

* Support Windows drives in the remote host filesystem picker

The remote picker was locked to the system drive on Windows hosts: the
breadcrumb root resolved to C:\ and typed drive paths (M:\dev) were
treated as filter text, so projects could only ever be created on C:.

- Server: answer host-root browses ('/') on win32 with the mounted
  drives instead of resolving to C:\.
- Client: recognize drive-anchored input (M:\, M:/, m:) as path mode,
  resolve segments from the normalized drive root, and make
  joinPath/parentPath/breadcrumbs drive-aware. Up from a drive root
  returns to the host root (the drive list).

Fixes #7438

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document why joinDrivePath uses a literal backslash

Review feedback suggested path.win32.join, but the renderer bundle
imports no Node builtins anywhere and runs sandboxed, so path.win32 is
not available here. The backslash targets the remote Windows host
regardless of client OS; say so at the call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Complete Windows drive browsing over SSH

* fix remote Windows drive browsing

* fix(ui): key remote breadcrumbs by path

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Michael 2026-07-29 04:51:14 +01:00 committed by GitHub
parent 3f53287554
commit afbd98d8a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 683 additions and 69 deletions

View File

@ -62,6 +62,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: '/home/user',
pathFlavor: 'posix',
entries: [
{ name: 'src', isDirectory: true },
{ name: 'notes file.txt', isDirectory: false },
@ -93,6 +94,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: "/tmp/it's here",
pathFlavor: 'posix',
entries: []
})
expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap")
@ -128,6 +130,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: 'C:/Users/alice',
pathFlavor: 'win32',
entries: [
{ name: 'Desktop', isDirectory: true },
{ name: 'notes.txt', isDirectory: false }
@ -150,6 +153,40 @@ describe('registerSshBrowseHandler', () => {
expect(script).toContain("Write-Output ($resolved -replace '\\\\', '/')")
})
it('lists Windows drive roots when an SSH picker browses the host root', async () => {
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/' })
await Promise.resolve()
posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
posixChannel.emit('exit', 1)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
windowsChannel.emit('data', Buffer.from('/\r\nC:\\/\r\nM:\\/\r\n'))
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
await expect(resultPromise).resolves.toEqual({
resolvedPath: '/',
pathFlavor: 'win32',
entries: [
{ name: 'C:\\', isDirectory: true },
{ name: 'M:\\', isDirectory: true }
]
})
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
expect(script).toContain('Get-PSDrive -PSProvider FileSystem')
expect(script).not.toContain('Set-Location')
})
it('falls back for a non-English cmd.exe reject (exit 1, localized stderr)', async () => {
// Regression: real Windows OpenSSH + cmd.exe forwards exit 1 (not 9009) with
// localized stderr. The old 9009/English-string trigger silently missed this,
@ -182,6 +219,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: 'C:/Users',
pathFlavor: 'win32',
entries: [{ name: 'Admin', isDirectory: true }]
})
expect(exec).toHaveBeenCalledTimes(2)
@ -213,6 +251,7 @@ describe('registerSshBrowseHandler', () => {
await expect(resultPromise).resolves.toEqual({
resolvedPath: "C:/O'Brien",
pathFlavor: 'win32',
entries: []
})
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
@ -241,7 +280,11 @@ describe('registerSshBrowseHandler', () => {
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users/alice', entries: [] })
await expect(resultPromise).resolves.toEqual({
resolvedPath: 'C:/Users/alice',
entries: [],
pathFlavor: 'win32'
})
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
// ~ must expand to $HOME, not be passed literally to Set-Location.
expect(script).toContain('$dir = $HOME')
@ -279,7 +322,11 @@ describe('registerSshBrowseHandler', () => {
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users', entries: [] })
await expect(resultPromise).resolves.toEqual({
resolvedPath: 'C:/Users',
entries: [],
pathFlavor: 'win32'
})
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
expect(script).toContain(expected)
}

View File

@ -2,12 +2,19 @@ import { ipcMain } from 'electron'
import type { SshConnectionManager } from '../ssh/ssh-connection-manager'
import type { SshExecOptions } from '../ssh/ssh-connection-utils'
import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell'
import type { FilesystemPathFlavor } from '../../shared/types'
export type RemoteDirEntry = {
name: string
isDirectory: boolean
}
type RemoteBrowseResult = {
entries: RemoteDirEntry[]
resolvedPath: string
pathFlavor: FilesystemPathFlavor
}
const SSH_BROWSE_TIMEOUT_MS = 15_000
// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one.
@ -32,10 +39,7 @@ export function registerSshBrowseHandler(
ipcMain.handle(
'ssh:browseDir',
async (
_event,
args: { targetId: string; dirPath: string }
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> => {
async (_event, args: { targetId: string; dirPath: string }): Promise<RemoteBrowseResult> => {
const mgr = getConnectionManager()
if (!mgr) {
throw new Error('SSH connection manager not initialized')
@ -68,15 +72,25 @@ type SshBrowseConnection = NonNullable<ReturnType<SshConnectionManager['getConne
function browseWithPosixShell(
conn: SshBrowseConnection,
dirPath: string
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
): Promise<RemoteBrowseResult> {
// Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs.
return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`)
return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`, 'posix')
}
function browseWithWindowsPowerShell(
conn: SshBrowseConnection,
dirPath: string
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
): Promise<RemoteBrowseResult> {
if (/^[\\/]+$/.test(dirPath.trim())) {
const script = [
"$ErrorActionPreference = 'Stop'",
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
"Write-Output '/'",
"Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Name -match '^[A-Za-z]$' } | Sort-Object Name | ForEach-Object { Write-Output (($_.Name.ToUpperInvariant() + ':\\') + '/') }"
].join('; ')
return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false })
}
const script = [
"$ErrorActionPreference = 'Stop'",
// Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake.
@ -91,14 +105,15 @@ function browseWithWindowsPowerShell(
'}'
].join('; ')
return runBrowseCommand(conn, powerShellCommand(script), { wrapCommand: false })
return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false })
}
async function runBrowseCommand(
conn: SshBrowseConnection,
command: string,
pathFlavor: FilesystemPathFlavor,
options?: SshExecOptions
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
): Promise<RemoteBrowseResult> {
const channel = options ? await conn.exec(command, options) : await conn.exec(command)
return new Promise((resolve, reject) => {
@ -145,7 +160,7 @@ async function runBrowseCommand(
rejectOnce(new Error('Remote directory listing timed out'))
closeChannel()
}
const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => {
const resolveOnce = (result: RemoteBrowseResult): void => {
if (settled) {
return
}
@ -213,7 +228,7 @@ async function runBrowseCommand(
return a.name.localeCompare(b.name)
})
resolveOnce({ entries, resolvedPath })
resolveOnce({ entries, resolvedPath, pathFlavor })
}
channel.on('data', onStdoutData)

View File

@ -6788,6 +6788,7 @@ describe('OrcaRuntimeService', () => {
const result = await runtime.browseServerDir(tempRoot)
expect(result.resolvedPath).toBe(tempRoot)
expect(result.pathFlavor).toBe(process.platform === 'win32' ? 'win32' : 'posix')
expect(result.entries).toEqual([
{ name: 'alpha', isDirectory: true, isSymlink: false },
{ name: 'zeta', isDirectory: true, isSymlink: false },
@ -6798,6 +6799,20 @@ describe('OrcaRuntimeService', () => {
}
})
it.runIf(process.platform === 'win32')('lists drive roots for a server-root browse', async () => {
const runtime = new OrcaRuntimeService(store)
const result = await runtime.browseServerDir('/')
expect(result.resolvedPath).toBe('/')
expect(result.pathFlavor).toBe('win32')
expect(result.entries).toContainEqual({
name: win32.parse(tmpdir()).root.toUpperCase(),
isDirectory: true,
isSymlink: false
})
})
it('defaults runtime addRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
const added: Record<string, unknown>[] = []
const colorStore = {

View File

@ -10,6 +10,7 @@ import {
normalizeTerminalTitle
} from '../../shared/agent-detection'
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
import type { AgentStatus } from '../../shared/agent-detection'
@ -185,6 +186,7 @@ import type {
WorkspaceCreateTelemetrySource,
WorkspaceSessionState,
DirEntry,
FilesystemPathFlavor,
GitHubIssueUpdate,
GitHubPullRequestStateUpdate,
GitHubPRFile,
@ -15631,7 +15633,15 @@ export class OrcaRuntimeService {
return scanNestedRepos({ path, options: { timeoutMs: 15_000 } })
}
async browseServerDir(pathValue: string): Promise<{ resolvedPath: string; entries: DirEntry[] }> {
async browseServerDir(pathValue: string): Promise<{
resolvedPath: string
entries: DirEntry[]
pathFlavor: FilesystemPathFlavor
}> {
// Windows resolves `/` to the current drive, so expose drive roots instead.
if (isServerDriveListRequest(pathValue)) {
return listWindowsDrives()
}
const dirPath = resolveServerBrowsePath(pathValue)
const dirStat = await stat(dirPath)
if (!dirStat.isDirectory()) {
@ -15651,7 +15661,11 @@ export class OrcaRuntimeService {
}
return a.name.localeCompare(b.name)
})
return { resolvedPath: dirPath, entries: mapped }
return {
resolvedPath: dirPath,
entries: mapped,
pathFlavor: process.platform === 'win32' ? 'win32' : 'posix'
}
}
async isGitAvailable(): Promise<boolean> {

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import type { Stats } from 'node:fs'
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
describe('isServerDriveListRequest', () => {
it('matches root browses only on win32', () => {
expect(isServerDriveListRequest('/', 'win32')).toBe(true)
expect(isServerDriveListRequest('\\', 'win32')).toBe(true)
expect(isServerDriveListRequest(' / ', 'win32')).toBe(true)
expect(isServerDriveListRequest('/', 'darwin')).toBe(false)
expect(isServerDriveListRequest('/', 'linux')).toBe(false)
})
it('does not intercept non-root paths on win32', () => {
expect(isServerDriveListRequest('C:\\', 'win32')).toBe(false)
expect(isServerDriveListRequest('/Users', 'win32')).toBe(false)
expect(isServerDriveListRequest('~', 'win32')).toBe(false)
expect(isServerDriveListRequest('', 'win32')).toBe(false)
})
})
describe('listWindowsDrives', () => {
const statOnly = (mounted: string[]) => async (p: string) => {
if (!mounted.includes(p)) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
return { isDirectory: () => true } as Stats
}
it('returns one directory entry per mounted drive, anchored at the host root', async () => {
const result = await listWindowsDrives(statOnly(['C:\\', 'M:\\']))
expect(result.resolvedPath).toBe('/')
expect(result.pathFlavor).toBe('win32')
expect(result.entries).toEqual([
{ name: 'C:\\', isDirectory: true, isSymlink: false },
{ name: 'M:\\', isDirectory: true, isSymlink: false }
])
})
it('skips letters whose stat fails or is not a directory', async () => {
const statPath = async (p: string): Promise<Stats> => {
if (p === 'C:\\') {
return { isDirectory: () => true } as Stats
}
if (p === 'D:\\') {
return { isDirectory: () => false } as Stats
}
throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
}
const result = await listWindowsDrives(statPath)
expect(result.entries.map((e) => e.name)).toEqual(['C:\\'])
})
it.each(['EIO', 'EMFILE'])('surfaces systemic %s failures', async (code) => {
const statPath = async (p: string): Promise<Stats> => {
if (p === 'D:\\') {
throw Object.assign(new Error(code), { code })
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
await expect(listWindowsDrives(statPath)).rejects.toMatchObject({ code })
})
})

View File

@ -0,0 +1,57 @@
import { stat } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import type { FilesystemPathFlavor } from '../../shared/types'
export type DriveListing = {
resolvedPath: string
entries: { name: string; isDirectory: boolean; isSymlink: boolean }[]
pathFlavor: FilesystemPathFlavor
}
// Windows has no shared filesystem root, so `/` represents mounted drives.
export function isServerDriveListRequest(
pathValue: string,
platform: NodeJS.Platform = process.platform
): boolean {
return platform === 'win32' && /^[\\/]+$/.test(pathValue.trim())
}
const DRIVE_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const EXPECTED_UNAVAILABLE_DRIVE_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM'])
export async function listWindowsDrives(
statPath: (p: string) => Promise<Stats> = stat
): Promise<DriveListing> {
// Keep the separator because bare `M:` is drive-relative on Windows.
const roots = await Promise.all(
[...DRIVE_LETTERS].map(async (letter) => {
const root = `${letter}:\\`
try {
const stats = await statPath(root)
return stats.isDirectory() ? root : null
} catch (error) {
if (isExpectedUnavailableDriveError(error)) {
return null
}
throw error
}
})
)
return {
resolvedPath: '/',
pathFlavor: 'win32',
entries: roots
.filter((root): root is string => root !== null)
.map((root) => ({ name: root, isDirectory: true, isSymlink: false }))
}
}
function isExpectedUnavailableDriveError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof error.code === 'string' &&
EXPECTED_UNAVAILABLE_DRIVE_CODES.has(error.code)
)
}

View File

@ -126,6 +126,7 @@ import type {
CustomPet,
DetectedWorktreeListResult,
DirEntry,
FilesystemPathFlavor,
ForceDeleteWorktreeBranchResult,
FsChangedPayload,
GhosttyImportPreview,
@ -3402,6 +3403,7 @@ export type PreloadApi = {
browseDir: (args: { targetId: string; dirPath: string }) => Promise<{
entries: { name: string; isDirectory: boolean }[]
resolvedPath: string
pathFlavor: FilesystemPathFlavor
}>
onCredentialRequest: (
callback: (data: {

View File

@ -54,6 +54,7 @@ import type {
BrowserViewportOverride,
CustomPet,
FsChangedPayload,
FilesystemPathFlavor,
GetRateLimitResult,
GitHubPRRefreshCandidate,
GitHubPRRefreshEvent,
@ -4451,6 +4452,7 @@ const api = {
}): Promise<{
entries: { name: string; isDirectory: boolean }[]
resolvedPath: string
pathFlavor: FilesystemPathFlavor
}> => ipcRenderer.invoke('ssh:browseDir', args),
onCredentialRequest: (

View File

@ -10,12 +10,17 @@ type BrowseDirArgs = {
targetId: string
}
let browsePathFlavor: 'posix' | 'win32' = 'posix'
let browseEntries = [
{ name: 'src', isDirectory: true },
{ name: 'README.md', isDirectory: false }
]
const browseDir = vi.fn(async ({ dirPath }: BrowseDirArgs) => ({
entries: [
{ name: 'src', isDirectory: true },
{ name: 'README.md', isDirectory: false }
],
resolvedPath: dirPath === '~' ? '/home/alice' : dirPath
entries: browseEntries,
resolvedPath:
dirPath === '~' ? (browsePathFlavor === 'win32' ? 'C:/Users/alice' : '/home/alice') : dirPath,
pathFlavor: browsePathFlavor
}))
async function flushPromises(count = 4): Promise<void> {
@ -65,6 +70,11 @@ describe('RemoteFileBrowser paste-sized input', () => {
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
browsePathFlavor = 'posix'
browseEntries = [
{ name: 'src', isDirectory: true },
{ name: 'README.md', isDirectory: false }
]
browseDir.mockClear()
Object.defineProperty(window, 'api', {
configurable: true,
@ -98,6 +108,44 @@ describe('RemoteFileBrowser paste-sized input', () => {
})
})
it('navigates a typed Windows drive root and renders its breadcrumb', async () => {
browsePathFlavor = 'win32'
const { container, input, root } = await renderRemoteFileBrowser()
browseDir.mockClear()
await changeInput(input, 'M:\\')
await advancePathResolveDebounce()
expect(browseDir).toHaveBeenCalledWith({ targetId: 'target-1', dirPath: 'M:\\' })
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
})
expect(
[...container.querySelectorAll('button')].some((button) => button.textContent === 'M:')
).toBe(true)
await act(async () => {
root.unmount()
})
})
it('keeps a drive-shaped POSIX directory as an ordinary filtered row', async () => {
browseEntries = [{ name: 'M:\\', isDirectory: true }]
const { container, input, root } = await renderRemoteFileBrowser()
browseDir.mockClear()
await changeInput(input, 'M:\\')
await advancePathResolveDebounce()
expect(browseDir).not.toHaveBeenCalled()
expect(container.textContent).toContain('M:\\')
await act(async () => {
root.unmount()
})
})
it('does not parse or remotely resolve oversized slash-containing paste text', async () => {
const { container, input, root } = await renderRemoteFileBrowser()
const pastedSecretPathList = 'C:/Users/alice/project/secret-token-value.txt\n'.repeat(2_000)

View File

@ -17,8 +17,10 @@ import {
shouldDeferRemoteFileBrowserPasteResolve,
type DirEntry
} from './remote-file-browser-helpers'
import { driveBreadcrumbPath, splitBrowsePath } from './remote-file-browser-drive-paths'
import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser'
import { translate } from '@/i18n/i18n'
import type { FilesystemPathFlavor } from '../../../../shared/types'
type RemoteFileBrowserProps = (
| { targetId: string; runtimeEnvironmentId?: never }
@ -33,7 +35,11 @@ const FILE_HINT_MS = 2000
const FILE_HINT_TEXT = "Files can't be opened as a project"
const PATH_DEBOUNCE_MS = 300
type BrowseResult = { resolvedPath: string; entries: DirEntry[] }
type BrowseResult = {
resolvedPath: string
entries: DirEntry[]
pathFlavor: FilesystemPathFlavor
}
type PreviewState = {
resolvedPath: string
@ -52,6 +58,7 @@ export function RemoteFileBrowser({
}: RemoteFileBrowserProps): React.JSX.Element {
const [resolvedPath, setResolvedPath] = useState('')
const [entries, setEntries] = useState<DirEntry[]>([])
const [pathFlavor, setPathFlavor] = useState<FilesystemPathFlavor>('posix')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState('')
@ -142,6 +149,7 @@ export function RemoteFileBrowser({
}
setResolvedPath(result.resolvedPath)
setEntries(result.entries)
setPathFlavor(result.pathFlavor)
// Only bare `~` yields the home dir itself; `~/sub` resolves elsewhere and must not overwrite the home anchor.
if (dirPath === '~') {
homePathRef.current = result.resolvedPath
@ -184,17 +192,17 @@ export function RemoteFileBrowser({
const navigateInto = useCallback(
(name: string) => {
navigate(joinPath(resolvedPath, name))
navigate(joinPath(resolvedPath, name, pathFlavor))
},
[resolvedPath, navigate]
[resolvedPath, navigate, pathFlavor]
)
const navigateUp = useCallback(() => {
if (resolvedPath === '/') {
return
}
navigate(parentPath(resolvedPath))
}, [resolvedPath, navigate])
navigate(parentPath(resolvedPath, pathFlavor))
}, [resolvedPath, navigate, pathFlavor])
const filteredEntries = useMemo(() => filterEntries(entries, filter), [entries, filter])
@ -217,7 +225,7 @@ export function RemoteFileBrowser({
// Resolve a path-mode input into preview state; stable callback so paste and the debounce tick share one instance.
const resolvePathInput = useCallback(
async (raw: string) => {
const parsed = parsePathInput(raw)
const parsed = parsePathInput(raw, pathFlavor)
if (parsed.mode !== 'path') {
return
}
@ -238,6 +246,8 @@ export function RemoteFileBrowser({
let basePath: string
if (parsed.base === 'root') {
basePath = '/'
} else if (parsed.base === 'drive') {
basePath = parsed.driveRoot ?? '/'
} else if (parsed.base === 'home') {
if (!homePathRef.current) {
setPreview({
@ -300,11 +310,11 @@ export function RemoteFileBrowser({
}
if (outcome.type === 'stay') {
if (segment === '..') {
currentPath = parentPath(currentPath)
currentPath = parentPath(currentPath, listing.pathFlavor)
}
continue
}
currentPath = joinPath(currentPath, outcome.name)
currentPath = joinPath(currentPath, outcome.name, listing.pathFlavor)
}
const finalListing = await fetchListing(currentPath)
@ -332,7 +342,7 @@ export function RemoteFileBrowser({
})
}
},
[resolvedPath, fetchListing]
[resolvedPath, fetchListing, pathFlavor]
)
// Filter-mode edits stay local; path-mode edits trigger a debounced resolve, but trailing-filter-only edits stay local too.
@ -357,7 +367,7 @@ export function RemoteFileBrowser({
return
}
if (!isPathMode(raw)) {
if (!isPathMode(raw, pathFlavor)) {
// Leaving path mode: drop preview immediately so the committed directory reappears without a flicker.
if (preview) {
setPreview(null)
@ -370,7 +380,7 @@ export function RemoteFileBrowser({
return
}
const parsed = parsePathInput(raw)
const parsed = parsePathInput(raw, pathFlavor)
// Fast path: unchanged committed prefix updates only the local filter, so intra-segment typing issues no browseDir call.
if (
parsed.mode === 'path' &&
@ -392,7 +402,7 @@ export function RemoteFileBrowser({
resolvePathInput(raw)
}, PATH_DEBOUNCE_MS)
},
[clearFileHint, preview, resolvePathInput]
[clearFileHint, preview, resolvePathInput, pathFlavor]
)
const handleInputPaste = useCallback(
@ -414,12 +424,12 @@ export function RemoteFileBrowser({
debounceTimerRef.current = null
}
const value = inputRef.current?.value ?? ''
if (!isRemoteFileBrowserPathResolveTextTooLarge(value) && isPathMode(value)) {
if (!isRemoteFileBrowserPathResolveTextTooLarge(value) && isPathMode(value, pathFlavor)) {
resolvePathInput(value)
}
}, 0)
},
[resolvePathInput]
[resolvePathInput, pathFlavor]
)
// Select always returns the committed directory; disabled during a path preview to avoid a mismatched selection.
@ -442,13 +452,13 @@ export function RemoteFileBrowser({
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null
if (entry.isDirectory) {
navigate(joinPath(listParentPath, entry.name))
navigate(joinPath(listParentPath, entry.name, pathFlavor))
} else {
triggerFileHint()
}
}, 220)
},
[navigate, triggerFileHint, listParentPath, preview?.loading]
[navigate, triggerFileHint, listParentPath, preview?.loading, pathFlavor]
)
const handleRowDoubleClick = useCallback(
@ -461,9 +471,9 @@ export function RemoteFileBrowser({
clearTimeout(clickTimerRef.current)
clickTimerRef.current = null
}
onSelect(joinPath(listParentPath, entry.name))
onSelect(joinPath(listParentPath, entry.name, pathFlavor))
},
[listParentPath, onSelect, preview?.loading]
[listParentPath, onSelect, preview?.loading, pathFlavor]
)
const handleFilterKeyDown = useCallback(
@ -475,7 +485,7 @@ export function RemoteFileBrowser({
e.preventDefault()
return
}
const parsed = parsePathInput(filter)
const parsed = parsePathInput(filter, pathFlavor)
// Fully-resolved directory (trailing `/` or bare base marker): navigate to the preview path itself.
if (parsed.mode === 'path' && parsed.trailingFilter === '') {
e.preventDefault()
@ -487,7 +497,7 @@ export function RemoteFileBrowser({
const action = decideEnterAction(filtered)
if (action.type === 'navigate') {
e.preventDefault()
navigate(joinPath(preview.resolvedPath, action.name))
navigate(joinPath(preview.resolvedPath, action.name, pathFlavor))
} else if (action.type === 'fileHint') {
e.preventDefault()
triggerFileHint()
@ -542,11 +552,21 @@ export function RemoteFileBrowser({
resolvedPath,
triggerFileHint,
clearFileHint,
onCancel
onCancel,
pathFlavor
]
)
const pathSegments = resolvedPath.split('/').filter(Boolean)
// Preserve the separator shape when rebuilding drive breadcrumbs.
const browseParts = splitBrowsePath(resolvedPath, pathFlavor)
const pathSegments = browseParts.segments
const breadcrumbPathTo = useCallback(
(segmentIndex: number): string =>
browseParts.kind === 'drive'
? driveBreadcrumbPath(browseParts.driveRoot, browseParts.segments, segmentIndex)
: `/${browseParts.segments.slice(0, segmentIndex + 1).join('/')}`,
[browseParts]
)
// Render the preview listing (own filter/error) during path mode, the committed listing otherwise.
const isPreviewActive = preview !== null
@ -598,12 +618,27 @@ export function RemoteFileBrowser({
>
/
</button>
{pathSegments.map((segment, i) => (
<React.Fragment key={i}>
{browseParts.kind === 'drive' && (
<>
<ChevronRight className="size-2.5 shrink-0 text-muted-foreground/50" />
<button
type="button"
onClick={() => navigate(`/${pathSegments.slice(0, i + 1).join('/')}`)}
onClick={() => navigate(browseParts.driveRoot)}
className={cn(
'truncate max-w-[120px] hover:text-foreground transition-colors cursor-pointer px-0.5',
pathSegments.length === 0 && 'text-foreground font-medium'
)}
>
{browseParts.driveRoot.slice(0, 2)}
</button>
</>
)}
{pathSegments.map((segment, i) => (
<React.Fragment key={breadcrumbPathTo(i)}>
<ChevronRight className="size-2.5 shrink-0 text-muted-foreground/50" />
<button
type="button"
onClick={() => navigate(breadcrumbPathTo(i))}
className={cn(
'truncate max-w-[120px] hover:text-foreground transition-colors cursor-pointer px-0.5',
i === pathSegments.length - 1 && 'text-foreground font-medium'
@ -752,9 +787,9 @@ export function RemoteFileBrowser({
)
}
// Portion of raw before the final `/`; lets callers tell a trailing-filter-only edit from a committed-segment change.
// Portion before the final separator; distinguishes filter-only edits from committed-path changes.
function committedPrefix(raw: string): string {
const i = raw.lastIndexOf('/')
const i = Math.max(raw.lastIndexOf('/'), raw.lastIndexOf('\\'))
return i === -1 ? '' : raw.slice(0, i + 1)
}

View File

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
driveBreadcrumbPath,
driveRootOf,
isDrivePath,
isDriveRoot,
joinDrivePath,
parentOfDrivePath,
splitBrowsePath
} from './remote-file-browser-drive-paths'
describe('isDrivePath', () => {
it('accepts drive anchors with either separator, bare colon, and any case', () => {
expect(isDrivePath('M:\\')).toBe(true)
expect(isDrivePath('M:/')).toBe(true)
expect(isDrivePath('m:')).toBe(true)
expect(isDrivePath('C:\\Users\\Administrator')).toBe(true)
})
it('rejects POSIX paths and ordinary filter text', () => {
expect(isDrivePath('/home/user')).toBe(false)
expect(isDrivePath('docs')).toBe(false)
expect(isDrivePath('M:x')).toBe(false)
expect(isDrivePath('12:\\')).toBe(false)
})
})
describe('driveRootOf / isDriveRoot', () => {
it('normalizes any drive anchor to an uppercase backslash root', () => {
expect(driveRootOf('m:/dev')).toBe('M:\\')
expect(driveRootOf('M:')).toBe('M:\\')
})
it('treats M:, M:\\ and M:/ as roots but not deeper paths', () => {
expect(isDriveRoot('M:')).toBe(true)
expect(isDriveRoot('M:\\')).toBe(true)
expect(isDriveRoot('M:/')).toBe(true)
expect(isDriveRoot('M:\\dev')).toBe(false)
})
})
describe('splitBrowsePath', () => {
it('splits drive paths on either separator', () => {
expect(splitBrowsePath('M:\\dev\\debox', 'win32')).toEqual({
kind: 'drive',
driveRoot: 'M:\\',
segments: ['dev', 'debox']
})
expect(splitBrowsePath('M:/dev', 'win32')).toEqual({
kind: 'drive',
driveRoot: 'M:\\',
segments: ['dev']
})
})
it('keeps POSIX paths in the POSIX shape', () => {
expect(splitBrowsePath('/home/user')).toEqual({ kind: 'posix', segments: ['home', 'user'] })
expect(splitBrowsePath('/')).toEqual({ kind: 'posix', segments: [] })
expect(splitBrowsePath('M:\\dev', 'posix')).toEqual({
kind: 'posix',
segments: ['M:\\dev']
})
})
})
describe('joinDrivePath / parentOfDrivePath', () => {
it('joins with a backslash without doubling the root separator', () => {
expect(joinDrivePath('M:\\', 'dev')).toBe('M:\\dev')
expect(joinDrivePath('M:\\dev', 'debox')).toBe('M:\\dev\\debox')
})
it('walks up to the drive root and then to the host root', () => {
expect(parentOfDrivePath('M:\\dev\\debox')).toBe('M:\\dev')
expect(parentOfDrivePath('M:\\dev')).toBe('M:\\')
expect(parentOfDrivePath('M:\\')).toBe('/')
})
})
describe('driveBreadcrumbPath', () => {
it('rebuilds absolute paths for breadcrumb clicks', () => {
const segments = ['dev', 'debox', 'repo']
expect(driveBreadcrumbPath('M:\\', segments, 0)).toBe('M:\\dev')
expect(driveBreadcrumbPath('M:\\', segments, 2)).toBe('M:\\dev\\debox\\repo')
expect(driveBreadcrumbPath('M:\\', [], -1)).toBe('M:\\')
})
})

View File

@ -0,0 +1,62 @@
export type BrowsePathParts =
| { kind: 'posix'; segments: string[] }
| { kind: 'drive'; driveRoot: string; segments: string[] }
// Bare `M:` is a root here because forwarding it would be drive-relative.
const DRIVE_ANCHOR_RE = /^[A-Za-z]:([\\/]|$)/
export function isDrivePath(p: string): boolean {
return DRIVE_ANCHOR_RE.test(p)
}
export function isDriveRoot(p: string): boolean {
return /^[A-Za-z]:[\\/]?$/.test(p)
}
export function driveRootOf(p: string): string {
return `${p[0].toUpperCase()}:\\`
}
export function splitBrowsePath(
p: string,
pathFlavor: FilesystemPathFlavor = 'posix'
): BrowsePathParts {
if (pathFlavor === 'win32' && isDrivePath(p)) {
return {
kind: 'drive',
driveRoot: driveRootOf(p),
segments: p.slice(2).split(/[\\/]/).filter(Boolean)
}
}
return { kind: 'posix', segments: p.split('/').filter(Boolean) }
}
// Backslash targets the remote Windows host regardless of the client's platform.
export function joinDrivePath(base: string, name: string): string {
return `${base.replace(/[\\/]+$/, '')}\\${name}`
}
// Up from a drive root returns to the host drive list.
export function parentOfDrivePath(p: string): string {
if (isDriveRoot(p)) {
return '/'
}
const parts = splitBrowsePath(p, 'win32')
if (parts.kind !== 'drive') {
return p
}
const parentSegments = parts.segments.slice(0, -1)
return parentSegments.length === 0
? parts.driveRoot
: `${parts.driveRoot}${parentSegments.join('\\')}`
}
export function driveBreadcrumbPath(
driveRoot: string,
segments: string[],
endIndex: number
): string {
const kept = segments.slice(0, endIndex + 1)
return kept.length === 0 ? driveRoot : `${driveRoot}${kept.join('\\')}`
}
import type { FilesystemPathFlavor } from '../../../../shared/types'

View File

@ -7,6 +7,7 @@ import {
isRemoteFileBrowserFilterQueryTooLarge,
isRemoteFileBrowserPathResolveTextTooLarge,
isPathMode,
joinPath,
parentPath,
parsePathInput,
resolveSegmentStep,
@ -336,3 +337,71 @@ describe('resolveSegmentStep', () => {
}
})
})
describe('Windows drive paths', () => {
it('isPathMode triggers on drive-anchored input', () => {
expect(isPathMode('M:\\', 'win32')).toBe(true)
expect(isPathMode('M:/', 'win32')).toBe(true)
expect(isPathMode('m:', 'win32')).toBe(true)
expect(isPathMode('M:\\dev', 'win32')).toBe(true)
expect(isPathMode('M', 'win32')).toBe(false)
expect(isPathMode('M:x', 'win32')).toBe(false)
})
it('keeps drive-shaped POSIX names in filter and child-path semantics', () => {
expect(isPathMode('M:\\', 'posix')).toBe(false)
expect(parsePathInput('M:\\', 'posix')).toEqual({ mode: 'filter', filter: 'M:\\' })
expect(joinPath('/', 'M:\\', 'posix')).toBe('/M:\\')
expect(parentPath('/M:\\', 'posix')).toBe('/')
})
it('parsePathInput anchors drive input at the normalized drive root', () => {
expect(parsePathInput('m:/dev/', 'win32')).toEqual({
mode: 'path',
base: 'drive',
driveRoot: 'M:\\',
committedSegments: ['dev'],
trailingFilter: ''
})
expect(parsePathInput('M:\\dev\\deb', 'win32')).toEqual({
mode: 'path',
base: 'drive',
driveRoot: 'M:\\',
committedSegments: ['dev'],
trailingFilter: 'deb'
})
expect(parsePathInput('M:', 'win32')).toEqual({
mode: 'path',
base: 'drive',
driveRoot: 'M:\\',
committedSegments: [],
trailingFilter: ''
})
})
it('parsePathInput rejects repeated separators in drive input, either kind', () => {
for (const raw of ['M:\\dev\\\\x', 'M:/dev//x', 'M:\\dev\\/x']) {
const parsed = parsePathInput(raw, 'win32')
expect(parsed.mode).toBe('path')
if (parsed.mode === 'path') {
expect(parsed.invalid).toMatch(/repeated separators/)
}
}
})
it('joinPath treats drive rows in the host-root listing as absolute', () => {
expect(joinPath('/', 'M:\\', 'win32')).toBe('M:\\')
expect(joinPath('/', 'usr', 'win32')).toBe('/usr')
})
it('joinPath appends with a backslash inside a drive', () => {
expect(joinPath('M:\\', 'dev', 'win32')).toBe('M:\\dev')
expect(joinPath('M:\\dev', 'debox', 'win32')).toBe('M:\\dev\\debox')
})
it('parentPath climbs drive paths and exits to the host root', () => {
expect(parentPath('M:\\dev\\debox', 'win32')).toBe('M:\\dev')
expect(parentPath('M:\\dev', 'win32')).toBe('M:\\')
expect(parentPath('M:\\', 'win32')).toBe('/')
})
})

View File

@ -1,6 +1,13 @@
import { translate } from '@/i18n/i18n'
import { shouldHandleTextControlPaste } from '@/lib/text-control-paste'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import type { FilesystemPathFlavor } from '../../../../shared/types'
import {
driveRootOf,
isDrivePath,
joinDrivePath,
parentOfDrivePath
} from './remote-file-browser-drive-paths'
export type DirEntry = {
name: string
isDirectory: boolean
@ -54,11 +61,25 @@ export function decideEscAction(filter: string): EscAction {
return filter.length > 0 ? { type: 'clearFilter' } : { type: 'cancel' }
}
export function joinPath(resolvedPath: string, name: string): string {
export function joinPath(
resolvedPath: string,
name: string,
pathFlavor: FilesystemPathFlavor = 'posix'
): string {
// Drive rows in a Windows host-root listing are already absolute (`M:\`).
if (pathFlavor === 'win32' && resolvedPath === '/' && isDrivePath(name)) {
return driveRootOf(name)
}
if (pathFlavor === 'win32' && isDrivePath(resolvedPath)) {
return joinDrivePath(resolvedPath, name)
}
return resolvedPath === '/' ? `/${name}` : `${resolvedPath}/${name}`
}
export function parentPath(p: string): string {
export function parentPath(p: string, pathFlavor: FilesystemPathFlavor = 'posix'): string {
if (pathFlavor === 'win32' && isDrivePath(p)) {
return parentOfDrivePath(p)
}
if (p === '/' || p === '') {
return '/'
}
@ -73,8 +94,10 @@ export type ParsedInput =
| {
mode: 'path'
// `root` = absolute `/`, `home` = resolved SSH user home, `cwd` = the
// currently committed resolvedPath.
base: 'root' | 'home' | 'cwd'
// currently committed resolvedPath, `drive` = a Windows drive root.
base: 'root' | 'home' | 'cwd' | 'drive'
// Canonical `M:\` root; only set when `base` is 'drive'.
driveRoot?: string
// Segments to resolve one-by-one from the base. Empty string segments
// never appear here — repeated separators are surfaced via `invalid`.
committedSegments: string[]
@ -86,13 +109,14 @@ export type ParsedInput =
invalid?: string
}
// Path mode triggers when the input contains `/` or is one of the three
// base-marker literals (`~`, `.`, `..`). The literal `..` rule is required
// because "contains /" alone would keep bare `..` in filter mode.
export function isPathMode(raw: string): boolean {
// Slashes, drive anchors, and standalone base markers enter path mode.
export function isPathMode(raw: string, pathFlavor: FilesystemPathFlavor = 'posix'): boolean {
if (raw.includes('/')) {
return true
}
if (pathFlavor === 'win32' && isDrivePath(raw)) {
return true
}
return raw === '~' || raw === '.' || raw === '..'
}
@ -104,8 +128,11 @@ export function shouldDeferRemoteFileBrowserPasteResolve(text: string): boolean
return isRemoteFileBrowserPathResolveTextTooLarge(text)
}
export function parsePathInput(raw: string): ParsedInput {
if (!isPathMode(raw)) {
export function parsePathInput(
raw: string,
pathFlavor: FilesystemPathFlavor = 'posix'
): ParsedInput {
if (!isPathMode(raw, pathFlavor)) {
// Filter mode preserves the raw text; trimming happens inside
// `filterEntries` so leading/trailing spaces don't alter the input shown
// back to the user.
@ -123,9 +150,15 @@ export function parsePathInput(raw: string): ParsedInput {
return { mode: 'path', base: 'cwd', committedSegments: ['..'], trailingFilter: '' }
}
let base: 'root' | 'home' | 'cwd'
let base: 'root' | 'home' | 'cwd' | 'drive'
let driveRoot: string | undefined
let remainder: string
if (raw.startsWith('/')) {
if (pathFlavor === 'win32' && isDrivePath(raw)) {
base = 'drive'
driveRoot = driveRootOf(raw)
// Strip the drive anchor; segments accept either Windows separator.
remainder = raw.slice(2).replace(/^[\\/]/, '')
} else if (raw.startsWith('/')) {
base = 'root'
remainder = raw.slice(1)
} else if (raw.startsWith('~/')) {
@ -138,10 +171,13 @@ export function parsePathInput(raw: string): ParsedInput {
// Don't collapse `//`: the visible input must agree with the path being
// resolved. Report it as invalid and let the caller surface the error.
if (remainder.includes('//')) {
const hasRepeatedSeparators =
base === 'drive' ? /[\\/]{2,}/.test(remainder) : remainder.includes('//')
if (hasRepeatedSeparators) {
return {
mode: 'path',
base,
driveRoot,
committedSegments: [],
trailingFilter: '',
invalid: 'Invalid path: repeated separators'
@ -159,6 +195,7 @@ export function parsePathInput(raw: string): ParsedInput {
return {
mode: 'path',
base,
driveRoot,
committedSegments: [],
trailingFilter: '',
invalid: 'Invalid path: control characters are not allowed'
@ -167,11 +204,12 @@ export function parsePathInput(raw: string): ParsedInput {
// `split('/')` leaves an empty string when `remainder` ends with `/`, which
// is the only legal "empty tail" and simply means "no trailing filter".
const parts = remainder === '' ? [''] : remainder.split('/')
const parts =
remainder === '' ? [''] : base === 'drive' ? remainder.split(/[\\/]/) : remainder.split('/')
const trailingFilter = parts.at(-1) ?? ''
const committedSegments = parts.slice(0, -1)
return { mode: 'path', base, committedSegments, trailingFilter }
return { mode: 'path', base, driveRoot, committedSegments, trailingFilter }
}
export type SegmentOutcome =

View File

@ -28,6 +28,7 @@ beforeEach(() => {
ok: true,
result: {
resolvedPath: '/home/me',
pathFlavor: 'posix',
entries: [{ name: 'repo', isDirectory: true, isSymlink: false }]
},
_meta: { runtimeId: 'remote-runtime' }
@ -46,6 +47,7 @@ describe('runtime server directory browser', () => {
it('routes browse requests through the selected runtime environment', async () => {
await expect(browseRuntimeServerDirectory('env-1', '~')).resolves.toEqual({
resolvedPath: '/home/me',
pathFlavor: 'posix',
entries: [{ name: 'repo', isDirectory: true, isSymlink: false }]
})

View File

@ -1,9 +1,10 @@
import type { DirEntry } from '../../../shared/types'
import type { DirEntry, FilesystemPathFlavor } from '../../../shared/types'
import { callRuntimeRpc } from './runtime-rpc-client'
export type RuntimeServerDirectoryListing = {
resolvedPath: string
entries: DirEntry[]
pathFlavor: FilesystemPathFlavor
}
export async function browseRuntimeServerDirectory(

View File

@ -3122,7 +3122,7 @@ function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
listDetectedPorts: () => Promise.resolve([]),
onPortForwardsChanged: () => noopUnsubscribe,
onDetectedPortsChanged: () => noopUnsubscribe,
browseDir: () => Promise.resolve({ entries: [], resolvedPath: '' }),
browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }),
onCredentialRequest: () => noopUnsubscribe,
onCredentialResolved: () => noopUnsubscribe,
submitCredential: () => Promise.resolve()

View File

@ -3563,6 +3563,8 @@ export type PersistedState = {
}
// ─── Filesystem ─────────────────────────────────────────────
export type FilesystemPathFlavor = 'posix' | 'win32'
export type DirEntry = {
name: string
isDirectory: boolean

View File

@ -33,7 +33,7 @@ export async function replaceRuntimePairingInPlace(args: {
if (!(await store.getState().refreshRuntimeEnvironmentStatus(selector))) {
throw new Error('Same-ID re-paired desktop could not reach the HUB runtime')
}
if (!(await store.getState().switchRuntimeEnvironment(selector))) {
if (!(await store.getState().setActiveRuntimeEnvironmentPreference(selector))) {
throw new Error('Same-ID re-paired desktop could not select the HUB runtime')
}
// Why: same-ID selection is a no-op, so explicitly rehydrate the graph from the replacement transport.

View File

@ -293,7 +293,7 @@ export async function addPairedRuntimeEnvironment(
if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) {
throw new Error(`Paired desktop could not reach ${name}`)
}
if (!(await store.getState().switchRuntimeEnvironment(result.environment.id))) {
if (!(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))) {
throw new Error(`Paired desktop could not select ${name}`)
}
return result.environment.id

View File

@ -198,7 +198,9 @@ export async function launchPairedElectronClient(
if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) {
throw new Error('Paired desktop could not reach the HUB runtime')
}
if (!(await store.getState().switchRuntimeEnvironment(result.environment.id))) {
if (
!(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))
) {
throw new Error('Paired desktop could not select the HUB runtime')
}
return result.environment.id
@ -263,7 +265,7 @@ export async function rePairPairedElectronClient(
if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) {
throw new Error('Re-paired desktop could not reach the HUB runtime')
}
if (!(await store.getState().switchRuntimeEnvironment(result.environment.id))) {
if (!(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))) {
throw new Error('Re-paired desktop could not select the HUB runtime')
}
return result.environment.id
@ -291,7 +293,7 @@ export async function rePairPairedElectronClient(
if (!(await store.getState().refreshRuntimeEnvironmentStatus(nextEnvironmentId))) {
return false
}
return store.getState().switchRuntimeEnvironment(nextEnvironmentId)
return store.getState().setActiveRuntimeEnvironmentPreference(nextEnvironmentId)
}, environmentId)
if (!reachable) {
throw new Error('Re-paired desktop could not reach the HUB after reload')

View File

@ -0,0 +1,53 @@
import os from 'node:os'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import {
createRuntimeDesktopPairingOffer,
launchPairedElectronClient
} from './helpers/paired-electron-client'
import { waitForSessionReady } from './helpers/store'
test.describe('paired runtime Windows file browser', () => {
test.skip(process.platform !== 'win32', 'Windows drive roots require a Windows runtime host')
test('reports the runtime path flavor with Windows drive roots', async ({
orcaPage
}, testInfo) => {
test.setTimeout(120_000)
await waitForSessionReady(orcaPage)
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const client = await launchPairedElectronClient(offer, testInfo, 'Windows drive browser')
try {
const driveRoot = path.parse(os.tmpdir()).root.toUpperCase()
const listing = await client.page.evaluate(async () => {
const [environment] = await window.api.runtimeEnvironments.list()
if (!environment) {
throw new Error('Paired client runtime environment is unavailable')
}
const response = await window.api.runtimeEnvironments.call({
selector: environment.id,
method: 'files.browseServerDir',
params: { path: '/' },
timeoutMs: 15_000
})
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as {
pathFlavor: string
entries: { name: string; isDirectory: boolean }[]
}
})
expect(listing.pathFlavor).toBe('win32')
expect(listing.entries).toContainEqual({
name: driveRoot,
isDirectory: true,
isSymlink: false
})
} finally {
await client.dispose()
}
})
})