Files
hamlab/web/src/Files.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 1038c010fa Files: a sweep of the edges
Found by walking every handler and the UI with odd inputs, then a battery of requests
against the live hub. Fixed:
- A save over the 8 MiB cap was cut short in place: the file was truncated, then the
  first 8 MiB written, and the caller told 204. Now the body is read whole first and a
  body over the cap is a 413 with the file untouched.
- A link to a file showed the link's own time; now the file's, so the row reads right
  and the preview key follows the file.
- Reading a link to nothing answered 200 with an empty body (the open failed after the
  headers in root mode); now a 404 that names the target.
- A play request with an unknown codec or size was answered by inspecting the file
  first, so an audio-only file said "no video stream" instead of what was wrong; the
  choice is checked before any work, and is a 400.
- A stream that ended in failure was handed back to the next identical request for ten
  minutes; a failed one is now dropped and a fresh one made.
- A player that left while its stream was still finding a first segment made the loop
  try the next recipe; it stops. A stream closed mid-start is not started again.
- Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's
  processes, so the wait is bounded and the helper's own grace does the rest.
- A page going away (a phone closing the tab) releases its stream by a beacon, rather
  than ten minutes later by the sweeper.
- The path box resolves like a shell — relative to the folder shown, . and .. applied,
  trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path
  that is a file opens it; a remembered folder that is gone falls back to / once.
- A dropped folder arrived as a nameless empty file; it is refused with a word. The
  upload toast counts what was actually uploaded.
- The viewer names a device, pipe or socket, and a dangling link, instead of calling
  them binary files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 20:31:10 -07:00

