import { DragEvent, useCallback, useEffect, useRef, useState } from 'react' import { api, FileEntry, FileInfo } from './api' import { fmtBytes } from './Dashboard' import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui' import { useHashSel } from './hashsel' 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 (isImage(e.name)) 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' // ---- 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 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 (
{name}{dirty && •} {info && {fmtBytes(info.size)} · {info.perm} · {info.user}} Download {text !== null && Save}
{err &&
{err}
} {!info && !err && } {info && text !== null && (