Adversarial review (4 lenses → per-finding verify) of v3 confirmed 6 issues:
- HIGH: splitForLayout({"grid-4":null}) threw a TypeError — null passed the
`!== undefined` guard, then null.cols threw. Since splitForLayout runs on every
grid render, one corrupt localStorage entry would brick the tab UI. Now guards
`!== null && typeof === 'object'`.
- MED: a monitor toggled before the session finished attaching (id still null)
showed the button active while the pane stayed live and sent a resize, and did
not self-correct. onSessionId now reconciles a pending monitor once the id lands.
- MED: renderGutters destroyed + recreated handles on every applyLayout, dropping
the drag listeners if a re-render fired mid-drag. A draggingGutter flag now skips
the rebuild while dragging.
- LOW: grid-presets hardcoded the 1024px breakpoint → now uses GRID_MIN_WIDTH.
Regression tests added (null-split no-throw, attach reconcile, drag survives a
concurrent re-render). typecheck + build:web clean, 1615 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
215 lines
6.4 KiB
TypeScript
215 lines
6.4 KiB
TypeScript
/**
|
||
* public/grid-presets.ts — saved split-grid layout presets (v3).
|
||
*
|
||
* A preset bundles a layout + its custom track fractions under a name, so you can
|
||
* save a board arrangement you like (e.g. "2×2, left column wide") and re-apply it
|
||
* in one click. Persistence + the tiny toolbar dropdown live here; TabApp exposes
|
||
* the current arrangement and an apply() via hooks, keeping tabs.ts lean.
|
||
*/
|
||
|
||
import { type GridLayout, type TrackSplit, gridAllowed, GRID_MIN_WIDTH } from './grid-layout.js'
|
||
|
||
export interface GridPreset {
|
||
name: string
|
||
layout: GridLayout
|
||
split: TrackSplit
|
||
}
|
||
|
||
const PRESETS_KEY = 'web-terminal:grid-presets'
|
||
const VALID_LAYOUTS: readonly string[] = ['single', 'split-2', 'row-3', 'grid-4', 'grid-6']
|
||
|
||
function isPreset(v: unknown): v is GridPreset {
|
||
if (v === null || typeof v !== 'object') return false
|
||
const p = v as Record<string, unknown>
|
||
return (
|
||
typeof p['name'] === 'string' &&
|
||
typeof p['layout'] === 'string' &&
|
||
VALID_LAYOUTS.includes(p['layout']) &&
|
||
typeof p['split'] === 'object' &&
|
||
p['split'] !== null
|
||
)
|
||
}
|
||
|
||
/** Load saved presets (best-effort; drops malformed entries). */
|
||
export function loadPresets(): GridPreset[] {
|
||
try {
|
||
const raw = localStorage.getItem(PRESETS_KEY)
|
||
if (raw === null) return []
|
||
const parsed: unknown = JSON.parse(raw)
|
||
if (Array.isArray(parsed)) return parsed.filter(isPreset)
|
||
} catch {
|
||
// localStorage / JSON unavailable
|
||
}
|
||
return []
|
||
}
|
||
|
||
/** Persist the presets list. */
|
||
export function savePresets(list: readonly GridPreset[]): void {
|
||
try {
|
||
localStorage.setItem(PRESETS_KEY, JSON.stringify(list))
|
||
} catch {
|
||
// localStorage unavailable — run without persistence
|
||
}
|
||
}
|
||
|
||
export interface PresetHooks {
|
||
/** The current layout + its split, to snapshot into a new preset. */
|
||
current: () => { layout: GridLayout; split: TrackSplit }
|
||
/** Apply a chosen preset (set layout + splits + re-render). */
|
||
apply: (preset: GridPreset) => void
|
||
}
|
||
|
||
export interface PresetMenu {
|
||
/** Re-sync visibility (desktop gate) — call on layout/resize changes. */
|
||
refresh: () => void
|
||
dispose: () => void
|
||
}
|
||
|
||
/** Short human label for a layout in a preset row. */
|
||
const LAYOUT_LABEL: Readonly<Record<GridLayout, string>> = {
|
||
single: 'single',
|
||
'split-2': '1×2',
|
||
'row-3': '1×3',
|
||
'grid-4': '2×2',
|
||
'grid-6': '2×3',
|
||
}
|
||
|
||
/** Mount the presets dropdown into the toolbar. Desktop-only (hidden below the
|
||
* grid width gate, like the layout toggle). */
|
||
export function mountGridPresets(toolbar: HTMLElement, hooks: PresetHooks): PresetMenu {
|
||
let presets = loadPresets()
|
||
|
||
const root = document.createElement('div')
|
||
root.className = 'grid-presets'
|
||
|
||
const btn = document.createElement('button')
|
||
btn.type = 'button'
|
||
btn.className = 'grid-presets-btn'
|
||
btn.textContent = '☰'
|
||
btn.title = 'Layout presets'
|
||
btn.setAttribute('aria-label', 'Layout presets')
|
||
|
||
const menu = document.createElement('div')
|
||
menu.className = 'grid-presets-menu'
|
||
menu.hidden = true
|
||
|
||
const list = document.createElement('div')
|
||
list.className = 'gp-list'
|
||
|
||
const saveRow = document.createElement('div')
|
||
saveRow.className = 'gp-save'
|
||
const nameInput = document.createElement('input')
|
||
nameInput.className = 'gp-name'
|
||
nameInput.type = 'text'
|
||
nameInput.placeholder = 'Save current as…'
|
||
nameInput.maxLength = 40
|
||
const saveBtn = document.createElement('button')
|
||
saveBtn.type = 'button'
|
||
saveBtn.className = 'gp-save-btn'
|
||
saveBtn.textContent = 'Save'
|
||
saveRow.append(nameInput, saveBtn)
|
||
|
||
menu.append(list, saveRow)
|
||
root.append(btn, menu)
|
||
toolbar.appendChild(root)
|
||
|
||
const renderList = (): void => {
|
||
list.replaceChildren()
|
||
if (presets.length === 0) {
|
||
list.appendChild(el('div', 'gp-empty', 'No saved presets'))
|
||
return
|
||
}
|
||
presets.forEach((preset, i) => {
|
||
const row = el('div', 'gp-row')
|
||
const apply = el('button', 'gp-apply')
|
||
apply.append(
|
||
el('span', 'gp-apply-name', preset.name),
|
||
el('span', 'gp-apply-layout', LAYOUT_LABEL[preset.layout]),
|
||
)
|
||
apply.addEventListener('click', () => {
|
||
hooks.apply(preset)
|
||
close()
|
||
})
|
||
const del = el('button', 'gp-del', '×')
|
||
del.title = 'Delete preset'
|
||
del.setAttribute('aria-label', `Delete preset ${preset.name}`)
|
||
del.addEventListener('click', (e) => {
|
||
e.stopPropagation()
|
||
presets = presets.filter((_, j) => j !== i)
|
||
savePresets(presets)
|
||
renderList()
|
||
})
|
||
row.append(apply, del)
|
||
list.appendChild(row)
|
||
})
|
||
}
|
||
|
||
const save = (): void => {
|
||
const name = nameInput.value.trim()
|
||
if (name === '') return
|
||
const { layout, split } = hooks.current()
|
||
// Replace a same-named preset rather than duplicating.
|
||
presets = [...presets.filter((p) => p.name !== name), { name, layout, split }]
|
||
savePresets(presets)
|
||
nameInput.value = ''
|
||
renderList()
|
||
}
|
||
saveBtn.addEventListener('click', save)
|
||
nameInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') save()
|
||
e.stopPropagation()
|
||
})
|
||
|
||
const open = (): void => {
|
||
renderList()
|
||
menu.hidden = false
|
||
document.addEventListener('pointerdown', onOutside, true)
|
||
document.addEventListener('keydown', onKey, true)
|
||
}
|
||
const close = (): void => {
|
||
menu.hidden = true
|
||
document.removeEventListener('pointerdown', onOutside, true)
|
||
document.removeEventListener('keydown', onKey, true)
|
||
}
|
||
const onOutside = (e: Event): void => {
|
||
if (!root.contains(e.target as Node)) close()
|
||
}
|
||
const onKey = (e: KeyboardEvent): void => {
|
||
if (e.key === 'Escape') close()
|
||
}
|
||
btn.addEventListener('click', () => (menu.hidden ? open() : close()))
|
||
|
||
const refresh = (): void => {
|
||
root.style.display = gridAllowed() ? 'inline-flex' : 'none'
|
||
if (!gridAllowed()) close()
|
||
}
|
||
|
||
const mql =
|
||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||
? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`)
|
||
: null
|
||
mql?.addEventListener('change', refresh)
|
||
refresh()
|
||
|
||
return {
|
||
refresh,
|
||
dispose: (): void => {
|
||
mql?.removeEventListener('change', refresh)
|
||
close()
|
||
root.remove()
|
||
},
|
||
}
|
||
}
|
||
|
||
/** Local element helper (kept tiny to avoid a cross-module import cycle). */
|
||
function el<K extends keyof HTMLElementTagNameMap>(
|
||
tag: K,
|
||
cls: string,
|
||
text?: string,
|
||
): HTMLElementTagNameMap[K] {
|
||
const node = document.createElement(tag)
|
||
node.className = cls
|
||
if (text !== undefined) node.textContent = text
|
||
return node
|
||
}
|