From 5475b661ae934c33903f08f4f757fff0d40b48b4 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 11 Jul 2026 19:49:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(grid):=20split-grid=20v2=20=E2=80=94=201?= =?UTF-8?q?=C3=973/2=C3=973,=20drag-to-quadrant,=20Ctrl+`=20cycle,=20maxim?= =?UTF-8?q?ize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/PROGRESS_LOG.md | 4 +- public/grid-layout.ts | 31 ++++++++-- public/main.ts | 17 +++++- public/style.css | 70 +++++++++++++++++----- public/tabs.ts | 80 ++++++++++++++++++++++++- test/grid-layout.test.ts | 24 +++++++- test/tabs.test.ts | 122 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 324 insertions(+), 24 deletions(-) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 48f5e6a..556af58 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -32,7 +32,9 @@ - **核心设计**: `activeIndex` 语义**不变**=聚焦格(keybar/voice/approval 全部零改动);新增 `gridLayout` + 派生 `visibleIndices`;每个 pane 包进 `.term-cell`(header + 终端 + 可选内联审批 footer);`applyLayout()` 独占 pane 显隐/grid class/cell 排序/占位符;`#term` 变 CSS grid。**服务端/WS 协议零改动**。 - **文件**: 新 `public/grid-layout.ts`(纯逻辑 + matchMedia 门 + 持久化 + toolbar 分段控件)、`tabs.ts`(applyLayout/renderCell/renderInlineApprove/setFocused/setGridLayout/refitVisible + `activate()` 改为**board-aware**)、`terminal-session.ts`(`show({focus})` 防 4 格抢焦点 + `onFocus` 回调)、`main.ts`(挂 toggle + refitVisible)、`style.css`(cell/grid/焦点环/pending 脉冲/内联审批/占位符/toggle + `.term-pane` 改 position:relative flex 子)。新测 `test/grid-layout.test.ts` + `tabs.test.ts` 追加 split-grid 块。 - **交叉验证抓修 3 真缺陷**:**HIGH** — `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex 但 `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-board→moveTab 上板逻辑折进 `activate()`,`setFocused`/`setGridLayout` 委托之。**LOW** — 通知抑制未算 `homeForced`(⌂ 覆盖时 pane 实际不在屏);修:`onScreen = !homeForced && isVisible`。**LOW** — toggle 触控尺寸(coarse-pointer 平板);修:`@media (pointer:coarse)` 加大。全部补了回归测试。 -- **[~] 下一步**: v2(1×3/2×3 布局、拖 tab 到格、Ctrl+` 循环焦点、单格最大化)→ v3(可拖拽分隔条、只读 monitor 格走 `/live-sessions/:id/preview` 免共享 PTY 缩放、布局预设)。 +- **[x] v2 DONE(2026-07-11)** — orchestrator 独立复验全绿:typecheck 两 config 干净、build:web 干净、**全套 1579 测试**(+13),覆盖率 grid-layout 95%/tabs 93%。加了:**1×3(row-3)/2×3(grid-6)布局**、**Ctrl+`/Ctrl+Shift+` 循环焦点**(main.ts capture keydown → cycleFocus,单格模式不吞键)、**每格最大化 ⛶**(`.maximized` 覆盖层)、**拖 tab 到格**(wireCellDropTarget 复用 dragIndex)。用单个 `grid` 标记类承载共享 cell 样式(不再枚举每个 lay-*)。 + - **交叉验证抓修 3 真缺陷**:**HIGH** — 最大化原用 `grid-column/row: 1/-1` **span 网格**是错的:CSS Grid 把兄弟格挤进隐式行→"最大化"格变成细条(非全屏)+ 兄弟格 box 变化触发 ResizeObserver→**给后台 live PTY 发错误 resize(伪 SIGWINCH)**。评审在**真 headless Chrome 里实测复现**。修:`.maximized` 改 `position:absolute; inset:0; z-index:4` 脱离网格流做覆盖层。**独立 Playwright 复验**:最大化格填满 #term(1000×700)、兄弟格尺寸 0px 变化,对照组证实旧规则确会 891×572 细条+兄弟塌成 52px。**MED** — 最大化时被盖住的 pending 格 amber 脉冲外溢;修:`(!maximized || focused)` 门。**LOW** — `.cell-max` 缺 coarse-pointer 触控尺寸。均补回归测试;并把 Ctrl+` 键匹配抽成 `matchFocusCycleKey` 纯函数单测。 +- **[~] 下一步**: v3(可拖拽分隔条、只读 monitor 格走 `/live-sessions/:id/preview` 免共享 PTY 缩放、布局预设)。 - **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换、点击移焦、内联审批)已给用户看过并据此拍板方向。 ### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08) diff --git a/public/grid-layout.ts b/public/grid-layout.ts index 693bea3..0441a8b 100644 --- a/public/grid-layout.ts +++ b/public/grid-layout.ts @@ -16,15 +16,23 @@ */ /** A screen layout for the terminal area. Ordered by pane capacity. */ -export type GridLayout = 'single' | 'split-2' | 'grid-4' +export type GridLayout = 'single' | 'split-2' | 'row-3' | 'grid-4' | 'grid-6' -/** All layouts, in the order the toggle presents them. */ -export const GRID_LAYOUTS: readonly GridLayout[] = ['single', 'split-2', 'grid-4'] +/** All layouts, in the order the toggle presents them (by capacity). */ +export const GRID_LAYOUTS: readonly GridLayout[] = [ + 'single', + 'split-2', + 'row-3', + 'grid-4', + 'grid-6', +] const CAPACITY: Readonly> = { single: 1, 'split-2': 2, + 'row-3': 3, 'grid-4': 4, + 'grid-6': 6, } /** How many panes a layout shows at once. */ @@ -57,6 +65,17 @@ export function isVisibleIndex( return idx >= 0 && idx < Math.min(Math.max(0, tabCount), layoutCapacity(layout)) } +/** + * Match the split-grid focus-cycle keybinding (Ctrl+` forward, Ctrl+Shift+` + * reverse). Returns the cycle direction, or null if the event isn't the binding. + * Extracted from the DOM listener so it's unit-testable in isolation. + */ +export function matchFocusCycleKey(e: KeyboardEvent): 1 | -1 | null { + if (!e.ctrlKey || e.altKey || e.metaKey) return null + if (e.key !== '`' && e.code !== 'Backquote') return null + return e.shiftKey ? -1 : 1 +} + /** Minimum viewport width (px) at which multi-pane layouts are offered. Four * terminals need real width; below this the board falls back to 'single'. */ export const GRID_MIN_WIDTH = 1024 @@ -73,7 +92,9 @@ export function gridAllowed(): boolean { const GRID_LAYOUT_KEY = 'web-terminal:grid-layout' function isGridLayout(v: unknown): v is GridLayout { - return v === 'single' || v === 'split-2' || v === 'grid-4' + return ( + v === 'single' || v === 'split-2' || v === 'row-3' || v === 'grid-4' || v === 'grid-6' + ) } /** Load the persisted layout. Forced to 'single' when the screen is too narrow @@ -114,7 +135,9 @@ export interface GridToggle { const LAYOUT_META: Readonly> = { single: { title: 'Single pane', cells: 1 }, 'split-2': { title: 'Side by side — compare two', cells: 2 }, + 'row-3': { title: '1×3 row — three across', cells: 3 }, 'grid-4': { title: '2×2 watch board', cells: 4 }, + 'grid-6': { title: '2×3 grid — six sessions', cells: 6 }, } /** A small CSS-drawn layout glyph (N cells), reliable across platforms. */ diff --git a/public/main.ts b/public/main.ts index 928fec4..7f432e2 100644 --- a/public/main.ts +++ b/public/main.ts @@ -20,7 +20,7 @@ import { mountDashboard } from './dashboard.js' import { mountHistory } from './history.js' import { mountShortcuts } from './shortcuts.js' import { mountShareSession } from './share.js' -import { mountGridToggle } from './grid-layout.js' +import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -59,6 +59,21 @@ document.addEventListener('visibilitychange', () => { if (!document.hidden) app.refitVisible() }) +// v2: Ctrl+` cycles the focused quadrant in a split grid (Ctrl+Shift+` reverses). +// Capture phase so xterm doesn't swallow it; left untouched (no preventDefault) +// in single mode so the terminal keeps the keystroke. +document.addEventListener( + 'keydown', + (e) => { + const dir = matchFocusCycleKey(e) + if (dir === null) return + if (app.getGridLayout() === 'single') return // let the terminal keep the key + e.preventDefault() + app.cycleFocus(dir) + }, + { capture: true }, +) + // Toolbar utilities. mountSearch(toolbar, { find: (query, dir) => app.findInActive(query, dir), diff --git a/public/style.css b/public/style.css index 4b85630..ba47dc9 100644 --- a/public/style.css +++ b/public/style.css @@ -327,8 +327,9 @@ body { } /* ── Split-grid layouts (desktop watch board) ────────────────────────── */ -#term.lay-split-2, -#term.lay-grid-4 { +/* Shared cell chrome keys off the single `grid` marker so it is independent of + * how many concrete layouts exist; each `lay-*` only sets the grid template. */ +#term.grid { display: grid; gap: 6px; padding: 6px; @@ -337,20 +338,40 @@ body { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; } +#term.lay-row-3 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr; +} #term.lay-grid-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; } -#term.lay-split-2 .term-cell, -#term.lay-grid-4 .term-cell { +#term.lay-grid-6 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +#term.grid .term-cell { position: relative; inset: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); } -#term.lay-split-2 .cell-head, -#term.lay-grid-4 .cell-head { +/* Maximized quadrant fills the whole grid as an ABSOLUTE overlay (taken out of + * grid flow so the siblings keep their placement — spanning grid tracks instead + * would shove auto-placed siblings into implicit rows, resizing their live PTYs). + * #term is a positioned containing block, so inset:0 covers the whole area. */ +#term.grid .term-cell.maximized { + position: absolute; + inset: 0; + z-index: 4; +} +/* A tab dragged from the tab bar is hovering this quadrant. */ +#term.grid .term-cell.drag-target { + outline: 2px dashed var(--accent); + outline-offset: -3px; +} +#term.grid .cell-head { display: flex; align-items: center; gap: 8px; @@ -361,6 +382,20 @@ body { cursor: pointer; user-select: none; } +.cell-max { + border: 0; + background: transparent; + color: var(--text-faint); + cursor: pointer; + font-size: 13px; + line-height: 1; + padding: 2px 4px; + border-radius: 5px; +} +.cell-max:hover { + color: var(--text); + background: var(--surface-3); +} .cell-name { font-weight: 600; color: var(--text); @@ -388,16 +423,14 @@ body { } /* Focus ring — the quadrant that owns keyboard / keybar / voice / approvals. */ -#term.lay-split-2 .term-cell.focused, -#term.lay-grid-4 .term-cell.focused { +#term.grid .term-cell.focused { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--shadow); } /* Pending glow — a non-focused quadrant is waiting for approval. */ -#term.lay-split-2 .term-cell.pending:not(.focused), -#term.lay-grid-4 .term-cell.pending:not(.focused) { +#term.grid .term-cell.pending:not(.focused) { border-color: var(--amber); animation: cell-pending 2.4s ease-in-out infinite; } @@ -413,15 +446,13 @@ body { } } @media (prefers-reduced-motion: reduce) { - #term.lay-split-2 .term-cell.pending, - #term.lay-grid-4 .term-cell.pending { + #term.grid .term-cell.pending { animation: none; } } /* Inline per-quadrant approve footer. */ -#term.lay-split-2 .cell-approve, -#term.lay-grid-4 .cell-approve { +#term.grid .cell-approve { display: flex; align-items: center; gap: 8px; @@ -548,10 +579,17 @@ body { .grid-glyph.gl-split-2 { grid-template-columns: 1fr 1fr; } +.grid-glyph.gl-row-3 { + grid-template-columns: 1fr 1fr 1fr; +} .grid-glyph.gl-grid-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; } +.grid-glyph.gl-grid-6 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; +} .grid-glyph i { background: currentColor; border-radius: 1px; @@ -566,6 +604,10 @@ body { width: 36px; height: 34px; } + .cell-max { + padding: 6px 9px; + font-size: 15px; + } } /* ── Key bar (rounded chips) ─────────────────────────────────────── */ diff --git a/public/tabs.ts b/public/tabs.ts index 5abe995..5bfaeae 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -109,6 +109,9 @@ export class TabApp { // back via gridToggle. 'single' preserves the original one-pane behavior. private gridLayout: GridLayout = 'single' private gridToggle: GridToggle | null = null + // 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 // 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). @@ -685,6 +688,22 @@ export class TabApp { cell.style.display = 'none' const head = this.buildCellHead() head.addEventListener('pointerdown', () => this.setFocused(this.tabs.indexOf(entry))) + // v2: ⛶ maximize/restore this quadrant (focuses it first). + const maxBtn = document.createElement('button') + maxBtn.type = 'button' + maxBtn.className = 'cell-max' + maxBtn.title = 'Maximize / restore' + maxBtn.setAttribute('aria-label', 'Maximize or restore this pane') + maxBtn.addEventListener('pointerdown', (e) => e.stopPropagation()) + maxBtn.addEventListener('click', (e) => { + e.stopPropagation() + const idx = this.tabs.indexOf(entry) + if (idx !== this.activeIndex) this.setFocused(idx) + this.toggleMaximize() + }) + head.appendChild(maxBtn) + // v2: drag a tab from the tab bar onto this quadrant to assign it here. + this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry)) cell.append(head, session.el) entry.cell = cell this.paneHost.appendChild(cell) @@ -764,6 +783,7 @@ export class TabApp { closeTab(i: number): void { if (i < 0 || i >= this.tabs.length) return + this.maximized = false // structural change exits maximize const [entry] = this.tabs.splice(i, 1) entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab entry?.session.dispose() // removes session.el (the .term-pane) @@ -905,6 +925,7 @@ export class TabApp { return } this.gridLayout = layout + this.maximized = false // a layout change exits maximize saveGridLayout(layout) // Re-activate the focused pane under the new layout: activate() is board-aware, // so if the capacity shrank and the pane is now off-board it gets pulled on; @@ -931,12 +952,58 @@ export class TabApp { }) } + /** v2: cycle the focused quadrant among the visible panes (wired to Ctrl+`). */ + cycleFocus(dir: 1 | -1): void { + if (this.gridLayout === 'single') return + const cap = Math.min(this.tabs.length, layoutCapacity(this.gridLayout)) + if (cap <= 1) return + const cur = this.activeIndex >= 0 && this.activeIndex < cap ? this.activeIndex : 0 + this.setFocused((cur + dir + cap) % cap) + } + + /** v2: expand/restore the focused quadrant to fill the grid (others stay live + * behind it via CSS grid-area + z-index). No-op in single mode. */ + toggleMaximize(): void { + if (this.gridLayout === 'single') return + this.maximized = !this.maximized + this.updateHomeView() + } + + /** v2: make a grid cell a drop target for a tab dragged from the tab bar — + * dropping assigns that tab to this cell's slot (grid only) and focuses it. */ + private wireCellDropTarget(cell: HTMLElement, slotIndex: () => number): void { + cell.addEventListener('dragover', (e) => { + if (this.dragIndex < 0 || this.gridLayout === 'single') return + e.preventDefault() + cell.classList.add('drag-target') + }) + cell.addEventListener('dragleave', () => cell.classList.remove('drag-target')) + cell.addEventListener('drop', (e) => { + cell.classList.remove('drag-target') + if (this.dragIndex < 0 || this.gridLayout === 'single') return + e.preventDefault() + const moved = this.tabs[this.dragIndex] + this.moveTab(this.dragIndex, slotIndex()) + this.dragIndex = -1 + if (moved) this.setFocused(this.tabs.indexOf(moved)) + }) + } + /** Show/hide panes, set the grid class, order cells, and render placeholders. * Single source of pane visibility (called via updateHomeView). */ private applyLayout(showHome: boolean): void { const layout: GridLayout = showHome ? 'single' : this.gridLayout - this.paneHost.classList.remove('lay-single', 'lay-split-2', 'lay-grid-4') + this.paneHost.classList.remove( + 'lay-single', + 'lay-split-2', + 'lay-row-3', + 'lay-grid-4', + 'lay-grid-6', + ) this.paneHost.classList.add(`lay-${layout}`) + // A single 'grid' marker keeps the shared cell CSS (headers, focus ring, + // inline approve, …) independent of how many layouts exist. + this.paneHost.classList.toggle('grid', layout !== 'single') const cap = layoutCapacity(this.gridLayout) const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap) @@ -1003,8 +1070,17 @@ export class TabApp { const idx = this.tabs.indexOf(entry) const focused = idx === this.activeIndex const grid = this.gridLayout !== 'single' && !this.homeForced && this.tabs.length > 0 + const maximized = grid && this.maximized && focused cell.classList.toggle('focused', grid && focused) - cell.classList.toggle('pending', grid && entry.session.pendingApproval) + // While a quadrant is maximized, a covered (non-focused) pane's amber pulse + // would bleed around the overlay — keep it silent until un-maximized. + cell.classList.toggle( + 'pending', + grid && entry.session.pendingApproval && (!this.maximized || focused), + ) + cell.classList.toggle('maximized', maximized) + const maxBtn = cell.querySelector('.cell-max') + if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶' const nameEl = cell.querySelector('.cell-name') if (nameEl) nameEl.textContent = this.displayTitle(entry, idx) diff --git a/test/grid-layout.test.ts b/test/grid-layout.test.ts index 8eb81a9..345bdf5 100644 --- a/test/grid-layout.test.ts +++ b/test/grid-layout.test.ts @@ -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', () => { diff --git a/test/tabs.test.ts b/test/tabs.test.ts index f8632bb..01bf808 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -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('.term-cell')! + // Start dragging tab 4 from the tab bar (sets dragIndex), drop on quadrant 0. + tabBar.querySelectorAll('.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('.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('.tab')[1]!.dispatchEvent( + new Event('dragstart', { bubbles: true }), + ) + const cell = FakeTerminalSession.instances[0]!.el.closest('.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('.term-cell')! + const maxBtn = cell2.querySelector('.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 + }) })