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
+49
-12
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user