/** * Transfer tab: upload/download one file with progress, plus a toggleable * remote directory browser (api.ls) whose file rows fill the remote path * input and whose directory rows navigate. */ import { useEffect, useRef, useState, type ChangeEvent } from 'react' import type { SshApi } from '../api.ts' import type { RemoteDirEntry, SshHostSummary, TransferProgress } from '../../protocol.ts' import { errorMessage, tt } from './helpers.ts' import css from './panel.module.css' /** Transfer tab props. */ export interface TransferTabProps { api: SshApi } /** One in-flight transfer (drives the progress bar). */ interface TransferState { kind: 'upload' | 'download' phase: TransferProgress['phase'] percent: number speedBps?: number file: string } /** The last transfer's outcome line. */ type TransferStatus = { kind: 'ok'; bytes: number } | { kind: 'error'; error: string } /** Join a directory path with a child entry name. */ function joinRemotePath(dir: string, name: string): string { const base = dir.endsWith('/') ? dir : dir + '/' return base + name } /** Parent path of a remote directory ('/' stays '/'). */ function parentOf(dir: string): string { const trimmed = dir.replace(/\/+$/, '') const index = trimmed.lastIndexOf('/') if (index <= 0) return '/' return trimmed.slice(0, index) } /** Human byte sizes through the transfer.* locale templates. */ function formatBytes(bytes: number): string { if (bytes < 1024) return tt('transfer.bytes', { value: bytes }) if (bytes < 1024 * 1024) return tt('transfer.kib', { value: (bytes / 1024).toFixed(1) }) if (bytes < 1024 * 1024 * 1024) return tt('transfer.mib', { value: (bytes / (1024 * 1024)).toFixed(1) }) return tt('transfer.gib', { value: (bytes / (1024 * 1024 * 1024)).toFixed(1) }) } /** The upload/download tab. */ export function TransferTab({ api }: TransferTabProps) { const [hosts, setHosts] = useState([]) const [listError, setListError] = useState(null) const [alias, setAlias] = useState('') const [remotePath, setRemotePath] = useState('') const [browseOpen, setBrowseOpen] = useState(false) const [browseDir, setBrowseDir] = useState('/') const [entries, setEntries] = useState([]) const [browsing, setBrowsing] = useState(false) const [transfer, setTransfer] = useState(null) const [status, setStatus] = useState(null) const fileRef = useRef(null) const seqRef = useRef(0) useEffect(() => { let disposed = false void (async () => { try { const list = await api.listHosts() if (!disposed) setHosts(list) } catch (cause) { if (!disposed) setListError(errorMessage(cause)) } })() return () => { disposed = true } }, [api]) const loadDir = async (path: string): Promise => { if (alias === '') return const seq = ++seqRef.current setBrowsing(true) try { const list = await api.ls(alias, path) if (seq !== seqRef.current) return const sorted = [...list].sort((a, b) => { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 return a.name.localeCompare(b.name) }) setEntries(sorted) setBrowseDir(path) setListError(null) } catch (cause) { if (seq === seqRef.current) setListError(errorMessage(cause)) } finally { if (seq === seqRef.current) setBrowsing(false) } } const openBrowse = (): void => { if (alias === '') return setBrowseOpen(true) const path = remotePath.trim() === '' ? '/' : remotePath.trim() void loadDir(path) } const handleFile = async (event: ChangeEvent): Promise => { const file = event.target.files?.[0] event.target.value = '' if (file === undefined || alias === '' || remotePath.trim() === '' || transfer !== null) return setStatus(null) setTransfer({ kind: 'upload', phase: 'connecting', percent: 0, file: file.name }) try { const outcome = await api.uploadFile(file, alias, remotePath.trim(), progress => { setTransfer(prev => prev === null ? prev : { kind: 'upload', phase: progress.phase, percent: progress.percent, speedBps: progress.speedBps, file: progress.file, }) }) setStatus({ kind: 'ok', bytes: outcome.transferredBytes }) } catch (cause) { setStatus({ kind: 'error', error: errorMessage(cause) }) } finally { setTransfer(null) } } const handleDownload = async (): Promise => { if (alias === '' || remotePath.trim() === '' || transfer !== null) return setStatus(null) setTransfer({ kind: 'download', phase: 'connecting', percent: 0, file: remotePath.trim() }) try { const result = await api.downloadFile(alias, remotePath.trim(), progress => { setTransfer(prev => prev === null ? prev : { kind: 'download', phase: progress.phase, percent: progress.percent, speedBps: progress.speedBps, file: progress.file, }) }) // Streamed downloads (File System Access API) were already saved; the // Blob fallback triggers a browser save here. if (!result.streamed && result.blob !== undefined) { const url = URL.createObjectURL(result.blob) const anchor = document.createElement('a') anchor.href = url anchor.download = result.filename document.body.appendChild(anchor) anchor.click() anchor.remove() // Defer the revoke: some engines need the URL alive past the click. setTimeout(() => { URL.revokeObjectURL(url) }, 10_000) } setStatus({ kind: 'ok', bytes: result.bytes }) } catch (cause) { setStatus({ kind: 'error', error: errorMessage(cause) }) } finally { setTransfer(null) } } const ready = alias !== '' && remotePath.trim() !== '' && transfer === null return (
{ setRemotePath(event.target.value) }} />
{ void handleFile(event) }} />
{listError !== null &&
{tt('common.error', { error: listError })}
} {browseOpen && (
{browseDir}
{browseDir !== '/' && ( )} {entries.map(entry => ( ))}
)} {transfer !== null && (
{transfer.kind === 'upload' ? tt('transfer.uploading', { file: transfer.file }) : tt('transfer.downloading')} {tt('transfer.percent', { value: Math.round(transfer.percent) })} {transfer.speedBps !== undefined && transfer.phase === 'transferring' && {tt('transfer.speed', { value: formatBytes(transfer.speedBps) })}}
)} {status !== null && (
{status.kind === 'ok' ? tt('transfer.done', { bytes: status.bytes }) : tt('transfer.failed', { error: status.error })}
)}
) }