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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user