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([from]) // where the picker has been, for back and forward const [at, setAt] = useState(0) const dir = hist[at] const [entries, setEntries] = useState(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 (
e.stopPropagation()}>

Move {isFolder ? 'folder ' : ''}{item.name}

{ e.preventDefault(); go(pathInput) }}> {crumbs.map((c, i) => )} setPathInput(e.target.value)} onBlur={() => setPathInput(dir)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
{entries === null && } {err &&
{err}
} {folders.map((e) => { const self = dir === from && e.name === item.name return (
{ if (!self) go(join(dir, e.name)) }}> {e.target ? '🔗' : '📁'} {e.name} {self && this is what's moving}
) })} {entries !== null && folders.length === 0 && !err &&
No folders here — “Move here” puts it in this one.
}
Move here
{why || `→ ${dest}`}
) } // ---- 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 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(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 ( {show && !broken && setFailed(url)} />} {(!show || broken) && {fallback}} ) } // 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 && (
Previews need ffmpeg: {st.error || 'it is not installed on this machine'}. {st.install && !task && start('install', api.thumbInstall, false)}>Install ffmpeg with {st.install.pm}}
)} {st.ok && blocked.length > 0 && (
{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. {st.gpu_fix && !task && !waiting && start('GPU access', api.thumbGPUAccess, true)}>Allow GPU access} {waiting && Ham Lab is restarting; the page reconnects by itself.}
)} {task && { 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(null) const [text, setText] = useState(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 (
{name}{dirty && •} {info && {fmtBytes(info.size)} · {info.perm} · {info.user}} Download {text !== null && Save}
{err &&
{err}
} {!info && !err && } {info && text !== null && (