diff --git a/src/web/public/app.js b/src/web/public/app.js index 6b1e59ae..b5075b32 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -525,6 +525,56 @@ const DEFAULT_SHORTCUTS = [ bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }], action: 'toggleSessionSidebar', }, + // Tile grid (tile-grid.js). Dispatched by tileShortcutFor()/runTileShortcut() + // and deliberately absent from SHORTCUT_ACTIONS: each applies only in some + // states (the focus chords only while the grid is open), and outside them the + // chord must reach the terminal untouched. Every xterm key handler swallows a + // chord that applies, so it never reaches a PTY. Defaults: Ctrl+Shift+G makes + // xterm emit nothing (a shifted Ctrl letter) and overrides only the browser's + // find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs. + { + id: 'toggle-tile-grid', + group: 'Tiles', + label: 'Toggle Tile Grid', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }], + action: 'toggleTileGrid', + }, + { + id: 'focus-tile-left', + group: 'Tiles', + label: 'Focus Tile Left', + bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowLeft' }], + action: 'focusTileLeft', + }, + { + id: 'focus-tile-right', + group: 'Tiles', + label: 'Focus Tile Right', + bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowRight' }], + action: 'focusTileRight', + }, + { + id: 'focus-tile-up', + group: 'Tiles', + label: 'Focus Tile Up', + bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowUp' }], + action: 'focusTileUp', + }, + { + id: 'focus-tile-down', + group: 'Tiles', + label: 'Focus Tile Down', + bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }], + action: 'focusTileDown', + }, + { + id: 'remove-tile', + group: 'Tiles', + label: 'Remove Focused Tile', + // Unbound by default; the session keeps running either way. + bindings: [], + action: 'removeTile', + }, { id: 'previous-next-session', group: 'Session', @@ -1362,6 +1412,15 @@ class CodemanApp { } } + // Tile grid chords: only where they apply (tile-grid.js tileShortcutFor), + // so outside the grid Alt+Shift+Arrows still reach the terminal. + const tileShortcut = this.tileShortcutFor?.(e); + if (tileShortcut) { + e.preventDefault(); + this.runTileShortcut(tileShortcut); + return; + } + // Match against the shortcut registry so user rebinds and per-shortcut // disables (App Settings → Shortcuts) take effect. Every dispatchable // binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ab9f2f73..0bee818a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2510,6 +2510,12 @@ html[data-tab-orientation='vertical'].tab-rail-compact display: none !important; } +/* The tile grid is open (tile-grid.js): the split cannot open beside it. */ +.btn-split.btn-split--blocked { + opacity: 0.4; + cursor: not-allowed; +} + /* While a split is open, the button's own click closes it instead of opening the picker — the accent fill is what tells the user which of its two behaviours the next click will get (aria-pressed carries the same state diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 8a5d839b..70df4081 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -46,6 +46,9 @@ Object.assign(CodemanApp.prototype, { // the old exact-node check below) bubbled straight through to // `document` and self-closed the menu it just opened. event?.stopPropagation(); + // The tile grid and the split are never open together (tile-grid.js); the + // Split button shows as unavailable meanwhile. + if (this._tilesOwnTerminal?.()) return; if (this._splitPane) { this.closeSplitPane(); return; @@ -139,6 +142,8 @@ Object.assign(CodemanApp.prototype, { // see _applySplitButtonVisibility's comment for why both a JS check and // a CSS backstop exist. if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return; + // Never beside the tile grid: the main terminal is parked while it is open. + if (this._tilesOwnTerminal?.()) return; // No active session means there is no `.terminal-wrap` to split against // (the welcome overlay is showing) — without this, a split opened from // the home screen still created the container and connected Pane B, just diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 76b3b445..6424ab7b 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -208,6 +208,10 @@ if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) { return false; } + // Tile grid chords (focus, toggle): acted on by the capture handler, so + // they must never reach this tile's PTY. Every event type, and before + // the Shift+Enter branch below. + if (global.app?.tileShortcutFor?.(ev)) return false; // Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so // a pasted image uploads to this pane's session and its path is typed // here, and pasted text goes into this xterm with its bracketed-paste diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d11e7f92..4dfa7364 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -590,6 +590,11 @@ Object.assign(CodemanApp.prototype, { return false; } + // Tile grid chords (Ctrl+Shift+G, Alt+Shift+Arrows): the capture handler + // has already acted on one that applies, and its preventDefault() does not + // stop xterm. Every event type, and BEFORE the Shift+Enter branch below. + if (this.tileShortcutFor?.(ev)) return false; + // Smart copy (#211): with a selection, Ctrl+C copies it instead of sending // ^C. With NO selection the branch must fall through (return true, and no // preventDefault) or the interrupt key is lost, which is the whole reason diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index bc96e631..1c7eca2a 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -26,6 +26,16 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; // Trailing debounce for refitting tiles after the grid area changes size, so a // window drag sends each tile's PTY one resize, not one per frame. const TILE_GRID_REFIT_MS = 150; +// Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each +// needs the grid open. The toggle applies wherever a grid could open. +const TILE_SHORTCUTS = { + 'toggle-tile-grid': { needsOpen: false }, + 'focus-tile-left': { needsOpen: true, direction: 'left' }, + 'focus-tile-right': { needsOpen: true, direction: 'right' }, + 'focus-tile-up': { needsOpen: true, direction: 'up' }, + 'focus-tile-down': { needsOpen: true, direction: 'down' }, + 'remove-tile': { needsOpen: true }, +}; /** The grid's state. `has(id)` answers only while it is open. */ class TileGridModel { @@ -137,8 +147,18 @@ Object.assign(CodemanApp.prototype, { if (!this.canOpenTileGrid()) return false; const grid = (this._tileGrid ||= new TileGridModel()); const max = window.CodemanTileGrid.TILE_GRID_MAX; + // The grid and the split are never open together. An open split becomes the + // grid's first two tiles (Pane A focused, Pane B beside it), so "split, then + // want more" is one step. No closing resize for Pane A: it is about to park. + let requested = ids || []; + if (this._splitPane) { + const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean); + this.closeSplitPane({ skipPrimaryResize: true }); + requested = [...seed, ...requested]; + if (!requested.includes(focusedId)) focusedId = seed[0] ?? null; + } const wanted = []; - for (const id of ids || []) { + for (const id of requested) { if (typeof id !== 'string' || wanted.includes(id)) continue; if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue; wanted.push(id); @@ -175,6 +195,7 @@ Object.assign(CodemanApp.prototype, { this._installTileGridWidthGate(); this._selectTiledSession(focus, { auto }); this._updateConnectionIndicator?.(); + this._updateSplitButtonForTiles(); return true; }, @@ -230,9 +251,95 @@ Object.assign(CodemanApp.prototype, { this.terminalBufferCache?.delete(id); } this._updateConnectionIndicator?.(); + this._updateSplitButtonForTiles(); if (reselect) this._selectAfterTileGrid(focusedId); }, + // The Split button cannot act while the grid is open (openSplitPicker and + // openSplitPane refuse), so it says so: aria-disabled plus a title, the same + // refusal the split already gives for web tabs and the welcome screen. + _updateSplitButtonForTiles() { + const btn = document.querySelector('.btn-split'); + if (!btn) return; + const blocked = this._tilesOwnTerminal(); + btn.classList.toggle('btn-split--blocked', blocked); + btn.setAttribute('aria-disabled', blocked ? 'true' : 'false'); + if (blocked) { + btn.title = 'Split: unavailable while tiles are open'; + btn.setAttribute('aria-label', btn.title); + } else { + this._updateSplitButtonState?.(!!this._splitPane); + } + }, + + /** + * The tile chord `e` asks for, if it applies right now, else null: the toggle + * wherever a grid could open (or is open), the focus and remove chords only + * while it is open, so outside the grid they reach the terminal untouched. + * Registry-aware (rebinds and disables in App Settings, Shortcuts). The + * capture handler (app.js) dispatches it; every xterm key handler returns + * false for it, so a chord that applies never reaches a PTY. + * + * @returns {string|null} the registry id + */ + tileShortcutFor(e) { + if (!e || (!e.ctrlKey && !e.metaKey && !e.altKey)) return null; + if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return null; + const open = this._tilesOwnTerminal(); + for (const shortcut of this.getShortcutRegistry()) { + const spec = TILE_SHORTCUTS[shortcut.id]; + if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue; + if (spec.needsOpen ? open : open || this.canOpenTileGrid()) return shortcut.id; + return null; + } + return null; + }, + + /** Runs a chord tileShortcutFor() matched. */ + runTileShortcut(id) { + const spec = TILE_SHORTCUTS[id]; + if (!spec) return; + if (id === 'toggle-tile-grid') this.toggleTileGrid(); + else if (id === 'remove-tile') this.removeFocusedTile(); + else if (spec.direction) this.focusTileInDirection(spec.direction); + }, + + /** + * Opens the grid, or closes it to the single view of the focused session. + * Opening brings back the grid this tab last left (decision 1: one step back + * after a selection outside it), else an open split as two tiles, else the + * active session as one tile. + */ + toggleTileGrid() { + if (this._tilesOwnTerminal()) { + this.closeTileGrid({ keepStored: true, reselect: true }); + return; + } + if (!this.canOpenTileGrid()) return; + const remembered = this._tileGridRemembered; + const ids = (remembered?.ids || []).filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id)); + if (ids.length > 0) { + this.openTileGrid(ids, { focusedId: remembered.focusedId, auto: true }); + return; + } + if (this.activeSessionId) this.openTileGrid([this.activeSessionId], { focusedId: this.activeSessionId }); + }, + + /** Alt+Shift+Arrows: a human selection of the tile in that direction. */ + focusTileInDirection(direction) { + const grid = this._tileGrid; + if (!grid?.open) return; + const id = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols); + if (id) this.selectSession(id); + }, + + /** Removes the focused tile (the session keeps running); a neighbour takes focus. */ + removeFocusedTile() { + const grid = this._tileGrid; + if (!grid?.open || !grid.focusedId) return; + this.removeTile(grid.focusedId, { refocus: true, auto: true }); + }, + // The single view after the grid closes: the session the grid was focused on, // replayed fresh (forceReload drops the stale snapshot and nulls // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index bb85244a..e26ac6dc 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -1,7 +1,7 @@ /** * @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real - * app around it: constants.js + app.js + terminal-ui.js + tile-grid.js in one - * context, a small fake DOM (just what the grid touches) and a fake + * app around it: constants.js + app.js + terminal-ui.js + terminal-split.js + + * tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake * TerminalTile that records what the grid asks of it. * * `makeGridApp()` returns an app instance with everything around the grid that @@ -57,7 +57,8 @@ export class FakeEl { remove() { if (!this.parentElement) return; const siblings = this.parentElement.children; - siblings.splice(siblings.indexOf(this), 1); + const i = siblings.indexOf(this); + if (i !== -1) siblings.splice(i, 1); this.parentElement = null; } setAttribute(k: string, v: string) { @@ -76,7 +77,15 @@ export class FakeEl { getBoundingClientRect() { return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 }; } - querySelector() { + /** `.class` selectors only: the first descendant carrying that class. */ + querySelector(sel: string): FakeEl | null { + if (!sel.startsWith('.') || /[\s[>:]/.test(sel)) return null; + const cls = sel.slice(1); + for (const child of this.children) { + if (child.classList.contains(cls)) return child; + const deeper = child.querySelector(sel); + if (deeper) return deeper; + } return null; } } @@ -117,6 +126,11 @@ section.id = 'tileGrid'; section.className = 'tile-grid'; main.appendChild(section); +/** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */ +export const bySelector = new Map(); +export const body = new FakeEl(); +/** `document.addEventListener`, so a test can find a listener the app installed. */ +export const documentAddEventListener = vi.fn(); export const localStore = new Map(); /** What the code under test deferred with requestIdleCallback; a test runs them. */ export const idleCallbacks: Array<() => void> = []; @@ -145,11 +159,13 @@ const context = vm.createContext({ location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, history: { replaceState: vi.fn(), state: null }, document: { - addEventListener: vi.fn(), + addEventListener: documentAddEventListener, documentElement: { dataset: {} }, createElement: () => new FakeEl(), getElementById: (id: string) => (id === 'tileGrid' ? section : null), - querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null), + body, + querySelector: (sel: string) => + sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : (bySelector.get(sel) ?? null), querySelectorAll: () => [], }, localStorage: { @@ -162,7 +178,8 @@ const context = vm.createContext({ MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, }); vm.runInContext( - `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` + + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` + + `${read('tile-grid.js')}\n` + 'globalThis.__CodemanApp = CodemanApp;', context ); @@ -237,4 +254,10 @@ export function resetGridHarness() { windowStub.innerWidth = 2400; section.children = []; main.className = 'main'; + // A split a test left open moved .terminal-wrap into its container. + main.children = []; + main.appendChild(wrap); + main.appendChild(section); + bySelector.clear(); + body.children = []; } diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts new file mode 100644 index 00000000..afd7e493 --- /dev/null +++ b/test/tile-grid-shortcuts.test.ts @@ -0,0 +1,331 @@ +/** + * @fileoverview The tile grid's chords: registry entries, when they apply, and + * that a chord which applies never reaches a PTY. + * + * Defaults (all rebindable in App Settings, Shortcuts): Ctrl+Shift+G toggles + * the grid, Alt+Shift+Arrows move focus between tiles, Remove Focused Tile is + * unbound. The toggle applies wherever a grid could open; the focus and remove + * chords only while the grid is open, so outside it Alt+Shift+Arrows reach the + * terminal untouched. The capture-phase handler (app.js) dispatches a chord + * that applies; its preventDefault() does not stop xterm, so every xterm key + * handler (the main terminal's and TerminalTile's) returns false for it too, + * for every event type and before the Shift+Enter gate (which would otherwise + * send S-Enter for Alt+Shift+Enter style chords). + * + * Real code: the grid harness (test/mocks/tile-grid-vm.ts) for the registry, + * the capture handler and the actions; a second `vm` context with the real + * TerminalTile for its key handler; the main terminal's handler, which needs a + * real xterm, is pinned at the source. Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + documentAddEventListener, + makeGridApp, + resetGridHarness, + windowStub as gridWindow, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; +const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); + +const chord = (overrides: Record) => ({ + type: 'keydown', + key: '', + code: '', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + preventDefault: vi.fn(), + target: { closest: () => null }, + ...overrides, +}); +const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true }; +const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true }; + +beforeEach(() => { + resetGridHarness(); +}); + +describe('registry', () => { + it('ships the tile chords, rebindable, with Remove Focused Tile unbound', () => { + const app = makeGridApp(IDS); + const byId = Object.fromEntries(app.getShortcutRegistry().map((s: { id: string }) => [s.id, s])) as Record< + string, + { bindings: Array>; group: string } + >; + expect(byId['toggle-tile-grid'].bindings).toEqual([{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }]); + for (const dir of ['Left', 'Right', 'Up', 'Down']) { + expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([ + { modifiers: ['alt', 'shift'], key: `Arrow${dir}` }, + ]); + } + expect(byId['remove-tile'].bindings).toEqual([]); + expect(byId['toggle-tile-grid'].group).toBe('Tiles'); + }); +}); + +describe('when a chord applies', () => { + it('the toggle applies wherever a grid could open, and while one is open', () => { + const app = makeGridApp(IDS); + expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid'); + app.openTileGrid(IDS); + expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid'); + }); + + it('the toggle does not apply in a narrow window or a solo window', () => { + const app = makeGridApp(IDS); + gridWindow.innerWidth = 1000; + expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull(); + gridWindow.innerWidth = 2400; + app.isSoloWindow = true; + expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull(); + }); + + it('the focus chords apply only while the grid is open', () => { + const app = makeGridApp(IDS); + expect(app.tileShortcutFor(chord(RIGHT))).toBeNull(); + app.openTileGrid(IDS); + expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right'); + }); + + it('plain typing and unrelated chords never match', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + expect(app.tileShortcutFor(chord({ key: 'g', code: 'KeyG' }))).toBeNull(); + expect(app.tileShortcutFor(chord({ key: 'ArrowRight', altKey: true }))).toBeNull(); + expect(app.tileShortcutFor(chord({ key: 'G', code: 'KeyG', ctrlKey: true }))).toBeNull(); + }); + + it('honours a disable and a rebind from App Settings', () => { + const app = makeGridApp(IDS); + app.loadAppSettingsFromStorage = () => ({ + shortcutOverrides: { + 'toggle-tile-grid': { disabled: true }, + 'focus-tile-right': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'l', code: 'KeyL' }] }, + }, + }); + expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull(); + app.openTileGrid(IDS); + expect(app.tileShortcutFor(chord(RIGHT))).toBeNull(); + expect(app.tileShortcutFor(chord({ key: 'l', code: 'KeyL', ctrlKey: true, altKey: true }))).toBe( + 'focus-tile-right' + ); + }); +}); + +describe('the capture-phase handler', () => { + function handlerFor(app: GridApp) { + app.$ = () => null; + app.setupColorPicker = vi.fn(); + const before = (documentAddEventListener.mock.calls as unknown[]).length; + app.setupEventListeners(); + const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before); + const keydown = added.find(([type, , capture]) => type === 'keydown' && capture === true); + if (!keydown) throw new Error('no capture-phase keydown listener'); + return keydown[1]; + } + + it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + const onKeydown = handlerFor(app); + const open = chord(TOGGLE); + onKeydown(open); + expect(open.preventDefault).toHaveBeenCalled(); + expect(app._tileGrid.ids).toEqual(['s-a']); + + onKeydown(chord(TOGGLE)); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app.selectSession).toHaveBeenCalledWith('s-a', { forceReload: true, auto: true }); + }); + + it('Alt+Shift+Right moves focus to the tile on the right, as a human selection', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.markIdleAlertSeen.mockClear(); + // The real selectSession routes a tiled id to the tile branch. + delete app.selectSession; + const onKeydown = handlerFor(app); + const e = chord(RIGHT); + onKeydown(e); + expect(e.preventDefault).toHaveBeenCalled(); + expect(app.activeSessionId).toBe('s-b'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b'); + }); + + it('outside the grid Alt+Shift+Right is left alone for the terminal', () => { + const app = makeGridApp(IDS); + const onKeydown = handlerFor(app); + const e = chord(RIGHT); + onKeydown(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(app._tilesOwnTerminal()).toBe(false); + }); +}); + +describe('the actions', () => { + it('the toggle brings back the grid this tab last left, focus included', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + app.openTileGrid(IDS, { focusedId: 's-c' }); + app.closeTileGrid({ reselect: false }); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(IDS); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('a remembered tile whose session is gone is not brought back', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.closeTileGrid({ reselect: false }); + app.sessions.delete('s-b'); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + }); + + it('with every remembered session gone, it opens on the active session instead', () => { + const app = makeGridApp(IDS); + app.openTileGrid(['s-b', 's-c']); + app.closeTileGrid({ reselect: false }); + app.sessions.delete('s-b'); + app.sessions.delete('s-c'); + app.activeSessionId = 's-a'; + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-a']); + }); + + it('Remove Focused Tile removes it (the session keeps running) and a neighbour takes focus', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS, { focusedId: 's-b' }); + app._apiDelete = vi.fn(); + app.removeFocusedTile(); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + expect(app.sessions.has('s-b')).toBe(true); + expect(app._apiDelete).not.toHaveBeenCalled(); + }); +}); + +describe('xterm key handlers swallow a chord that applies', () => { + it("the main terminal's handler checks the chord before the Shift+Enter gate, for every event type", () => { + // That handler lives in initTerminal's closure over a real xterm, so the + // rule is pinned at the source, as terminal-copy-clean.test.ts does. + const src = read('terminal-ui.js'); + const handler = src.slice(src.indexOf('this.terminal.attachCustomKeyEventHandler((ev) => {')); + const gate = handler.indexOf('if (this.tileShortcutFor?.(ev)) return false;'); + const enter = handler.indexOf("if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {"); + expect(gate).toBeGreaterThan(0); + expect(gate).toBeLessThan(enter); + }); + + describe("TerminalTile's handler (real tile, real tileShortcutFor)", () => { + class FakeTerminal { + static last: FakeTerminal; + keyHandler: ((ev: Record) => boolean) | null = null; + options: Record; + cols = 80; + rows = 24; + buffer = { active: { type: 'normal', viewportY: 0, length: 24 } }; + textarea = { addEventListener() {}, removeEventListener() {} }; + constructor(options: Record) { + this.options = options; + FakeTerminal.last = this; + } + loadAddon() {} + open() {} + onData() {} + attachCustomKeyEventHandler(fn: (ev: Record) => boolean) { + this.keyHandler = fn; + } + registerLinkProvider() {} + write() {} + clear() {} + dispose() {} + } + const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { terminalBuffer: '' } }) })); + const windowStub: Record = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + CodemanBase: { base: '' }, + innerWidth: 2400, + }; + const context = vm.createContext({ + console: { ...console, log: vi.fn(), debug: vi.fn() }, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: class { + static OPEN = 1; + send() {} + close() {} + }, + Terminal: FakeTerminal, + FitAddon: { + FitAddon: class { + fit() {} + proposeDimensions() { + return { cols: 80, rows: 24 }; + } + }, + }, + fetch: fetchMock, + location: { protocol: 'http:', host: 'codeman.test' }, + document: { addEventListener: vi.fn(), documentElement: { dataset: {} } }, + localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: windowStub, + MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` + + `${read('terminal-split.js')}\n${read('tile-grid.js')}\nglobalThis.__CodemanApp = CodemanApp;`, + context + ); + const App = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp; + const TerminalTile = windowStub.TerminalTile as new ( + id: string, + mount: unknown, + opts: object + ) => { connect(): Promise; destroy(): void }; + + async function tileHandler(gridOpen: boolean) { + const app = Object.create(App.prototype) as GridApp; + app.loadAppSettingsFromStorage = () => ({}); + app.isSoloWindow = false; + app._tileGrid = { open: gridOpen }; + windowStub.app = app; + const tile = new TerminalTile('s1', { addEventListener() {}, removeEventListener() {} }, { mode: 'claude' }); + await tile.connect(); + const handler = FakeTerminal.last.keyHandler!; + tile.destroy(); + return handler; + } + + it.each(['keydown', 'keypress', 'keyup'])( + 'returns false for Alt+Shift+Right (%s) while the grid is open', + async (type) => { + const handler = await tileHandler(true); + expect(handler({ ...chord(RIGHT), type })).toBe(false); + } + ); + + it('lets Alt+Shift+Right through to the PTY when no grid is open', async () => { + const handler = await tileHandler(false); + expect(handler(chord(RIGHT))).toBe(true); + }); + + it('returns false for the toggle chord', async () => { + const handler = await tileHandler(true); + expect(handler(chord(TOGGLE))).toBe(false); + }); + }); +}); diff --git a/test/tile-grid-split-coexistence.test.ts b/test/tile-grid-split-coexistence.test.ts new file mode 100644 index 00000000..2a82a5b6 --- /dev/null +++ b/test/tile-grid-split-coexistence.test.ts @@ -0,0 +1,124 @@ +/** + * @fileoverview The tile grid and the split pane are never open together. + * + * Both run on TerminalTile, and the split's wrappers (`selectSession`, + * `_onSessionDeleted` in terminal-split.js) key on `this._splitPane`, so they + * would fight the grid over the same terminal area if both were ever up: + * + * - opening the grid while a split is open closes the split first and seeds + * the grid with both of its sessions, Pane A focused and Pane B beside it; + * - while the grid is open, `openSplitPicker` and `openSplitPane` refuse, and + * the Split button says so (`aria-disabled`, a title); + * - closing the grid never reopens a split; + * - the split's wrappers stay inert while the grid is open. + * + * Real code: constants.js + app.js + terminal-ui.js + terminal-split.js + + * tile-grid.js (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + FakeTile, + body, + bySelector, + main, + makeGridApp, + resetGridHarness, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; + +function makeSplitButton() { + const btn = new FakeEl(); + btn.className = 'btn-icon-header btn-split'; + bySelector.set('.btn-split', btn); + return btn; +} + +/** s-a in the main pane, s-b in Pane B (the split's real openSplitPane). */ +function openSplit(app: GridApp) { + app.openSplitPane('s-b'); + expect(app._splitSessionId).toBe('s-b'); + // closeSplitPane finds its container by selector. + const container = main.querySelector('.terminal-split-container'); + if (container) bySelector.set('.terminal-split-container', container); + return FakeTile.all.at(-1) as FakeTile; +} + +beforeEach(() => { + resetGridHarness(); +}); + +describe('opening the grid over an open split', () => { + it('closes the split and seeds the grid with both of its sessions, Pane A focused', () => { + const app = makeGridApp(IDS); + const paneB = openSplit(app); + + app.toggleTileGrid(); + + expect(paneB.destroy).toHaveBeenCalledTimes(1); + expect(app._splitPane).toBeNull(); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + expect(app.activeSessionId).toBe('s-a'); + // Pane A is about to park: no closing resize for it. + expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only + }); + + it('an explicit open over a split keeps both split sessions first', () => { + const app = makeGridApp(IDS); + openSplit(app); + app.openTileGrid(['s-c']); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); + expect(app.activeSessionId).toBe('s-a'); + }); +}); + +describe('while the grid is open', () => { + it('the split refuses to open, from the picker or directly', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + const before = FakeTile.all.length; + + app.openSplitPicker({ stopPropagation: vi.fn() }); + app.openSplitPane('s-b'); + + expect(app._splitPane ?? null).toBeNull(); + expect(FakeTile.all.length).toBe(before); + // The picker itself never opened either. + expect(body.children).toHaveLength(0); + expect(app._splitPickerDismissHandlers ?? null).toBeNull(); + }); + + it('the Split button is marked unavailable, and back to normal once the grid closes', () => { + const app = makeGridApp(IDS); + const btn = makeSplitButton(); + app.openTileGrid(IDS); + expect(btn.getAttribute('aria-disabled')).toBe('true'); + expect(btn.classList.contains('btn-split--blocked')).toBe(true); + + app.closeTileGrid({ reselect: false }); + expect(btn.getAttribute('aria-disabled')).toBe('false'); + expect(btn.classList.contains('btn-split--blocked')).toBe(false); + expect(btn.getAttribute('aria-pressed')).toBe('false'); + }); + + it("the split's wrappers do nothing (no split to close or promote)", () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.closeSplitPane = vi.fn(); + app._onSessionDeleted({ id: 's-c' }); + expect(app.closeSplitPane).not.toHaveBeenCalled(); + }); +}); + +describe('closing the grid', () => { + it('never reopens the split it replaced', () => { + const app = makeGridApp(IDS); + openSplit(app); + app.toggleTileGrid(); + app.closeTileGrid({ reselect: false }); + expect(app._splitPane).toBeNull(); + expect(app._tilesOwnTerminal()).toBe(false); + }); +});