- M3: settings.ts (⚙ panel: dark/light/solarized theme + font size A-/A+), persisted to localStorage; terminal-session.applyTheme; TabApp.applySettings applies to all + new tabs - M7: dashboard.ts (▦ overlay listing every tab with connection dot + folder + Claude status; click a row to focus; live re-render while open); TabApp.snapshot()/focusTab() - Browser-verified: light theme renders + persists; dashboard lists tabs and focuses on click. 208 tests green, typechecks + build ok.
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
/**
|
||
* public/tabs.ts — multi-tab manager + tab bar UI.
|
||
*
|
||
* Each tab owns an independent TerminalSession (own WS + server session).
|
||
*
|
||
* Titles: auto from the terminal (current folder, via folderFromTitle) →
|
||
* double-click to rename inline → falls back to "Term N". Manual wins.
|
||
* Interaction: pointerdown to switch (robust with draggable), drag to reorder,
|
||
* middle-click / × to close, status dot (connection color), unread dot.
|
||
*
|
||
* DOM is built once per tab and updated in place. A full rebuild happens ONLY
|
||
* on structural changes (add / close / reorder / rename) — never on a plain
|
||
* activate or on background output — so clicks are never eaten by a re-render.
|
||
*
|
||
* Closing a tab only disconnects its WS (a *detach*); the server PTY keeps
|
||
* running and is reclaimed by IDLE_TTL.
|
||
*/
|
||
|
||
import { TerminalSession } from './terminal-session.js'
|
||
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.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
|
||
customTitle: string | null // user-set; null = use auto/fallback (persisted)
|
||
autoTitle: string | null // current folder from the terminal title
|
||
hasActivity: boolean // inactive tab got output since last viewed
|
||
el: HTMLDivElement | null // the .tab element (updated in place)
|
||
}
|
||
|
||
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 notifyAsked = false
|
||
private settings: Settings = DEFAULT_SETTINGS
|
||
private readonly paneHost: HTMLElement
|
||
private readonly tabBar: HTMLElement
|
||
private readonly approvalBar: HTMLDivElement
|
||
|
||
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
||
this.paneHost = paneHost
|
||
this.tabBar = tabBar
|
||
this.approvalBar = document.createElement('div')
|
||
this.approvalBar.id = 'approvalbar'
|
||
this.approvalBar.style.display = 'none'
|
||
document.body.appendChild(this.approvalBar)
|
||
this.restore()
|
||
}
|
||
|
||
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
|
||
private updateApprovalBar(): void {
|
||
const session = this.tabs[this.activeIndex]?.session
|
||
if (!session || !session.pendingApproval) {
|
||
this.approvalBar.style.display = 'none'
|
||
return
|
||
}
|
||
this.approvalBar.replaceChildren()
|
||
const label = document.createElement('span')
|
||
label.className = 'approval-label'
|
||
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
|
||
const approve = document.createElement('button')
|
||
approve.className = 'approval-yes'
|
||
approve.textContent = '✓ Approve'
|
||
approve.addEventListener('click', () => {
|
||
session.approve()
|
||
this.updateApprovalBar()
|
||
})
|
||
const reject = document.createElement('button')
|
||
reject.className = 'approval-no'
|
||
reject.textContent = '✗ Reject'
|
||
reject.addEventListener('click', () => {
|
||
session.reject()
|
||
this.updateApprovalBar()
|
||
})
|
||
this.approvalBar.append(label, approve, reject)
|
||
this.approvalBar.style.display = 'flex'
|
||
}
|
||
|
||
private displayTitle(entry: TabEntry, idx: number): string {
|
||
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
|
||
}
|
||
|
||
/* ── persistence ─────────────────────────────────────────────── */
|
||
|
||
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 => {
|
||
if (typeof v === 'string') return { id: v, title: null } // v0.2.0 format
|
||
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) {
|
||
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.addEntry(s.id, s.title)
|
||
|
||
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.rebuild()
|
||
this.activate(active)
|
||
}
|
||
|
||
/* ── tab lifecycle ───────────────────────────────────────────── */
|
||
|
||
private addEntry(sessionId: string | null, customTitle: string | null): TabEntry {
|
||
const entry: TabEntry = {
|
||
session: null as unknown as TerminalSession,
|
||
customTitle,
|
||
autoTitle: null,
|
||
hasActivity: false,
|
||
el: null,
|
||
}
|
||
entry.session = new TerminalSession({
|
||
sessionId,
|
||
onSessionId: () => this.persist(),
|
||
// onActivity only fires for hidden (inactive) panes (see TerminalSession).
|
||
onActivity: () => {
|
||
entry.hasActivity = true
|
||
this.refreshTab(entry)
|
||
},
|
||
onTitle: (title) => {
|
||
entry.autoTitle = title.trim() || null
|
||
this.refreshTab(entry)
|
||
},
|
||
onStatus: () => this.refreshTab(entry),
|
||
onClaudeStatus: (status) => {
|
||
this.refreshTab(entry)
|
||
this.updateApprovalBar()
|
||
// Notify when a background tab needs approval (H2/H4).
|
||
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
|
||
this.notify(entry)
|
||
}
|
||
},
|
||
})
|
||
this.paneHost.appendChild(entry.session.el)
|
||
this.tabs.push(entry)
|
||
entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
|
||
entry.session.connect()
|
||
return entry
|
||
}
|
||
|
||
newTab(): void {
|
||
this.addEntry(null, null)
|
||
this.persist()
|
||
this.rebuild()
|
||
this.activate(this.tabs.length - 1)
|
||
}
|
||
|
||
activate(i: number): void {
|
||
if (i < 0 || i >= this.tabs.length) return
|
||
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
||
this.activeIndex = i
|
||
const entry = this.tabs[i]
|
||
if (entry) entry.hasActivity = false // viewing clears the unread dot
|
||
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
|
||
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
|
||
this.updateApprovalBar()
|
||
this.persist()
|
||
}
|
||
|
||
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.addEntry(null, null)
|
||
this.persist()
|
||
this.rebuild()
|
||
this.activate(0)
|
||
return
|
||
}
|
||
const next = Math.min(i, this.tabs.length - 1)
|
||
this.persist()
|
||
this.rebuild()
|
||
this.activate(next)
|
||
}
|
||
|
||
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.rebuild()
|
||
}
|
||
|
||
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.rebuild()
|
||
}
|
||
|
||
/** Route key-bar input to the currently active tab. */
|
||
sendToActive(data: string): void {
|
||
this.tabs[this.activeIndex]?.session.send(data)
|
||
}
|
||
|
||
/** Apply theme + font settings to every terminal (M3). */
|
||
applySettings(s: Settings): void {
|
||
this.settings = s
|
||
const theme = THEMES[s.theme] ?? THEMES['dark']!
|
||
for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize)
|
||
}
|
||
|
||
/** A read-only view of all tabs for the dashboard (M7). */
|
||
snapshot(): Array<{
|
||
idx: number
|
||
title: string
|
||
conn: string
|
||
claude: string
|
||
active: boolean
|
||
pending: boolean
|
||
}> {
|
||
return this.tabs.map((t, idx) => ({
|
||
idx,
|
||
title: this.displayTitle(t, idx),
|
||
conn: t.session.status,
|
||
claude: t.session.claudeStatus,
|
||
active: idx === this.activeIndex,
|
||
pending: t.session.pendingApproval,
|
||
}))
|
||
}
|
||
|
||
focusTab(idx: number): void {
|
||
this.activate(idx)
|
||
}
|
||
|
||
/** Scrollback search in the active tab (M1). */
|
||
findInActive(query: string, dir: 'next' | 'prev'): void {
|
||
const s = this.tabs[this.activeIndex]?.session
|
||
if (!s) return
|
||
if (dir === 'next') s.findNext(query)
|
||
else s.findPrevious(query)
|
||
}
|
||
|
||
clearActiveSearch(): void {
|
||
this.tabs[this.activeIndex]?.session.clearSearch()
|
||
}
|
||
|
||
/** Ask for notification permission once, on a user gesture (H2/H4). */
|
||
private maybeAskNotify(): void {
|
||
if (this.notifyAsked) return
|
||
this.notifyAsked = true
|
||
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
||
void Notification.requestPermission()
|
||
}
|
||
}
|
||
|
||
/** Browser notification for a background tab needing approval. */
|
||
private notify(entry: TabEntry): void {
|
||
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return
|
||
const title = this.displayTitle(entry, this.tabs.indexOf(entry))
|
||
new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
|
||
}
|
||
|
||
/* ── rendering ───────────────────────────────────────────────── */
|
||
|
||
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */
|
||
private refreshTab(entry: TabEntry): void {
|
||
const el = entry.el
|
||
if (!el) return
|
||
const idx = this.tabs.indexOf(entry)
|
||
el.classList.toggle('active', idx === this.activeIndex)
|
||
el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex)
|
||
const cs = entry.session.claudeStatus
|
||
el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex)
|
||
const title = this.displayTitle(entry, idx)
|
||
el.title = title
|
||
const dot = el.querySelector('.tab-dot')
|
||
if (dot) dot.className = `tab-dot dot-${entry.session.status}`
|
||
const label = el.querySelector('.tab-label')
|
||
if (label) label.textContent = title
|
||
const claude = el.querySelector('.tab-claude')
|
||
if (claude) {
|
||
claude.textContent =
|
||
cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : ''
|
||
}
|
||
}
|
||
|
||
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
|
||
private rebuild(): void {
|
||
this.tabBar.replaceChildren()
|
||
|
||
this.tabs.forEach((entry, idx) => {
|
||
const tabEl = document.createElement('div')
|
||
entry.el = tabEl
|
||
tabEl.className = 'tab'
|
||
|
||
// Switch on pointerdown (fires before any drag; reliable with draggable, works on touch).
|
||
tabEl.addEventListener('pointerdown', () => this.activate(idx))
|
||
// Middle-click closes.
|
||
tabEl.addEventListener('auxclick', (e) => {
|
||
if (e.button === 1) {
|
||
e.preventDefault()
|
||
this.closeTab(idx)
|
||
}
|
||
})
|
||
|
||
// Drag-to-reorder (disabled while renaming).
|
||
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')
|
||
})
|
||
}
|
||
|
||
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 = this.displayTitle(entry, idx)
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') this.commitRename(idx, input.value)
|
||
else if (e.key === 'Escape') {
|
||
this.editingIndex = -1
|
||
this.rebuild()
|
||
}
|
||
e.stopPropagation()
|
||
})
|
||
input.addEventListener('blur', () => this.commitRename(idx, input.value))
|
||
input.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't re-activate
|
||
tabEl.appendChild(input)
|
||
requestAnimationFrame(() => {
|
||
input.focus()
|
||
input.select()
|
||
})
|
||
} else {
|
||
const label = document.createElement('span')
|
||
label.className = 'tab-label'
|
||
label.textContent = this.displayTitle(entry, idx)
|
||
label.addEventListener('dblclick', () => {
|
||
this.editingIndex = idx
|
||
this.rebuild()
|
||
})
|
||
tabEl.appendChild(label)
|
||
|
||
const claude = document.createElement('span')
|
||
claude.className = 'tab-claude'
|
||
tabEl.appendChild(claude)
|
||
|
||
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('pointerdown', (e) => e.stopPropagation())
|
||
close.addEventListener('click', (e) => {
|
||
e.stopPropagation()
|
||
this.closeTab(idx)
|
||
})
|
||
tabEl.appendChild(close)
|
||
}
|
||
|
||
// Apply current active/unread state.
|
||
this.refreshTab(entry)
|
||
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)
|
||
}
|
||
}
|