diff --git a/public/style.css b/public/style.css index a36c2f8..f26357b 100644 --- a/public/style.css +++ b/public/style.css @@ -62,6 +62,43 @@ html, body { box-shadow: inset 0 -2px 0 #4a9eff; /* active underline accent */ } +/* Status dot — color reflects connection state. */ +.tab-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; + flex: none; + background-color: #666; +} +.dot-connected { + background-color: #3fb950; /* green */ +} +.dot-connecting, +.dot-reconnecting { + background-color: #d29922; /* amber */ +} +.dot-exited { + background-color: #f85149; /* red */ +} + +/* Unread: an inactive tab got new output — blue ring + brighter label. */ +.tab.unread .tab-dot { + box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.6); +} +.tab.unread .tab-label { + color: #fff; + font-weight: 600; +} + +/* Drag-to-reorder feedback. */ +.tab.dragging { + opacity: 0.4; +} +.tab.drag-over { + box-shadow: inset 3px 0 0 #4a9eff; /* drop-position indicator */ +} + .tab-label { padding-right: 8px; overflow: hidden; diff --git a/public/tabs.ts b/public/tabs.ts index fef8d6f..00a9fca 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -28,6 +28,8 @@ interface TabEntry { 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. */ @@ -40,6 +42,7 @@ export class TabApp { private readonly tabs: TabEntry[] = [] private activeIndex = -1 private editingIndex = -1 + private dragIndex = -1 private readonly paneHost: HTMLElement private readonly tabBar: HTMLElement @@ -120,15 +123,25 @@ export class TabApp { } private createTab(sessionId: string | null, customTitle: string | null, activate: boolean): void { - const entry: TabEntry = { session: null as unknown as TerminalSession, customTitle, autoTitle: null } + const entry: TabEntry = { + session: null as unknown as TerminalSession, + customTitle, + autoTitle: null, + hasActivity: false, + } entry.session = new TerminalSession({ sessionId, onSessionId: () => this.persist(), - onActivity: () => this.render(), + // 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) @@ -145,11 +158,27 @@ export class TabApp { 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) @@ -185,7 +214,10 @@ export class TabApp { this.tabs.forEach((entry, idx) => { const tabEl = document.createElement('div') - tabEl.className = idx === this.activeIndex ? 'tab active' : 'tab' + 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) @@ -197,6 +229,35 @@ export class TabApp { } }) + // 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' diff --git a/public/terminal-session.ts b/public/terminal-session.ts index e01b980..577f7e6 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -34,6 +34,9 @@ function buildWsUrl(): string { return `${scheme}://${location.host}/term` } +/** Connection state, surfaced as a colored status dot on the tab. */ +export type SessionStatus = 'connecting' | 'connected' | 'reconnecting' | 'exited' + export interface TerminalSessionOpts { /** Existing session to re-attach to, or null to spawn a fresh one. */ sessionId: string | null @@ -43,6 +46,8 @@ export interface TerminalSessionOpts { onActivity?: () => void /** Optional: fired when the terminal title changes (OSC 0/2 — cwd/process). */ onTitle?: (title: string) => void + /** Optional: fired when connection status changes (for the tab status dot). */ + onStatus?: (status: SessionStatus) => void } export class TerminalSession { @@ -53,6 +58,9 @@ export class TerminalSession { private readonly resizeObserver: ResizeObserver private readonly onSessionId: (id: string) => void private readonly onActivity: (() => void) | undefined + private readonly onTitle: ((title: string) => void) | undefined + private readonly onStatus: ((status: SessionStatus) => void) | undefined + private statusValue: SessionStatus = 'connecting' private ws: WebSocket | null = null private sessionId: string | null @@ -69,6 +77,8 @@ export class TerminalSession { this.sessionId = opts.sessionId this.onSessionId = opts.onSessionId this.onActivity = opts.onActivity + this.onTitle = opts.onTitle + this.onStatus = opts.onStatus this.el = document.createElement('div') this.el.className = 'term-pane' @@ -84,6 +94,7 @@ export class TerminalSession { this.term.open(this.el) this.term.onData((data) => this.send(data)) + this.term.onTitleChange((title) => this.onTitle?.(title)) this.resizeObserver = new ResizeObserver(() => this.scheduleResize()) this.resizeObserver.observe(this.el) @@ -94,6 +105,16 @@ export class TerminalSession { return this.sessionId } + /** Current connection status (for the tab status dot). */ + get status(): SessionStatus { + return this.statusValue + } + + private setStatus(s: SessionStatus): void { + this.statusValue = s + this.onStatus?.(s) + } + /** fit() only when the pane has real dimensions (display:none → NaN, §9). */ private safefit(): { cols: number; rows: number } | null { try { @@ -129,6 +150,7 @@ export class TerminalSession { connect(): void { if (this.disposed || this.isConnecting) return this.isConnecting = true + this.setStatus('connecting') this.term.write(statusLine(`${YELLOW}Connecting…${RESET}`)) const socket = new WebSocket(buildWsUrl()) @@ -136,6 +158,7 @@ export class TerminalSession { socket.addEventListener('open', () => { this.reconnectDelay = 1000 + this.setStatus('connected') this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`)) socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId })) }) @@ -176,6 +199,7 @@ export class TerminalSession { break } case 'exit': { + this.setStatus('exited') const reason = msg.reason ? ` (${msg.reason})` : '' this.term.write( statusLine( @@ -200,6 +224,7 @@ export class TerminalSession { private scheduleReconnect(): void { if (this.reconnectTimer !== null) return + this.setStatus('reconnecting') const delay = this.reconnectDelay this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000) this.term.write(