mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 09:49:41 +02:00
The Tiles toggle used to fill a remembered grid to the count picked in the menu (refilling a hole the user made, or trimming tiles), removing the last tile or "Open group as tiles" forgot it, and Ctrl/Cmd+click repacked it. The owner asked for the opposite: "when I turn tiles off and on, always keep what the last setting was". Now the layout the user arranged (which session sits in which cell, holes included, the tile count, the divider sizes, the focused tile and a zoom they chose) is written to localStorage on every change and comes back exactly from the toggle, Ctrl+Shift+G and a reload, however the grid was closed (toggle, a non-tiled tab, leaveTiles or a #session= link, Home, the width gate, the last tile, a group, closing or killing sessions). Nothing of it reaches the server. - The stored value stays v: 1 and gains `count`: how many tiles the user's own last change left. A session that goes away by itself (deleted, popped out, socket refused) does not lower it, so the next activation fills that cell from the ranking; a hole the user made stays a hole. - sanitizeTileGridState reports `freed` cells (sessions gone since) and derives `count` for values written before it; restoreTileGridCells (pure) puts a stored grid back, freed cells first, never trimmed to the window (a too-small window shows the focused tile until it fits). - Only when none of the stored sessions survive does activation rank from scratch. A count picked in the menu still re-forms the grid to it. - Nothing is persisted while a stored grid is being put back, and a #session= link only flips `open`, keeping gone ids for the fill. - The hover card no longer promises "a click opens N" when a stored grid will open instead. This supersedes decision 10's answer that the count wins over a remembered grid's size; the affected tests say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
185 lines
7.6 KiB
TypeScript
185 lines
7.6 KiB
TypeScript
/**
|
|
* @fileoverview The Tiles button opens the grid at once (owner decision 8).
|
|
*
|
|
* - `tileGridOpenSet` (constants.js, pure): what opens, in order: (a) the grid
|
|
* this tab last had, if any of its sessions survive; (b) else an open
|
|
* split's two sessions; (c) else the open sessions in ranked order (tab
|
|
* order when no ranking is given, as here; the ranking itself is pinned in
|
|
* test/tile-grid-ranking.test.ts) up to the limit (never past the cap of
|
|
* 6), the active one always among them and 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 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.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
FakeEl,
|
|
body,
|
|
bySelector,
|
|
documentAddEventListener,
|
|
makeGridApp,
|
|
resetGridHarness,
|
|
windowStub,
|
|
} from './mocks/tile-grid-vm.js';
|
|
|
|
type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null;
|
|
type Helpers = { tileGridOpenSet(p: Record<string, unknown>): OpenSet; TILE_GRID_MAX: number };
|
|
|
|
// constants.js as the harness loaded it (window.CodemanTileGrid).
|
|
const T = windowStub.CodemanTileGrid as Helpers;
|
|
const INDEX_HTML = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
|
|
|
const order = Array.from({ length: 9 }, (_, i) => `t${i + 1}`);
|
|
const sessions = new Map(order.map((id) => [id, { id, name: id }]));
|
|
const base = { sessions, sessionOrder: order, limit: 6 };
|
|
|
|
describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
|
it('a: the grid this tab last had, its focus (or its zoom) included', () => {
|
|
const stored = { ids: ['t4', 't2'], focused: 't2', zoomed: null };
|
|
expect(T.tileGridOpenSet({ ...base, stored, split: ['t1', 't9'], activeId: 't1' })).toEqual({
|
|
source: 'stored',
|
|
ids: ['t4', 't2'],
|
|
cells: ['t4', 't2'],
|
|
focusedId: 't2',
|
|
});
|
|
expect(T.tileGridOpenSet({ ...base, stored: { ids: ['t4', 't2'], focused: 't4', zoomed: 't2' } })?.focusedId).toBe(
|
|
't2'
|
|
);
|
|
});
|
|
|
|
it('b: else an open split, Pane A focused', () => {
|
|
expect(T.tileGridOpenSet({ ...base, stored: { ids: [] }, split: ['t5', 't7'], activeId: 't5' })).toEqual({
|
|
source: 'split',
|
|
ids: ['t5', 't7'],
|
|
focusedId: 't5',
|
|
});
|
|
});
|
|
|
|
it('c: else the open sessions (no ranking given: tab order) up to the limit, the active one focused', () => {
|
|
expect(T.tileGridOpenSet({ ...base, activeId: 't3' })).toEqual({
|
|
source: 'ranked',
|
|
ids: order.slice(0, 6),
|
|
focusedId: 't3',
|
|
});
|
|
});
|
|
|
|
it('c: an active session past the limit still comes, with the first ones before it', () => {
|
|
expect(T.tileGridOpenSet({ ...base, activeId: 't9' })).toEqual({
|
|
source: 'ranked',
|
|
ids: ['t1', 't2', 't3', 't4', 't5', 't9'],
|
|
focusedId: 't9',
|
|
});
|
|
});
|
|
|
|
it('c: a window that takes fewer gets fewer; nothing ever passes the cap', () => {
|
|
expect(T.tileGridOpenSet({ ...base, limit: 4, activeId: 't1' })?.ids).toEqual(['t1', 't2', 't3', 't4']);
|
|
expect(T.tileGridOpenSet({ ...base, limit: 99, activeId: 't1' })?.ids).toHaveLength(T.TILE_GRID_MAX);
|
|
});
|
|
|
|
it('leaves out detached sessions and ones that no longer exist, as the picker does', () => {
|
|
const detachedIds = new Set(['t2']);
|
|
const out = T.tileGridOpenSet({ ...base, sessionOrder: ['gone', ...order], detachedIds, activeId: 't1' });
|
|
expect(out?.ids).toEqual(['t1', 't3', 't4', 't5', 't6', 't7']);
|
|
// A split whose Pane B was popped out: Pane A alone.
|
|
expect(T.tileGridOpenSet({ ...base, split: ['t1', 't2'], detachedIds, activeId: 't1' })?.ids).toEqual(['t1']);
|
|
});
|
|
|
|
it('is null when there is nothing to open', () => {
|
|
expect(T.tileGridOpenSet({ sessions: new Map(), sessionOrder: [], limit: 6, activeId: null })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('the Tiles button and Ctrl+Shift+G', () => {
|
|
const button = () => {
|
|
const html = INDEX_HTML.match(/<button class="btn-icon-header btn-tile-grid[^>]*>/)?.[0] ?? '';
|
|
return html;
|
|
};
|
|
|
|
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.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', () => {
|
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/tile-grid.js'), 'utf8');
|
|
expect(src).toContain("if (id === 'toggle-tile-grid') this.toggleTileGrid();");
|
|
});
|
|
});
|
|
|
|
describe('opening at once, in the app', () => {
|
|
const IDS = ['s-a', 's-b', 's-c'];
|
|
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 menu', () => {
|
|
const app = makeGridApp(IDS);
|
|
app.activeSessionId = 's-b';
|
|
app.toggleTileGrid();
|
|
expect(picker()).toBeNull();
|
|
expect(app._tilesOwnTerminal()).toBe(true);
|
|
expect(app._tileGrid.ids).toEqual(['s-other', ...IDS]);
|
|
expect(app.activeSessionId).toBe('s-b');
|
|
});
|
|
|
|
it('a click closes a menu that a right-click left open', () => {
|
|
const app = makeGridApp(IDS);
|
|
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 menu; a click inside it does not', () => {
|
|
const app = makeGridApp(IDS);
|
|
const before = documentAddEventListener.mock.calls.length;
|
|
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] });
|
|
expect(picker()).not.toBeNull();
|
|
onClick({ target: new FakeEl() });
|
|
expect(picker()).toBeNull();
|
|
});
|
|
|
|
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.openTileCountMenu(ev);
|
|
expect(ev.preventDefault).toHaveBeenCalled();
|
|
expect(picker()).not.toBeNull();
|
|
expect(app._tilesOwnTerminal()).toBe(false);
|
|
});
|
|
|
|
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.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
|
expect(app._tilesOwnTerminal()).toBe(true);
|
|
pick(4);
|
|
// Its two first, then the ranking (every session quiet and unstamped here: 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');
|
|
});
|
|
});
|