/**
* @fileoverview The header Tiles button, its picker, and a tile's + menu.
*
* - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker
* class) and hard-gated to desktop widths like Split: a JS width check plus a
* CSS `@media (max-width: 1179px)` backstop, and never in a solo window.
* With the grid open it closes it (`aria-pressed`).
* - The picker has a checkbox per open session, in tab order, never a session
* popped out to its own window; names are text, never markup. It is
* preselected with the grid this tab last left, else the active session and
* an open split's two. Boxes past what the window can fit are disabled; Open
* opens the grid on the checked ones, focusing the active session if checked.
* - Escape closes it, and its close method is idempotent (the global Escape
* handler calls every close method).
* - A tile's + lists the open sessions not yet tiled; picking one adds and
* focuses it; a grid that holds what the window fits disables them.
*
* 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,
body,
bySelector,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
function makeButton() {
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
bySelector.set('.btn-tile-grid', btn);
return btn;
}
const picker = () => body.children.find((c) => c.id === 'tilePickerMenu') ?? null;
const rows = () => picker()!.children[0].children;
const boxOf = (id: string) => rows().find((r) => r.children[0].value === id)!.children[0];
const openButton = () => picker()!.children[1].children[1];
const check = (id: string, on = true) => {
const box = boxOf(id);
box.checked = on;
box.dispatch('change');
};
let wrapRect = { width: 2400, height: 1200 };
beforeEach(() => {
resetGridHarness();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
});
afterEach(() => {
vi.useRealTimers();
});
describe('the Tiles button', () => {
it('shows only when its setting is on and the window is desktop-wide', () => {
const app = makeGridApp(IDS);
const btn = makeButton();
app._applyTileGridButtonVisibility(false);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(false);
windowStub.innerWidth = 1100;
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('never in a solo window', () => {
const app = makeGridApp(IDS);
app.isSoloWindow = true;
const btn = makeButton();
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('has the CSS backstops: the hidden marker, the 1179px media query, solo mode', () => {
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
expect(css).toMatch(
/@media \(max-width: 1179px\)\s*\{[^}]*\.btn-icon-header\.btn-tile-grid[^{]*\{\s*display: none !important;/
);
expect(css).toMatch(/body\.solo-mode \.btn-tile-grid,/);
});
it('with the grid open, a click closes it, and the button says so meanwhile', () => {
const app = makeGridApp(IDS);
const btn = makeButton();
app.selectSession = vi.fn();
app.openTileGrid(IDS);
expect(btn.getAttribute('aria-pressed')).toBe('true');
expect(btn.classList.contains('tiles-open')).toBe(true);
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
expect(picker()).toBeNull();
});
});
describe('the picker', () => {
it('lists open sessions in tab order, never a detached one, names as text', () => {
const app = makeGridApp(IDS);
app.sessions.get('s-b').name = 'b';
app.detachedSessions.add('s-c');
app.sessions.get('s-a').pid = null; // offered: its tile shows the Attach overlay
app.openTilePicker({ stopPropagation: vi.fn() });
const labels = rows().map((r) => r.children[1]);
expect(rows().map((r) => r.children[0].value)).toEqual(['s-other', 's-a', 's-b']);
expect(labels[2].textContent).toBe('b');
expect(labels[2].children).toHaveLength(0);
expect(labels[2].getAttribute('data-i18n-skip')).toBe('');
});
it('preselects the active session', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-b';
app.openTilePicker({ stopPropagation: vi.fn() });
expect(
rows()
.filter((r) => r.children[0].checked)
.map((r) => r.children[0].value)
).toEqual(['s-b']);
});
it('preselects the grid this tab last left', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-a', 's-c']);
app.closeTileGrid({ reselect: false });
app.openTilePicker({ stopPropagation: vi.fn() });
expect(
rows()
.filter((r) => r.children[0].checked)
.map((r) => r.children[0].value)
).toEqual(['s-a', 's-c']);
});
it('disables boxes past what the window fits, and says how many', () => {
const app = makeGridApp(IDS);
wrapRect = { width: 1000, height: 400 }; // fits 2 (2x1 of 500x400)
app.openTilePicker({ stopPropagation: vi.fn() });
expect(picker()!.children[1].children[0].textContent).toBe('This window fits 2 tiles');
check('s-b');
expect(boxOf('s-c').disabled).toBe(true);
expect(boxOf('s-other').disabled).toBe(true);
check('s-b', false);
expect(boxOf('s-c').disabled).toBe(false);
});
it('Open opens the grid on the checked sessions, in tab order, focusing the active one', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-b';
app.openTilePicker({ stopPropagation: vi.fn() });
check('s-a');
check('s-c');
openButton().dispatch('click');
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(picker()).toBeNull();
});
it('Open is disabled with nothing checked', () => {
const app = makeGridApp(IDS);
app.openTilePicker({ stopPropagation: vi.fn() });
check('s-a', false);
expect(openButton().disabled).toBe(true);
});
it('Escape closes it, and closing twice is harmless (the global Escape calls every close method)', () => {
const app = makeGridApp(IDS);
app.openTilePicker({ stopPropagation: vi.fn() });
const onKey = app._tilePicker.onKey;
onKey({ key: 'Escape' });
expect(picker()).toBeNull();
expect(() => app.closeTilePicker()).not.toThrow();
});
it('the global Escape handler closes the picker and the + menu', () => {
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const escape = src.slice(src.indexOf("if (e.key === 'Escape') {"), src.indexOf('Option/Alt session navigation'));
expect(escape).toContain('this.closeTilePicker?.();');
expect(escape).toContain('this.closeTileAddMenu?.();');
});
it('refuses in a narrow window', () => {
const app = makeGridApp(IDS);
windowStub.innerWidth = 1100;
app.openTilePicker({ stopPropagation: vi.fn() });
expect(picker()).toBeNull();
});
});
describe("a tile's + menu", () => {
const addMenu = () => body.children.find((c) => c.className.includes('tile-add-menu')) ?? null;
const addButtonOf = (id: string) =>
(section.children.find((el) => el.dataset.sessionId === id) as FakeEl).children[0].children[2].children.find((b) =>
b.className.includes('tile-add')
) as FakeEl;
it('lists the open sessions not yet tiled; picking one adds and focuses it', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b']);
app.markIdleAlertSeen.mockClear();
addButtonOf('s-a').dispatch('click', { stopPropagation: vi.fn(), currentTarget: null });
const items = addMenu()!.children.filter((i) => i.className !== 'tile-add-new');
expect(items.map((i) => i.children[0].textContent)).toEqual(['s-other', 's-c']);
items[1].dispatch('click');
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
expect(addMenu()).toBeNull();
});
it('a grid holding what the window fits disables the entries', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b']);
const sectionRect = section.getBoundingClientRect;
section.getBoundingClientRect = () => ({ width: 1000, height: 400, top: 0, left: 0, right: 1000, bottom: 400 });
app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null });
expect(addMenu()!.children.every((i) => i.disabled)).toBe(true);
section.getBoundingClientRect = sectionRect;
});
it('closing the grid closes it', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-a']);
app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null });
app.closeTileGrid({ reselect: false });
expect(addMenu()).toBeNull();
});
});