From afbd98d8a440af7bb3c9c3ce24a883316f8197c3 Mon Sep 17 00:00:00 2001 From: Michael <123170392+MichaelHoughtonDeBox@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:51:14 +0100 Subject: [PATCH] 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 * 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 * Complete Windows drive browsing over SSH * fix remote Windows drive browsing * fix(ui): key remote breadcrumbs by path --------- Co-authored-by: Claude Fable 5 Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/ipc/ssh-browse.test.ts | 51 ++++++++++- src/main/ipc/ssh-browse.ts | 37 +++++--- src/main/runtime/orca-runtime.test.ts | 15 ++++ src/main/runtime/orca-runtime.ts | 18 +++- .../runtime/windows-drive-listing.test.ts | 64 +++++++++++++ src/main/runtime/windows-drive-listing.ts | 57 ++++++++++++ src/preload/api-types.ts | 2 + src/preload/index.ts | 2 + .../sidebar/RemoteFileBrowser.paste.test.tsx | 58 ++++++++++-- .../components/sidebar/RemoteFileBrowser.tsx | 89 +++++++++++++------ .../remote-file-browser-drive-paths.test.ts | 86 ++++++++++++++++++ .../remote-file-browser-drive-paths.ts | 62 +++++++++++++ .../remote-file-browser-helpers.test.ts | 69 ++++++++++++++ .../sidebar/remote-file-browser-helpers.ts | 68 ++++++++++---- .../runtime-server-directory-browser.test.ts | 2 + .../runtime-server-directory-browser.ts | 3 +- src/renderer/src/web/web-preload-api.ts | 2 +- src/shared/types.ts | 2 + .../helpers/nested-runtime-same-id-pairing.ts | 2 +- .../nested-runtime-ssh-client-route.ts | 2 +- tests/e2e/helpers/paired-electron-client.ts | 8 +- ...untime-file-browser-windows-drives.spec.ts | 53 +++++++++++ 22 files changed, 683 insertions(+), 69 deletions(-) create mode 100644 src/main/runtime/windows-drive-listing.test.ts create mode 100644 src/main/runtime/windows-drive-listing.ts create mode 100644 src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts create mode 100644 src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts create mode 100644 tests/e2e/runtime-file-browser-windows-drives.spec.ts diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 6d04e8c7b..716c35f30 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -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) } diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index f42c64a80..2356de614 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -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 => { const mgr = getConnectionManager() if (!mgr) { throw new Error('SSH connection manager not initialized') @@ -68,15 +72,25 @@ type SshBrowseConnection = NonNullable { +): Promise { // 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 { + 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 { 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) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 1e08d90d9..cbf0b08ba 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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[] = [] const colorStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aa4d66ada..792eb43eb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { diff --git a/src/main/runtime/windows-drive-listing.test.ts b/src/main/runtime/windows-drive-listing.test.ts new file mode 100644 index 000000000..1d7387e79 --- /dev/null +++ b/src/main/runtime/windows-drive-listing.test.ts @@ -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 => { + 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 => { + 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 }) + }) +}) diff --git a/src/main/runtime/windows-drive-listing.ts b/src/main/runtime/windows-drive-listing.ts new file mode 100644 index 000000000..9b2bbcfdb --- /dev/null +++ b/src/main/runtime/windows-drive-listing.ts @@ -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 = stat +): Promise { + // 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) + ) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 88cef6374..180e48d2d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 524ff3ede..22f05f2dc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: ( diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx index cc5c52356..9d3aa7a3d 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx @@ -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 { @@ -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) diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx index 7673e0231..dc43f2bb8 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx @@ -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([]) + const [pathFlavor, setPathFlavor] = useState('posix') const [loading, setLoading] = useState(true) const [error, setError] = useState(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({ > / - {pathSegments.map((segment, i) => ( - + {browseParts.kind === 'drive' && ( + <> + + )} + {pathSegments.map((segment, i) => ( + + +