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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d200e5dc2e
commit
1038c010fa
+38
-8
@@ -7,6 +7,19 @@ 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, "'\\''") + "'"
|
||||
@@ -48,9 +61,9 @@ function MovePicker({ item, from, sudo, hidden, onClose, onMoved }: { item: File
|
||||
}, [sudo])
|
||||
useEffect(() => { setPathInput(dir); load(dir) }, [dir, load])
|
||||
useEscape(onClose)
|
||||
const go = (p: string) => {
|
||||
p = p.trim().replace(/\/+$/, '') || '/'
|
||||
if (!p.startsWith('/') || p === dir) return
|
||||
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('/')) || '/') }
|
||||
@@ -263,7 +276,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
<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.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
|
||||
<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>
|
||||
)
|
||||
@@ -306,15 +319,22 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
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(p || '/') }
|
||||
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())))
|
||||
@@ -348,6 +368,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
// ---- 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
|
||||
@@ -358,13 +379,22 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
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)
|
||||
toast(list.length === 1 ? `uploaded ${list[0].name}` : `uploaded ${list.length} files`)
|
||||
load()
|
||||
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)
|
||||
}
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user