Quality of life: in-app dialogs, selections in the URL, editable widgets, token dialog, gallery search, status history, stream errors that say why, tab reordering and shortcuts, log out everywhere

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 10:34:59 -07:00
co-authored by Claude Fable 5.1
parent 8ee2a2d293
commit d7ed4d8549
24 changed files with 361 additions and 101 deletions
+17 -13
View File
@@ -1,7 +1,8 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { api, FileEntry, FileInfo } from './api'
import { fmtBytes } from './Dashboard'
import { Btn, Loading, Spinner, setUnsaved, toast, useEscape } from './ui'
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' }) }
@@ -64,7 +65,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
return () => window.removeEventListener('keydown', h)
}, [save])
const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; setUnsaved(''); onClose() }
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 : '')
@@ -100,7 +101,10 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
// ---- page ----
export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) {
const [path, setPath] = useState<string>(() => sessionStorage.getItem('hamlab.files.path') || '/')
const [hashPath, setHashPath] = useHashSel() // 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('')
@@ -148,20 +152,20 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
}
// 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 }
const mkdir = () => { const n = window.prompt('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
const touch = () => { const n = window.prompt('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
const rename = (e: FileEntry) => { const n = window.prompt('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) => { const n = window.prompt('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
const chmod = (e: FileEntry) => { const n = window.prompt(`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 = (e: FileEntry) => {
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)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
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 = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
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 = window.prompt(`Delete the folder "${e.name}" and everything in it?\nType its name to confirm.`)
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 (window.confirm(`Delete ${e.name}?`)) act(`deleted ${e.name}`, () => api.fileDelete(full, false, 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 { window.prompt('Path', join(path, e.name)) } }
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 ----
@@ -171,7 +175,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
const f = list[i]
let overwrite = false
if ((entries ?? []).some((e) => e.name === f.name)) {
if (!window.confirm(`"${f.name}" already exists here. Replace it?`)) continue
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 })