The picker walks the machine like the tab does — back, forward, up, breadcrumbs, a path box, a new folder — and "Move here" puts the file or folder in the folder being looked at; it says why it can't when it can't (already here, into itself, a name clash). POST /api/files/move is a rename when the two ends share a filesystem and otherwise a task: on a NAS every move between datasets is a different filesystem, and a rename there is EXDEV. The copy is built under a hidden temporary name beside its destination, renamed into place once complete, and only then is the original removed, so a failure or a cancel leaves the original as it was. Modes, times, symlinks and, as root, owners are kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
428 lines
27 KiB
TypeScript
428 lines
27 KiB
TypeScript
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'
|
||
|
||
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
|
||
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 (/\.(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 = (p: string) => {
|
||
p = p.trim().replace(/\/+$/, '') || '/'
|
||
if (!p.startsWith('/') || 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'; 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 name = path.split('/').pop() ?? path
|
||
const dirty = text !== null && text !== orig
|
||
|
||
useEffect(() => {
|
||
setInfo(null); setText(null); setErr('')
|
||
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>}
|
||
{info && text === null && !isImage(name) && !err && (
|
||
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : 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'
|
||
setErr(msg); setEntries([])
|
||
}
|
||
}, [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(p || '/') }
|
||
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)
|
||
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)
|
||
} catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break }
|
||
}
|
||
setUpload(null)
|
||
toast(list.length === 1 ? `uploaded ${list[0].name}` : `uploaded ${list.length} files`)
|
||
load()
|
||
}
|
||
const onDrop = (e: DragEvent) => { e.preventDefault(); setDrag(false); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.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)}>>_ 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>
|
||
)
|
||
}
|