Frontend feature (backend unchanged — manager was already multi-session): - Multi-tab: each tab = independent WS + Terminal + session (terminal-session.ts, tabs.ts); tab bar with +, ×, middle-click close; persisted to localStorage (migrates v0.1 single-session key) - Tab titles: auto from xterm onTitleChange (OSC 0/2), double-click to rename inline (manual wins over auto); ellipsis + tooltip - Shortcut key bar now shown on ALL devices (clickable buttons trigger shortcuts), reordered for Claude Code (Esc prominent, ⇧Tab, ↑↓, ⏎, ^C, Tab, ←→, /); responsive sizing via @media (pointer:coarse) for touch - Verified in browser: rename, two independent sessions, keybar on desktop + narrow viewport. 191 tests green; both typechecks + esbuild build pass.
256 lines
7.7 KiB
TypeScript
256 lines
7.7 KiB
TypeScript
/**
|
||
* 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
|
||
}
|
||
|
||
/** 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 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 }
|
||
entry.session = new TerminalSession({
|
||
sessionId,
|
||
onSessionId: () => this.persist(),
|
||
onActivity: () => this.render(),
|
||
onTitle: (title) => {
|
||
entry.autoTitle = title.trim() || null
|
||
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
|
||
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
|
||
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')
|
||
tabEl.className = idx === this.activeIndex ? 'tab active' : 'tab'
|
||
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)
|
||
}
|
||
})
|
||
|
||
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)
|
||
}
|
||
}
|