Terminal sessions move from the rail into a tab strip on the terminal page
The rail is now a flat list of pages; Terminal is one of them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7eb00d089f
commit
91b7f4f009
+16
-49
@@ -1,58 +1,25 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Term } from './api'
|
||||
import { Spinner } from './ui'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
// A group is either a page (clicking the head opens it — Dashboard) or a folder of
|
||||
// leaves (Terminal). `soon` groups are inert placeholders for the roadmap.
|
||||
export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean; page?: boolean }
|
||||
// Every group is a page: clicking it opens it. `soon` groups are inert placeholders
|
||||
// for the roadmap.
|
||||
export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean }
|
||||
|
||||
interface Props {
|
||||
groups: Group[]; terms: Term[]; active: string | null; view: string; creating?: boolean; header?: ReactNode
|
||||
onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void
|
||||
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||||
}
|
||||
interface Props { groups: Group[]; view: string; header?: ReactNode; onView: (id: string) => void }
|
||||
|
||||
export default function Rail({ groups, terms, active, view, creating, header, onSelect, onView, onNew, onRename, onClose }: Props) {
|
||||
const [open, setOpen] = useState<string>('terminal')
|
||||
export default function Rail({ groups, view, header, onView }: Props) {
|
||||
return (
|
||||
<nav className="rail">
|
||||
{header}
|
||||
{groups.map((g) => {
|
||||
const expanded = !g.page && open === g.id
|
||||
const isPage = !!g.page && view === g.id
|
||||
return (
|
||||
<div key={g.id} className={'group' + (expanded ? ' open' : '') + (g.soon ? ' soon' : '')}>
|
||||
<button className={'group-head' + (isPage ? ' active' : '')} disabled={g.soon}
|
||||
onClick={() => { if (g.soon) return; if (g.page) onView(g.id); else setOpen(expanded ? '' : g.id) }}>
|
||||
<span className="chev">{g.page ? '' : expanded ? '▾' : '▸'}</span>
|
||||
<span className="gicon">{g.icon}</span>
|
||||
<span>{g.label}</span>
|
||||
{g.soon && <span className="badge">soon</span>}
|
||||
</button>
|
||||
{expanded && g.id === 'terminal' && (
|
||||
<div className="leaves">
|
||||
{terms.map((t) => {
|
||||
const sel = view === 'terminal' && t.id === active
|
||||
return (
|
||||
<div key={t.id} className={'leaf' + (sel ? ' active' : '') + (t.running ? '' : ' exited')}
|
||||
onClick={() => onSelect(t.id)}>
|
||||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||
<span className="lname" title={t.name}>{t.name}</span>
|
||||
{sel && (
|
||||
<span className="leaf-actions">
|
||||
<button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>
|
||||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button className="leaf new" disabled={creating} onClick={onNew}>{creating ? <><Spinner /> creating…</> : '+ New Terminal'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{groups.map((g) => (
|
||||
<div key={g.id} className={'group' + (g.soon ? ' soon' : '')}>
|
||||
<button className={'group-head' + (view === g.id ? ' active' : '')} disabled={g.soon}
|
||||
onClick={() => { if (!g.soon) onView(g.id) }}>
|
||||
<span className="gicon">{g.icon}</span>
|
||||
<span>{g.label}</span>
|
||||
{g.soon && <span className="badge">soon</span>}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
+14
-13
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, Me, Term } from './api'
|
||||
import Rail, { Group } from './Rail'
|
||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||
import TermTabs from './TermTabs'
|
||||
import KeyBar from './KeyBar'
|
||||
import Dashboard from './Dashboard'
|
||||
import Services from './Services'
|
||||
@@ -26,16 +27,16 @@ const FolderIcon = (
|
||||
)
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
||||
{ id: 'procs', label: 'Processes', icon: '≡', page: true },
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
|
||||
{ id: 'procs', label: 'Processes', icon: '≡' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '◈', page: true },
|
||||
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪', page: true },
|
||||
{ id: 'disks', label: 'Disks', icon: '▤', page: true },
|
||||
{ id: 'files', label: 'Files', icon: FolderIcon, page: true },
|
||||
{ id: 'machines', label: 'Machines', icon: '⌂', page: true },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙', page: true },
|
||||
{ id: 'services', label: 'Services', icon: '◈' },
|
||||
{ id: 'cron', label: 'Cron', icon: '⏱' },
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
|
||||
@@ -167,11 +168,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
<Rail groups={GROUPS} terms={terms} active={active} view={view} creating={creating}
|
||||
<Rail groups={GROUPS} view={view}
|
||||
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||
onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }}
|
||||
onView={(id) => { setView(id as View); setDrawer(false) }}
|
||||
onNew={create} onRename={rename} onClose={close} />
|
||||
onView={(id) => { setView(id as View); setDrawer(false) }} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
@@ -184,6 +183,8 @@ 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 && terms.length === 0 && (
|
||||
<div className="center muted">
|
||||
<div>No terminals.</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Term } from './api'
|
||||
import { Spinner } from './ui'
|
||||
|
||||
interface Props {
|
||||
terms: Term[]; active: string | null; creating?: boolean
|
||||
onSelect: (id: string) => void; onNew: () => void
|
||||
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||||
}
|
||||
|
||||
// Tab strip across the top of the terminal page: one tab per session, "+" at the end.
|
||||
export default function TermTabs({ terms, active, creating, onSelect, onNew, onRename, onClose }: Props) {
|
||||
const strip = useRef<HTMLDivElement>(null)
|
||||
// keep the active tab in sight when the strip overflows
|
||||
useEffect(() => { strip.current?.querySelector('.ttab.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [active, terms.length])
|
||||
return (
|
||||
<div className="term-tabs" ref={strip} onMouseDown={(e) => e.preventDefault()}>
|
||||
{terms.map((t) => {
|
||||
const sel = t.id === active
|
||||
return (
|
||||
<div key={t.id} className={'ttab' + (sel ? ' active' : '') + (t.running ? '' : ' exited')}
|
||||
onClick={() => onSelect(t.id)} onDoubleClick={() => onRename(t)}
|
||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t) } }}>
|
||||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||
<span className="tname" title={t.name}>{t.name}</span>
|
||||
{sel && <button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>}
|
||||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button className="ttab new" title="new terminal" disabled={creating} onClick={onNew}>{creating ? <Spinner /> : '+'}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+17
-12
@@ -4,7 +4,7 @@
|
||||
--ok: #3fb950; --bad: #ff7b72;
|
||||
--s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18);
|
||||
--btn-fg: #111; --term-bg: var(--term-bg); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--header-h: 44px; --footer-h: 28px; --rail-w: 220px;
|
||||
--header-h: 44px; --footer-h: 28px; --rail-w: 220px; --tabs-h: 34px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
@@ -46,20 +46,22 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.chev { width: 10px; color: var(--muted); font-size: 11px; }
|
||||
.gicon { width: 18px; text-align: center; color: var(--accent2); font-family: ui-monospace, monospace; font-size: 12px; display: inline-flex; justify-content: center; align-items: center; }
|
||||
.badge { margin-left: auto; font-size: 10px; text-transform: uppercase; border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; }
|
||||
.leaves { padding: 2px 0 6px; }
|
||||
.leaf { display: flex; align-items: center; gap: 8px; padding: 7px 12px 7px 30px; cursor: pointer; width: 100%; text-align: left; }
|
||||
.leaf:hover { background: var(--bg3); }
|
||||
.leaf.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.leaf.exited .lname { color: var(--muted); text-decoration: line-through; }
|
||||
.leaf.new { color: var(--accent2); }
|
||||
.lname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.leaf-actions { display: flex; gap: 2px; }
|
||||
.sdot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
|
||||
.sdot.run { background: var(--ok); }
|
||||
|
||||
/* main + terminal */
|
||||
.main { flex: 1; min-width: 0; position: relative; background: var(--term-bg); }
|
||||
.term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
|
||||
.term { position: absolute; inset: var(--tabs-h) 0 0 0; padding: 6px 0 0 6px; }
|
||||
.term-tabs { position: absolute; top: 0; left: 0; right: 0; height: var(--tabs-h); display: flex; align-items: stretch; background: var(--bg2); border-bottom: 1px solid var(--border); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.term-tabs::-webkit-scrollbar { display: none; }
|
||||
.ttab { flex: 0 1 200px; min-width: 96px; display: flex; align-items: center; gap: 6px; padding: 0 4px 0 10px; cursor: pointer; border-right: 1px solid var(--border); color: var(--muted); font-size: 13px; user-select: none; }
|
||||
.ttab:hover { background: var(--bg3); color: var(--fg); }
|
||||
.ttab.active { background: var(--term-bg); color: var(--fg); box-shadow: inset 0 2px 0 var(--accent); }
|
||||
.ttab.exited .tname { text-decoration: line-through; }
|
||||
.ttab .icon-btn { padding: 0 5px; font-size: 12px; }
|
||||
.ttab:not(.active):not(:hover) .icon-btn { visibility: hidden; }
|
||||
.ttab.new { flex: 0 0 auto; min-width: 40px; justify-content: center; padding: 0 12px; border-right: 0; color: var(--accent2); font-size: 16px; position: sticky; right: 0; background: var(--bg2); box-shadow: -1px 0 0 var(--border); }
|
||||
.tname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.term-inner { height: 100%; }
|
||||
.term .xterm { height: 100%; }
|
||||
.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); }
|
||||
@@ -79,6 +81,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.drawer-open .rail { transform: none; }
|
||||
.drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); }
|
||||
.footer { font-size: 11px; }
|
||||
.ttab { flex: 0 0 auto; max-width: 70vw; }
|
||||
}
|
||||
|
||||
/* dashboard */
|
||||
@@ -369,7 +372,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
/* per-theme flourishes */
|
||||
[data-theme="cyberpunk"] .brand, [data-theme="cyberpunk"] .login h1 { text-shadow: 0 0 8px rgba(255, 43, 214, .7), 0 0 18px rgba(255, 43, 214, .35); }
|
||||
[data-theme="cyberpunk"] .gicon, [data-theme="cyberpunk"] .box-title { text-shadow: 0 0 6px rgba(0, 229, 255, .6); }
|
||||
[data-theme="cyberpunk"] .leaf.active, [data-theme="cyberpunk"] .group-head.active { box-shadow: inset 3px 0 0 var(--accent), 0 0 12px rgba(255, 43, 214, .25); }
|
||||
[data-theme="cyberpunk"] .group-head.active { box-shadow: inset 3px 0 0 var(--accent), 0 0 12px rgba(255, 43, 214, .25); }
|
||||
[data-theme="retro"] body, [data-theme="retro"] .btn, [data-theme="retro"] input, [data-theme="retro"] select { font-family: var(--mono); }
|
||||
[data-theme="retro"] .brand, [data-theme="retro"] .login h1 { text-shadow: 0 0 6px rgba(255, 176, 0, .6); letter-spacing: 1px; }
|
||||
[data-theme="retro"] .app::after { content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 100; background: repeating-linear-gradient(0deg, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.12) 2px 3px); }
|
||||
@@ -384,7 +387,9 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
@media (pointer: coarse) {
|
||||
.btn, .chip, .icon-btn, .link, .th, .mod-tools button, .hide-item, .crumb, .files-tools select, .procs-tools select, .dash-bar select { min-height: 36px; padding-top: 6px; padding-bottom: 6px; }
|
||||
.icon-btn { min-width: 36px; }
|
||||
.leaf-actions .icon-btn { min-width: 34px; }
|
||||
:root { --tabs-h: 40px; }
|
||||
.ttab .icon-btn { min-width: 30px; }
|
||||
.ttab:not(.active) .icon-btn { display: none; }
|
||||
.header .link, .header .icon-btn { min-height: 36px; display: inline-flex; align-items: center; }
|
||||
.mod-tools { margin-top: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user