Opening the app no longer auto-creates a blank tab or auto-restores/discovers sessions as tabs. Instead it lands on a launcher (session chooser): a grid of the host's running sessions as live preview thumbnails — pick one to open (replays its full scrollback), or '+ New session'. Closing the last tab returns to the chooser. - public/launcher.ts: mountLauncher() renders the chooser into the terminal area (live xterm thumbnails via /live-sessions + /preview, 5s refresh), New session + Manage link - tabs.ts: drop restore()/syncLiveSessions() auto-tab behavior; constructor lands on the launcher; rebuild() toggles launcher when tab count is 0; closeTab no longer spawns a blank tab - style.css: .launcher chrome (reuses the .mg-* card/grid/thumbnail styles) Verified: fresh load → 0 tabs, chooser with thumbnails; Open → tab + chooser hides; close last tab → chooser returns. 228 tests green, tsc + build clean.
235 lines
6.9 KiB
TypeScript
235 lines
6.9 KiB
TypeScript
/**
|
||
* public/launcher.ts — the home "session chooser" shown when no tab is open.
|
||
*
|
||
* Opening the app no longer auto-creates or auto-restores tabs. Instead this
|
||
* start screen lists the host's running sessions as live preview thumbnails
|
||
* (read-only xterm, same as the manage page) so the user picks which to open —
|
||
* or starts a new one. Sessions persist server-side, so opening one replays its
|
||
* full scrollback.
|
||
*/
|
||
|
||
import { Terminal } from '@xterm/xterm'
|
||
|
||
interface LiveSession {
|
||
id: string
|
||
createdAt: number
|
||
clientCount: number
|
||
status: 'working' | 'waiting' | 'idle' | 'unknown'
|
||
exited: boolean
|
||
cwd: string | null
|
||
cols: number
|
||
rows: number
|
||
}
|
||
|
||
interface Preview {
|
||
id: string
|
||
cols: number
|
||
rows: number
|
||
data: string
|
||
}
|
||
|
||
export interface LauncherHooks {
|
||
onOpen: (id: string) => void
|
||
onNew: () => void
|
||
}
|
||
|
||
const THUMB_W = 320
|
||
const THUMB_MAX_H = 200
|
||
const REFRESH_MS = 5000
|
||
const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m'
|
||
const THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
|
||
|
||
function el<K extends keyof HTMLElementTagNameMap>(
|
||
tag: K,
|
||
cls?: string,
|
||
text?: string,
|
||
): HTMLElementTagNameMap[K] {
|
||
const node = document.createElement(tag)
|
||
if (cls) node.className = cls
|
||
if (text !== undefined) node.textContent = text
|
||
return node
|
||
}
|
||
|
||
function relTime(ms: number): string {
|
||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||
if (s < 60) return `${Math.floor(s)}s`
|
||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
||
return `${Math.floor(s / 86400)}d`
|
||
}
|
||
|
||
function statusText(s: LiveSession['status']): string {
|
||
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
|
||
}
|
||
|
||
function sessionName(s: LiveSession): string {
|
||
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
|
||
}
|
||
|
||
interface Card {
|
||
el: HTMLElement
|
||
term: Terminal
|
||
inner: HTMLElement
|
||
thumb: HTMLElement
|
||
watch: HTMLElement
|
||
status: HTMLElement
|
||
meta: HTMLElement
|
||
}
|
||
|
||
export interface Launcher {
|
||
setVisible(v: boolean): void
|
||
refresh(): void
|
||
}
|
||
|
||
export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher {
|
||
const root = el('div', 'launcher')
|
||
root.style.display = 'none'
|
||
host.appendChild(root)
|
||
|
||
const head = el('div', 'launcher-head')
|
||
head.append(el('div', 'launcher-title', 'Your sessions'))
|
||
const sub = el('div', 'launcher-sub', '')
|
||
const actions = el('div', 'launcher-actions')
|
||
const newBtn = el('button', 'launcher-new', '+ New session')
|
||
newBtn.addEventListener('click', () => hooks.onNew())
|
||
const manage = el('a', 'mg-btn', '🗂 Manage') as HTMLAnchorElement
|
||
manage.href = '/manage.html'
|
||
actions.append(newBtn, manage)
|
||
head.append(sub, actions)
|
||
root.append(head)
|
||
|
||
const grid = el('div', 'mg-grid')
|
||
root.append(grid)
|
||
|
||
const cards = new Map<string, Card>()
|
||
let timer: ReturnType<typeof setInterval> | null = null
|
||
|
||
function fit(card: Card): void {
|
||
const w = card.inner.offsetWidth
|
||
const h = card.inner.offsetHeight
|
||
if (w === 0 || h === 0) return
|
||
const scale = Math.min(1, THUMB_W / w)
|
||
card.inner.style.transform = `scale(${scale})`
|
||
card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px`
|
||
}
|
||
|
||
function makeCard(s: LiveSession): Card {
|
||
const cardEl = el('div', 'mg-card')
|
||
const h = el('div', 'mg-card-head')
|
||
const title = el('span', 'mg-name', sessionName(s))
|
||
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
|
||
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
|
||
h.append(title, status, watch)
|
||
|
||
const thumb = el('div', 'mg-thumb')
|
||
const inner = el('div', 'mg-thumb-inner')
|
||
thumb.append(inner)
|
||
thumb.addEventListener('click', () => hooks.onOpen(s.id))
|
||
|
||
const meta = el('div', 'mg-meta')
|
||
const actions2 = el('div', 'mg-actions')
|
||
const open = el('button', 'mg-open', 'Open ↗')
|
||
open.addEventListener('click', () => hooks.onOpen(s.id))
|
||
actions2.append(open)
|
||
|
||
const term = new Terminal({
|
||
cols: Math.max(2, s.cols),
|
||
rows: Math.max(2, s.rows),
|
||
disableStdin: true,
|
||
cursorBlink: false,
|
||
fontFamily: 'Menlo, Consolas, monospace',
|
||
fontSize: 12,
|
||
scrollback: 0,
|
||
theme: THEME,
|
||
})
|
||
term.open(inner)
|
||
|
||
cardEl.append(h, thumb, meta, actions2)
|
||
return { el: cardEl, term, inner, thumb, watch, status, meta }
|
||
}
|
||
|
||
async function loadPreview(id: string, card: Card): Promise<void> {
|
||
try {
|
||
const res = await fetch(`/live-sessions/${id}/preview`)
|
||
if (!res.ok) return
|
||
const p = (await res.json()) as Preview
|
||
if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) {
|
||
card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows))
|
||
}
|
||
card.term.reset()
|
||
card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fit(card)))
|
||
} catch {
|
||
/* preview is best-effort */
|
||
}
|
||
}
|
||
|
||
async function refresh(): Promise<void> {
|
||
let sessions: LiveSession[] = []
|
||
try {
|
||
const res = await fetch('/live-sessions')
|
||
const data: unknown = await res.json()
|
||
if (Array.isArray(data)) sessions = data as LiveSession[]
|
||
} catch {
|
||
sessions = []
|
||
}
|
||
|
||
sub.textContent = sessions.length
|
||
? `${sessions.length} running on this host — pick one to open`
|
||
: 'No sessions running yet'
|
||
|
||
const seen = new Set<string>()
|
||
for (const s of sessions) {
|
||
seen.add(s.id)
|
||
let card = cards.get(s.id)
|
||
if (!card) {
|
||
card = makeCard(s)
|
||
cards.set(s.id, card)
|
||
grid.append(card.el)
|
||
}
|
||
card.status.className = `mg-status mg-${s.status}`
|
||
card.status.textContent = statusText(s.status)
|
||
card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
|
||
card.watch.textContent = `👁 ${s.clientCount}`
|
||
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old`
|
||
void loadPreview(s.id, card)
|
||
}
|
||
for (const [id, card] of cards) {
|
||
if (!seen.has(id)) {
|
||
card.term.dispose()
|
||
card.el.remove()
|
||
cards.delete(id)
|
||
}
|
||
}
|
||
|
||
grid.querySelector('.mg-empty')?.remove()
|
||
if (sessions.length === 0) {
|
||
grid.append(el('div', 'mg-empty', 'No running sessions. Click “New session” to start one.'))
|
||
}
|
||
}
|
||
|
||
return {
|
||
setVisible(v: boolean): void {
|
||
root.style.display = v ? 'block' : 'none'
|
||
if (v) {
|
||
void refresh()
|
||
if (timer === null) {
|
||
timer = setInterval(() => {
|
||
if (root.style.display !== 'none') void refresh()
|
||
}, REFRESH_MS)
|
||
}
|
||
} else {
|
||
if (timer !== null) {
|
||
clearInterval(timer)
|
||
timer = null
|
||
}
|
||
for (const card of cards.values()) card.term.dispose()
|
||
cards.clear()
|
||
grid.replaceChildren()
|
||
}
|
||
},
|
||
refresh(): void {
|
||
void refresh()
|
||
},
|
||
}
|
||
}
|