/** * public/tabs.ts — multi-tab manager + tab bar UI. * * Each tab owns an independent TerminalSession (own WS + server session). * * Titles (learned from Warp / VS Code / Arc): * - auto title from the terminal (xterm onTitleChange = OSC 0/2: cwd/process) * - double-click a tab to rename inline (Enter commit, Esc cancel) * - manual title wins over auto; empty rename reverts to auto/"Term N" * Interaction: * - middle-click a tab to close (browser/iTerm2 convention) * - × button (hover on desktop, always shown on touch) * - long titles ellipsis-truncate with a full-title tooltip * * Closing a tab only disconnects its WS (a *detach*); the server PTY keeps * running and is reclaimed by IDLE_TTL — consistent with session survival. */ import { TerminalSession } from './terminal-session.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' const LEGACY_KEY = 'web-terminal:sessionId' // single-session key from v0.1 interface TabEntry { session: TerminalSession /** User-set title; null = use auto/fallback. Persisted. */ customTitle: string | null /** Latest OSC title from the terminal; null until the shell sets one. */ autoTitle: string | null /** True when an inactive tab received output since last viewed (unread dot). */ hasActivity: boolean } /** Persisted shape per tab. */ interface StoredTab { id: string | null title: string | null } export class TabApp { private readonly tabs: TabEntry[] = [] private activeIndex = -1 private editingIndex = -1 private dragIndex = -1 private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement constructor(paneHost: HTMLElement, tabBar: HTMLElement) { this.paneHost = paneHost this.tabBar = tabBar this.restore() } private displayTitle(entry: TabEntry, idx: number): string { return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` } private persist(): void { try { const stored: StoredTab[] = this.tabs.map((t) => ({ id: t.session.id, title: t.customTitle, })) localStorage.setItem(TABS_KEY, JSON.stringify(stored)) localStorage.setItem(ACTIVE_KEY, String(this.activeIndex)) } catch { // localStorage unavailable — run without persistence } } private restore(): void { let stored: StoredTab[] = [] try { const raw = localStorage.getItem(TABS_KEY) if (raw) { const parsed: unknown = JSON.parse(raw) if (Array.isArray(parsed)) { stored = parsed.map((v): StoredTab => { // v0.2.0 stored bare ids (string|null); v0.2.1+ stores {id,title}. if (typeof v === 'string') return { id: v, title: null } if (v && typeof v === 'object' && 'id' in v) { const o = v as { id: unknown; title?: unknown } return { id: typeof o.id === 'string' ? o.id : null, title: typeof o.title === 'string' ? o.title : null, } } return { id: null, title: null } }) } } } catch { // ignore malformed state } if (stored.length === 0) { // First run: seed from the v0.1 single-session key if present. let legacy: string | null = null try { legacy = localStorage.getItem(LEGACY_KEY) } catch { legacy = null } stored = [{ id: legacy, title: null }] } for (const s of stored) { this.createTab(s.id, s.title, false) } let active = 0 try { active = parseInt(localStorage.getItem(ACTIVE_KEY) ?? '0', 10) } catch { active = 0 } if (!Number.isInteger(active) || active < 0 || active >= this.tabs.length) { active = 0 } this.activate(active) this.render() } private createTab(sessionId: string | null, customTitle: string | null, activate: boolean): void { const entry: TabEntry = { session: null as unknown as TerminalSession, customTitle, autoTitle: null, hasActivity: false, } entry.session = new TerminalSession({ sessionId, onSessionId: () => this.persist(), // onActivity only fires for hidden (inactive) panes (see TerminalSession). onActivity: () => { entry.hasActivity = true this.render() }, onTitle: (title) => { entry.autoTitle = title.trim() || null this.render() }, onStatus: () => this.render(), }) this.paneHost.appendChild(entry.session.el) this.tabs.push(entry) entry.session.connect() if (activate) this.activate(this.tabs.length - 1) this.persist() } newTab(): void { this.createTab(null, null, true) this.render() } activate(i: number): void { if (i < 0 || i >= this.tabs.length) return this.activeIndex = i const entry = this.tabs[i] if (entry) entry.hasActivity = false // viewing it clears the unread dot this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide())) this.persist() this.render() } /** Drag-reorder: move the tab at `from` to position `to`, preserving the active tab. */ private moveTab(from: number, to: number): void { if (from < 0 || to < 0 || from === to || from >= this.tabs.length || to >= this.tabs.length) { return } const activeEntry = this.tabs[this.activeIndex] const [moved] = this.tabs.splice(from, 1) if (!moved) return this.tabs.splice(to, 0, moved) if (activeEntry) this.activeIndex = this.tabs.indexOf(activeEntry) this.persist() this.render() } closeTab(i: number): void { if (i < 0 || i >= this.tabs.length) return const [entry] = this.tabs.splice(i, 1) entry?.session.dispose() if (this.editingIndex === i) this.editingIndex = -1 if (this.tabs.length === 0) { this.createTab(null, null, true) } else { this.activate(Math.min(i, this.tabs.length - 1)) } this.persist() this.render() } private commitRename(i: number, value: string): void { const entry = this.tabs[i] if (entry) { const trimmed = value.trim() entry.customTitle = trimmed.length > 0 ? trimmed : null // empty → revert to auto } this.editingIndex = -1 this.persist() this.render() } /** Route key-bar input to the currently active tab. */ sendToActive(data: string): void { this.tabs[this.activeIndex]?.session.send(data) } private render(): void { this.tabBar.replaceChildren() this.tabs.forEach((entry, idx) => { const tabEl = document.createElement('div') const classes = ['tab'] if (idx === this.activeIndex) classes.push('active') if (entry.hasActivity) classes.push('unread') tabEl.className = classes.join(' ') const title = this.displayTitle(entry, idx) tabEl.title = title // full-title tooltip (truncation handled by CSS) // Middle-click anywhere on the tab closes it. tabEl.addEventListener('auxclick', (e) => { if (e.button === 1) { e.preventDefault() this.closeTab(idx) } }) // Drag-to-reorder (disabled while renaming so the input is usable). if (idx !== this.editingIndex) { tabEl.draggable = true tabEl.addEventListener('dragstart', (e) => { this.dragIndex = idx e.dataTransfer?.setData('text/plain', String(idx)) tabEl.classList.add('dragging') }) tabEl.addEventListener('dragover', (e) => { e.preventDefault() tabEl.classList.add('drag-over') }) tabEl.addEventListener('dragleave', () => tabEl.classList.remove('drag-over')) tabEl.addEventListener('drop', (e) => { e.preventDefault() tabEl.classList.remove('drag-over') this.moveTab(this.dragIndex, idx) }) tabEl.addEventListener('dragend', () => { this.dragIndex = -1 tabEl.classList.remove('dragging') }) } // Status dot: color = connection state; brightened when unread. const dot = document.createElement('span') dot.className = `tab-dot dot-${entry.session.status}` tabEl.appendChild(dot) if (idx === this.editingIndex) { const input = document.createElement('input') input.className = 'tab-rename' input.value = title input.addEventListener('keydown', (e) => { if (e.key === 'Enter') this.commitRename(idx, input.value) else if (e.key === 'Escape') { this.editingIndex = -1 this.render() } e.stopPropagation() }) input.addEventListener('blur', () => this.commitRename(idx, input.value)) tabEl.appendChild(input) this.tabBar.appendChild(tabEl) // focus + select after it's in the DOM requestAnimationFrame(() => { input.focus() input.select() }) return } const label = document.createElement('span') label.className = 'tab-label' label.textContent = title label.addEventListener('click', () => this.activate(idx)) label.addEventListener('dblclick', () => { this.editingIndex = idx this.render() }) tabEl.appendChild(label) const close = document.createElement('button') close.className = 'tab-close' close.textContent = '×' close.title = 'Close tab (the shell keeps running on the server)' close.setAttribute('aria-label', 'Close tab') close.addEventListener('click', (e) => { e.stopPropagation() this.closeTab(idx) }) tabEl.appendChild(close) this.tabBar.appendChild(tabEl) }) const add = document.createElement('button') add.className = 'tab-add' add.textContent = '+' add.title = 'New session' add.setAttribute('aria-label', 'New session') add.addEventListener('click', () => this.newTab()) this.tabBar.appendChild(add) } }