From 007e598802171d34122412bc496bd254463f1d73 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 11 Jul 2026 20:09:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(grid):=20v3b/c=20=E2=80=94=20resizable=20s?= =?UTF-8?q?plitters=20+=20saved=20layout=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resizable splitters: draggable gutters between grid tracks adjust per-layout column/row fractions (fr units), applied as an inline grid-template and persisted (web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested helper in grid-layout.ts; the drag translates pixel motion into an fr delta. Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current layout + its split under a name, re-apply it in one click, or delete it (web-terminal:grid-presets). Desktop-only, like the layout toggle. - grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate, load/saveGridSplits, splitForLayout (validates stored shape). - tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline template; gridArrangement()/applyGridPreset() hooks. - main.ts: mount the presets dropdown. style.css: gutters + presets menu. - tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/grid-layout.ts | 109 +++++++++++++++++++ public/grid-presets.ts | 214 ++++++++++++++++++++++++++++++++++++++ public/main.ts | 7 ++ public/style.css | 174 +++++++++++++++++++++++++++++++ public/tabs.ts | 133 ++++++++++++++++++++++- test/grid-layout.test.ts | 66 ++++++++++++ test/grid-presets.test.ts | 133 +++++++++++++++++++++++ test/tabs.test.ts | 66 ++++++++++++ vitest.config.ts | 1 + 9 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 public/grid-presets.ts create mode 100644 test/grid-presets.test.ts diff --git a/public/grid-layout.ts b/public/grid-layout.ts index 0441a8b..0abd651 100644 --- a/public/grid-layout.ts +++ b/public/grid-layout.ts @@ -201,3 +201,112 @@ export function mountGridToggle(toolbar: HTMLElement, hooks: GridToggleHooks): G }, } } + +/* ── Resizable splitters (v3) ────────────────────────────────────────── */ + +/** The (columns, rows) track counts a layout lays its panes out in. */ +export function layoutTracks(layout: GridLayout): { cols: number; rows: number } { + switch (layout) { + case 'single': + return { cols: 1, rows: 1 } + case 'split-2': + return { cols: 2, rows: 1 } + case 'row-3': + return { cols: 3, rows: 1 } + case 'grid-4': + return { cols: 2, rows: 2 } + case 'grid-6': + return { cols: 3, rows: 2 } + } +} + +/** Per-axis track fractions (relative fr units) for one layout. */ +export interface TrackSplit { + cols: number[] + rows: number[] +} + +/** Persisted custom track fractions, per layout (missing → equal tracks). */ +export type GridSplits = Partial> + +/** Equal-fraction split for a layout (every track = 1fr). */ +export function defaultSplit(layout: GridLayout): TrackSplit { + const { cols, rows } = layoutTracks(layout) + return { cols: Array(cols).fill(1), rows: Array(rows).fill(1) } +} + +/** Smallest fraction a track may shrink to while its neighbor grows. */ +export const MIN_TRACK_FRACTION = 0.3 + +/** + * Move the boundary between track `gutterIndex` and `gutterIndex+1` by `delta` + * (in fr units), trading the delta between the two adjacent tracks and clamping + * both to MIN_TRACK_FRACTION. Pure — returns a new array (the input untouched). + */ +export function adjustSplit( + fractions: readonly number[], + gutterIndex: number, + delta: number, + min = MIN_TRACK_FRACTION, +): number[] { + if (gutterIndex < 0 || gutterIndex >= fractions.length - 1) return fractions.slice() + let a = (fractions[gutterIndex] ?? 1) + delta + let b = (fractions[gutterIndex + 1] ?? 1) - delta + if (a < min) { + b -= min - a + a = min + } + if (b < min) { + a -= min - b + b = min + } + if (a < min || b < min) return fractions.slice() // can't satisfy both + const next = fractions.slice() + next[gutterIndex] = a + next[gutterIndex + 1] = b + return next +} + +/** Render fractions as a grid-template value (e.g. "1fr 1.4fr"). */ +export function tracksToTemplate(fractions: readonly number[]): string { + return fractions.map((f) => `${f}fr`).join(' ') +} + +const GRID_SPLITS_KEY = 'web-terminal:grid-splits' + +/** Load persisted splits (best-effort; shape validated per-layout at use). */ +export function loadGridSplits(): GridSplits { + try { + const raw = localStorage.getItem(GRID_SPLITS_KEY) + if (raw === null) return {} + const parsed: unknown = JSON.parse(raw) + if (parsed !== null && typeof parsed === 'object') return parsed as GridSplits + } catch { + // localStorage / JSON unavailable — use equal tracks + } + return {} +} + +/** Persist the custom splits map. */ +export function saveGridSplits(splits: GridSplits): void { + try { + localStorage.setItem(GRID_SPLITS_KEY, JSON.stringify(splits)) + } catch { + // localStorage unavailable — run without persistence + } +} + +/** The valid split for a layout: the stored one if its shape matches the layout's + * track counts and all fractions are positive, else the equal default. */ +export function splitForLayout(splits: GridSplits, layout: GridLayout): TrackSplit { + const { cols, rows } = layoutTracks(layout) + const stored = splits[layout] + const ok = + stored !== undefined && + Array.isArray(stored.cols) && + stored.cols.length === cols && + Array.isArray(stored.rows) && + stored.rows.length === rows && + [...stored.cols, ...stored.rows].every((n) => typeof n === 'number' && n > 0) + return ok ? stored : defaultSplit(layout) +} diff --git a/public/grid-presets.ts b/public/grid-presets.ts new file mode 100644 index 0000000..2a525c1 --- /dev/null +++ b/public/grid-presets.ts @@ -0,0 +1,214 @@ +/** + * 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 } 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 + 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> = { + 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: 1024px)') + : 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( + tag: K, + cls: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + node.className = cls + if (text !== undefined) node.textContent = text + return node +} diff --git a/public/main.ts b/public/main.ts index 7f432e2..06aa011 100644 --- a/public/main.ts +++ b/public/main.ts @@ -21,6 +21,7 @@ import { mountHistory } from './history.js' import { mountShortcuts } from './shortcuts.js' import { mountShareSession } from './share.js' import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js' +import { mountGridPresets } from './grid-presets.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -106,6 +107,12 @@ const gridToggle = mountGridToggle(toolbar, { }) app.setGridToggle(gridToggle) +// Saved layout presets (desktop-only, next to the layout toggle). +mountGridPresets(toolbar, { + current: () => app.gridArrangement(), + apply: (preset) => app.applyGridPreset(preset), +}) + // Session management lives on the home Sessions chooser now (open / kill / // new), so the standalone /manage.html page was removed. diff --git a/public/style.css b/public/style.css index 010117e..9c519a7 100644 --- a/public/style.css +++ b/public/style.css @@ -371,6 +371,54 @@ body { outline: 2px dashed var(--accent); outline-offset: -3px; } + +/* Draggable splitter handles overlaying the track boundaries (v3). */ +.grid-gutter { + position: absolute; + z-index: 3; + touch-action: none; +} +.grid-gutter::after { + content: ''; + position: absolute; + background: var(--border-strong); + border-radius: 2px; + opacity: 0; + transition: opacity 0.15s; +} +.grid-gutter:hover::after, +.grid-gutter:active::after { + opacity: 1; + background: var(--accent); +} +.grid-gutter-col { + top: 0; + bottom: 0; + width: 12px; + transform: translateX(-50%); + cursor: col-resize; +} +.grid-gutter-col::after { + top: 15%; + bottom: 15%; + left: 50%; + width: 3px; + transform: translateX(-50%); +} +.grid-gutter-row { + left: 0; + right: 0; + height: 12px; + transform: translateY(-50%); + cursor: row-resize; +} +.grid-gutter-row::after { + left: 15%; + right: 15%; + top: 50%; + height: 3px; + transform: translateY(-50%); +} #term.grid .cell-head { display: flex; align-items: center; @@ -2008,3 +2056,129 @@ body.home-open #term { .push-toggle-btn[data-tip]:hover::after { opacity: 1; } + +/* ── Saved layout presets dropdown (v3) ──────────────────────────────── */ +.grid-presets { + position: relative; + display: inline-flex; + margin-left: 2px; +} +.grid-presets-btn { + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-dim); + width: 30px; + height: 30px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; +} +.grid-presets-btn:hover { + color: var(--text); + background: var(--surface-3); +} +.grid-presets-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 220px; + background: var(--surface-1); + border: 1px solid var(--border-strong); + border-radius: 10px; + box-shadow: var(--shadow); + padding: 6px; + z-index: 40; +} +.grid-presets-menu[hidden] { + display: none; +} +.gp-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 240px; + overflow-y: auto; +} +.gp-empty { + color: var(--text-faint); + font-size: 12px; + padding: 8px; + text-align: center; +} +.gp-row { + display: flex; + align-items: center; + gap: 4px; +} +.gp-apply { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 0; + background: transparent; + color: var(--text); + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 12.5px; + text-align: left; +} +.gp-apply:hover { + background: var(--surface-2); +} +.gp-apply-layout { + color: var(--text-faint); + font-size: 11px; +} +.gp-del { + border: 0; + background: transparent; + color: var(--text-faint); + width: 22px; + height: 22px; + border-radius: 5px; + cursor: pointer; + font-size: 15px; + line-height: 1; +} +.gp-del:hover { + color: var(--red); + background: var(--surface-2); +} +.gp-save { + display: flex; + gap: 4px; + margin-top: 6px; + border-top: 1px solid var(--border); + padding-top: 6px; +} +.gp-name { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 5px 8px; + font-size: 12px; + font-family: var(--ui-font); +} +.gp-name:focus { + outline: none; + border-color: var(--accent); +} +.gp-save-btn { + border: 0; + background: var(--accent); + color: var(--on-accent); + border-radius: 6px; + padding: 5px 10px; + cursor: pointer; + font-size: 12px; + font-weight: 600; +} +.gp-save-btn:hover { + filter: brightness(1.05); +} diff --git a/public/tabs.ts b/public/tabs.ts index cd9c41f..2166eb1 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -37,10 +37,18 @@ import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js' import { type GridLayout, type GridToggle, + type GridSplits, + type TrackSplit, isVisibleIndex, layoutCapacity, + layoutTracks, loadGridLayout, saveGridLayout, + adjustSplit, + tracksToTemplate, + loadGridSplits, + saveGridSplits, + splitForLayout, } from './grid-layout.js' import { mountCellMonitor, type CellMonitor } from './cell-monitor.js' @@ -118,6 +126,8 @@ export class TabApp { // v2: when true, the focused quadrant is expanded to fill the grid (others stay // live behind it). Follows the focused pane; reset on layout change / tab close. private maximized = false + // v3: persisted per-layout column/row track fractions (draggable splitters). + private gridSplits: GridSplits = {} // When true, the home chooser is overlaid on top of the open tabs (so you can // jump back to Sessions/Projects without closing anything). Reset whenever a // tab is activated. Irrelevant while no tab is open (home shows regardless). @@ -168,8 +178,9 @@ export class TabApp { this.paneHost.appendChild(this.segControl) // Split-grid: restore the persisted layout (forced to 'single' on screens - // too narrow for a multi-pane board — see loadGridLayout). + // too narrow for a multi-pane board — see loadGridLayout) + custom splits. this.gridLayout = loadGridLayout() + this.gridSplits = loadGridSplits() // v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds): this.setupPushToggle() // A1 🔔 @@ -965,6 +976,23 @@ export class TabApp { this.gridToggle = toggle } + /** v3: the current layout + its split, for saving as a preset. */ + gridArrangement(): { layout: GridLayout; split: TrackSplit } { + return { layout: this.gridLayout, split: splitForLayout(this.gridSplits, this.gridLayout) } + } + + /** v3: apply a saved preset — its layout plus its custom track fractions. */ + applyGridPreset(preset: { layout: GridLayout; split: TrackSplit }): void { + this.gridSplits = { ...this.gridSplits, [preset.layout]: preset.split } + saveGridSplits(this.gridSplits) + if (preset.layout === this.gridLayout) { + this.updateHomeView() // same layout, new split → just re-apply the template + this.gridToggle?.refresh() + } else { + this.setGridLayout(preset.layout) + } + } + /** Re-assert every VISIBLE pane's size (latest-writer-wins) when this device * regains focus — in a grid all visible quadrants reclaim size, not just the * focused one. */ @@ -1057,6 +1085,17 @@ export class TabApp { // inline approve, …) independent of how many layouts exist. this.paneHost.classList.toggle('grid', layout !== 'single') + // v3: apply custom track fractions (draggable splitters) as inline templates; + // clear them in single mode so #term is not a grid. + if (layout !== 'single') { + const sp = splitForLayout(this.gridSplits, layout) + this.paneHost.style.gridTemplateColumns = tracksToTemplate(sp.cols) + this.paneHost.style.gridTemplateRows = tracksToTemplate(sp.rows) + } else { + this.paneHost.style.gridTemplateColumns = '' + this.paneHost.style.gridTemplateRows = '' + } + const cap = layoutCapacity(this.gridLayout) const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap) @@ -1088,6 +1127,98 @@ export class TabApp { const placeholders = showHome || this.gridLayout === 'single' ? 0 : Math.max(0, cap - visibleCount) this.renderPlaceholders(placeholders, visibleCount) + this.renderGutters(layout) + } + + /* ── v3: draggable splitters ──────────────────────────────────────── */ + + /** Replace the gutter handles for the current layout (none in single mode). */ + private renderGutters(layout: GridLayout): void { + this.paneHost.querySelectorAll('.grid-gutter').forEach((g) => g.remove()) + if (layout === 'single') return + const { cols, rows } = layoutTracks(layout) + const sp = splitForLayout(this.gridSplits, layout) + this.placeAxisGutters('col', cols, sp.cols, true) + this.placeAxisGutters('row', rows, sp.rows, true) + } + + /** Create (create=true) or reposition the gutter handles for one axis. */ + private placeAxisGutters( + axis: 'col' | 'row', + count: number, + fractions: number[], + create: boolean, + ): void { + const total = fractions.reduce((a, b) => a + b, 0) || 1 + const existing = create + ? null + : this.paneHost.querySelectorAll(`.grid-gutter-${axis}`) + let acc = 0 + for (let g = 0; g < count - 1; g++) { + acc += fractions[g] ?? 1 + const pct = (acc / total) * 100 + const handle = create ? this.makeGutter(axis, g) : existing?.[g] + if (!handle) continue + if (axis === 'col') handle.style.left = `${pct}%` + else handle.style.top = `${pct}%` + if (create) this.paneHost.appendChild(handle) + } + } + + private makeGutter(axis: 'col' | 'row', gutterIndex: number): HTMLElement { + const g = document.createElement('div') + g.className = `grid-gutter grid-gutter-${axis}` + g.setAttribute('role', 'separator') + g.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal') + g.addEventListener('pointerdown', (e) => this.beginGutterDrag(e, axis, gutterIndex)) + return g + } + + /** Drag a gutter: translate pixel motion into an fr delta and re-template live. */ + private beginGutterDrag(e: PointerEvent, axis: 'col' | 'row', gutterIndex: number): void { + const layout = this.gridLayout + if (layout === 'single') return + e.preventDefault() + e.stopPropagation() + const handle = e.currentTarget as HTMLElement + const rect = this.paneHost.getBoundingClientRect() + const area = axis === 'col' ? rect.width : rect.height + const start = axis === 'col' ? e.clientX : e.clientY + const base = splitForLayout(this.gridSplits, layout) + const startFractions = (axis === 'col' ? base.cols : base.rows).slice() + const total = startFractions.reduce((a, b) => a + b, 0) + try { + handle.setPointerCapture(e.pointerId) + } catch { + // setPointerCapture may be unavailable (e.g. jsdom) — drag still works + } + const move = (ev: PointerEvent): void => { + if (area <= 0) return + const pos = axis === 'col' ? ev.clientX : ev.clientY + const delta = ((pos - start) / area) * total + this.setSplit(layout, axis, adjustSplit(startFractions, gutterIndex, delta)) + } + const up = (): void => { + handle.removeEventListener('pointermove', move) + handle.removeEventListener('pointerup', up) + handle.removeEventListener('pointercancel', up) + saveGridSplits(this.gridSplits) // persist only on release + } + handle.addEventListener('pointermove', move) + handle.addEventListener('pointerup', up) + handle.addEventListener('pointercancel', up) + } + + /** Apply a new fraction array live (template + gutter positions), no persist. */ + private setSplit(layout: GridLayout, axis: 'col' | 'row', fractions: number[]): void { + const cur = splitForLayout(this.gridSplits, layout) + const next: TrackSplit = + axis === 'col' ? { cols: fractions, rows: cur.rows } : { cols: cur.cols, rows: fractions } + this.gridSplits = { ...this.gridSplits, [layout]: next } + if (axis === 'col') this.paneHost.style.gridTemplateColumns = tracksToTemplate(fractions) + else this.paneHost.style.gridTemplateRows = tracksToTemplate(fractions) + const { cols, rows } = layoutTracks(layout) + this.placeAxisGutters(axis, axis === 'col' ? cols : rows, fractions, false) } /** Replace the placeholder cells with `count` new ones ordered after the panes. */ diff --git a/test/grid-layout.test.ts b/test/grid-layout.test.ts index 345bdf5..645d018 100644 --- a/test/grid-layout.test.ts +++ b/test/grid-layout.test.ts @@ -21,6 +21,13 @@ import { saveGridLayout, mountGridToggle, matchFocusCycleKey, + layoutTracks, + defaultSplit, + adjustSplit, + tracksToTemplate, + splitForLayout, + loadGridSplits, + saveGridSplits, } from '../public/grid-layout.js' const KEY = 'web-terminal:grid-layout' @@ -110,6 +117,65 @@ describe('grid-layout: pure logic', () => { }) }) +describe('grid-layout: resizable splitters (v3)', () => { + beforeEach(() => localStorage.clear()) + + it('layoutTracks gives each layout its column/row counts', () => { + expect(layoutTracks('single')).toEqual({ cols: 1, rows: 1 }) + expect(layoutTracks('split-2')).toEqual({ cols: 2, rows: 1 }) + expect(layoutTracks('row-3')).toEqual({ cols: 3, rows: 1 }) + expect(layoutTracks('grid-4')).toEqual({ cols: 2, rows: 2 }) + expect(layoutTracks('grid-6')).toEqual({ cols: 3, rows: 2 }) + }) + + it('defaultSplit is equal fractions matching the track counts', () => { + expect(defaultSplit('grid-4')).toEqual({ cols: [1, 1], rows: [1, 1] }) + expect(defaultSplit('grid-6')).toEqual({ cols: [1, 1, 1], rows: [1, 1] }) + }) + + it('adjustSplit trades the delta between adjacent tracks, conserving the total', () => { + expect(adjustSplit([1, 1], 0, 0.5)).toEqual([1.5, 0.5]) + expect(adjustSplit([1, 1, 1], 1, 0.4)).toEqual([1, 1.4, 0.6]) + }) + + it('adjustSplit clamps both tracks to the minimum (no collapse)', () => { + const out = adjustSplit([1, 1], 0, 5, 0.3) // huge delta + expect(out[1]).toBeCloseTo(0.3) + expect(out[0]! + out[1]!).toBeCloseTo(2) // total preserved + expect(Math.min(...out)).toBeGreaterThanOrEqual(0.3) + }) + + it('adjustSplit ignores an out-of-range gutter and never mutates the input', () => { + const input = [1, 1] + expect(adjustSplit(input, 1, 0.5)).toEqual([1, 1]) // gutter 1 has no right neighbor + expect(input).toEqual([1, 1]) // untouched + }) + + it('tracksToTemplate renders an fr template', () => { + expect(tracksToTemplate([1, 1.4])).toBe('1fr 1.4fr') + }) + + it('splitForLayout returns the stored split only when its shape matches', () => { + const good = { 'grid-4': { cols: [1.3, 0.7], rows: [1, 1] } } + expect(splitForLayout(good, 'grid-4')).toEqual({ cols: [1.3, 0.7], rows: [1, 1] }) + // wrong column count for grid-4 → falls back to equal default + expect(splitForLayout({ 'grid-4': { cols: [1, 1, 1], rows: [1, 1] } }, 'grid-4')).toEqual( + defaultSplit('grid-4'), + ) + expect(splitForLayout({}, 'split-2')).toEqual(defaultSplit('split-2')) + }) + + it('saveGridSplits + loadGridSplits round-trip', () => { + saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) + expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) + }) + + it('loadGridSplits tolerates garbage', () => { + localStorage.setItem('web-terminal:grid-splits', 'not json') + expect(loadGridSplits()).toEqual({}) + }) +}) + describe('grid-layout: gate + persistence', () => { beforeEach(() => { localStorage.clear() diff --git a/test/grid-presets.test.ts b/test/grid-presets.test.ts new file mode 100644 index 0000000..3e84c96 --- /dev/null +++ b/test/grid-presets.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +/** + * test/grid-presets.test.ts — saved split-grid layout presets + dropdown. + * + * Covers persistence (round-trip, garbage tolerance, malformed drop) and the + * dropdown behavior: empty state, save current, apply (calls back), delete. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { loadPresets, savePresets, mountGridPresets, type GridPreset } from '../public/grid-presets.js' + +const KEY = 'web-terminal:grid-presets' +const CURRENT: { layout: GridPreset['layout']; split: GridPreset['split'] } = { + layout: 'grid-4', + split: { cols: [1.5, 0.5], rows: [1, 1] }, +} + +beforeEach(() => localStorage.clear()) + +describe('grid-presets: persistence', () => { + it('round-trips presets through localStorage', () => { + const list: GridPreset[] = [{ name: 'wide-left', layout: 'grid-4', split: CURRENT.split }] + savePresets(list) + expect(loadPresets()).toEqual(list) + }) + + it('returns [] for missing or garbage storage', () => { + expect(loadPresets()).toEqual([]) + localStorage.setItem(KEY, 'not json') + expect(loadPresets()).toEqual([]) + }) + + it('drops malformed entries', () => { + localStorage.setItem( + KEY, + JSON.stringify([ + { name: 'ok', layout: 'grid-4', split: { cols: [1, 1], rows: [1, 1] } }, + { name: 'bad-layout', layout: 'nope', split: {} }, + { nope: true }, + ]), + ) + const out = loadPresets() + expect(out).toHaveLength(1) + expect(out[0]!.name).toBe('ok') + }) +}) + +describe('grid-presets: dropdown', () => { + let toolbar: HTMLElement + let apply: ReturnType + const mount = () => { + apply = vi.fn() + return mountGridPresets(toolbar, { current: () => CURRENT, apply }) + } + beforeEach(() => { + toolbar = document.createElement('div') + document.body.replaceChildren(toolbar) + }) + + const openMenu = (): HTMLElement => { + toolbar.querySelector('.grid-presets-btn')!.click() + return toolbar.querySelector('.grid-presets-menu')! + } + + it('shows an empty state when there are no presets', () => { + mount() + const menu = openMenu() + expect(menu.hidden).toBe(false) + expect(menu.querySelector('.gp-empty')).not.toBeNull() + }) + + it('saves the current arrangement under a typed name', () => { + mount() + openMenu() + const input = toolbar.querySelector('.gp-name')! + input.value = 'wide-left' + toolbar.querySelector('.gp-save-btn')!.click() + expect(loadPresets()).toEqual([{ name: 'wide-left', layout: 'grid-4', split: CURRENT.split }]) + expect(toolbar.querySelector('.gp-apply-name')?.textContent).toBe('wide-left') + }) + + it('does not save a blank name', () => { + mount() + openMenu() + toolbar.querySelector('.gp-save-btn')!.click() + expect(loadPresets()).toEqual([]) + }) + + it('applies a preset (calls back) and closes the menu', () => { + savePresets([{ name: 'p1', layout: 'grid-4', split: CURRENT.split }]) + mount() + const menu = openMenu() + toolbar.querySelector('.gp-apply')!.click() + expect(apply).toHaveBeenCalledWith({ name: 'p1', layout: 'grid-4', split: CURRENT.split }) + expect(menu.hidden).toBe(true) + }) + + it('deletes a preset', () => { + savePresets([{ name: 'p1', layout: 'grid-4', split: CURRENT.split }]) + mount() + openMenu() + toolbar.querySelector('.gp-del')!.click() + expect(loadPresets()).toEqual([]) + expect(toolbar.querySelector('.gp-empty')).not.toBeNull() + }) + + it('closes on an outside pointerdown', () => { + mount() + const menu = openMenu() + expect(menu.hidden).toBe(false) + document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) + expect(menu.hidden).toBe(true) + }) + + it('closes on Escape', () => { + mount() + const menu = openMenu() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(menu.hidden).toBe(true) + }) + + it('replaces a same-named preset instead of duplicating', () => { + savePresets([{ name: 'dup', layout: 'split-2', split: { cols: [1, 1], rows: [1] } }]) + mount() + openMenu() + const input = toolbar.querySelector('.gp-name')! + input.value = 'dup' + toolbar.querySelector('.gp-save-btn')!.click() + const out = loadPresets() + expect(out).toHaveLength(1) + expect(out[0]!.layout).toBe('grid-4') // overwritten with the current arrangement + }) +}) diff --git a/test/tabs.test.ts b/test/tabs.test.ts index 6cd0597..a1395c6 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -1460,4 +1460,70 @@ describe('TabApp — split-grid watch board (v1)', () => { app.setGridLayout('single') expect(monitorHandles[0]!.dispose).toHaveBeenCalled() }) + + // ── v3: resizable splitters ────────────────────────────────────────────────── + + it('grid-4 renders one column + one row splitter and sets an fr template', () => { + const { app, paneHost } = withTabs(4) + app.focusTab(0) + app.setGridLayout('grid-4') + expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(1) + expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1) + expect(paneHost.style.gridTemplateColumns).toBe('1fr 1fr') + expect(paneHost.style.gridTemplateRows).toBe('1fr 1fr') + }) + + it('grid-6 renders two column + one row splitters', () => { + const { app, paneHost } = withTabs(6) + app.focusTab(0) + app.setGridLayout('grid-6') + expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(2) + expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1) + }) + + it('single mode has no splitters and clears the inline grid template', () => { + const { app, paneHost } = withTabs(3) + app.setGridLayout('grid-4') + app.setGridLayout('single') + expect(paneHost.querySelectorAll('.grid-gutter')).toHaveLength(0) + expect(paneHost.style.gridTemplateColumns).toBe('') + }) + + it('a persisted custom split is applied to the grid template on load', () => { + localStorage.setItem( + 'web-terminal:grid-splits', + JSON.stringify({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }), + ) + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() + app.newTab() + app.setGridLayout('grid-4') + expect(paneHost.style.gridTemplateColumns).toBe('1.5fr 0.5fr') + }) + + it('dragging a column splitter re-templates the grid and persists on release', () => { + const { app, paneHost } = withTabs(4) + app.focusTab(0) + app.setGridLayout('grid-4') + // jsdom has no layout — give #term a measurable width for the drag math. + vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({ + width: 800, + height: 600, + left: 0, + top: 0, + right: 800, + bottom: 600, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + const gutter = paneHost.querySelector('.grid-gutter-col')! + gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true })) + gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true })) // +0.2fr + gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) + expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr') + const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!) + expect(saved['grid-4'].cols[0]).toBeCloseTo(1.2) + }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 4a5d9f5..f55beaf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ 'public/terminal-session.ts', 'public/tabs.ts', 'public/grid-layout.ts', + 'public/grid-presets.ts', 'public/cell-monitor.ts', 'public/preview-grid.ts', 'public/title-util.ts',