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:
parent
3f53287554
commit
afbd98d8a4
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:\\')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
@ -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('/')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -3563,6 +3563,8 @@ export type PersistedState = {
|
|||
}
|
||||
|
||||
// ─── Filesystem ─────────────────────────────────────────────
|
||||
export type FilesystemPathFlavor = 'posix' | 'win32'
|
||||
|
||||
export type DirEntry = {
|
||||
name: string
|
||||
isDirectory: boolean
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue