feat(v0.3): M3 themes/font settings + M7 session dashboard
- 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.
This commit is contained in:
94
public/dashboard.ts
Normal file
94
public/dashboard.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* public/dashboard.ts — all-sessions overview (M7).
|
||||
*
|
||||
* A ▦ toolbar button opens an overlay listing every tab with its connection
|
||||
* dot, folder name, and Claude status; click a row to focus that tab. While
|
||||
* open it re-renders every second so statuses stay live.
|
||||
*/
|
||||
|
||||
export interface TabInfo {
|
||||
idx: number
|
||||
title: string
|
||||
conn: string // SessionStatus
|
||||
claude: string // ClaudeStatus
|
||||
active: boolean
|
||||
pending: boolean
|
||||
}
|
||||
|
||||
export interface DashboardHooks {
|
||||
snapshot: () => TabInfo[]
|
||||
focus: (idx: number) => void
|
||||
}
|
||||
|
||||
function claudeText(claude: string): string {
|
||||
if (claude === 'working') return '⚙ working'
|
||||
if (claude === 'waiting') return '⏳ needs approval'
|
||||
if (claude === 'idle') return '✓ idle'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function mountDashboard(toolbar: HTMLElement, hooks: DashboardHooks): void {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'dashboard'
|
||||
overlay.style.display = 'none'
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.className = 'dash-card'
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const render = (): void => {
|
||||
card.replaceChildren()
|
||||
const title = document.createElement('div')
|
||||
title.className = 'dash-title'
|
||||
title.textContent = 'Sessions'
|
||||
card.appendChild(title)
|
||||
|
||||
for (const t of hooks.snapshot()) {
|
||||
const row = document.createElement('div')
|
||||
row.className = t.active ? 'dash-row active' : 'dash-row'
|
||||
|
||||
const dot = document.createElement('span')
|
||||
dot.className = `tab-dot dot-${t.conn}`
|
||||
const name = document.createElement('span')
|
||||
name.className = 'dash-name'
|
||||
name.textContent = t.title
|
||||
const claude = document.createElement('span')
|
||||
claude.className = t.pending ? 'dash-claude pending' : 'dash-claude'
|
||||
claude.textContent = claudeText(t.claude)
|
||||
|
||||
row.append(dot, name, claude)
|
||||
row.addEventListener('click', () => {
|
||||
hooks.focus(t.idx)
|
||||
hide()
|
||||
})
|
||||
card.appendChild(row)
|
||||
}
|
||||
}
|
||||
|
||||
const open = (): void => {
|
||||
render()
|
||||
overlay.style.display = 'flex'
|
||||
timer = setInterval(render, 1000)
|
||||
}
|
||||
const hide = (): void => {
|
||||
overlay.style.display = 'none'
|
||||
if (timer !== null) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) hide()
|
||||
})
|
||||
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'toolbtn'
|
||||
btn.textContent = '▦'
|
||||
btn.title = 'All sessions'
|
||||
btn.setAttribute('aria-label', 'All sessions')
|
||||
btn.addEventListener('click', () => (overlay.style.display === 'none' ? open() : hide()))
|
||||
toolbar.appendChild(btn)
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { mountKeybar } from './keybar.js'
|
||||
import { TabApp } from './tabs.js'
|
||||
import { mountSearch } from './search.js'
|
||||
import { mountQrConnect } from './qr.js'
|
||||
import { mountSettings, loadSettings, type Settings } from './settings.js'
|
||||
import { mountDashboard } from './dashboard.js'
|
||||
|
||||
const paneHost = document.getElementById('term')
|
||||
const tabs = document.getElementById('tabs')
|
||||
@@ -25,6 +27,10 @@ if (!toolbar) throw new Error('#toolbar element not found in DOM')
|
||||
|
||||
const app = new TabApp(paneHost, tabs)
|
||||
|
||||
// Apply saved theme/font (M3) to the restored tabs.
|
||||
let settings: Settings = loadSettings()
|
||||
app.applySettings(settings)
|
||||
|
||||
// Key bar (mobile + desktop) sends to whichever tab is active.
|
||||
mountKeybar((data) => app.sendToActive(data))
|
||||
|
||||
@@ -33,6 +39,18 @@ mountSearch(toolbar, {
|
||||
find: (query, dir) => app.findInActive(query, dir),
|
||||
clear: () => app.clearActiveSearch(),
|
||||
})
|
||||
mountSettings(
|
||||
toolbar,
|
||||
() => settings,
|
||||
(s) => {
|
||||
settings = s
|
||||
app.applySettings(s)
|
||||
},
|
||||
)
|
||||
mountDashboard(toolbar, {
|
||||
snapshot: () => app.snapshot(),
|
||||
focus: (idx) => app.focusTab(idx),
|
||||
})
|
||||
mountQrConnect(toolbar)
|
||||
|
||||
// PWA: register the service worker (installable + offline shell, M4).
|
||||
|
||||
128
public/settings.ts
Normal file
128
public/settings.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* public/settings.ts — theme + font-size settings (M3).
|
||||
*
|
||||
* A ⚙ toolbar button opens a small panel; choices persist to localStorage and
|
||||
* apply to every terminal via the onChange callback.
|
||||
*/
|
||||
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
|
||||
export interface Settings {
|
||||
theme: string
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
export const THEMES: Record<string, ITheme> = {
|
||||
dark: { background: '#1a1a1a', foreground: '#e8e8e8', cursor: '#e8e8e8' },
|
||||
light: {
|
||||
background: '#f5f5f5',
|
||||
foreground: '#1a1a1a',
|
||||
cursor: '#1a1a1a',
|
||||
selectionBackground: '#bcd3f5',
|
||||
},
|
||||
solarized: { background: '#002b36', foreground: '#93a1a1', cursor: '#93a1a1' },
|
||||
}
|
||||
|
||||
const KEY = 'web-terminal:settings'
|
||||
export const DEFAULT_SETTINGS: Settings = { theme: 'dark', fontSize: 14 }
|
||||
|
||||
export function loadSettings(): Settings {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (raw) {
|
||||
const s = JSON.parse(raw) as Partial<Settings>
|
||||
return {
|
||||
theme: typeof s.theme === 'string' && THEMES[s.theme] ? s.theme : DEFAULT_SETTINGS.theme,
|
||||
fontSize:
|
||||
typeof s.fontSize === 'number' && s.fontSize >= 10 && s.fontSize <= 24
|
||||
? s.fontSize
|
||||
: DEFAULT_SETTINGS.fontSize,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
|
||||
function saveSettings(s: Settings): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(s))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function mountSettings(
|
||||
toolbar: HTMLElement,
|
||||
current: () => Settings,
|
||||
onChange: (s: Settings) => void,
|
||||
): void {
|
||||
const panel = document.createElement('div')
|
||||
panel.id = 'settingspanel'
|
||||
panel.style.display = 'none'
|
||||
|
||||
const apply = (next: Settings): void => {
|
||||
saveSettings(next)
|
||||
onChange(next)
|
||||
render()
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const s = current()
|
||||
panel.replaceChildren()
|
||||
|
||||
const themeRow = document.createElement('div')
|
||||
themeRow.className = 'settings-row'
|
||||
themeRow.append(label('Theme'))
|
||||
for (const key of Object.keys(THEMES)) {
|
||||
const b = document.createElement('button')
|
||||
b.textContent = key
|
||||
b.className = key === s.theme ? 'settings-opt active' : 'settings-opt'
|
||||
b.addEventListener('click', () => apply({ ...s, theme: key }))
|
||||
themeRow.appendChild(b)
|
||||
}
|
||||
|
||||
const fontRow = document.createElement('div')
|
||||
fontRow.className = 'settings-row'
|
||||
fontRow.append(label('Font'))
|
||||
const minus = document.createElement('button')
|
||||
minus.className = 'settings-opt'
|
||||
minus.textContent = 'A−'
|
||||
minus.addEventListener('click', () => apply({ ...s, fontSize: Math.max(10, s.fontSize - 1) }))
|
||||
const size = document.createElement('span')
|
||||
size.className = 'settings-size'
|
||||
size.textContent = String(s.fontSize)
|
||||
const plus = document.createElement('button')
|
||||
plus.className = 'settings-opt'
|
||||
plus.textContent = 'A+'
|
||||
plus.addEventListener('click', () => apply({ ...s, fontSize: Math.min(24, s.fontSize + 1) }))
|
||||
fontRow.append(minus, size, plus)
|
||||
|
||||
panel.append(themeRow, fontRow)
|
||||
}
|
||||
|
||||
function label(text: string): HTMLElement {
|
||||
const el = document.createElement('span')
|
||||
el.className = 'settings-label'
|
||||
el.textContent = text
|
||||
return el
|
||||
}
|
||||
|
||||
document.body.appendChild(panel)
|
||||
|
||||
const toggle = document.createElement('button')
|
||||
toggle.className = 'toolbtn'
|
||||
toggle.textContent = '⚙'
|
||||
toggle.title = 'Settings (theme, font)'
|
||||
toggle.setAttribute('aria-label', 'Settings')
|
||||
toggle.addEventListener('click', () => {
|
||||
if (panel.style.display === 'none') {
|
||||
render()
|
||||
panel.style.display = 'block'
|
||||
} else {
|
||||
panel.style.display = 'none'
|
||||
}
|
||||
})
|
||||
toolbar.appendChild(toggle)
|
||||
}
|
||||
100
public/style.css
100
public/style.css
@@ -153,6 +153,106 @@ html, body {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Settings panel (M3) */
|
||||
#settingspanel {
|
||||
position: fixed;
|
||||
top: calc(var(--tabbar-h) + 6px);
|
||||
right: 8px;
|
||||
z-index: 1100;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
min-width: 210px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
color: #ddd;
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.settings-label {
|
||||
width: 48px;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-opt {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 4px 9px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.settings-opt.active {
|
||||
background: #4a9eff;
|
||||
color: #fff;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
.settings-size {
|
||||
min-width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Dashboard (M7) */
|
||||
#dashboard {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 56px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.dash-card {
|
||||
background: #222;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 10px;
|
||||
min-width: 320px;
|
||||
max-width: 90vw;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.dash-title {
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
.dash-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
color: #ddd;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.dash-row:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
.dash-row.active {
|
||||
background: #1a1a1a;
|
||||
box-shadow: inset 3px 0 0 #4a9eff;
|
||||
}
|
||||
.dash-name {
|
||||
flex: 1;
|
||||
}
|
||||
.dash-claude {
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dash-claude.pending {
|
||||
color: #ffcf8f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
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'
|
||||
@@ -41,6 +42,7 @@ export class TabApp {
|
||||
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
|
||||
@@ -181,6 +183,7 @@ export class TabApp {
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -251,6 +254,36 @@ export class TabApp {
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
@@ -302,6 +303,16 @@ export class TerminalSession {
|
||||
this.searchAddon.clearDecorations()
|
||||
}
|
||||
|
||||
/** Apply a theme + font size (M3) and re-fit if visible. */
|
||||
applyTheme(theme: ITheme, fontSize: number): void {
|
||||
this.term.options.theme = theme
|
||||
this.term.options.fontSize = fontSize
|
||||
if (this.el.style.display !== 'none') {
|
||||
const dims = this.safefit()
|
||||
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
||||
}
|
||||
}
|
||||
|
||||
/** Make this pane visible, fit it, and focus it. */
|
||||
show(): void {
|
||||
this.el.style.display = 'block'
|
||||
|
||||
Reference in New Issue
Block a user