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
+49 -12
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, Me, Term } from './api'
import Rail, { Group } from './Rail'
import TerminalPane, { TermHandle } from './TerminalPane'
@@ -18,7 +18,7 @@ import MachineSwitcher from './MachineSwitcher'
import { MachineStatus, setMachine } from './api'
import { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power'
import { Btn, Toasts, okToLeave, toast } from './ui'
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
const FolderIcon = (
@@ -48,7 +48,7 @@ const HUB_ONLY: View[] = ['machines', 'settings']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
// forward work, reload restores the tab, and links can point at a page on a machine.
function parseHash(): { machine: string | null; view: View } | null {
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)$/)
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
if (!m || !VIEWS.includes(m[2] as View)) return null
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
}
@@ -90,8 +90,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
const switchMachine = (name: string | null) => {
if (name !== machine && !okToLeave()) return
const switchMachine = async (name: string | null) => {
if (name !== machine && !(await okToLeave())) return
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
setMachineState(name)
@@ -103,10 +103,19 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${machine ?? hubMe.host} — Ham Lab` }, [view, machine, hubMe.host])
useEffect(() => {
const want = `#/${machine ?? 'hub'}/${view}`
if (location.hash !== want) history.pushState(null, '', want)
if (location.hash !== want && !location.hash.startsWith(want + '/')) history.pushState(null, '', want)
}, [machine, view])
useEffect(() => {
const h = () => { const p = parseHash(); if (!p) return; if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view) }
const h = async () => {
const p = parseHash(); if (!p) return
if (hasUnsaved() && (p.machine !== machine || p.view !== view)) { // Back/Forward away from unsaved work: stay put until answered
const there = location.hash
history.pushState(null, '', `#/${machine ?? 'hub'}/${view}`)
if (!(await okToLeave())) return
history.replaceState(null, '', there)
}
if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view)
}
window.addEventListener('popstate', h)
return () => window.removeEventListener('popstate', h)
}) // no deps: needs the latest switchMachine/view each time
@@ -131,13 +140,13 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
finally { setCreating(false) }
}
async function rename(t: Term) {
const name = window.prompt('Rename terminal', t.name)
const name = await askText('Rename terminal', t.name)
if (name && name.trim() && name !== t.name) {
try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
}
async function close(t: Term) {
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
if (t.running && !await askYes(`Close "${t.name}"? The shell will be hung up.`)) return
try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
@@ -154,6 +163,33 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
window.setTimeout(() => send(20), 50)
}
// Tab order is the user's, kept per machine in this browser; new terminals join at the end.
const orderKey = `hamlab.termorder.${machine ?? 'hub'}`
const [order, setOrder] = useState<string[]>([])
useEffect(() => { try { setOrder(JSON.parse(localStorage.getItem(orderKey) ?? '[]')) } catch { setOrder([]) } }, [orderKey])
const ordered = useMemo(() => [...terms].sort((a, b) => { const i = order.indexOf(a.id), j = order.indexOf(b.id); return (i < 0 ? 1e9 : i) - (j < 0 ? 1e9 : j) }), [terms, order])
const moveTab = (id: string, before: string | null) => {
const ids = ordered.map((t) => t.id).filter((x) => x !== id)
const at = before ? ids.indexOf(before) : -1
ids.splice(at < 0 ? ids.length : at, 0, id)
setOrder(ids); localStorage.setItem(orderKey, JSON.stringify(ids))
}
// Alt+Shift+←/→ switches tabs, Alt+Shift+T opens one. Caught before xterm sees the keys.
useEffect(() => {
if (view !== 'terminal') return
const h = (e: KeyboardEvent) => {
if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return
const dir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0
if (dir && ordered.length > 1) { const i = ordered.findIndex((t) => t.id === active); setActive(ordered[(i + dir + ordered.length) % ordered.length].id) }
else if (e.key.toLowerCase() === 't') create()
else return
e.preventDefault(); e.stopPropagation()
}
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}) // no deps: needs the latest tabs each time
const activeRef = useRef(active); activeRef.current = active
const activeTerm = terms.find((t) => t.id === active)
const termView = view === 'terminal'
@@ -175,7 +211,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<div className="body">
<Rail groups={GROUPS} view={view}
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
onView={(id) => { if (id !== view && !okToLeave()) return; setView(id as View); setDrawer(false) }} />
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
<div className="backdrop" onClick={() => setDrawer(false)} />
<main className="main" key={machine ?? 'hub'}>
@@ -189,8 +225,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'updates' && <Updates />}
{view === 'disks' && <Disks />}
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
{termView && <TermTabs terms={terms} active={active} creating={creating}
onSelect={setActive} onNew={create} onRename={rename} onClose={close} />}
{termView && <TermTabs terms={ordered} active={active} creating={creating}
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
{termView && terms.length === 0 && (
<div className="center muted">
<div>No terminals.</div>