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) => ( + + +