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.
This commit is contained in:
@@ -62,6 +62,43 @@ html, body {
|
|||||||
box-shadow: inset 0 -2px 0 #4a9eff; /* active underline accent */
|
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 {
|
.tab-label {
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface TabEntry {
|
|||||||
customTitle: string | null
|
customTitle: string | null
|
||||||
/** Latest OSC title from the terminal; null until the shell sets one. */
|
/** Latest OSC title from the terminal; null until the shell sets one. */
|
||||||
autoTitle: string | null
|
autoTitle: string | null
|
||||||
|
/** True when an inactive tab received output since last viewed (unread dot). */
|
||||||
|
hasActivity: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persisted shape per tab. */
|
/** Persisted shape per tab. */
|
||||||
@@ -40,6 +42,7 @@ export class TabApp {
|
|||||||
private readonly tabs: TabEntry[] = []
|
private readonly tabs: TabEntry[] = []
|
||||||
private activeIndex = -1
|
private activeIndex = -1
|
||||||
private editingIndex = -1
|
private editingIndex = -1
|
||||||
|
private dragIndex = -1
|
||||||
private readonly paneHost: HTMLElement
|
private readonly paneHost: HTMLElement
|
||||||
private readonly tabBar: HTMLElement
|
private readonly tabBar: HTMLElement
|
||||||
|
|
||||||
@@ -120,15 +123,25 @@ export class TabApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createTab(sessionId: string | null, customTitle: string | null, activate: boolean): void {
|
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({
|
entry.session = new TerminalSession({
|
||||||
sessionId,
|
sessionId,
|
||||||
onSessionId: () => this.persist(),
|
onSessionId: () => this.persist(),
|
||||||
onActivity: () => this.render(),
|
// onActivity only fires for hidden (inactive) panes (see TerminalSession).
|
||||||
|
onActivity: () => {
|
||||||
|
entry.hasActivity = true
|
||||||
|
this.render()
|
||||||
|
},
|
||||||
onTitle: (title) => {
|
onTitle: (title) => {
|
||||||
entry.autoTitle = title.trim() || null
|
entry.autoTitle = title.trim() || null
|
||||||
this.render()
|
this.render()
|
||||||
},
|
},
|
||||||
|
onStatus: () => this.render(),
|
||||||
})
|
})
|
||||||
this.paneHost.appendChild(entry.session.el)
|
this.paneHost.appendChild(entry.session.el)
|
||||||
this.tabs.push(entry)
|
this.tabs.push(entry)
|
||||||
@@ -145,11 +158,27 @@ export class TabApp {
|
|||||||
activate(i: number): void {
|
activate(i: number): void {
|
||||||
if (i < 0 || i >= this.tabs.length) return
|
if (i < 0 || i >= this.tabs.length) return
|
||||||
this.activeIndex = i
|
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.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
|
||||||
this.persist()
|
this.persist()
|
||||||
this.render()
|
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 {
|
closeTab(i: number): void {
|
||||||
if (i < 0 || i >= this.tabs.length) return
|
if (i < 0 || i >= this.tabs.length) return
|
||||||
const [entry] = this.tabs.splice(i, 1)
|
const [entry] = this.tabs.splice(i, 1)
|
||||||
@@ -185,7 +214,10 @@ export class TabApp {
|
|||||||
|
|
||||||
this.tabs.forEach((entry, idx) => {
|
this.tabs.forEach((entry, idx) => {
|
||||||
const tabEl = document.createElement('div')
|
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)
|
const title = this.displayTitle(entry, idx)
|
||||||
tabEl.title = title // full-title tooltip (truncation handled by CSS)
|
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) {
|
if (idx === this.editingIndex) {
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.className = 'tab-rename'
|
input.className = 'tab-rename'
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ function buildWsUrl(): string {
|
|||||||
return `${scheme}://${location.host}/term`
|
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 {
|
export interface TerminalSessionOpts {
|
||||||
/** Existing session to re-attach to, or null to spawn a fresh one. */
|
/** Existing session to re-attach to, or null to spawn a fresh one. */
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
@@ -43,6 +46,8 @@ export interface TerminalSessionOpts {
|
|||||||
onActivity?: () => void
|
onActivity?: () => void
|
||||||
/** Optional: fired when the terminal title changes (OSC 0/2 — cwd/process). */
|
/** Optional: fired when the terminal title changes (OSC 0/2 — cwd/process). */
|
||||||
onTitle?: (title: string) => void
|
onTitle?: (title: string) => void
|
||||||
|
/** Optional: fired when connection status changes (for the tab status dot). */
|
||||||
|
onStatus?: (status: SessionStatus) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TerminalSession {
|
export class TerminalSession {
|
||||||
@@ -53,6 +58,9 @@ export class TerminalSession {
|
|||||||
private readonly resizeObserver: ResizeObserver
|
private readonly resizeObserver: ResizeObserver
|
||||||
private readonly onSessionId: (id: string) => void
|
private readonly onSessionId: (id: string) => void
|
||||||
private readonly onActivity: (() => void) | undefined
|
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 ws: WebSocket | null = null
|
||||||
private sessionId: string | null
|
private sessionId: string | null
|
||||||
@@ -69,6 +77,8 @@ export class TerminalSession {
|
|||||||
this.sessionId = opts.sessionId
|
this.sessionId = opts.sessionId
|
||||||
this.onSessionId = opts.onSessionId
|
this.onSessionId = opts.onSessionId
|
||||||
this.onActivity = opts.onActivity
|
this.onActivity = opts.onActivity
|
||||||
|
this.onTitle = opts.onTitle
|
||||||
|
this.onStatus = opts.onStatus
|
||||||
|
|
||||||
this.el = document.createElement('div')
|
this.el = document.createElement('div')
|
||||||
this.el.className = 'term-pane'
|
this.el.className = 'term-pane'
|
||||||
@@ -84,6 +94,7 @@ export class TerminalSession {
|
|||||||
this.term.open(this.el)
|
this.term.open(this.el)
|
||||||
|
|
||||||
this.term.onData((data) => this.send(data))
|
this.term.onData((data) => this.send(data))
|
||||||
|
this.term.onTitleChange((title) => this.onTitle?.(title))
|
||||||
|
|
||||||
this.resizeObserver = new ResizeObserver(() => this.scheduleResize())
|
this.resizeObserver = new ResizeObserver(() => this.scheduleResize())
|
||||||
this.resizeObserver.observe(this.el)
|
this.resizeObserver.observe(this.el)
|
||||||
@@ -94,6 +105,16 @@ export class TerminalSession {
|
|||||||
return this.sessionId
|
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). */
|
/** fit() only when the pane has real dimensions (display:none → NaN, §9). */
|
||||||
private safefit(): { cols: number; rows: number } | null {
|
private safefit(): { cols: number; rows: number } | null {
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +150,7 @@ export class TerminalSession {
|
|||||||
connect(): void {
|
connect(): void {
|
||||||
if (this.disposed || this.isConnecting) return
|
if (this.disposed || this.isConnecting) return
|
||||||
this.isConnecting = true
|
this.isConnecting = true
|
||||||
|
this.setStatus('connecting')
|
||||||
this.term.write(statusLine(`${YELLOW}Connecting…${RESET}`))
|
this.term.write(statusLine(`${YELLOW}Connecting…${RESET}`))
|
||||||
|
|
||||||
const socket = new WebSocket(buildWsUrl())
|
const socket = new WebSocket(buildWsUrl())
|
||||||
@@ -136,6 +158,7 @@ export class TerminalSession {
|
|||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
this.reconnectDelay = 1000
|
this.reconnectDelay = 1000
|
||||||
|
this.setStatus('connected')
|
||||||
this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`))
|
this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`))
|
||||||
socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId }))
|
socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId }))
|
||||||
})
|
})
|
||||||
@@ -176,6 +199,7 @@ export class TerminalSession {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'exit': {
|
case 'exit': {
|
||||||
|
this.setStatus('exited')
|
||||||
const reason = msg.reason ? ` (${msg.reason})` : ''
|
const reason = msg.reason ? ` (${msg.reason})` : ''
|
||||||
this.term.write(
|
this.term.write(
|
||||||
statusLine(
|
statusLine(
|
||||||
@@ -200,6 +224,7 @@ export class TerminalSession {
|
|||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
if (this.reconnectTimer !== null) return
|
if (this.reconnectTimer !== null) return
|
||||||
|
this.setStatus('reconnecting')
|
||||||
const delay = this.reconnectDelay
|
const delay = this.reconnectDelay
|
||||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000)
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000)
|
||||||
this.term.write(
|
this.term.write(
|
||||||
|
|||||||
Reference in New Issue
Block a user