mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(tiles): zoom a tile (button, Alt+Shift+Enter), and auto-zoom when the window is too small
⤢ in a tile's header, or Alt+Shift+Enter (registry entry zoom-tile, applies only while the grid is open and is swallowed before the Shift+Enter newline gate), makes that tile fill the grid like tmux zoom. The other tiles stay connected but hidden, so they measure nothing and send no resize; pressing it again restores the grid and refits every tile, since the hidden ones have a stale size. Zooming a tile that is not focused focuses it first (a human selection). As in tmux, moving focus to another tile restores the grid, and so does removing the zoomed tile or adding one while a tile is zoomed by hand. When the grid area cannot fit the tiles' minimum size, the grid zooms the focused tile itself with a hint; that zoom follows focus and lifts once the window fits again. A zoom the user chose is left alone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -567,6 +567,15 @@ const DEFAULT_SHORTCUTS = [
|
|||||||
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
|
||||||
action: 'focusTileDown',
|
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',
|
id: 'remove-tile',
|
||||||
group: 'Tiles',
|
group: 'Tiles',
|
||||||
|
|||||||
@@ -19486,6 +19486,17 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
|||||||
outline: none;
|
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
|
/* A permission prompt or question, visible across the room. Only the border
|
||||||
colour and a box-shadow animate: anything that moves layout would resize
|
colour and a box-shadow animate: anything that moves layout would resize
|
||||||
the tile's PTY every frame. */
|
the tile's PTY every frame. */
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const TILE_SHORTCUTS = {
|
|||||||
'focus-tile-up': { needsOpen: true, direction: 'up' },
|
'focus-tile-up': { needsOpen: true, direction: 'up' },
|
||||||
'focus-tile-down': { needsOpen: true, direction: 'down' },
|
'focus-tile-down': { needsOpen: true, direction: 'down' },
|
||||||
'remove-tile': { needsOpen: true },
|
'remove-tile': { needsOpen: true },
|
||||||
|
'zoom-tile': { needsOpen: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The grid's state. `has(id)` answers only while it is open. */
|
/** The grid's state. `has(id)` answers only while it is open. */
|
||||||
@@ -46,6 +47,11 @@ class TileGridModel {
|
|||||||
// id -> { tile: TerminalTile, el: HTMLElement }
|
// id -> { tile: TerminalTile, el: HTMLElement }
|
||||||
this.tiles = new Map();
|
this.tiles = new Map();
|
||||||
this.focusedId = null;
|
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.cols = 0;
|
||||||
this.rows = 0;
|
this.rows = 0;
|
||||||
this.queue = null;
|
this.queue = null;
|
||||||
@@ -232,11 +238,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
grid.tiles.clear();
|
grid.tiles.clear();
|
||||||
grid.ids = [];
|
grid.ids = [];
|
||||||
grid.focusedId = null;
|
grid.focusedId = null;
|
||||||
|
grid.zoomedId = null;
|
||||||
|
grid.autoZoom = false;
|
||||||
document.querySelector('.main')?.classList.remove('tiles-active');
|
document.querySelector('.main')?.classList.remove('tiles-active');
|
||||||
const section = document.getElementById('tileGrid');
|
const section = document.getElementById('tileGrid');
|
||||||
if (section) {
|
if (section) {
|
||||||
section.style.gridTemplateColumns = '';
|
section.style.gridTemplateColumns = '';
|
||||||
section.style.gridTemplateRows = '';
|
section.style.gridTemplateRows = '';
|
||||||
|
section.classList.remove('tile-grid--zoomed');
|
||||||
}
|
}
|
||||||
// As _redock does: the tiles sized these PTYs, so the main terminal's
|
// As _redock does: the tiles sized these PTYs, so the main terminal's
|
||||||
// record of the last size it sent no longer describes them.
|
// record of the last size it sent no longer describes them.
|
||||||
@@ -303,6 +312,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!spec) return;
|
if (!spec) return;
|
||||||
if (id === 'toggle-tile-grid') this.toggleTileGrid();
|
if (id === 'toggle-tile-grid') this.toggleTileGrid();
|
||||||
else if (id === 'remove-tile') this.removeFocusedTile();
|
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);
|
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?.open || grid.tiles.has(sessionId)) return false;
|
||||||
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
||||||
if (!this._mountTile(sessionId)) 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._applyTileLayout();
|
||||||
this._connectTile(sessionId);
|
this._connectTile(sessionId);
|
||||||
this._scheduleTileGridRefit();
|
this._scheduleTileGridRefit();
|
||||||
@@ -395,18 +407,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
const wasFocused = grid.focusedId === sessionId;
|
const wasFocused = grid.focusedId === sessionId;
|
||||||
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, 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);
|
grid.queue?.drop(entry.tile);
|
||||||
entry.tile.destroy();
|
entry.tile.destroy();
|
||||||
entry.el.remove();
|
entry.el.remove();
|
||||||
grid.tiles.delete(sessionId);
|
grid.tiles.delete(sessionId);
|
||||||
grid.ids.splice(grid.ids.indexOf(sessionId), 1);
|
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._applyTileLayout();
|
||||||
this._scheduleTileGridRefit();
|
this._scheduleTileGridRefit();
|
||||||
this.renderSessionTabs?.();
|
this.renderSessionTabs?.();
|
||||||
if (wasFocused) {
|
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto });
|
||||||
grid.focusedId = null;
|
|
||||||
if (refocus && neighbor) this._selectTiledSession(neighbor, { auto });
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -444,7 +458,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
boundedLoad: true,
|
boundedLoad: true,
|
||||||
onExit: (code) => this._onTileExit(sessionId, tile, code),
|
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);
|
grid.ids.push(sessionId);
|
||||||
this._renderTileHeader(sessionId);
|
this._renderTileHeader(sessionId);
|
||||||
return true;
|
return true;
|
||||||
@@ -487,8 +509,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
return b;
|
return b;
|
||||||
};
|
};
|
||||||
|
const zoomBtn = button('tile-zoom', 'Zoom this tile', '\u2922', () => this.zoomTile(sessionId));
|
||||||
|
zoomBtn.setAttribute('aria-pressed', 'false');
|
||||||
actions.append(
|
actions.append(
|
||||||
button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)),
|
button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)),
|
||||||
|
zoomBtn,
|
||||||
// Removes the tile ONLY: the session keeps running. Killing it stays
|
// Removes the tile ONLY: the session keeps running. Killing it stays
|
||||||
// behind the menu's Close session and its confirm.
|
// behind the menu's Close session and its confirm.
|
||||||
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () =>
|
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () =>
|
||||||
@@ -496,7 +521,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
el.append(dot, name, actions);
|
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 grid = this._tileGrid;
|
||||||
const section = this._tileGridSection();
|
const section = this._tileGridSection();
|
||||||
const rect = section.getBoundingClientRect?.() || { width: 0, height: 0 };
|
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,
|
count: grid.ids.length,
|
||||||
width: rect.width || window.innerWidth,
|
width: rect.width || window.innerWidth,
|
||||||
height: rect.height || window.innerHeight,
|
height: rect.height || window.innerHeight,
|
||||||
});
|
});
|
||||||
grid.cols = cols;
|
grid.cols = cols;
|
||||||
grid.rows = rows;
|
grid.rows = rows;
|
||||||
section.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
|
// A window too small for the tiles' minimum size shows the focused tile
|
||||||
section.style.gridTemplateRows = `repeat(${rows}, minmax(0, 1fr))`;
|
// 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
|
// 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._hideWebviewLayer?.();
|
||||||
this.activeSessionId = sessionId;
|
this.activeSessionId = sessionId;
|
||||||
grid.focusedId = 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);
|
this._activateFileBrowserSession?.(sessionId);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('codeman-active-session', sessionId);
|
localStorage.setItem('codeman-active-session', sessionId);
|
||||||
|
|||||||
@@ -323,6 +323,13 @@ describe('xterm key handlers swallow a chord that applies', () => {
|
|||||||
expect(handler(chord(RIGHT))).toBe(true);
|
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 () => {
|
it('returns false for the toggle chord', async () => {
|
||||||
const handler = await tileHandler(true);
|
const handler = await tileHandler(true);
|
||||||
expect(handler(chord(TOGGLE))).toBe(false);
|
expect(handler(chord(TOGGLE))).toBe(false);
|
||||||
|
|||||||
@@ -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<string, unknown>).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;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user