diff --git a/src/web/public/app.js b/src/web/public/app.js index 32774db0..5965a181 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -567,6 +567,15 @@ const DEFAULT_SHORTCUTS = [ bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }], action: 'focusTileDown', }, + { + id: 'zoom-tile', + group: 'Tiles', + label: 'Zoom Focused Tile', + // Toggles. Alt+Shift+Enter would otherwise reach the CLI as ESC CR (Alt+Enter + // is a newline in Claude Code); it is swallowed only while the grid is open. + bindings: [{ modifiers: ['alt', 'shift'], key: 'Enter' }], + action: 'zoomTile', + }, { id: 'remove-tile', group: 'Tiles', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 958c8732..3aec9677 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19486,6 +19486,17 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle outline: none; } +/* Zoom (tmux-style): one tile fills the grid; the others stay connected but + hidden, so they measure nothing and send no resize. */ +.tile-grid.tile-grid--zoomed .tile:not(.tile--zoomed) { + display: none; +} + +.tile-btn.tile-zoom[aria-pressed='true'] { + opacity: 1; + color: var(--accent, #4a9eff); +} + /* A permission prompt or question, visible across the room. Only the border colour and a box-shadow animate: anything that moves layout would resize the tile's PTY every frame. */ diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index c76509dd..3db67e4a 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -35,6 +35,7 @@ const TILE_SHORTCUTS = { 'focus-tile-up': { needsOpen: true, direction: 'up' }, 'focus-tile-down': { needsOpen: true, direction: 'down' }, 'remove-tile': { needsOpen: true }, + 'zoom-tile': { needsOpen: true }, }; /** The grid's state. `has(id)` answers only while it is open. */ @@ -46,6 +47,11 @@ class TileGridModel { // id -> { tile: TerminalTile, el: HTMLElement } this.tiles = new Map(); this.focusedId = null; + // The tile filling the grid (tmux zoom), or null. `autoZoom`: zoomed by the + // grid itself because the window cannot fit the tiles; it follows focus and + // lifts once the window fits again. + this.zoomedId = null; + this.autoZoom = false; this.cols = 0; this.rows = 0; this.queue = null; @@ -232,11 +238,14 @@ Object.assign(CodemanApp.prototype, { grid.tiles.clear(); grid.ids = []; grid.focusedId = null; + grid.zoomedId = null; + grid.autoZoom = false; document.querySelector('.main')?.classList.remove('tiles-active'); const section = document.getElementById('tileGrid'); if (section) { section.style.gridTemplateColumns = ''; section.style.gridTemplateRows = ''; + section.classList.remove('tile-grid--zoomed'); } // As _redock does: the tiles sized these PTYs, so the main terminal's // record of the last size it sent no longer describes them. @@ -303,6 +312,7 @@ Object.assign(CodemanApp.prototype, { if (!spec) return; if (id === 'toggle-tile-grid') this.toggleTileGrid(); else if (id === 'remove-tile') this.removeFocusedTile(); + else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId); else if (spec.direction) this.focusTileInDirection(spec.direction); }, @@ -372,6 +382,8 @@ Object.assign(CodemanApp.prototype, { if (!grid?.open || grid.tiles.has(sessionId)) return false; if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false; if (!this._mountTile(sessionId)) return false; + // A tile added while one is zoomed by hand is meant to be seen. + if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; this._applyTileLayout(); this._connectTile(sessionId); this._scheduleTileGridRefit(); @@ -395,18 +407,20 @@ Object.assign(CodemanApp.prototype, { } const wasFocused = grid.focusedId === sessionId; const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId); + // The zoomed tile leaving restores the grid (an automatic zoom moves to + // the neighbour with focus, below). + if (grid.zoomedId === sessionId) grid.zoomedId = grid.autoZoom && refocus ? neighbor : null; grid.queue?.drop(entry.tile); entry.tile.destroy(); entry.el.remove(); grid.tiles.delete(sessionId); grid.ids.splice(grid.ids.indexOf(sessionId), 1); + // Before the layout, which may zoom the focused tile on a small window. + if (wasFocused) grid.focusedId = null; this._applyTileLayout(); this._scheduleTileGridRefit(); this.renderSessionTabs?.(); - if (wasFocused) { - grid.focusedId = null; - if (refocus && neighbor) this._selectTiledSession(neighbor, { auto }); - } + if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto }); return true; }, @@ -444,7 +458,15 @@ Object.assign(CodemanApp.prototype, { boundedLoad: true, onExit: (code) => this._onTileExit(sessionId, tile, code), }); - grid.tiles.set(sessionId, { tile, el, header: header.el, dot: header.dot, name: header.name, renaming: false }); + grid.tiles.set(sessionId, { + tile, + el, + header: header.el, + dot: header.dot, + name: header.name, + zoomBtn: header.zoomBtn, + renaming: false, + }); grid.ids.push(sessionId); this._renderTileHeader(sessionId); return true; @@ -487,8 +509,11 @@ Object.assign(CodemanApp.prototype, { }); return b; }; + const zoomBtn = button('tile-zoom', 'Zoom this tile', '\u2922', () => this.zoomTile(sessionId)); + zoomBtn.setAttribute('aria-pressed', 'false'); actions.append( button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)), + zoomBtn, // Removes the tile ONLY: the session keeps running. Killing it stays // behind the menu's Close session and its confirm. button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => @@ -496,7 +521,7 @@ Object.assign(CodemanApp.prototype, { ) ); el.append(dot, name, actions); - return { el, dot, name }; + return { el, dot, name, zoomBtn }; }, /** @@ -604,15 +629,64 @@ Object.assign(CodemanApp.prototype, { const grid = this._tileGrid; const section = this._tileGridSection(); const rect = section.getBoundingClientRect?.() || { width: 0, height: 0 }; - const { cols, rows } = window.CodemanTileGrid.computeTileLayout({ + const { cols, rows, fits } = window.CodemanTileGrid.computeTileLayout({ count: grid.ids.length, width: rect.width || window.innerWidth, height: rect.height || window.innerHeight, }); grid.cols = cols; grid.rows = rows; - section.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; - section.style.gridTemplateRows = `repeat(${rows}, minmax(0, 1fr))`; + // A window too small for the tiles' minimum size shows the focused tile + // alone, with a hint; once it fits again the grid comes back. A zoom the + // user chose is theirs: it stays until they lift it. + if (!fits && !grid.zoomedId && grid.focusedId) { + grid.zoomedId = grid.focusedId; + grid.autoZoom = true; + this.showToast?.(`The window is too small for ${grid.ids.length} tiles: showing the focused one`, 'info'); + } else if (fits && grid.autoZoom) { + grid.zoomedId = null; + grid.autoZoom = false; + } + const zoomed = grid.zoomedId && grid.tiles.has(grid.zoomedId) ? grid.zoomedId : null; + section.classList.toggle('tile-grid--zoomed', !!zoomed); + for (const [id, entry] of grid.tiles) { + entry.el.classList.toggle('tile--zoomed', id === zoomed); + const zoomBtn = entry.zoomBtn; + if (zoomBtn) { + const on = id === zoomed; + const label = on ? 'Restore the grid' : 'Zoom this tile'; + zoomBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + if (zoomBtn.title !== label) { + zoomBtn.title = label; + zoomBtn.setAttribute('aria-label', label); + } + } + } + // Zoomed: one cell; the other tiles stay connected but hidden (CSS), so + // they measure nothing and send no resize. + section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : `repeat(${cols}, minmax(0, 1fr))`; + section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : `repeat(${rows}, minmax(0, 1fr))`; + }, + + /** + * Zooms a tile to fill the grid, like tmux zoom, or restores the grid when it + * is the one zoomed. A tile that is not focused is focused first (a human + * selection: the user asked to look at it). Every tile is refitted after, the + * shown ones to their new size and the zoomed one to the whole grid. + */ + zoomTile(sessionId) { + const grid = this._tileGrid; + if (!grid?.open || !grid.tiles.has(sessionId)) return; + if (grid.zoomedId === sessionId) { + grid.zoomedId = null; + grid.autoZoom = false; + } else { + if (grid.focusedId !== sessionId) this.selectSession(sessionId); + grid.zoomedId = sessionId; + grid.autoZoom = false; + } + this._applyTileLayout(); + this._scheduleTileGridRefit(); }, // Refits every tile once the grid area has settled: one xterm resize and one @@ -667,6 +741,14 @@ Object.assign(CodemanApp.prototype, { this._hideWebviewLayer?.(); this.activeSessionId = sessionId; grid.focusedId = sessionId; + // Moving focus off a zoomed tile restores the grid, as selecting another + // pane does in tmux. An automatic zoom (the window cannot fit the tiles) + // follows focus instead: there is no grid to restore. + if (grid.zoomedId && grid.zoomedId !== sessionId) { + grid.zoomedId = grid.autoZoom ? sessionId : null; + this._applyTileLayout(); + this._scheduleTileGridRefit(); + } this._activateFileBrowserSession?.(sessionId); try { localStorage.setItem('codeman-active-session', sessionId); diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index afd7e493..591b9991 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -323,6 +323,13 @@ describe('xterm key handlers swallow a chord that applies', () => { expect(handler(chord(RIGHT))).toBe(true); }); + it('Alt+Shift+Enter (zoom) never becomes a Shift+Enter newline in the tile session', async () => { + const handler = await tileHandler(true); + fetchMock.mockClear(); + expect(handler(chord({ key: 'Enter', code: 'Enter', altKey: true, shiftKey: true }))).toBe(false); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/send-key'))).toBe(false); + }); + 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-zoom.test.ts b/test/tile-grid-zoom.test.ts new file mode 100644 index 00000000..ec60ec8b --- /dev/null +++ b/test/tile-grid-zoom.test.ts @@ -0,0 +1,196 @@ +/** + * @fileoverview Zooming a tile (tmux zoom). + * + * `⤢` (or Alt+Shift+Enter) makes one tile fill the grid; the others stay + * connected but hidden, so they measure nothing and send no resize. Pressing it + * again restores the grid, and every tile is refitted (the hidden ones have a + * stale size). Moving focus to another tile restores the grid, as selecting a + * pane does in tmux; removing the zoomed tile does too. + * + * When the window cannot fit the tiles' minimum size, the grid zooms the + * focused tile by itself, with a hint; that automatic zoom follows focus and + * lifts once the window fits again. A zoom the user chose is left alone. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeEl, FakeTile, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c', 's-d']; +const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; +const zoomButton = (id: string) => + tileEl(id).children[0].children[2].children.find((b) => b.className.includes('tile-zoom')) as FakeEl; + +function openGrid(ids = IDS, focus = ids[0]): GridApp { + const app = makeGridApp(ids); + app.openTileGrid(ids, { focusedId: focus }); + app.markIdleAlertSeen.mockClear(); + // selectSession is real: a tiled id goes to the tile branch. + return app; +} + +/** Runs the trailing refit (and its layout pass). */ +function settleRefit() { + vi.advanceTimersByTime(200); +} + +beforeEach(() => { + resetGridHarness(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + delete (section as unknown as Record).getBoundingClientRect; +}); + +describe('zoom and restore', () => { + it('⤢ fills the grid with that tile; the others are hidden, not disconnected', () => { + const app = openGrid(); + zoomButton('s-a').dispatch('click', { stopPropagation: vi.fn() }); + + expect(app._tileGrid.zoomedId).toBe('s-a'); + expect(section.classList.contains('tile-grid--zoomed')).toBe(true); + expect(tileEl('s-a').classList.contains('tile--zoomed')).toBe(true); + expect(tileEl('s-b').classList.contains('tile--zoomed')).toBe(false); + expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr)'); + expect(section.style.gridTemplateRows).toBe('minmax(0, 1fr)'); + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true); + expect(zoomButton('s-a').getAttribute('aria-pressed')).toBe('true'); + }); + + it('pressing it again restores the grid and refits EVERY tile', () => { + const app = openGrid(); + app.zoomTile('s-a'); + settleRefit(); + for (const t of FakeTile.all) t.fit.mockClear(); + app.zoomTile('s-a'); + settleRefit(); + + expect(app._tileGrid.zoomedId).toBeNull(); + expect(section.classList.contains('tile-grid--zoomed')).toBe(false); + expect(section.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))'); + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 1)).toBe(true); + expect(zoomButton('s-a').getAttribute('aria-pressed')).toBe('false'); + }); + + it('zooming a tile that is not focused focuses it first, as a human selection', () => { + const app = openGrid(); + app.zoomTile('s-c'); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c'); + expect(app._tileGrid.zoomedId).toBe('s-c'); + }); + + it('Alt+Shift+Enter toggles the focused tile, only while the grid is open', () => { + const app = openGrid(); + const ev = { key: 'Enter', code: 'Enter', altKey: true, shiftKey: true, ctrlKey: false, metaKey: false }; + expect(app.tileShortcutFor(ev)).toBe('zoom-tile'); + app.runTileShortcut('zoom-tile'); + expect(app._tileGrid.zoomedId).toBe('s-a'); + app.runTileShortcut('zoom-tile'); + expect(app._tileGrid.zoomedId).toBeNull(); + + const closed = makeGridApp(IDS); + expect(closed.tileShortcutFor(ev)).toBeNull(); + }); + + it('the registry ships Alt+Shift+Enter for it', () => { + const app = openGrid(); + const zoom = app.getShortcutRegistry().find((s: { id: string }) => s.id === 'zoom-tile'); + expect(zoom.bindings).toEqual([{ modifiers: ['alt', 'shift'], key: 'Enter' }]); + }); +}); + +describe('what restores the grid', () => { + it('moving focus to another tile (tmux select-pane)', () => { + const app = openGrid(); + app.zoomTile('s-a'); + app.selectSession('s-b'); + expect(app._tileGrid.zoomedId).toBeNull(); + expect(section.classList.contains('tile-grid--zoomed')).toBe(false); + }); + + it('removing the zoomed tile; its neighbour takes focus in the grid', () => { + const app = openGrid(); + app.zoomTile('s-a'); + app.removeTile('s-a'); + expect(app._tileGrid.zoomedId).toBeNull(); + expect(app.activeSessionId).toBe('s-b'); + }); + + it('removing the zoomed tile without moving focus (a close from this tab) leaves no zoom behind', () => { + const app = openGrid(); + app.zoomTile('s-a'); + app.removeTile('s-a', { refocus: false }); + expect(app._tileGrid.zoomedId).toBeNull(); + expect(section.classList.contains('tile-grid--zoomed')).toBe(false); + }); + + it('adding a tile while one is zoomed by hand', () => { + const app = makeGridApp(IDS); + app.openTileGrid(['s-a', 's-b', 's-c']); + app.zoomTile('s-a'); + expect(app.addTile('s-d')).toBe(true); + expect(app._tileGrid.zoomedId).toBeNull(); + }); + + it('closing the grid forgets the zoom', () => { + const app = openGrid(); + app.selectSession = vi.fn(); + app.zoomTile('s-a'); + app.closeTileGrid({ reselect: false }); + expect(app._tileGrid.zoomedId).toBeNull(); + expect(section.classList.contains('tile-grid--zoomed')).toBe(false); + }); +}); + +describe('a window too small for the tiles', () => { + const small = () => ({ width: 900, height: 400, top: 0, left: 0, right: 900, bottom: 400 }); + const large = () => ({ width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 }); + + it('zooms the focused tile with a hint, and the grid comes back once it fits', () => { + const app = openGrid(); + (section as unknown as { getBoundingClientRect: () => object }).getBoundingClientRect = small; + app._scheduleTileGridRefit(); + settleRefit(); + expect(app._tileGrid.zoomedId).toBe('s-a'); + expect(app._tileGrid.autoZoom).toBe(true); + expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('too small'), 'info'); + + (section as unknown as { getBoundingClientRect: () => object }).getBoundingClientRect = large; + app._scheduleTileGridRefit(); + settleRefit(); + expect(app._tileGrid.zoomedId).toBeNull(); + expect(app._tileGrid.autoZoom).toBe(false); + }); + + it('the automatic zoom follows focus instead of restoring a grid that does not fit', () => { + const app = openGrid(); + (section as unknown as { getBoundingClientRect: () => object }).getBoundingClientRect = small; + app._scheduleTileGridRefit(); + settleRefit(); + app.selectSession('s-c'); + expect(app._tileGrid.zoomedId).toBe('s-c'); + expect(tileEl('s-c').classList.contains('tile--zoomed')).toBe(true); + // Moved, not lifted and re-applied: the hint is not repeated. + expect(app.showToast).toHaveBeenCalledTimes(1); + }); + + it('a zoom the user chose is not lifted when the window fits', () => { + const app = openGrid(); + app.zoomTile('s-b'); + app._scheduleTileGridRefit(); + settleRefit(); + expect(app._tileGrid.zoomedId).toBe('s-b'); + }); +}); + +describe('styles', () => { + it('hides every tile but the zoomed one', () => { + const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8'); + expect(css).toMatch(/\.tile-grid\.tile-grid--zoomed \.tile:not\(\.tile--zoomed\)\s*\{\s*display: none;/); + }); +});