feat(grid): split-grid v2 — 1×3/2×3, drag-to-quadrant, Ctrl+` cycle, maximize

Builds on the v1 watch board:
- Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on
  #term now carries the shared cell chrome so it no longer enumerates each lay-*.
- Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey
  is an exported pure helper so it's unit-tested. No-op / passthrough in single mode.
- Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute
  overlay while siblings stay live behind it; follows focus, resets on layout
  change / tab close.
- Drag a tab from the tab bar onto a quadrant to assign it there (reuses the
  existing tab-drag dragIndex); grid-only.

Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro)
caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit
rows — a strip instead of fullscreen AND a spurious resize to backgrounded live
PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified
in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta.
Also: silence a covered pane's pending pulse under maximize; coarse-pointer target
for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-11 19:49:39 +02:00
parent 06814ba276
commit 5475b661ae
7 changed files with 324 additions and 24 deletions

View File

@@ -20,6 +20,7 @@ import {
loadGridLayout,
saveGridLayout,
mountGridToggle,
matchFocusCycleKey,
} from '../public/grid-layout.js'
const KEY = 'web-terminal:grid-layout'
@@ -62,11 +63,30 @@ describe('grid-layout: pure logic', () => {
it('layoutCapacity maps each layout to its pane count', () => {
expect(layoutCapacity('single')).toBe(1)
expect(layoutCapacity('split-2')).toBe(2)
expect(layoutCapacity('row-3')).toBe(3)
expect(layoutCapacity('grid-4')).toBe(4)
expect(layoutCapacity('grid-6')).toBe(6)
})
it('GRID_LAYOUTS is ordered single → split-2 → grid-4', () => {
expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'grid-4'])
it('GRID_LAYOUTS is ordered by capacity', () => {
expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'row-3', 'grid-4', 'grid-6'])
})
it('visibleIndices honors the higher-capacity layouts', () => {
expect(visibleIndices(9, 'row-3')).toEqual([0, 1, 2])
expect(visibleIndices(9, 'grid-6')).toEqual([0, 1, 2, 3, 4, 5])
expect(visibleIndices(4, 'grid-6')).toEqual([0, 1, 2, 3])
})
it('matchFocusCycleKey recognizes Ctrl+` (fwd) and Ctrl+Shift+` (rev), else null', () => {
const k = (init: KeyboardEventInit): KeyboardEvent => new KeyboardEvent('keydown', init)
expect(matchFocusCycleKey(k({ ctrlKey: true, key: '`' }))).toBe(1)
expect(matchFocusCycleKey(k({ ctrlKey: true, shiftKey: true, key: '`' }))).toBe(-1)
expect(matchFocusCycleKey(k({ ctrlKey: true, code: 'Backquote' }))).toBe(1)
expect(matchFocusCycleKey(k({ key: '`' }))).toBeNull() // no ctrl
expect(matchFocusCycleKey(k({ ctrlKey: true, metaKey: true, key: '`' }))).toBeNull() // meta excluded
expect(matchFocusCycleKey(k({ ctrlKey: true, altKey: true, key: '`' }))).toBeNull() // alt excluded
expect(matchFocusCycleKey(k({ ctrlKey: true, key: 'a' }))).toBeNull() // wrong key
})
it('visibleIndices returns the first min(tabCount, capacity) indices', () => {

View File

@@ -1268,4 +1268,126 @@ describe('TabApp — split-grid watch board (v1)', () => {
expect(NotificationMock).toHaveBeenCalled()
vi.unstubAllGlobals()
})
// ── v2: higher-capacity layouts, focus cycling, maximize, drag-to-quadrant ────
it('grid-6 shows up to six live panes', () => {
const { app, paneHost } = withTabs(6)
app.focusTab(0)
app.setGridLayout('grid-6')
expect(visibleReal(paneHost)).toHaveLength(6)
expect(paneHost.classList.contains('lay-grid-6')).toBe(true)
expect(paneHost.classList.contains('grid')).toBe(true)
})
it('row-3 shows exactly three panes', () => {
const { app, paneHost } = withTabs(5)
app.focusTab(0)
app.setGridLayout('row-3')
expect(visibleReal(paneHost)).toHaveLength(3)
})
it('cycleFocus moves the focus ring around the visible panes and wraps', () => {
const { app } = withTabs(3)
app.focusTab(0)
app.setGridLayout('grid-4') // 3 visible, active 0
app.cycleFocus(1)
expect(app.snapshot().findIndex((t) => t.active)).toBe(1)
app.cycleFocus(1)
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
app.cycleFocus(1) // wrap
expect(app.snapshot().findIndex((t) => t.active)).toBe(0)
app.cycleFocus(-1) // reverse wrap
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
})
it('cycleFocus is a no-op in single mode', () => {
const { app } = withTabs(3) // single mode, active 2
app.cycleFocus(1)
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
})
it('toggleMaximize marks the focused cell and toggling off clears it', () => {
const { app, paneHost } = withTabs(3)
app.setGridLayout('grid-4')
app.toggleMaximize()
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
expect(paneHost.querySelector('.term-cell.maximized')!.classList.contains('focused')).toBe(true)
app.toggleMaximize()
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
})
it('maximize follows the focused pane as you cycle', () => {
const { app, paneHost } = withTabs(3)
app.focusTab(0)
app.setGridLayout('grid-4')
app.toggleMaximize()
const first = FakeTerminalSession.instances[0]!.el.closest('.term-cell')
expect(first!.classList.contains('maximized')).toBe(true)
app.cycleFocus(1) // now tab 1 is focused
expect(first!.classList.contains('maximized')).toBe(false)
expect(FakeTerminalSession.instances[1]!.el.closest('.term-cell')!.classList.contains('maximized')).toBe(true)
})
it('changing layout exits maximize', () => {
const { app, paneHost } = withTabs(3)
app.setGridLayout('grid-4')
app.toggleMaximize()
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
app.setGridLayout('split-2')
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
})
it('dragging an off-board tab onto a quadrant assigns it there and focuses it', () => {
const { app, paneHost, tabBar } = withTabs(5)
app.focusTab(0)
app.setGridLayout('grid-4') // tab 4 off-board
const dragged = FakeTerminalSession.instances[4]!
const targetCell = FakeTerminalSession.instances[0]!.el.closest<HTMLElement>('.term-cell')!
// Start dragging tab 4 from the tab bar (sets dragIndex), drop on quadrant 0.
tabBar.querySelectorAll<HTMLElement>('.tab')[4]!.dispatchEvent(
new Event('dragstart', { bubbles: true }),
)
targetCell.dispatchEvent(new Event('dragover', { bubbles: true }))
targetCell.dispatchEvent(new Event('drop', { bubbles: true }))
const cell = dragged.el.closest<HTMLElement>('.term-cell')!
expect(cell.style.display).not.toBe('none') // now on-board
expect(cell.classList.contains('focused')).toBe(true) // and focused
})
it('closing a tab while a quadrant is maximized clears the maximized state', () => {
const { app, paneHost } = withTabs(3)
app.focusTab(0)
app.setGridLayout('grid-4')
app.toggleMaximize()
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
app.closeTab(1) // close a different (non-focused) tab
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
})
it('drag-to-quadrant is a no-op in single mode (no reorder, no drag-target)', () => {
const { app, paneHost, tabBar } = withTabs(3) // default single layout
const before = app.snapshot().map((t) => t.title)
tabBar.querySelectorAll<HTMLElement>('.tab')[1]!.dispatchEvent(
new Event('dragstart', { bubbles: true }),
)
const cell = FakeTerminalSession.instances[0]!.el.closest<HTMLElement>('.term-cell')!
cell.dispatchEvent(new Event('dragover', { bubbles: true }))
cell.dispatchEvent(new Event('drop', { bubbles: true }))
expect(cell.classList.contains('drag-target')).toBe(false)
expect(app.snapshot().map((t) => t.title)).toEqual(before) // order unchanged
})
it('the ⛶ button focuses its quadrant and maximizes it in one click', () => {
const { app } = withTabs(3)
app.focusTab(0)
app.setGridLayout('grid-4')
const cell2 = FakeTerminalSession.instances[2]!.el.closest<HTMLElement>('.term-cell')!
const maxBtn = cell2.querySelector<HTMLButtonElement>('.cell-max')!
expect(maxBtn.textContent).toBe('⛶')
maxBtn.click()
expect(cell2.classList.contains('focused')).toBe(true) // focused the clicked quadrant
expect(cell2.classList.contains('maximized')).toBe(true) // and maximized it
expect(maxBtn.textContent).toBe('⤡') // glyph flips to restore
})
})