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:
co-authored by
Claude Fable 5.1
parent
8ee2a2d293
commit
d7ed4d8549
+17
-13
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user