Files
web-terminal/public/tabs.ts
Yaojia Wang 7b4adf5072 feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring
Home screen gains a Sessions↔Projects segmented control. The Projects view
discovers the host's git repos and, on click, opens a tab named after the
repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project).

Backend (P2/P4):
- src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions):
  depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops
  descending at a repo), .git/HEAD branch parse, rate-limited `git status`
  dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of
  ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session
  merge (fresh each call), TTL discovery cache with in-flight dedup.
- src/server.ts — read-only GET /projects (no Origin guard, []-fallback).

Frontend (P5/P6):
- public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot,
  session rows, "+ start claude here"), live search, localStorage favourites;
  pure helpers extracted for unit tests.
- public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'),
  countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView).
- public/style.css — Projects panel + .home-seg control styles.

Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already
added; this commit adds fail-fast rejection of relative PROJECT_ROOTS.

Review hardening (4 confirmed HIGH + boundary validation):
- carriage-return fix so claude auto-executes (A3); seg-control z-order;
  parallelised history merge; concurrent-cache-miss dedup.
- normalizeProject guards the /projects response; getFavs filters non-strings.

Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%.
Verified: both typechecks clean, full suite green, build:web OK, coverage
≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
2026-06-30 11:04:49 +02:00

568 lines
20 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: 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.
*
* v0.6 (P6): the home screen has a segmented control ("Sessions" / "Projects")
* that switches between the session launcher and the projects panel. State is
* persisted to localStorage. openProject() spawns a new claude session in a
* repo directory, labelled with the repo name (deduped with #N suffixes).
*/
import { TerminalSession } from './terminal-session.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.js'
import { mountProjects, type ProjectsPanel } from './projects.js'
const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
const HOME_VIEW_KEY = 'web-terminal:home-view'
type HomeView = 'sessions' | 'projects'
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
private readonly launcher: Launcher
private readonly projects: ProjectsPanel
private homeView: HomeView = 'sessions'
private readonly segControl: HTMLElement
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.launcher = mountLauncher(this.paneHost, {
onOpen: (id) => this.openSession(id),
onNew: () => this.newTab(),
})
// Load persisted home view choice.
try {
const stored = localStorage.getItem(HOME_VIEW_KEY)
if (stored === 'sessions' || stored === 'projects') this.homeView = stored
} catch {
// localStorage unavailable — use default
}
this.projects = mountProjects(this.paneHost, {
onOpenProject: (repoPath, repoName) => this.openProject(repoPath, repoName),
onEnterSession: (id) => this.openSession(id),
})
this.segControl = this.buildSegControl()
this.paneHost.appendChild(this.segControl)
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
// the user picks which session to open. Sessions persist server-side, so
// opening one replays its full scrollback.
this.rebuild()
this.updateHomeView()
}
/* ── Home-view visibility ────────────────────────────────────────── */
/**
* Controls visibility of the segmented control and both home sub-panels.
* When tabs are open: hide everything (the terminal panes own the screen).
* When no tab is open: show the seg control and the selected sub-panel.
*/
private updateHomeView(): void {
const empty = this.tabs.length === 0
// Sync active-class on segmented control buttons.
const btns = this.segControl.querySelectorAll<HTMLButtonElement>('.home-seg-btn')
btns.forEach((btn, i) => {
const view: HomeView = i === 0 ? 'sessions' : 'projects'
btn.classList.toggle('active', view === this.homeView)
})
if (empty) {
this.segControl.style.display = 'flex'
this.launcher.setVisible(this.homeView === 'sessions')
this.projects.setVisible(this.homeView === 'projects')
} else {
this.segControl.style.display = 'none'
this.launcher.setVisible(false)
this.projects.setVisible(false)
}
}
private saveHomeView(): void {
try {
localStorage.setItem(HOME_VIEW_KEY, this.homeView)
} catch {
// localStorage unavailable
}
}
private buildSegControl(): HTMLElement {
const seg = document.createElement('div')
seg.className = 'home-seg'
seg.style.display = 'none' // updateHomeView() will show it when needed
const sessBtn = document.createElement('button')
sessBtn.className = 'home-seg-btn'
sessBtn.textContent = 'Sessions'
sessBtn.addEventListener('click', () => {
this.homeView = 'sessions'
this.saveHomeView()
this.updateHomeView()
})
const projBtn = document.createElement('button')
projBtn.className = 'home-seg-btn'
projBtn.textContent = 'Projects'
projBtn.addEventListener('click', () => {
this.homeView = 'projects'
this.saveHomeView()
this.updateHomeView()
})
seg.append(sessBtn, projBtn)
return seg
}
/* ── Approval bar ───────────────────────────────────────────────── */
/** 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
}
}
/** The active tab's server session id (null until it has attached). */
activeSessionId(): string | null {
return this.tabs[this.activeIndex]?.session.id ?? null
}
/** Focus the tab for `sessionId`, or open one that joins it (share-link target). */
openSession(sessionId: string): void {
const existing = this.tabs.findIndex((t) => t.session.id === sessionId)
if (existing >= 0) {
this.activate(existing)
return
}
this.addEntry(sessionId, null)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/* ── tab lifecycle ───────────────────────────────────────────────── */
private addEntry(
sessionId: string | null,
customTitle: string | null,
cwd?: string,
initialInput?: string,
): TabEntry {
// Build the session FIRST so `entry` is never typed with a null session.
// The callbacks below capture `entry` by reference; they only fire after
// construction (async), by which point `entry` is assigned.
let entry: TabEntry
const session = new TerminalSession({
sessionId,
...(cwd !== undefined ? { cwd } : {}),
...(initialInput !== undefined ? { initialInput } : {}),
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)
}
},
})
entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null }
this.paneHost.appendChild(session.el)
this.tabs.push(entry)
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
session.connect()
return entry
}
newTab(): void {
// M6: open the new tab in the active tab's current directory, if known.
const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined
this.addEntry(null, null, cwd)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/** O2: open a new tab in `cwd` and run `claude --resume <id>`. */
newTabForResume(cwd: string, sessionId: string): void {
this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}\r`)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/**
* v0.6 (P6): spawn a new claude session in `repoPath`, labelled `repoName`.
* If tabs with the same base name already exist, appends a #N suffix to
* distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
* structure: addEntry → persist → rebuild → activate.
*/
openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void {
const n = this.countOpenWithTitlePrefix(repoName)
const label = n === 0 ? repoName : `${repoName} #${n + 1}`
this.addEntry(null, label, repoPath || undefined, cmd)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/** Count tabs whose customTitle is exactly `name` or starts with `name + ' #'`. */
private countOpenWithTitlePrefix(name: string): number {
return this.tabs.filter(
(t) => t.customTitle === name || t.customTitle?.startsWith(`${name} #`) === true,
).length
}
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) {
// v0.5: closing the last tab returns to the home screen — no auto-blank tab.
this.activeIndex = -1
this.persist()
this.rebuild() // updateHomeView() (in rebuild) shows the chooser
this.updateApprovalBar()
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)
}
/** Re-assert the active tab's size (latest-writer-wins) when this device
* regains focus, so a shared session snaps back to this screen's size. */
refitActive(): void {
this.tabs[this.activeIndex]?.session.refit()
}
/** 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)
// Show/hide the home screen based on whether any tab is open.
this.updateHomeView()
}
}