feat(tiles): right-click Tiles is a 2 / 4 / 6 count menu

Owner decision 10 ("give me then the option to choose only HOW many
tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives:
"Click opens 6"). The click still opens the grid at once; right-click,
Shift+F10 or the Menu key on the button opens a small menu of three
counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the
remembered one checked. It replaces the session picker, which is gone
(method, markup hook, CSS, zh-CN entries).

- A pick is remembered per device in codeman:tile-count (default 6;
  codeman:tile-grid stays ids only) and opens that many tiles. The click
  and Ctrl+Shift+G then open with it, at most what the window fits.
- Which sessions: the rule the click already had (the grid this tab
  last had, else an open split's two, else tab order, the active one
  included and focused), trimmed from the end (the focused one kept) or
  filled from tab order to the count. A remembered grid comes back with
  its tiles first, in their cells, holes filled first, then tab order
  (owner answer, superseding decision 8's "exactly the stored set"). A
  page-load restore still brings back exactly what was stored.
- With the grid open a pick re-forms it: a count change is a shape
  change under the cell model's rule (reformTileCells), the focused tile
  always kept, every joining tile mounted and laid out before any of
  them connects, so each fits once and sends one PTY resize.
- Ctrl/Cmd+click on a tab with the grid closed opens the count in total,
  that session among them and focused (owner answer: N, not N+1).
- A count the window cannot fit is greyed out with the reason; a
  remembered one stays checked, the keyboard starts on the largest that
  fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone
  (the global handler gives it the key first, like the tab-group menu)
  and puts the keyboard back on the Tiles button; Tab and a click
  elsewhere close it.
- zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help
  modal row); the i18n test harvests the menu now, with a session named
  "6 tiles" as the user-text trap.

Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts
(the button checks kept, every picker check carried over to the menu,
plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch
connect); the open-set, cap, restore, shortcuts, split-coexistence and
i18n expectations follow the count; the Help modal test escapes its
label (the new one has parentheses). The vm harness tracks
document.activeElement and makes SVG elements.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 04:10:55 +02:00
parent 64298101b0
commit 7debebb7b3
16 changed files with 973 additions and 483 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ function escapeRegExp(value: string): string {
function expectShortcut(html: string, keys: string[], label: string): void {
const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*');
expect(html).toMatch(new RegExp(`${keyPattern}.*?${label}`, 'i'));
expect(html).toMatch(new RegExp(`${keyPattern}.*?${escapeRegExp(label)}`, 'i'));
}
describe('help modal shortcuts', () => {
@@ -58,7 +58,7 @@ describe('help modal shortcuts', () => {
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down');
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile');
expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid');
expectShortcut(helpModal, ['Right-click'], 'Choose Which Sessions to Tile');
expectShortcut(helpModal, ['Right-click'], 'Choose How Many Tiles (2, 4 or 6)');
});
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
+13 -1
View File
@@ -13,6 +13,10 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { vi } from 'vitest';
/** What has the keyboard: the last FakeEl focused (document.activeElement). */
let focusedEl: unknown = null;
export const activeElement = () => focusedEl;
/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */
export class FakeEl {
id = '';
@@ -59,7 +63,10 @@ export class FakeEl {
textContent = '';
value = '';
type = '';
focus = vi.fn();
/** Records itself as `document.activeElement` (see activeElement()). */
focus = vi.fn(() => {
focusedEl = this;
});
select = vi.fn();
setPointerCapture = vi.fn();
releasePointerCapture = vi.fn();
@@ -226,6 +233,10 @@ const context = vm.createContext({
removeEventListener: documentRemoveEventListener,
documentElement: { dataset: {} },
createElement: () => new FakeEl(),
createElementNS: () => new FakeEl(),
get activeElement() {
return focusedEl;
},
getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)),
body,
querySelector: (sel: string) =>
@@ -313,6 +324,7 @@ export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp {
/** Resets the shared fake DOM and tile registry between tests. */
export function resetGridHarness() {
FakeTile.all = [];
focusedEl = null;
idleCallbacks.length = 0;
rafCallbacks.length = 0;
localStore.clear();
+13 -22
View File
@@ -5,7 +5,7 @@
*
* Every way into the grid stops at the cap even where the window would fit
* nine (the harness window is 2400x1200): opening, adding, a session Run
* makes, Ctrl/Cmd+click, the picker and "Open group as tiles".
* makes, Ctrl/Cmd+click, the count menu and "Open group as tiles".
* The texts say which limit binds: "at most 6" for the cap, "what this window
* fits" for a smaller window.
*
@@ -36,7 +36,7 @@ function fullGrid(): GridApp {
describe('the cap', () => {
it('is 6, worded as the cap where the window would fit more', () => {
const app = makeGridApp(EIGHT);
expect(app._tileGridLimit()).toEqual({ capacity: 6, hint: 'Up to 6 tiles', full: AT_MOST });
expect(app._tileGridLimit()).toEqual({ capacity: 6, full: AT_MOST });
});
it('a smaller window is worded by the window', () => {
@@ -44,7 +44,6 @@ describe('the cap', () => {
wrapRect = { width: 1200, height: 900 };
expect(app._tileGridLimit()).toEqual({
capacity: 4,
hint: 'This window fits 4 tiles',
full: 'The grid already holds what this window fits (4)',
});
});
@@ -77,26 +76,18 @@ describe('every way in stops at the cap', () => {
expect(app.showToast).toHaveBeenCalledWith(AT_MOST, 'info');
});
it('the picker greys out the seventh box', () => {
it('the count menu offers nothing past the cap, and its 6 opens six of eight', () => {
const app = makeGridApp(EIGHT);
app.openTilePicker({ stopPropagation: vi.fn() });
const picker = body.children.find((c) => c.id === 'tilePickerMenu')!;
const boxes = picker.children[0].children.map((row) => row.children[0]);
expect(picker.children[1].children[0].textContent).toBe('Up to 6 tiles');
for (const box of boxes) {
// A disabled box cannot be ticked (the browser ignores the click).
if (!EIGHT.includes(box.value) || box.checked || box.disabled) continue;
box.checked = true;
box.dispatch('change');
}
const checked = boxes.filter((b) => b.checked).map((b) => b.value);
expect(checked).toHaveLength(6);
const left = boxes.filter((b) => !b.checked);
expect(left.length).toBeGreaterThan(0);
for (const box of left) {
expect(box.disabled).toBe(true);
expect(box.title).toBe(AT_MOST);
}
app.openTileCountMenu({ preventDefault: vi.fn() });
const menu = body.children.find((c) => c.id === 'tileCountMenu')!;
const items = menu.children.filter((c) => c.attrs.role === 'menuitemradio');
expect(items.map((i) => Number(i.dataset.count))).toEqual([2, 4, 6]);
// The cap binds, not the window: nothing greyed out, no "fits" line.
expect(items.every((i) => !i.disabled)).toBe(true);
expect(menu.children.some((c) => c.className === 'tile-count-hint')).toBe(false);
items.at(-1)!.dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toHaveLength(6);
expect(app._tileGrid.ids).toEqual(['s-other', ...EIGHT.slice(0, 5)]);
});
it('"Open group as tiles" shows the first six of a larger group', () => {
+453
View File
@@ -0,0 +1,453 @@
/**
* @fileoverview The header Tiles button and its right-click count menu (owner
* decision 10, which replaced the session picker).
*
* - 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`).
* - Right-click (contextmenu, which Shift+F10 and the Menu key fire too) opens
* a menu of three counts, 2, 4 and 6, each with its shape, the remembered
* count checked (default 6). No session names in it, so no user text.
* - A count the window cannot fit is greyed out with the reason, and the
* keyboard starts on the largest that fits.
* - Arrows move over the counts that fit (wrapping), Home/End, Enter or Space
* picks, Escape closes and gives the keyboard back to the Tiles button (the
* menu owns its Escape in the global handler, like the tab-group menu), Tab
* and a click elsewhere close it.
* - A pick is remembered per device (`codeman:tile-count`; `codeman:tile-grid`
* stays ids only) and opens that many tiles: the session to focus included,
* the rest from tab order. The click and Ctrl+Shift+G then open with it.
* - With the grid open a pick re-forms it: a shape change under the cell
* model's rule, the focused tile always kept, tiles dropped from the end,
* new ones filling the empty cells first, all mounted and laid out before
* any connects. Fewer open sessions than the count give fewer tiles.
* - The session picker is gone (no method, no CSS, no markup hook).
*
* 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,
activeElement,
body,
bySelector,
localStore,
makeGridApp,
resetGridHarness,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f', 's-g'];
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const appSrc = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const gridSrc = readFileSync(resolve(PUBLIC, 'tile-grid.js'), '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 menu = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null;
const items = () => menu()!.children.filter((c) => c.attrs.role === 'menuitemradio');
const item = (n: number) => items().find((i) => i.dataset.count === String(n))!;
const hint = () => menu()!.children.find((c) => c.className === 'tile-count-hint') ?? null;
const focused = () => (activeElement() as FakeEl | null)?.dataset?.count ?? null;
const key = (app: GridApp, k: string) => {
const ev = { key: k, preventDefault: vi.fn() };
app._tileCountMenu.onKey(ev);
return ev;
};
const open = (app: GridApp) => app.openTileCountMenu({ preventDefault: vi.fn() });
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);
makeButton();
});
afterEach(() => {
vi.useRealTimers();
});
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
return app;
}
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 = gridApp(IDS.slice(0, 3));
const btn = bySelector.get('.btn-tile-grid')!;
app.openTileGrid(IDS.slice(0, 3));
expect(btn.getAttribute('aria-pressed')).toBe('true');
expect(btn.classList.contains('tiles-open')).toBe(true);
expect(btn.title).toBe('Tiles: back to a single session (right-click for how many tiles)');
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
expect(btn.title).toBe('Tiles: show several sessions side by side (right-click for how many)');
expect(menu()).toBeNull();
});
it('right-click opens the count menu, the click still toggles (one function with Ctrl+Shift+G)', () => {
expect(html).toContain('onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)"');
expect(html).toContain('title="Tiles: show several sessions side by side (right-click for how many)"');
});
});
describe('the count menu', () => {
it('offers 2, 4 and 6 with their shapes, the default 6 checked and focused; no user text', () => {
const app = gridApp();
const ev = { preventDefault: vi.fn() };
app.openTileCountMenu(ev);
expect(ev.preventDefault).toHaveBeenCalled();
expect(menu()!.attrs.role).toBe('menu');
expect(menu()!.attrs['aria-label']).toBe('How many tiles');
expect(items().map((i) => i.dataset.count)).toEqual(['2', '4', '6']);
expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'false', 'true']);
expect(items().map((i) => i.children[1].textContent)).toEqual(['2 tiles', '4 tiles', '6 tiles']);
// Each shape is the grid's own layout for that count: 2x1, 2x2, 3x2 cells.
expect(items().map((i) => i.children[0].children.length)).toEqual([2, 4, 6]);
expect(items().every((i) => !i.disabled)).toBe(true);
expect(hint()).toBeNull();
expect(focused()).toBe('6');
expect(app._tilesOwnTerminal()).toBe(false);
// No session name anywhere in it.
expect(JSON.stringify(menu()!.children.map((c) => c.textContent))).not.toMatch(/s-[a-g]/);
});
it('checks the remembered count; a bad stored value reads as the default', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
open(app);
expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'true', 'false']);
expect(focused()).toBe('4');
app.closeTileCountMenu();
localStore.set('codeman:tile-count', '5');
open(app);
expect(item(6).attrs['aria-checked']).toBe('true');
});
it('greys out a count the window cannot fit, says why, and starts on the largest that fits', () => {
const app = gridApp();
wrapRect = { width: 1200, height: 900 }; // fits 4 (2x2 of 600x450), not 3x2
open(app);
expect(item(6).disabled).toBe(true);
expect(item(6).attrs['aria-disabled']).toBe('true');
expect(item(6).title).toBe('This window fits 4 tiles');
expect(item(4).disabled).toBe(false);
expect(hint()!.textContent).toBe('This window fits 4 tiles');
// The remembered 6 stays checked (owner answer), the keyboard starts on 4.
expect(item(6).attrs['aria-checked']).toBe('true');
expect(focused()).toBe('4');
app.closeTileCountMenu();
wrapRect = { width: 1000, height: 400 }; // fits 2
open(app);
expect(items().map((i) => i.disabled)).toEqual([false, true, true]);
expect(hint()!.textContent).toBe('This window fits 2 tiles');
});
it('a greyed count is never picked, by click or by Enter', () => {
const app = gridApp();
wrapRect = { width: 1200, height: 900 };
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(menu()).not.toBeNull();
item(6).focus();
key(app, 'Enter');
expect(app._tilesOwnTerminal()).toBe(false);
expect(localStore.has('codeman:tile-count')).toBe(false);
});
it('arrows move over the counts that fit and wrap; Home and End', () => {
const app = gridApp();
open(app);
expect(key(app, 'ArrowDown').preventDefault).toHaveBeenCalled();
expect(focused()).toBe('2');
key(app, 'ArrowRight');
expect(focused()).toBe('4');
key(app, 'ArrowUp');
key(app, 'ArrowLeft');
expect(focused()).toBe('6');
key(app, 'Home');
expect(focused()).toBe('2');
key(app, 'End');
expect(focused()).toBe('6');
app.closeTileCountMenu();
wrapRect = { width: 1200, height: 900 };
open(app);
key(app, 'ArrowDown');
expect(focused()).toBe('2');
key(app, 'ArrowUp');
expect(focused()).toBe('4');
// The greyed 6 is skipped both ways.
key(app, 'ArrowDown');
expect(focused()).toBe('2');
});
it('Escape closes it and gives the keyboard back to the Tiles button; closing twice is harmless', () => {
const app = gridApp();
open(app);
const btn = bySelector.get('.btn-tile-grid')!;
const ev = key(app, 'Escape');
expect(ev.preventDefault).toHaveBeenCalled();
expect(menu()).toBeNull();
expect(activeElement()).toBe(btn);
expect(() => app.closeTileCountMenu()).not.toThrow();
});
it('the menu owns its Escape in the global handler: it closes alone, with the keyboard back on Tiles', () => {
const escape = appSrc.slice(appSrc.indexOf("if (e.key === 'Escape') {"), appSrc.indexOf('this.closeAllPanels();'));
expect(escape).toMatch(/if \(this\._tileCountMenu\) \{\s*this\.closeTileCountMenu\(\{ refocus: true \}\);\s*return;/);
const app = gridApp();
open(app);
app.closeTileCountMenu({ refocus: true });
expect(activeElement()).toBe(bySelector.get('.btn-tile-grid'));
});
it('Tab closes it; a click elsewhere closes it, a click inside does not', () => {
const app = gridApp();
open(app);
key(app, 'Tab');
expect(menu()).toBeNull();
open(app);
app._tileCountMenu.onOutside({ target: item(4) });
expect(menu()).not.toBeNull();
app._tileCountMenu.onOutside({ target: new FakeEl() });
expect(menu()).toBeNull();
});
it('a second right-click closes it; a click on Tiles closes it and toggles', () => {
const app = gridApp();
open(app);
open(app);
expect(menu()).toBeNull();
open(app);
app.toggleTileGrid();
expect(menu()).toBeNull();
expect(app._tilesOwnTerminal()).toBe(true);
});
it('refuses in a narrow window', () => {
const app = gridApp();
windowStub.innerWidth = 1100;
open(app);
expect(menu()).toBeNull();
});
});
describe('picking a count', () => {
it('Enter on 2 opens two tiles, the active session included and focused, and remembers 2', () => {
const app = gridApp();
app.activeSessionId = 's-c';
open(app);
key(app, 'ArrowDown'); // 6 -> 2
key(app, 'Enter');
expect(menu()).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-other', 's-c']);
expect(app._tileGrid.focusedId).toBe('s-c');
expect(localStore.get('codeman:tile-count')).toBe('2');
// The grid's own key stays ids only.
const stored = JSON.parse(localStore.get('codeman:tile-grid')!);
expect(Object.keys(stored).sort()).toEqual(['colFr', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']);
});
it('a click on 4 opens four, Space works too', () => {
const app = gridApp();
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toHaveLength(4);
app.closeTileGrid({ reselect: false });
open(app);
item(2).focus();
key(app, ' ');
expect(app._tileGrid.ids).toHaveLength(2);
});
it('then the click and Ctrl+Shift+G open with the remembered count', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
app.toggleTileGrid();
localStore.delete('codeman:tile-grid');
localStore.set('codeman:tile-count', '2');
app.runTileShortcut('toggle-tile-grid');
expect(app._tileGrid.ids).toHaveLength(2);
});
it('the default is 6, and the click never opens more than the window fits', () => {
const app = gridApp();
app.toggleTileGrid();
expect(app._tileGrid.ids).toHaveLength(6);
app.closeTileGrid({ keepStored: false, reselect: false });
wrapRect = { width: 1200, height: 900 };
app.toggleTileGrid();
expect(app._tileGrid.ids).toHaveLength(4);
});
it('fewer open sessions than the count: fewer tiles', () => {
const app = gridApp(['s-a', 's-b']);
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b']);
});
});
describe('Ctrl/Cmd+click on a tab with the grid closed', () => {
it('opens the remembered count in total, the clicked session among them and focused (owner answer 2)', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
app.addSessionToTiles('s-f');
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-f']);
expect(app._tileGrid.focusedId).toBe('s-f');
});
it('with the default 6, six in total, never seven', () => {
const app = gridApp();
app.addSessionToTiles('s-g');
expect(app._tileGrid.ids).toHaveLength(6);
expect(app._tileGrid.ids.at(-1)).toBe('s-g');
});
});
describe('re-forming an open grid', () => {
it('6 to 2 keeps the tiles it has first, drops from the end, never the focused one', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-e' });
open(app);
item(2).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toEqual(['s-a', 's-e']);
expect(app._tileGrid.focusedId).toBe('s-e');
expect(app.activeSessionId).toBe('s-e');
// The dropped ones were destroyed, not reloaded or kept connected.
const destroyed = FakeTile.all.filter((t) => t.destroy.mock.calls.length).map((t) => t.sessionId);
expect(destroyed.sort()).toEqual(['s-b', 's-c', 's-d', 's-f']);
expect(localStore.get('codeman:tile-count')).toBe('2');
});
it('2 to 6 keeps both in their cells and fills from tab order; nothing remounts', () => {
const app = gridApp();
app.openTileGrid(['s-c', 's-a']);
const before = new Map(FakeTile.all.map((t) => [t.sessionId, t]));
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-other', 's-b', 's-d', 's-e']);
expect(app._tileFor('s-c')).toBe(before.get('s-c'));
expect(app._tileFor('s-a')).toBe(before.get('s-a'));
expect(before.get('s-c')!.connect).toHaveBeenCalledTimes(1);
});
it('every joining tile connects only once the grid has its final cells (one fit, one PTY resize)', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
const seen: string[] = [];
const realMount = app._mountTile.bind(app);
app._mountTile = (id: string, opts?: unknown) => {
const ok = realMount(id, opts);
const tile = app._tileGrid.tiles.get(id)?.tile as FakeTile | undefined;
tile?.connect.mockImplementation(async () => {
seen.push(`${id}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(id)}`);
});
return ok;
};
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
// Each one connected into the 3x2 grid, already in its final cell.
expect(seen).toEqual(['s-other:6:2', 's-c:6:3', 's-d:6:4', 's-e:6:5']);
});
it('holes fill first: a 3x2 of five with an empty middle cell grows to six there', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 5));
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
app._applyTileLayout();
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-b', 's-c', 's-d', 's-e']);
});
it('6 to 4: the cell model rule (a tile left in the third column packs the rest)', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-a' });
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cols).toBe(2);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
});
it('the same count changes nothing; a zoom the user chose ends with a re-form', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 4));
const cells = app._tileGrid.cells.slice();
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(cells);
app.zoomTile('s-b');
open(app);
item(2).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.zoomedId).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
});
});
describe('the session picker is gone', () => {
it('no picker method, element, style or markup hook is left', () => {
const app = makeGridApp(IDS);
expect(app.openTilePicker).toBeUndefined();
expect(app.closeTilePicker).toBeUndefined();
expect(gridSrc).not.toMatch(/tilePickerMenu|tile-picker|openTilePicker|closeTilePicker/);
expect(css).not.toMatch(/tile-picker/);
expect(html).not.toMatch(/openTilePicker/);
expect(appSrc).not.toMatch(/closeTilePicker/);
});
it('a tile has no + (owner decision 9): tiles come from the Tiles button, its menu, Ctrl/Cmd+click, a tab, a group or Run', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
const header = app._tileGrid.tiles.get('s-a').header as FakeEl;
const actions = header.children.find((c) => c.className === 'tile-actions')!;
expect(actions.children.map((b) => b.className)).toEqual(['tile-btn tile-menu', 'tile-btn tile-zoom', 'tile-btn tile-remove']);
});
});
+31 -40
View File
@@ -5,7 +5,7 @@
*
* - Runtime strings are HARVESTED from the real tile code (the shared vm
* harness, test/mocks/tile-grid-vm.ts) driven through every state that
* writes text: the picker (cap and window wording), the Attach
* writes text: the count menu (cap and window wording), the Attach
* overlay (not attached, attaching, exited, ended), zoom, the header
* tooltip, the dividers, the empty slot, every toast, the crash-restart
* confirm, the Tiles and Split button titles. Each must translate to text
@@ -14,8 +14,8 @@
* - Static strings: the shortcut registry's tile entries (overlay and App
* Settings list), and index.html run through the real translator in JSDOM
* (the Tiles button, the App Settings chips, the Help modal's Tiles rows).
* - User text stays as typed: session names (tile header, picker)
* and group names carry data-i18n-skip, and a session name inside the
* - User text stays as typed: session names (tile header) and group names
* carry data-i18n-skip, and a session name inside the
* confirm passes through the pattern untranslated.
*
* Port: N/A.
@@ -163,34 +163,22 @@ async function exercise() {
app.sessions.get('s-2').pid = null;
app.sessions.get('s-3').paneExit = { status: 3 };
app.sessions.get('s-4').paneExit = { signal: 9 };
// A session named like a UI string: user text, never translated.
app.sessions.get('s-5').name = 'Open tiles';
// A session named like a UI string (a count menu label): user text, never translated.
app.sessions.get('s-5').name = '6 tiles';
// The picker, the cap wording, then the window wording.
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
const boxes = () =>
body.children.find((c) => c.id === 'tilePickerMenu')!.children[0].children.map((r) => r.children[0]);
for (const b of boxes()) {
if (b.checked || b.disabled) continue;
b.checked = true;
b.dispatch('change');
}
harvestAll(app, 'picker, cap');
app.closeTilePicker();
// The count menu: the cap (nothing greyed), then the window wording.
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'count menu, cap');
app.closeTileCountMenu();
wrapRect = { width: 1200, height: 900 };
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
for (const b of boxes()) {
if (b.checked || b.disabled) continue;
b.checked = true;
b.dispatch('change');
}
harvestAll(app, 'picker, window');
app.closeTilePicker();
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'count menu, window');
app.closeTileCountMenu();
// A window that fits one tile words it in the singular.
wrapRect = { width: 700, height: 500 };
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'picker, one tile');
app.closeTilePicker();
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'count menu, one tile');
app.closeTileCountMenu();
wrapRect = { width: 2400, height: 1200 };
// The grid: five tiles first (an empty slot), then the sixth.
@@ -270,9 +258,11 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
({ confirmText } = await exercise());
// ('Tiled sessions', the grid region's label, is markup: the JSDOM test below.)
const expected = [
'Show sessions as tiles',
'Open tiles',
'Up to 6 tiles',
'How many tiles',
'Tiles',
'2 tiles',
'4 tiles',
'6 tiles',
'The grid holds at most 6 tiles',
'This window fits 4 tiles',
'This window fits 1 tile',
@@ -303,9 +293,9 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
'This group has no session to show as tiles',
'No sessions to show as tiles',
'Split: unavailable while tiles are open',
'Tiles: show several sessions side by side (right-click to choose which)',
'Tiles: back to a single session (right-click to choose which sessions)',
'Open tiles was stopped after crashing repeatedly. Restart it?',
'Tiles: show several sessions side by side (right-click for how many)',
'Tiles: back to a single session (right-click for how many tiles)',
'6 tiles was stopped after crashing repeatedly. Restart it?',
];
const missing = expected.filter((s) => !seen.has(s));
expect(missing).toEqual([]);
@@ -316,7 +306,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
for (const [source, where] of seen) {
const text = zh.api.t(source);
// The session name inside the confirm is user text, allowed to stay.
const words = leftover(text.replace('Open tiles', ''));
const words = leftover(text.replace('6 tiles', ''));
if (text === source || words.length) bad.push(`${where}: "${source}" -> "${text}"`);
}
expect(bad).toEqual([]);
@@ -386,7 +376,7 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
const doc = dom.window.document;
const extra = doc.createElement('div');
extra.innerHTML =
'<span id="u-session" data-i18n-skip>Open tiles</span>' +
'<span id="u-session" data-i18n-skip>6 tiles</span>' +
'<span id="u-group" class="tab-layout-group-name" data-i18n-skip>Tiles</span>' +
'<span id="control">Tiles</span>';
doc.body.appendChild(extra);
@@ -417,6 +407,7 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
expect(leftover(tiles!.textContent!)).toEqual([]);
expect(tiles!.textContent).toContain('切换平铺网格');
expect(tiles!.textContent).toContain('右键单击');
expect(tiles!.textContent).toContain('选择窗格数量(2、4 或 6)');
// Moving tiles: the chords and the header drag.
expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格');
expect(tiles!.textContent).toContain('拖动');
@@ -424,16 +415,16 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
});
it('user text stays as typed: a session name and a group name that are also UI words', () => {
expect(doc.getElementById('u-session')!.textContent).toBe('Open tiles');
expect(doc.getElementById('u-session')!.textContent).toBe('6 tiles');
expect(doc.getElementById('u-group')!.textContent).toBe('Tiles');
expect(doc.getElementById('control')!.textContent).toBe('平铺');
});
});
describe('user text in the tile code', () => {
it('session names in a tile header and the picker are marked data-i18n-skip', () => {
it('session names in a tile header are marked data-i18n-skip', () => {
// The harvest saw them only inside skipped subtrees.
expect(userText.has('Open tiles')).toBe(true);
expect(userText.has('6 tiles')).toBe(true);
expect([...seen.keys()]).not.toContain('s-1');
const src = read('tile-grid.js');
expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(2);
@@ -444,7 +435,7 @@ describe('user text in the tile code', () => {
});
it('a session name inside the crash-restart confirm passes through the pattern untranslated', () => {
expect(confirmText).toEqual(['Open tiles was stopped after crashing repeatedly. Restart it?']);
expect(zh.api.t(confirmText[0])).toBe('Open tiles 因反复崩溃已被停止。要重启吗?');
expect(confirmText).toEqual(['6 tiles was stopped after crashing repeatedly. Restart it?']);
expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?');
});
});
+27 -27
View File
@@ -8,8 +8,8 @@
* focused. Detached sessions and ones that no longer exist are left out, as
* in the picker.
* - The button's click and Ctrl+Shift+G are the same function
* (`toggleTileGrid`); right-click (contextmenu) opens the picker. With the
* grid open the picker shows the current tiles, and Open replaces them.
* (`toggleTileGrid`); right-click (contextmenu) opens the count menu (owner
* decision 10). With the grid open a pick re-forms it, the focused tile kept.
*
* The pure helper and the app both via the shared harness (test/mocks/tile-grid-vm.ts).
* Port: N/A.
@@ -99,11 +99,11 @@ describe('the Tiles button and Ctrl+Shift+G', () => {
return html;
};
it('a click opens the grid at once (the toggle), a right-click opens the picker', () => {
it('a click opens the grid at once (the toggle), a right-click opens the count menu', () => {
expect(button()).toContain('onclick="app.toggleTileGrid()"');
expect(button()).toContain('oncontextmenu="app.openTilePicker(event)"');
expect(button()).toContain('right-click to choose which');
expect(button()).not.toContain('onclick="app.openTilePicker');
expect(button()).toContain('oncontextmenu="app.openTileCountMenu(event)"');
expect(button()).toContain('right-click for how many');
expect(button()).not.toContain('onclick="app.openTileCountMenu');
});
it('Ctrl+Shift+G runs the same toggle', () => {
@@ -114,14 +114,18 @@ describe('the Tiles button and Ctrl+Shift+G', () => {
describe('opening at once, in the app', () => {
const IDS = ['s-a', 's-b', 's-c'];
const picker = () => body.children.find((c) => c.id === 'tilePickerMenu') ?? null;
const picker = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null;
const pick = (n: number) =>
picker()!
.children.find((c) => c.dataset.count === String(n))!
.dispatch('click', { stopPropagation: vi.fn() });
beforeEach(() => {
resetGridHarness();
const wrap = new FakeEl();
bySelector.set('.terminal-wrap', wrap);
});
it('a click with the grid closed shows the tiles, no picker', () => {
it('a click with the grid closed shows the tiles, no menu', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-b';
app.toggleTileGrid();
@@ -131,19 +135,19 @@ describe('opening at once, in the app', () => {
expect(app.activeSessionId).toBe('s-b');
});
it('a click closes a picker that a right-click left open', () => {
it('a click closes a menu that a right-click left open', () => {
const app = makeGridApp(IDS);
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(picker()).not.toBeNull();
app.toggleTileGrid();
expect(picker()).toBeNull();
expect(app._tilesOwnTerminal()).toBe(true);
});
it('a click elsewhere closes the picker; a click inside it does not', () => {
it('a click elsewhere closes the menu; a click inside it does not', () => {
const app = makeGridApp(IDS);
const before = documentAddEventListener.mock.calls.length;
app.openTilePicker({ preventDefault: vi.fn() });
app.openTileCountMenu({ preventDefault: vi.fn() });
const calls = documentAddEventListener.mock.calls.slice(before) as Array<[string, (e: unknown) => void]>;
const onClick = calls.find(([type]) => type === 'click')![1];
onClick({ target: picker()!.children[0] });
@@ -152,31 +156,27 @@ describe('opening at once, in the app', () => {
expect(picker()).toBeNull();
});
it('a right-click opens the picker and keeps the browser menu away', () => {
it('a right-click opens the count menu and keeps the browser menu away', () => {
const app = makeGridApp(IDS);
const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
app.openTilePicker(ev);
app.openTileCountMenu(ev);
expect(ev.preventDefault).toHaveBeenCalled();
expect(picker()).not.toBeNull();
expect(app._tilesOwnTerminal()).toBe(false);
});
it('a right-click with the grid open shows its tiles; Open replaces them', () => {
it('a right-click with the grid open re-forms it to the count picked, the focused tile kept', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b'], { focusedId: 's-b' });
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(app._tilesOwnTerminal()).toBe(true);
const menu = picker()!;
const boxes = menu.children[0].children.map((row) => row.children[0]);
expect(boxes.filter((b) => b.checked).map((b) => b.value)).toEqual(['s-a', 's-b']);
const boxA = boxes.find((b) => b.value === 's-a')!;
boxA.checked = false;
boxA.dispatch('change');
const boxC = boxes.find((b) => b.value === 's-c')!;
boxC.checked = true;
boxC.dispatch('change');
menu.children[1].children[1].dispatch('click');
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
pick(4);
// Its two first, then the open sessions in tab order.
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
expect(app.activeSessionId).toBe('s-b');
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
pick(2);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-b');
});
});
-198
View File
@@ -1,198 +0,0 @@
/**
* @fileoverview The header Tiles button and its picker.
*
* - 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 has no + (owner decision 9): tiles are added from this picker,
* Ctrl/Cmd+click, a dragged tab, a tab group or Run.
*
* 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,
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', () => {
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?.();');
});
it('refuses in a narrow window', () => {
const app = makeGridApp(IDS);
windowStub.innerWidth = 1100;
app.openTilePicker({ stopPropagation: vi.fn() });
expect(picker()).toBeNull();
});
});
+30
View File
@@ -168,6 +168,7 @@ describe('page load with a stored open grid', () => {
it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
localStore.set('codeman:tile-count', '2');
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true });
@@ -178,6 +179,35 @@ describe('page load with a stored open grid', () => {
expect(app.activeSessionId).toBe('s-c');
});
it('the toggle fills a stored grid to the remembered count: its tiles first, in their cells, then tab order', () => {
// Default 6 (owner answer 1, superseding decision 8's "exactly the stored set").
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
app.activeSessionId = 's-a';
app.toggleTileGrid();
// The two stay in the first row (2x1 to 2x2 keeps them), the rest join
// in tab order: every live session, fewer than the count.
expect(app._tileGrid.cells).toEqual(['s-b', 's-c', 's-a', 's-d']);
expect(app.activeSessionId).toBe('s-c');
});
it('a stored hole is filled first when the count needs more tiles', () => {
// A 2x2 of three with the hole first; the count asks for four.
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: [null, 's-b', 's-c', 's-d'], focused: 's-b' }));
localStore.set('codeman:tile-count', '4');
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
app.toggleTileGrid();
// s-a joins in the hole (packing would have put it last).
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
});
it('a reload brings back exactly the stored grid, whatever the count', () => {
localStore.set('codeman:tile-count', '6');
storeGrid({ ids: ['s-b', 's-c'], focused: 's-c' });
const app = pageLoad(IDS);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
});
it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => {
storeGrid({ ids: IDS, focused: 's-a' });
windowStub.innerWidth = 1100;
+6 -3
View File
@@ -224,13 +224,14 @@ describe('the capture-phase handler', () => {
});
describe('the actions', () => {
it('the toggle brings back the grid this tab last left, focus included', () => {
it('the toggle brings back the grid this tab last left, focus included, filled to the count', () => {
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);
// Its three first, then the open sessions in tab order (the default count, 6).
expect(app._tileGrid.ids).toEqual([...IDS, 's-other']);
expect(app.activeSessionId).toBe('s-c');
});
@@ -240,7 +241,9 @@ describe('the actions', () => {
app.closeTileGrid({ reselect: false });
app.sessions.delete('s-b');
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app._tileGrid.ids).not.toContain('s-b');
// Its cell stays where it was, and the count fills it first (s-other, tab order).
expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-c']);
});
it('with every remembered session gone, it opens on the open sessions in tab order instead', () => {
+15 -3
View File
@@ -21,6 +21,7 @@ import {
FakeTile,
body,
bySelector,
localStore,
main,
makeGridApp,
resetGridHarness,
@@ -59,25 +60,36 @@ describe('opening the grid over an open split', () => {
expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
// The split's two first, then tab order up to the count (default 6).
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
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('a remembered grid wins over an open split: exactly its tiles, the split closed and not merged', () => {
it("with a count of 2, exactly the split's two", () => {
const app = makeGridApp(IDS);
localStore.set('codeman:tile-count', '2');
openSplit(app);
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
});
it('a remembered grid wins over an open split: its tiles first, the split closed and not merged', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-c']);
app.closeTileGrid({ reselect: false });
app.activeSessionId = 's-a';
localStore.set('codeman:tile-count', '2');
const paneB = openSplit(app);
app.toggleTileGrid();
expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-c']);
// Filled from tab order (s-other first), not seeded with the split's two.
expect(app._tileGrid.ids).toEqual(['s-c', 's-other']);
expect(app.activeSessionId).toBe('s-c');
});