Files
web-terminal/public/tabs.ts
Yaojia Wang b5f21e5fdd feat(v0.2): tab status dot, activity (unread) indicator, drag-reorder
- terminal-session.ts: track SessionStatus (connecting/connected/reconnecting/
  exited) + onStatus callback; FIX: wire onTitleChange (onTitle was in opts but
  never bound — auto-title was dead)
- tabs.ts: per-tab status dot (color = connection state), unread flag set on
  background output + cleared on activate, HTML5 drag-to-reorder (preserves
  active tab, persisted)
- style.css: .tab-dot colors, .unread ring, .dragging/.drag-over feedback
- Browser-verified: green dot connected, unread ring after bg output, reorder
  AAA|BBB→BBB|AAA persists across reload. 191 tests green, typechecks + build ok.
2026-06-17 14:55:19 +02:00

317 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
}
}