mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Owner decision 8 ("when I hit the tiles button, open the tiles already!").
A click with the grid closed now opens it straight away, and Ctrl+Shift+G
runs the same function (toggleTileGrid), so the two cannot drift. What
opens comes from one pure helper, tileGridOpenSet (constants.js):
a. the grid this tab last had, if any of its sessions survive, opened
exactly (an open split closes and its sessions do not join);
b. else an open split's two sessions, Pane A focused;
c. else the open sessions in tab order (the picker's list: no detached
ones), up to what the grid takes here (the cap of 6, fewer when the
window fits fewer), the active session always among them and focused.
A click with the grid open still closes it.
The picker moved to right-click (oncontextmenu, browser menu suppressed).
With the grid open it is preselected with the current tiles, and Open
replaces them. Ctrl/Cmd+click on a tab with the grid closed opens the
toggle's set plus that session. The button's title, the Help modal and
the wiki say right-click chooses which sessions.
Docs: decision 8 and an as-built entry in the spec (Entry points too),
CLAUDE.md, the invariants (#tile-grid, Opening), the wiki's Tile Grid and
Keyboard Shortcuts pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
242 lines
9.5 KiB
TypeScript
242 lines
9.5 KiB
TypeScript
/**
|
|
* @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 = '<i>b</i>';
|
|
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('<i>b</i>');
|
|
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.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();
|
|
});
|
|
});
|