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 { TabApp } from './tabs.js'
|
||||||
import { mountSearch } from './search.js'
|
import { mountSearch } from './search.js'
|
||||||
import { mountQrConnect } from './qr.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 paneHost = document.getElementById('term')
|
||||||
const tabs = document.getElementById('tabs')
|
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)
|
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.
|
// Key bar (mobile + desktop) sends to whichever tab is active.
|
||||||
mountKeybar((data) => app.sendToActive(data))
|
mountKeybar((data) => app.sendToActive(data))
|
||||||
|
|
||||||
@@ -33,6 +39,18 @@ mountSearch(toolbar, {
|
|||||||
find: (query, dir) => app.findInActive(query, dir),
|
find: (query, dir) => app.findInActive(query, dir),
|
||||||
clear: () => app.clearActiveSearch(),
|
clear: () => app.clearActiveSearch(),
|
||||||
})
|
})
|
||||||
|
mountSettings(
|
||||||
|
toolbar,
|
||||||
|
() => settings,
|
||||||
|
(s) => {
|
||||||
|
settings = s
|
||||||
|
app.applySettings(s)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mountDashboard(toolbar, {
|
||||||
|
snapshot: () => app.snapshot(),
|
||||||
|
focus: (idx) => app.focusTab(idx),
|
||||||
|
})
|
||||||
mountQrConnect(toolbar)
|
mountQrConnect(toolbar)
|
||||||
|
|
||||||
// PWA: register the service worker (installable + offline shell, M4).
|
// 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;
|
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 {
|
.tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TerminalSession } from './terminal-session.js'
|
import { TerminalSession } from './terminal-session.js'
|
||||||
|
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
||||||
|
|
||||||
const TABS_KEY = 'web-terminal:tabs'
|
const TABS_KEY = 'web-terminal:tabs'
|
||||||
const ACTIVE_KEY = 'web-terminal:active'
|
const ACTIVE_KEY = 'web-terminal:active'
|
||||||
@@ -41,6 +42,7 @@ export class TabApp {
|
|||||||
private editingIndex = -1
|
private editingIndex = -1
|
||||||
private dragIndex = -1
|
private dragIndex = -1
|
||||||
private notifyAsked = false
|
private notifyAsked = false
|
||||||
|
private settings: Settings = DEFAULT_SETTINGS
|
||||||
private readonly paneHost: HTMLElement
|
private readonly paneHost: HTMLElement
|
||||||
private readonly tabBar: HTMLElement
|
private readonly tabBar: HTMLElement
|
||||||
private readonly approvalBar: HTMLDivElement
|
private readonly approvalBar: HTMLDivElement
|
||||||
@@ -181,6 +183,7 @@ export class TabApp {
|
|||||||
})
|
})
|
||||||
this.paneHost.appendChild(entry.session.el)
|
this.paneHost.appendChild(entry.session.el)
|
||||||
this.tabs.push(entry)
|
this.tabs.push(entry)
|
||||||
|
entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
|
||||||
entry.session.connect()
|
entry.session.connect()
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
@@ -251,6 +254,36 @@ export class TabApp {
|
|||||||
this.tabs[this.activeIndex]?.session.send(data)
|
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). */
|
/** Scrollback search in the active tab (M1). */
|
||||||
findInActive(query: string, dir: 'next' | 'prev'): void {
|
findInActive(query: string, dir: 'next' | 'prev'): void {
|
||||||
const s = this.tabs[this.activeIndex]?.session
|
const s = this.tabs[this.activeIndex]?.session
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import type { ITheme } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { SearchAddon } from '@xterm/addon-search'
|
import { SearchAddon } from '@xterm/addon-search'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
@@ -302,6 +303,16 @@ export class TerminalSession {
|
|||||||
this.searchAddon.clearDecorations()
|
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. */
|
/** Make this pane visible, fit it, and focus it. */
|
||||||
show(): void {
|
show(): void {
|
||||||
this.el.style.display = 'block'
|
this.el.style.display = 'block'
|
||||||
|
|||||||
Reference in New Issue
Block a user