feat(grid): v3b/c — resizable splitters + saved layout presets
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Record<GridLayout, TrackSplit>>
|
||||
|
||||
/** Equal-fraction split for a layout (every track = 1fr). */
|
||||
export function defaultSplit(layout: GridLayout): TrackSplit {
|
||||
const { cols, rows } = layoutTracks(layout)
|
||||
return { cols: Array<number>(cols).fill(1), rows: Array<number>(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)
|
||||
}
|
||||
|
||||
214
public/grid-presets.ts
Normal file
214
public/grid-presets.ts
Normal file
@@ -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<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: 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<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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
174
public/style.css
174
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);
|
||||
}
|
||||
|
||||
133
public/tabs.ts
133
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<HTMLElement>(`.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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
133
test/grid-presets.test.ts
Normal file
133
test/grid-presets.test.ts
Normal file
@@ -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<typeof vi.fn>
|
||||
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<HTMLButtonElement>('.grid-presets-btn')!.click()
|
||||
return toolbar.querySelector<HTMLElement>('.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<HTMLInputElement>('.gp-name')!
|
||||
input.value = 'wide-left'
|
||||
toolbar.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLInputElement>('.gp-name')!
|
||||
input.value = 'dup'
|
||||
toolbar.querySelector<HTMLButtonElement>('.gp-save-btn')!.click()
|
||||
const out = loadPresets()
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0]!.layout).toBe('grid-4') // overwritten with the current arrangement
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLElement>('.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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user