469 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
import { fmtBytes } from './fmt'
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
import { useHashSel } from './hashsel'
import TaskOutput from './TaskOutput'
import Player from './Player'
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
// A typed path, made absolute: relative to the folder being looked at, "." and ".." applied,
// trailing slashes dropped — what a shell would make of it.
export function resolvePath(input: string, here: string): string {
const raw = input.trim()
if (!raw) return here
const parts = (raw.startsWith('/') ? [] : here.split('/')).concat(raw.split('/'))
const out: string[] = []
for (const p of parts) {
if (p === '' || p === '.') continue
if (p === '..') out.pop(); else out.push(p)
}
return '/' + out.join('/')
}
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
const MAX_EDIT = 2 * 1024 * 1024
function icon(e: FileEntry): string {
if (e.type === 'dir') return e.target ? '🔗' : '📁'
if (e.type === 'link') return e.broken ? '⛓️‍💥' : '🔗'
if (e.type === 'other') return '⚙️'
if (e.preview === 'image' || isImage(e.name)) return '🖼️'
if (e.preview === 'video') return '🎞️'
if (e.preview === 'audio') return '🎵'
if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️'
if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜'
if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄'
return '📄'
}
type Sort = 'name' | 'size' | 'mtime'
const PREVIEWS_KEY = 'hamlab.files.previews'
const byName = (a: FileEntry, b: FileEntry) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
// ---- move: walk to the destination, then "Move here" ----
function MovePicker({ item, from, sudo, hidden, onClose, onMoved }: { item: FileEntry; from: string; sudo: boolean; hidden: boolean; onClose: () => void; onMoved: (task?: TaskInfo) => void }) {
const src = join(from, item.name)
const isFolder = item.type === 'dir' && !item.target
const [hist, setHist] = useState<string[]>([from]) // where the picker has been, for back and forward
const [at, setAt] = useState(0)
const dir = hist[at]
const [entries, setEntries] = useState<FileEntry[] | null>(null)
const [err, setErr] = useState('')
const [pathInput, setPathInput] = useState(from)
const [busy, setBusy] = useState(false)
const load = useCallback(async (p: string) => {
setEntries(null); setErr('')
try { const l = await api.files(p, sudo); setEntries(l.entries) } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setEntries([]) }
}, [sudo])
useEffect(() => { setPathInput(dir); load(dir) }, [dir, load])
useEscape(onClose)
const go = (input: string) => {
const p = resolvePath(input, dir)
if (p === dir) return
setHist((h) => [...h.slice(0, at + 1), p]); setAt(at + 1)
}
const up = () => { if (dir !== '/') go(dir.slice(0, dir.lastIndexOf('/')) || '/') }
const folders = (entries ?? []).filter((e) => e.type === 'dir' && (hidden || !e.name.startsWith('.'))).sort(byName)
const crumbs = dir === '/' ? [] : dir.slice(1).split('/')
const dest = join(dir, item.name)
const why = dir === from ? 'it is already here'
: isFolder && (dir === src || dir.startsWith(src + '/')) ? 'a folder can\'t move into itself'
: (entries ?? []).some((e) => e.name === item.name) ? `something called ${item.name} is already there` : ''
const move = async () => {
setBusy(true)
try {
const t = await api.fileMove(src, dest, sudo)
if (t) toast(`${dir} is on another filesystem — copying ${item.name} there, then removing it here`, 'info')
else toast(`moved ${item.name} to ${dir}`)
onMoved(t)
} catch (e) { toast(`move — ${e instanceof Error ? e.message : 'failed'}`, 'err'); setBusy(false) }
}
const mkdir = async () => {
const n = await askText('New folder name')
if (!okName(n)) return
try { await api.fileMkdir(join(dir, n.trim()), sudo); go(join(dir, n.trim())) } catch (e) { toast(`create folder — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="modal mover" onClick={(e) => e.stopPropagation()}>
<h3>Move {isFolder ? 'folder ' : ''}<span className="mono">{item.name}</span></h3>
<div className="mover-nav">
<button className="icon-btn" title="back" disabled={at === 0} onClick={() => setAt(at - 1)}>‹</button>
<button className="icon-btn" title="forward" disabled={at >= hist.length - 1} onClick={() => setAt(at + 1)}>›</button>
<button className="icon-btn" title="up one level" disabled={dir === '/'} onClick={up}>↑</button>
<form className="crumbs" onSubmit={(e) => { e.preventDefault(); go(pathInput) }}>
<button type="button" className="crumb" onClick={() => go('/')}>/</button>
{crumbs.map((c, i) => <button type="button" key={i} className="crumb" onClick={() => go('/' + crumbs.slice(0, i + 1).join('/'))}>{c}</button>)}
<input className="path-input" value={pathInput} onChange={(e) => setPathInput(e.target.value)} onBlur={() => setPathInput(dir)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
</form>
</div>
<div className="mover-list">
{entries === null && <Loading label="Listing…" />}
{err && <div className="error" style={{ margin: 10 }}>{err}</div>}
{folders.map((e) => {
const self = dir === from && e.name === item.name
return (
<div key={e.name} className={'mover-row' + (self ? ' self' : '')} onClick={() => { if (!self) go(join(dir, e.name)) }}>
<span className="ficon">{e.target ? '🔗' : '📁'}</span>
<span className="fname">{e.name}</span>
{self && <span className="muted small">this is what's moving</span>}
</div>
)
})}
{entries !== null && folders.length === 0 && !err && <div className="muted small" style={{ padding: 12 }}>No folders here — “Move here” puts it in this one.</div>}
</div>
<div className="modal-foot">
<div className="svc-actions">
<button className="btn" onClick={mkdir}>+ folder</button>
<span className="spacer" />
<button className="btn" onClick={onClose}>Cancel</button>
<Btn className="btn on" disabled={!!why || entries === null} busy={busy} onClick={move}>Move here</Btn>
</div>
<div className="muted small mono mover-dest">{why || `→ ${dest}`}</div>
</div>
</div>
</div>
)
}
// ---- previews ----
// Rows ask for their thumbnail only as they scroll near: a browser's own lazy loading
// watches the window, and this list scrolls inside a box of its own. One observer for the
// list, a callback per row, each fired once.
class Near {
private obs: IntersectionObserver
private cbs = new Map<Element, () => void>()
constructor(root: Element) {
this.obs = new IntersectionObserver((ents) => {
for (const e of ents) if (e.isIntersecting) { const cb = this.cbs.get(e.target); if (cb) { this.forget(e.target); cb() } }
}, { root, rootMargin: '400px 0px' })
}
watch(el: Element, cb: () => void) { this.cbs.set(el, cb); this.obs.observe(el); return () => this.forget(el) }
private forget(el: Element) { this.cbs.delete(el); this.obs.unobserve(el) }
}
function Thumb({ url, kind, near, fallback }: { url: string; kind: 'image' | 'video' | 'audio'; near: Near | null; fallback: string }) {
const ref = useRef<HTMLSpanElement>(null)
const [show, setShow] = useState(false)
const [failed, setFailed] = useState('')
useEffect(() => { const el = ref.current; if (!el || !near || show) return; return near.watch(el, () => setShow(true)) }, [near, show])
useEffect(() => { setFailed('') }, [url]) // a changed file is a new URL, and worth another try
const broken = failed === url
return (
<span ref={ref} className={'fthumb ' + kind} title={broken ? 'no preview for this file' : undefined}>
{show && !broken && <img src={url} alt="" decoding="async" onError={() => setFailed(url)} />}
{(!show || broken) && <span className="ficon">{fallback}</span>}
</span>
)
}
// What the previews button says about how they get made here, and the one-tap fixes.
function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: boolean; onChanged: () => void }) {
const [task, setTask] = useState<{ id: string; restart: boolean } | null>(null)
const [waiting, setWaiting] = useState(false)
// the GPU fix ends by restarting Ham Lab: poll until it answers again, then re-read the status
useEffect(() => {
if (!waiting) return
const t = setInterval(async () => { try { await api.thumbStatus(sudo, true); setWaiting(false); onChanged() } catch { /* restarting */ } }, 3000)
return () => clearInterval(t)
}, [waiting, sudo, onChanged])
if (!st) return null
const blocked = (st.unusable ?? []).filter((g) => g.permission)
const start = async (label: string, fn: () => Promise<{ id: string }>, restart: boolean) => {
try { const t = await fn(); setTask({ id: t.id, restart }) } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
return (
<>
{!st.ok && (
<div className="thumb-note notice">
<span>Previews need ffmpeg: {st.error || 'it is not installed on this machine'}.</span>
{st.install && !task && <Btn className="btn on" onClick={() => start('install', api.thumbInstall, false)}>Install ffmpeg with {st.install.pm}</Btn>}
</div>
)}
{st.ok && blocked.length > 0 && (
<div className="thumb-note notice">
<span>{blocked.map((g) => g.name || g.device).join(', ')} is here, but Ham Lab runs as {st.user} and can't open {blocked.length > 1 ? 'them' : 'it'} ({blocked[0].reason}). Previews of videos are on the CPU until it can.</span>
{st.gpu_fix && !task && !waiting && <Btn className="btn on" confirm="Ham Lab adds itself to the GPU's group and restarts. Open terminals are interrupted. Continue?" onClick={() => start('GPU access', api.thumbGPUAccess, true)}>Allow GPU access</Btn>}
{waiting && <span className="muted small"><Spinner /> Ham Lab is restarting; the page reconnects by itself.</span>}
</div>
)}
{task && <TaskOutput id={task.id} onDone={(t) => { const ok = t.exit_code === 0 && !t.error; if (ok && task.restart) setWaiting(true); else if (ok) onChanged(); else toast('failed — the output says where', 'err'); setTask(null) }} />}
</>
)
}
// ---- editor / viewer ----
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
const [info, setInfo] = useState<FileInfo | null>(null)
const [text, setText] = useState<string | null>(null)
const [orig, setOrig] = useState('')
const [err, setErr] = useState('')
const [saving, setSaving] = useState(false)
const [cantPlay, setCantPlay] = useState(false) // the browser tried the file as it is and gave up
const name = path.split('/').pop() ?? path
const dirty = text !== null && text !== orig
useEffect(() => {
setInfo(null); setText(null); setErr(''); setCantPlay(false)
let stop = false
;(async () => {
try {
const i = await api.fileStat(path, sudo)
if (stop) return
setInfo(i)
if (i.text && i.size <= MAX_EDIT && !isImage(name)) {
const t = await api.fileRead(path, sudo)
if (stop) return
setText(t); setOrig(t)
}
} catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
})()
return () => { stop = true }
}, [path, sudo, name])
const save = useCallback(async () => {
if (text === null || !dirty) return
setSaving(true)
try { await api.fileWrite(path, text, sudo); setOrig(text); toast(`${name} saved`); onChanged() }
catch (e) { toast(`save failed — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
finally { setSaving(false) }
}, [text, dirty, path, sudo, name, onChanged])
useEffect(() => {
const h = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save() } }
window.addEventListener('keydown', h)
return () => window.removeEventListener('keydown', h)
}, [save])
const close = async () => { if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return; setUnsaved(''); onClose() }
// Unsaved edits are announced, so leaving the page, switching machine or closing the tab asks first.
useEffect(() => {
setUnsaved(dirty ? name : '')
if (!dirty) return
const h = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' }
window.addEventListener('beforeunload', h)
return () => { window.removeEventListener('beforeunload', h); setUnsaved('') }
}, [dirty, name])
const dl = api.fileDownloadURL(path, sudo)
return (
<div className="viewer">
<div className="viewer-bar">
<button className="btn" onClick={close}>‹ Back</button>
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
<span className="spacer" />
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
<a className="btn" href={dl} download={name}>Download</a>
{text !== null && <Btn className="btn on" disabled={!dirty || saving} onClick={save}>Save</Btn>}
</div>
{err && <div className="error">{err}</div>}
{!info && !err && <Loading label="Opening…" />}
{info && text !== null && (
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
)}
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
{/* Video: the browser's own decoder first, and a re-encode on the machine when that fails. Audio as it is. */}
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />}
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
)}
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
)}
{info && text === null && !isImage(name) && !info.preview && !err && (
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.type === 'other' ? 'This is a device, pipe or socket — nothing to show.' : info.broken ? `This link points at nothing (${info.target}).` : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
)}
</div>
)
}
// ---- page ----
export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) {
const [hashPath, setHashPath] = useHashSel('files') // the folder is in the URL; without one, the last folder that opened
const [path, setPath] = useState<string>(() => (hashPath.startsWith('/') ? hashPath : sessionStorage.getItem('hamlab.files.path') || '/'))
useEffect(() => { setHashPath(path) }, [path, setHashPath])
useEffect(() => { if (hashPath.startsWith('/') && hashPath !== path) setPath(hashPath) }, [hashPath]) // eslint-disable-line react-hooks/exhaustive-deps
const [sudo, setSudo] = useState<boolean>(() => sessionStorage.getItem('hamlab.files.sudo') === '1')
const [entries, setEntries] = useState<FileEntry[] | null>(null)
const [err, setErr] = useState('')
const [hidden, setHidden] = useState(false)
const [sort, setSort] = useState<Sort>('name')
const [q, setQ] = useState('')
const [pathInput, setPathInput] = useState(path)
const [open, setOpen] = useState<string | null>(null)
const [menu, setMenu] = useState<string | null>(null)
const [moving, setMoving] = useState<FileEntry | null>(null)
const [moveTask, setMoveTask] = useState<TaskInfo | null>(null)
const [upload, setUpload] = useState<{ name: string; pct: number; n: number; of: number } | null>(null)
const [drag, setDrag] = useState(false)
const fileInput = useRef<HTMLInputElement>(null)
const canSudo = useRef(true)
const [previews, setPreviews] = useState<boolean>(() => { try { return localStorage.getItem(PREVIEWS_KEY) === '1' } catch { return false } })
const [thumbs, setThumbs] = useState<ThumbStatus | null>(null)
const [listEl, setListEl] = useState<HTMLDivElement | null>(null)
const near = useMemo(() => (listEl && previews ? new Near(listEl) : null), [listEl, previews])
const readThumbs = useCallback((refresh = false) => { api.thumbStatus(sudo, refresh).then(setThumbs).catch((e) => setThumbs({ ok: false, engine: '', summary: '', gpus: [], error: e instanceof Error ? e.message : 'failed', user: '', cache: { path: '', entries: 0, bytes: 0 } })) }, [sudo])
useEffect(() => { try { localStorage.setItem(PREVIEWS_KEY, previews ? '1' : '0') } catch { /* private mode */ } }, [previews])
useEffect(() => { if (previews) readThumbs(); else setThumbs(null) }, [previews, readThumbs])
const load = useCallback(async (p = path, s = sudo) => {
try {
const l = await api.files(p, s)
setEntries(l.entries); setErr('')
canSudo.current = true
sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to
} catch (e) {
const msg = e instanceof Error ? e.message : 'failed'
if (/not a directory/i.test(msg) && p !== '/') { // a file was typed: open it, from its folder
setPath(p.slice(0, p.lastIndexOf('/')) || '/'); setOpen(p); return
}
if (/no such file/i.test(msg) && p !== '/' && !sessionStorage.getItem('hamlab.files.lost')) { // gone since last time (another machine, a deleted folder)
sessionStorage.setItem('hamlab.files.lost', '1'); toast(`${p} isn't here — showing /`, 'info'); setPath('/'); return
}
setErr(msg); setEntries([])
}
sessionStorage.removeItem('hamlab.files.lost')
}, [path, sudo])
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, [])
useEscape(() => setMenu(null), menu !== null)
const go = (p: string) => { setOpen(null); setPath(resolvePath(p, path)) }
const crumbs = path === '/' ? [] : path.slice(1).split('/')
const shown = (entries ?? []).filter((e) => (hidden || !e.name.startsWith('.')) && (!q || e.name.toLowerCase().includes(q.toLowerCase())))
.sort((a, b) => {
if ((a.type === 'dir') !== (b.type === 'dir')) return a.type === 'dir' ? -1 : 1
if (sort === 'size') return b.size - a.size
if (sort === 'mtime') return b.mtime - a.mtime
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
})
// ---- actions ----
const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
try { await fn(); toast(label); await load(); return true } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
}
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
const touch = async () => { const n = await askText('New file name'); if (okName(n) && await act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo))) setOpen(join(path, n.trim())) } // opened only once it exists
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
const move = (e: FileEntry) => setMoving(e)
const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
const del = async (e: FileEntry) => {
const full = join(path, e.name)
if (e.type === 'dir' && !e.target) {
const typed = await askText(`Delete the folder "${e.name}" and everything in it?\nType its name to confirm.`)
if (typed !== e.name) return
act(`deleted ${e.name}`, () => api.fileDelete(full, true, sudo))
} else if (await askYes(`Delete ${e.name}?`)) act(`deleted ${e.name}`, () => api.fileDelete(full, false, sudo))
}
const copyPath = async (e: FileEntry) => { try { await navigator.clipboard.writeText(join(path, e.name)); toast('path copied') } catch { await askText('Path', join(path, e.name)) } }
const openTerm = (p: string) => { onOpenInTerminal?.(`cd ${shellQuote(p)}`); }
// ---- upload ----
const uploadFiles = async (files: FileList | File[]) => {
const list = Array.from(files)
let done = 0
for (let i = 0; i < list.length; i++) {
const f = list[i]
let overwrite = false
if ((entries ?? []).some((e) => e.name === f.name)) {
if (!await askYes(`"${f.name}" already exists here. Replace it?`)) continue
overwrite = true
}
setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
try {
await api.fileUpload(path, f, sudo, (pct) => setUpload({ name: f.name, pct, n: i + 1, of: list.length }), overwrite)
done++
} catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break }
}
setUpload(null)
if (done === 1) toast(`uploaded ${list.length === 1 ? list[0].name : '1 file'}`)
else if (done > 1) toast(`uploaded ${done} files`)
if (done > 0) load()
}
// A dropped folder arrives as a nameless 0-byte "file"; the browser gives no way to read it here.
const onDrop = (e: DragEvent) => {
e.preventDefault(); setDrag(false)
const items = Array.from(e.dataTransfer.items ?? [])
const files = Array.from(e.dataTransfer.files).filter((_, i) => !(items[i]?.webkitGetAsEntry?.()?.isDirectory))
if (files.length < e.dataTransfer.files.length) toast('folders can\'t be dropped — drop the files inside, or upload a zip', 'err')
if (files.length) uploadFiles(files)
}
if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
return (
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
<div className="files-bar">
<form className="crumbs" onSubmit={(e) => { e.preventDefault(); go(pathInput.trim()) }}>
<button type="button" className="crumb" onClick={() => go('/')}>/</button>
{crumbs.map((c, i) => <button type="button" key={i} className="crumb" onClick={() => go('/' + crumbs.slice(0, i + 1).join('/'))}>{c}</button>)}
<input className="path-input" value={pathInput} onChange={(e) => setPathInput(e.target.value)} onBlur={() => setPathInput(path)} spellCheck={false} />
</form>
<div className="files-tools">
<button className={'btn' + (sudo ? ' on' : '')} title="browse as root" onClick={() => setSudo(!sudo)}>{sudo ? 'root' : 'sudo'}</button>
<button className={'btn' + (hidden ? ' on' : '')} title="show hidden files" onClick={() => setHidden(!hidden)}>.hidden</button>
<button className={'btn' + (previews ? ' on' : '')} title={'thumbnails for images and videos' + (thumbs ? ` — ${thumbs.summary}` + (thumbs.cache.entries ? ` · ${thumbs.cache.entries} kept (${fmtBytes(thumbs.cache.bytes)})` : '') : '')} onClick={() => setPreviews(!previews)}>previews</button>
<select className="btn" value={sort} onChange={(e) => setSort(e.target.value as Sort)}><option value="name">name</option><option value="size">size</option><option value="mtime">modified</option></select>
<input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn" onClick={mkdir}>+ folder</button>
<button className="btn" onClick={touch}>+ file</button>
<button className="btn" onClick={() => fileInput.current?.click()}>Upload</button>
<input ref={fileInput} type="file" multiple style={{ display: 'none' }} onChange={(e) => { if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = '' }} />
{onOpenInTerminal && <button className="btn" title="cd here in the active terminal" onClick={() => openTerm(path)}>&gt;_ here</button>}
<Btn className="btn" title="reload" onClick={() => load()}>↻</Btn>
</div>
</div>
{previews && <ThumbNote st={thumbs} sudo={sudo} onChanged={() => readThumbs(true)} />}
{moveTask && <TaskOutput id={moveTask.id} onDone={(t) => { load(); if (t.exit_code === 0 && !t.error) toast(`${moveTask.title}: done`); else toast(`${moveTask.title}: failed`, 'err') }} />}
{moveTask && <div className="thumb-note"><button className="btn" onClick={() => setMoveTask(null)}>Hide output</button></div>}
{upload && <div className="upload-bar"><Spinner /> {upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%<div className="meter"><div className="fill" style={{ width: `${upload.pct}%` }} /></div></div>}
{err && <div className="error" style={{ padding: '6px 12px' }}>{err}{/permission denied/i.test(err) && !sudo && <> · <button className="link" onClick={() => setSudo(true)}>retry as root</button></>}</div>}
<div className={'file-list' + (previews ? ' thumbs' : '')} ref={setListEl}>
{entries === null && <Loading label="Listing…" />}
{path !== '/' && entries !== null && (
<div className="file-row up" onClick={() => go(path.slice(0, path.lastIndexOf('/')) || '/')}><span className="ficon">↩</span><span className="fname">..</span></div>
)}
{shown.map((e) => {
const full = join(path, e.name)
const isDir = e.type === 'dir'
return (
<div key={e.name} className={'file-row' + (e.broken ? ' broken' : '')} onClick={() => (isDir ? go(full) : setOpen(full))}>
{previews && e.preview && thumbs?.ok
? <Thumb url={api.fileThumbURL(full, sudo, e.mtime, e.size)} kind={e.preview} near={near} fallback={icon(e)} />
: <span className="ficon">{icon(e)}</span>}
<span className="fname" title={e.target ? `→ ${e.target}` : e.name}>{e.name}{e.target && <span className="muted"> → {e.target}</span>}</span>
<span className="fmeta num">{isDir ? '' : fmtBytes(e.size)}</span>
<span className="fmeta num">{fmtTime(e.mtime)}</span>
<span className="fmeta num perm">{e.perm} {e.user}</span>
<button className="icon-btn more" onClick={(ev) => { ev.stopPropagation(); setMenu(menu === e.name ? null : e.name) }}>⋯</button>
{menu === e.name && (
<div className="ctx" onClick={(ev) => ev.stopPropagation()}>
{!isDir && <a href={api.fileDownloadURL(full, sudo)} download={e.name} onClick={() => setMenu(null)}>Download</a>}
{isDir && onOpenInTerminal && <button onClick={() => { setMenu(null); openTerm(full) }}>Open in terminal</button>}
<button onClick={() => { setMenu(null); rename(e) }}>Rename</button>
<button onClick={() => { setMenu(null); move(e) }}>Move…</button>
<button onClick={() => { setMenu(null); chmod(e) }}>Permissions ({e.perm})</button>
<button onClick={() => { setMenu(null); copyPath(e) }}>Copy path</button>
<button className="danger" onClick={() => { setMenu(null); del(e) }}>Delete</button>
</div>
)}
</div>
)
})}
{entries !== null && shown.length === 0 && !err && <div className="muted" style={{ padding: 12 }}>{q ? 'Nothing matches.' : 'Empty folder.'}{!hidden && entries.some((e) => e.name.startsWith('.')) ? ' (hidden files not shown)' : ''}</div>}
</div>
{drag && <div className="drop-hint">Drop to upload into {path}</div>}
{moving && <MovePicker item={moving} from={path} sudo={sudo} hidden={hidden} onClose={() => setMoving(null)} onMoved={(t) => { setMoving(null); if (t) setMoveTask(t); load() }} />}
</div>
)
}