mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 02:09:42 +02:00
Merge the Tiles open order and kept layout into the 1.41.0 release branch
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
|
||||
* app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
|
||||
* tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake
|
||||
* tile-grid.js + mobile-overview.js (the session state classifier the grid's
|
||||
* ranking reuses) in one context, a small fake DOM (just what the grid touches) and a fake
|
||||
* TerminalTile that records what the grid asks of it.
|
||||
*
|
||||
* `makeGridApp()` returns an app instance with everything around the grid that
|
||||
@@ -295,7 +296,7 @@ const context = vm.createContext({
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
|
||||
`${read('tile-grid.js')}\n` +
|
||||
`${read('tile-grid.js')}\n${read('mobile-overview.js')}\n` +
|
||||
'globalThis.__CodemanApp = CodemanApp;',
|
||||
context
|
||||
);
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* and Ctrl+Tab / Alt+[ ] cycle through the tiles only.
|
||||
* - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload
|
||||
* brings the holes back when the shape is the same, a session gone by then
|
||||
* leaves its cell empty, a different shape packs, and the old packed format
|
||||
* reads unchanged. A followed `#session=` link keeps the holes.
|
||||
* frees its cell for the ranking to fill (empty only when no other session
|
||||
* is left), a different shape packs, and the old packed format reads
|
||||
* unchanged. A followed `#session=` link keeps the holes.
|
||||
* - A divider drag refits only the tiles in its two tracks, holes skipped.
|
||||
*
|
||||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||
@@ -246,11 +247,16 @@ describe('focus never lands on an empty cell', () => {
|
||||
});
|
||||
|
||||
describe('persistence: the cells, holes included', () => {
|
||||
function reload(ids: string[] = SIX): GridApp {
|
||||
function reload(ids: string[] = SIX, { others = true } = {}): GridApp {
|
||||
// The page goes away; a fresh one on the same device restores the grid.
|
||||
section.children = [];
|
||||
FakeTile.all = [];
|
||||
const app = makeGridApp(ids);
|
||||
// `others: false`: no session but `ids` is open, so a freed cell has nothing to take.
|
||||
if (!others) {
|
||||
app.sessions.delete('s-other');
|
||||
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-other');
|
||||
}
|
||||
expect(app._restoreTileGrid()).toBe(true);
|
||||
return app;
|
||||
}
|
||||
@@ -266,10 +272,18 @@ describe('persistence: the cells, holes included', () => {
|
||||
expect(slots()[0].dataset.cell).toBe('2');
|
||||
});
|
||||
|
||||
it('a session gone by then leaves its cell empty if the shape still fits', () => {
|
||||
it('a session gone by then frees its cell, which the ranking fills, the hole the user left kept', () => {
|
||||
const app = openGrid(FIVE);
|
||||
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||
app._tileGrid.open = false;
|
||||
const again = reload(['s-a', 's-b', 's-d', 's-e']);
|
||||
expect(again._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-other', 's-d', 's-e']);
|
||||
});
|
||||
|
||||
it('a freed cell stays empty only when no other session is left, if the shape still fits', () => {
|
||||
const app = openGrid(SIX);
|
||||
app._tileGrid.open = false;
|
||||
const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f']);
|
||||
const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f'], { others: false });
|
||||
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-f']);
|
||||
});
|
||||
|
||||
@@ -277,15 +291,15 @@ describe('persistence: the cells, holes included', () => {
|
||||
const app = openGrid(FIVE);
|
||||
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||
app._tileGrid.open = false;
|
||||
// s-d gone: four tiles take a 2x2.
|
||||
const again = reload(['s-a', 's-b', 's-c', 's-e']);
|
||||
// s-d gone and nothing to take its cell: four tiles take a 2x2.
|
||||
const again = reload(['s-a', 's-b', 's-c', 's-e'], { others: false });
|
||||
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-e']);
|
||||
});
|
||||
|
||||
it('another shape packs even when the stored holes would fit the new one', () => {
|
||||
// Stored as 3x2 [a _ b / c d e]; d and e gone: three tiles take a 2x2.
|
||||
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] }));
|
||||
const again = reload(['s-a', 's-b', 's-c']);
|
||||
const again = reload(['s-a', 's-b', 's-c'], { others: false });
|
||||
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
* 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.
|
||||
* holds ids and layout, never content) and opens that many tiles: the
|
||||
* session to focus included, the rest from the ranking (all quiet and
|
||||
* unstamped here: tab order). The click and Ctrl+Shift+G then open with it
|
||||
* when no grid is stored (a stored one comes back as it was:
|
||||
* tile-grid-layout-memory.test.ts).
|
||||
* - 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
|
||||
@@ -310,9 +313,10 @@ describe('picking a count', () => {
|
||||
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.
|
||||
// The grid's own key holds ids and layout only (its tile count included), never content.
|
||||
const stored = JSON.parse(localStore.get('codeman:tile-grid')!);
|
||||
expect(Object.keys(stored).sort()).toEqual(['colFr', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']);
|
||||
expect(Object.keys(stored).sort()).toEqual(['colFr', 'count', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']);
|
||||
expect(stored.count).toBe(2);
|
||||
});
|
||||
|
||||
it('a click on 4 opens four, Space works too', () => {
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* @fileoverview The grid keeps the layout the user arranged, per browser
|
||||
* (owner request: "when I moved around and modified it, save it per browser
|
||||
* the layout, so when I turn tiles off and on, always keep what the last
|
||||
* setting was").
|
||||
*
|
||||
* - Saved on every change (`codeman:tile-grid` in localStorage, never the
|
||||
* server): which session sits in which cell, holes included, the tile count,
|
||||
* the divider sizes, the focused tile and a zoom the user chose.
|
||||
* - The Tiles toggle brings back exactly that, however the grid was closed:
|
||||
* the toggle, a tab that is not tiled, a followed link (`leaveTiles`, or
|
||||
* `#session=` on load), Home, the window narrowing past the desktop gate,
|
||||
* the last tile removed, "Open group as tiles", closing a session, killing
|
||||
* them all. A page reload brings it back only when the grid was open (a
|
||||
* closed one stays remembered for the toggle). It is never filled to the
|
||||
* remembered count and never trimmed to the window (a too-small window
|
||||
* shows the focused tile until it fits, the arrangement kept).
|
||||
* - A session that no longer exists frees its cell, and the ranking fills it
|
||||
* (the hole a user left stays a hole); with none of the stored sessions left
|
||||
* the grid opens from the ranking, as with nothing stored.
|
||||
* - The stored format stays `v: 1`: a value written before `count` (or before
|
||||
* cells) loads, and a malformed one is ignored safely.
|
||||
*
|
||||
* Pure helpers (sanitizeTileGridState, restoreTileGridCells) and the app via
|
||||
* the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FakeEl,
|
||||
FakeTile,
|
||||
body,
|
||||
bySelector,
|
||||
fetchSpy,
|
||||
flushFrames,
|
||||
localStore,
|
||||
makeGridApp,
|
||||
resetGridHarness,
|
||||
section,
|
||||
windowStub,
|
||||
type GridApp,
|
||||
} from './mocks/tile-grid-vm.js';
|
||||
|
||||
type Restored = { ids: string[]; cells: Array<string | null>; focusedId: string } | null;
|
||||
type Sanitized = {
|
||||
ids: string[];
|
||||
cells: Array<string | null>;
|
||||
freed: number[];
|
||||
count: number;
|
||||
open: boolean;
|
||||
focused: string | null;
|
||||
zoomed: string | null;
|
||||
colFr: number[] | null;
|
||||
} | null;
|
||||
type Helpers = {
|
||||
sanitizeTileGridState(raw: unknown, live: Iterable<string>, detached?: Set<string>): Sanitized;
|
||||
restoreTileGridCells(stored: unknown, ranked: string[]): Restored;
|
||||
};
|
||||
|
||||
const T = windowStub.CodemanTileGrid as Helpers;
|
||||
const KEY = 'codeman:tile-grid';
|
||||
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
||||
|
||||
describe('sanitizeTileGridState: freed cells and the count', () => {
|
||||
const live = ['a', 'b', 'c', 'd'];
|
||||
|
||||
it('marks the cells whose session went away (deleted or popped out), never a hole the user left', () => {
|
||||
const out = T.sanitizeTileGridState(
|
||||
{ v: 1, open: true, ids: ['a', null, 'gone', 'b', 'c', 'x'] },
|
||||
live,
|
||||
new Set(['c'])
|
||||
);
|
||||
expect(out?.cells).toEqual(['a', null, null, 'b', null, null]);
|
||||
expect(out?.freed).toEqual([2, 4, 5]);
|
||||
});
|
||||
|
||||
it('a repeat or a malformed entry is a plain hole, not freed', () => {
|
||||
const out = T.sanitizeTileGridState({ v: 1, ids: ['a', 'a', 7, '', 'gone', 'gone'] }, live);
|
||||
expect(out?.cells).toEqual(['a', null, null, null, null, null]);
|
||||
expect(out?.freed).toEqual([4]);
|
||||
});
|
||||
|
||||
it('reads a stored count; one written before it existed is the number of sessions the cells name', () => {
|
||||
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', null, 'b'], count: 4 }, live)?.count).toBe(4);
|
||||
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', null, 'gone', 'b'] }, live)?.count).toBe(3);
|
||||
// The old packed format of nine (before the cap) reads as the cap.
|
||||
const nine = Array.from({ length: 9 }, (_, i) => `n${i}`);
|
||||
expect(T.sanitizeTileGridState({ v: 1, ids: nine }, nine)?.count).toBe(6);
|
||||
});
|
||||
|
||||
it.each([[0], [7], [2.5], ['4'], [null], [-1]])('a malformed count (%j) is ignored', (count) => {
|
||||
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', 'b'], count }, live)?.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreTileGridCells', () => {
|
||||
const sanitize = (value: Record<string, unknown>, live: string[]) =>
|
||||
T.sanitizeTileGridState({ v: 1, open: false, ...value }, live);
|
||||
|
||||
it('gives back the cells exactly, holes included, focused on the zoomed tile, else the focused one', () => {
|
||||
const s = sanitize({ ids: ['a', null, 'b', 'c', 'd', 'e'], focused: 'c', zoomed: 'd' }, [
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'z',
|
||||
]);
|
||||
expect(T.restoreTileGridCells(s, ['z'])).toEqual({
|
||||
ids: ['a', 'b', 'c', 'd', 'e'],
|
||||
cells: ['a', null, 'b', 'c', 'd', 'e'],
|
||||
focusedId: 'd',
|
||||
});
|
||||
const t = sanitize({ ids: ['a', 'b'], focused: 'b' }, ['a', 'b']);
|
||||
expect(T.restoreTileGridCells(t, [])?.focusedId).toBe('b');
|
||||
});
|
||||
|
||||
it('fills a freed cell from the ranking, best first, never one already in the grid; the user hole stays', () => {
|
||||
const s = sanitize({ ids: ['a', null, 'gone', 'b', 'c', 'd'], count: 5 }, ['a', 'b', 'c', 'd', 'x', 'y']);
|
||||
expect(T.restoreTileGridCells(s, ['b', 'y', 'x'])?.cells).toEqual(['a', null, 'y', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('fills freed cells first, then other empty cells only up to the count', () => {
|
||||
// A hand-made value: cell 3 freed (its session gone), cell 1 a plain hole, a count of six.
|
||||
const s = sanitize({ ids: ['a', null, 'b', 'gone', 'c', 'd'], count: 6 }, ['a', 'b', 'c', 'd', 'x', 'y', 'z']);
|
||||
expect(T.restoreTileGridCells(s, ['x', 'y', 'z'])?.cells).toEqual(['a', 'y', 'b', 'x', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('a freed cell stays empty only when no session is left to place', () => {
|
||||
const s = sanitize({ ids: ['a', 'gone', 'b'] }, ['a', 'b']);
|
||||
expect(T.restoreTileGridCells(s, [])).toEqual({ ids: ['a', 'b'], cells: ['a', null, 'b'], focusedId: 'a' });
|
||||
});
|
||||
|
||||
it('more to place than the stored cells hold: they join after them (the shape grows when laid out)', () => {
|
||||
// Four stored cells (2x2), the count says six: two sessions closed while the grid was open packed it.
|
||||
const s = sanitize({ ids: ['a', 'b', 'c', 'd'], count: 6 }, ['a', 'b', 'c', 'd', 'x', 'y', 'z']);
|
||||
expect(T.restoreTileGridCells(s, ['x', 'y', 'z'])?.ids).toEqual(['a', 'b', 'c', 'd', 'x', 'y']);
|
||||
});
|
||||
|
||||
it('is null when none of its sessions survive', () => {
|
||||
expect(T.restoreTileGridCells(sanitize({ ids: ['gone', null] }, ['x']), ['x'])).toBeNull();
|
||||
expect(T.restoreTileGridCells(null, ['x'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── In the app ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** The real selectSession (tile branch, leaving the grid); the main terminal's select path bails at once. */
|
||||
function layoutApp(ids: string[] = SIX): GridApp {
|
||||
const app = makeGridApp(ids);
|
||||
app._setTerminalLoadState = vi.fn();
|
||||
app._clearTerminalLoadState = vi.fn();
|
||||
app._renderHistoryTruncationBanner = vi.fn();
|
||||
app._isStaleSelect = vi.fn(() => true);
|
||||
app._retireUrlSession = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl;
|
||||
|
||||
/** A column divider dragged from `from` to `to` (px), released. */
|
||||
function dragColumn(app: GridApp, key: string, from: number, to: number) {
|
||||
const d = divider(app, key);
|
||||
d.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 3,
|
||||
clientX: from,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
d.dispatch('pointermove', { clientX: to });
|
||||
flushFrames();
|
||||
d.dispatch('pointerup', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Six tiles opened, then arranged by hand: s-b removed (its cell left empty),
|
||||
* s-a and s-f swapped, the first column divider dragged, s-c focused and
|
||||
* zoomed. [ f _ c / d e a ].
|
||||
*/
|
||||
async function arrange(app: GridApp) {
|
||||
app.openTileGrid(SIX, { focusedId: 's-a' });
|
||||
flushFrames();
|
||||
app.removeTile('s-b');
|
||||
app._swapTiles('s-a', 's-f');
|
||||
dragColumn(app, 'col-0', 800, 1000);
|
||||
await app.selectSession('s-c');
|
||||
app.zoomTile('s-c');
|
||||
}
|
||||
|
||||
const layoutOf = (app: GridApp) => ({
|
||||
cells: app._tileGrid.cells.slice(),
|
||||
count: app._tileGrid.count,
|
||||
colFr: app._tileGrid.colFr.slice(),
|
||||
rowFr: app._tileGrid.rowFr.slice(),
|
||||
focused: app._tileGrid.focusedId,
|
||||
zoomed: app._tileGrid.zoomedId,
|
||||
});
|
||||
|
||||
const ARRANGED_CELLS = ['s-f', null, 's-c', 's-d', 's-e', 's-a'];
|
||||
|
||||
beforeEach(() => {
|
||||
resetGridHarness();
|
||||
bySelector.set('.terminal-wrap', new FakeEl());
|
||||
fetchSpy.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||
});
|
||||
|
||||
describe('saved on every change', () => {
|
||||
it('a move, a removal, a divider drag, a focus and a zoom are each written as they happen', async () => {
|
||||
const app = layoutApp();
|
||||
app.openTileGrid(SIX, { focusedId: 's-a' });
|
||||
flushFrames();
|
||||
expect(stored()).toMatchObject({ open: true, ids: SIX, count: 6, focused: 's-a', colFr: [1, 1, 1] });
|
||||
|
||||
app.removeTile('s-b');
|
||||
expect(stored()).toMatchObject({ ids: ['s-a', null, 's-c', 's-d', 's-e', 's-f'], count: 5 });
|
||||
|
||||
app._swapTiles('s-a', 's-f');
|
||||
expect(stored().ids).toEqual(ARRANGED_CELLS);
|
||||
|
||||
dragColumn(app, 'col-0', 800, 1000);
|
||||
expect(stored().colFr[0]).toBeGreaterThan(1);
|
||||
expect(stored().colFr).toEqual(app._tileGrid.colFr);
|
||||
|
||||
await app.selectSession('s-c');
|
||||
expect(stored().focused).toBe('s-c');
|
||||
|
||||
app.zoomTile('s-c');
|
||||
expect(stored().zoomed).toBe('s-c');
|
||||
});
|
||||
|
||||
it('adding a tile (a tab dropped on the empty cell) and picking a count are written, the count with them', () => {
|
||||
const app = layoutApp([...SIX, 's-g']);
|
||||
app.openTileGrid(SIX.slice(0, 5));
|
||||
flushFrames();
|
||||
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
|
||||
app._applyTileLayout();
|
||||
app.dropSessionOnSlot('s-g', 1);
|
||||
expect(stored()).toMatchObject({ ids: ['s-a', 's-g', 's-b', 's-c', 's-d', 's-e'], count: 6 });
|
||||
app._pickTileCount(4);
|
||||
expect(stored().count).toBe(4);
|
||||
expect(stored().ids.filter(Boolean)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('is never sent to the server', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
app.toggleTileGrid();
|
||||
app.toggleTileGrid();
|
||||
const calls = JSON.stringify(fetchSpy.mock.calls);
|
||||
expect(calls).not.toContain('tile');
|
||||
expect(calls).not.toContain('colFr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Tiles toggle (and a reload while it is open) brings back exactly what was arranged', () => {
|
||||
it('off and on: the cells and the hole, the count, the divider sizes, the focus and the zoom', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
const before = layoutOf(app);
|
||||
expect(before.cells).toEqual(ARRANGED_CELLS);
|
||||
|
||||
app.toggleTileGrid();
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5, focused: 's-c', zoomed: 's-c' });
|
||||
app.toggleTileGrid();
|
||||
expect(layoutOf(app)).toEqual(before);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
});
|
||||
|
||||
it('never filled to the remembered count, though more sessions are free', async () => {
|
||||
const app = layoutApp([...SIX, 's-g', 's-h']);
|
||||
await arrange(app);
|
||||
app._rememberTileGridCount(6);
|
||||
app.toggleTileGrid();
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(ARRANGED_CELLS);
|
||||
});
|
||||
|
||||
it('a reload with the grid open: the same layout on the next page', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
const before = layoutOf(app);
|
||||
// The page goes away; a fresh one on the same browser.
|
||||
section.children = [];
|
||||
FakeTile.all = [];
|
||||
const next = layoutApp();
|
||||
expect(next._restoreTileGrid()).toBe(true);
|
||||
expect(layoutOf(next)).toEqual(before);
|
||||
});
|
||||
|
||||
it('a reload after the grid was closed: the single view, and the Tiles toggle brings it back exactly', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
const before = layoutOf(app);
|
||||
app.toggleTileGrid();
|
||||
section.children = [];
|
||||
FakeTile.all = [];
|
||||
const next = layoutApp();
|
||||
// The reload leaves it closed (handleInit then selects the single view)...
|
||||
expect(next._restoreTileGrid()).toBe(false);
|
||||
expect(next._tilesOwnTerminal()).toBe(false);
|
||||
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS });
|
||||
// ...and remembered: the toggle puts it back as it was.
|
||||
next.toggleTileGrid();
|
||||
expect(layoutOf(next)).toEqual(before);
|
||||
});
|
||||
|
||||
it('never trimmed to a smaller window: the focused tile shows alone, and the arrangement is kept for a wider one', async () => {
|
||||
const app = layoutApp();
|
||||
app.openTileGrid(SIX, { focusedId: 's-c' });
|
||||
flushFrames();
|
||||
app.toggleTileGrid();
|
||||
// A window that fits four (3x2 needs 1440px across).
|
||||
const small = { width: 1300, height: 900, top: 0, left: 0, right: 1300, bottom: 900 };
|
||||
section.getBoundingClientRect = () => small;
|
||||
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = () => small;
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(SIX);
|
||||
expect(app._tileGrid.zoomedId).toBe('s-c');
|
||||
expect(app._tileGrid.autoZoom).toBe(true);
|
||||
expect(stored().ids).toEqual(SIX);
|
||||
app.toggleTileGrid();
|
||||
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = FakeEl.prototype.getBoundingClientRect;
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(SIX);
|
||||
expect(app._tileGrid.zoomedId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('every way the grid closes keeps it', () => {
|
||||
const closers: Array<[string, (app: GridApp) => unknown]> = [
|
||||
['the Tiles toggle', (app) => app.toggleTileGrid()],
|
||||
['picking a tab that is not tiled', (app) => app.selectSession('s-b').catch(() => {})],
|
||||
[
|
||||
'a followed link (leaveTiles)',
|
||||
(app) => app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {}),
|
||||
],
|
||||
['Home', (app) => app.goHome()],
|
||||
['"Open group as tiles" (its grid opens, then that one is kept)', () => {}],
|
||||
];
|
||||
|
||||
it.each(closers.slice(0, 4))('%s', async (_label, close) => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
const before = layoutOf(app);
|
||||
await close(app);
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5 });
|
||||
app.toggleTileGrid();
|
||||
expect(layoutOf(app)).toEqual(before);
|
||||
});
|
||||
|
||||
it('the window narrowing past the desktop gate', async () => {
|
||||
const gates: Array<(e: { matches: boolean }) => void> = [];
|
||||
windowStub.matchMedia = (q: string) => ({
|
||||
matches: false,
|
||||
addEventListener: (_t: string, cb: (e: { matches: boolean }) => void) => {
|
||||
if (q.includes('min-width')) gates.push(cb);
|
||||
},
|
||||
});
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
const before = layoutOf(app);
|
||||
for (const gate of gates) gate({ matches: false });
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
app.toggleTileGrid();
|
||||
expect(layoutOf(app)).toEqual(before);
|
||||
});
|
||||
|
||||
it('"Open group as tiles" replaces it with the group, and THAT is what comes back', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
app.tabLayout = {
|
||||
groups: [
|
||||
{
|
||||
id: 'g',
|
||||
refs: [
|
||||
{ kind: 'session', id: 's-b' },
|
||||
{ kind: 'session', id: 's-d' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
app.openGroupAsTiles('g');
|
||||
expect(app._tileGrid.ids).toEqual(['s-b', 's-d']);
|
||||
app.toggleTileGrid();
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(['s-b', 's-d']);
|
||||
});
|
||||
|
||||
it('the last tile removed: that tile comes back', () => {
|
||||
const app = layoutApp();
|
||||
app.openTileGrid(['s-c', 's-d'], { focusedId: 's-c' });
|
||||
app.removeTile('s-d');
|
||||
app.removeTile('s-c');
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
expect(stored()).toMatchObject({ open: false, ids: ['s-c'], count: 1 });
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(['s-c']);
|
||||
});
|
||||
|
||||
it('a #session= link on load closes it, its value kept as stored (a session gone since still frees its cell)', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
const page = layoutApp();
|
||||
page.sessions.delete('s-d');
|
||||
page._closeStoredTileGrid();
|
||||
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5 });
|
||||
page.toggleTileGrid();
|
||||
// s-d's cell taken by the ranking's best untiled session (all quiet here: tab order, s-other first).
|
||||
expect(page._tileGrid.cells).toEqual(['s-f', null, 's-c', 's-other', 's-e', 's-a']);
|
||||
});
|
||||
|
||||
it('closing the last tiled session: kept, and with none of its sessions left the ranking opens from scratch', () => {
|
||||
const app = layoutApp();
|
||||
app.openTileGrid(['s-c'], { focusedId: 's-c' });
|
||||
app._onSessionDeleted({ id: 's-c' });
|
||||
app.sessions.delete('s-c');
|
||||
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-c');
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
expect(stored()).toMatchObject({ open: false, ids: ['s-c'] });
|
||||
Object.assign(app.sessions.get('s-e'), { status: 'busy', lastSubmitAt: 50 });
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids[0]).toBe('s-e');
|
||||
expect(app._tileGrid.ids).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('killing every session: kept, naming only gone sessions, so the next click ranks from scratch', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
app._apiDelete = vi.fn(async () => ({ ok: true }));
|
||||
app.terminalBuffers = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
// killAllSessions asks with a bare confirm(): the harness context's own global.
|
||||
const contextGlobal = (app.toggleTileGrid.constructor as FunctionConstructor)('return globalThis')();
|
||||
contextGlobal.confirm = () => true;
|
||||
try {
|
||||
await app.killAllSessions();
|
||||
} finally {
|
||||
delete contextGlobal.confirm;
|
||||
}
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS });
|
||||
// New sessions later: none of the stored ones survive.
|
||||
app.sessions = new Map(['n-1', 'n-2'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1 }]));
|
||||
app.sessionOrder = ['n-1', 'n-2'];
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(['n-1', 'n-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a session that went away by itself while the grid was open', () => {
|
||||
// With the cap at 6 a grid has at most one empty cell (5 tiles in a 3x2, or
|
||||
// 3 in a narrow 2x2), so the stored count alone says that cell is to be filled.
|
||||
function six(extra: string[] = []): GridApp {
|
||||
const app = layoutApp([...SIX, ...extra]);
|
||||
app.openTileGrid(SIX, { focusedId: 's-a' });
|
||||
flushFrames();
|
||||
return app;
|
||||
}
|
||||
|
||||
it('closed from elsewhere: its cell empties now, and the ranking fills it the next time the grid opens', () => {
|
||||
const app = six(['s-g']);
|
||||
app._onSessionDeleted({ id: 's-d' });
|
||||
app.sessions.delete('s-d');
|
||||
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-d');
|
||||
// Nothing joins on its own while the grid is open.
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null, 's-e', 's-f']);
|
||||
// The count stays what the user left: six.
|
||||
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', null, 's-e', 's-f'], count: 6 });
|
||||
Object.assign(app.sessions.get('s-g'), { status: 'busy', lastSubmitAt: 10 });
|
||||
app.toggleTileGrid();
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-g', 's-e', 's-f']);
|
||||
});
|
||||
|
||||
it('popped out (or its socket refused): the same, the place kept for the ranking', () => {
|
||||
const app = six();
|
||||
app.detachedSessions.add('s-e');
|
||||
app._reconcileTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-f']);
|
||||
FakeTile.all.find((t) => t.sessionId === 's-b' && !t._destroyed)!.onExit!(4010);
|
||||
expect(stored().count).toBe(6);
|
||||
app.toggleTileGrid();
|
||||
app.toggleTileGrid();
|
||||
// Two places to fill, the two sessions left that are not popped out (all quiet: tab order).
|
||||
expect(app._tileGrid.ids).toHaveLength(6);
|
||||
expect(app._tileGrid.ids).not.toContain('s-e');
|
||||
});
|
||||
|
||||
it('popped out while open (the pop-out button, or another tab announcing it): the count stays, the cell refills after re-dock', () => {
|
||||
const app = six();
|
||||
// What the real pop-out paths touch: detachSession and the window channel
|
||||
// both mark through _markDetached; _redock clears it again.
|
||||
app._detachOrphanStrikes = new Map();
|
||||
app._detachWatchTimers = new Map();
|
||||
app._redockGrace = new Map();
|
||||
app.detachedWindows = new Map();
|
||||
app._elemCache = {};
|
||||
app._onWindowMessage({ type: 'detached', id: 's-e' });
|
||||
expect(app.detachedSessions.has('s-e')).toBe(true);
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-f']);
|
||||
// Not the user's hand: the count stays six, the same as a pop-out with the grid closed.
|
||||
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', 's-d', null, 's-f'], count: 6 });
|
||||
app._redock('s-e');
|
||||
expect(app.detachedSessions.has('s-e')).toBe(false);
|
||||
app.toggleTileGrid();
|
||||
app.toggleTileGrid();
|
||||
// The freed cell is filled from the ranking (all quiet here: tab order, s-other first).
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', 's-other', 's-f']);
|
||||
expect(stored().count).toBe(6);
|
||||
});
|
||||
|
||||
it('removed by hand instead: the hole stays a hole', () => {
|
||||
const app = six(['s-g']);
|
||||
app.removeTile('s-d');
|
||||
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', null, 's-e', 's-f'], count: 5 });
|
||||
app.toggleTileGrid();
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null, 's-e', 's-f']);
|
||||
});
|
||||
|
||||
it('a tile added by hand meanwhile sets the count to what is shown', () => {
|
||||
const app = six(['s-g']);
|
||||
app._onSessionDeleted({ id: 's-d' });
|
||||
app.sessions.delete('s-d');
|
||||
app._onSessionDeleted({ id: 's-e' });
|
||||
app.sessions.delete('s-e');
|
||||
// Four tiles now (a 2x2), count still six; then a Ctrl/Cmd+click adds one.
|
||||
app.addSessionToTiles('s-g');
|
||||
expect(app._tileGrid.ids).toHaveLength(5);
|
||||
expect(stored().count).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stored format', () => {
|
||||
it('a value written before the count (packed, no holes) loads, its sessions in their cells', () => {
|
||||
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-c', 's-a', 's-e', 's-d'], focused: 's-e' }));
|
||||
const app = layoutApp();
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-e', 's-d']);
|
||||
expect(app.activeSessionId).toBe('s-e');
|
||||
expect(stored().count).toBe(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['another version', JSON.stringify({ v: 2, open: false, ids: SIX })],
|
||||
['not JSON', '{oops'],
|
||||
['an array', '[1,2]'],
|
||||
['ids that are not a list', JSON.stringify({ v: 1, open: false, ids: 's-a' })],
|
||||
['nothing but junk', JSON.stringify({ v: 1, open: false, ids: [1, {}, null], count: 'x', colFr: 'y' })],
|
||||
])('a malformed value (%s) is ignored: the grid opens from the ranking', (_label, raw) => {
|
||||
localStore.set(KEY, raw);
|
||||
const app = layoutApp();
|
||||
Object.assign(app.sessions.get('s-f'), { status: 'busy', lastSubmitAt: 99 });
|
||||
expect(() => app.toggleTileGrid()).not.toThrow();
|
||||
expect(app._tileGrid.ids[0]).toBe('s-f');
|
||||
expect(app._tileGrid.ids).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('a malformed divider size is dropped, the rest of the layout kept', () => {
|
||||
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-a', 's-b'], colFr: [1, 'x'], rowFr: [-1] }));
|
||||
const app = layoutApp();
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b']);
|
||||
expect(app._tileGrid.colFr).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ctrl/Cmd+click with a stored grid', () => {
|
||||
it('joins the hole while the grid holds fewer than the count, the rest kept where it was', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
app.toggleTileGrid();
|
||||
app._rememberTileGridCount(6);
|
||||
app.addSessionToTiles('s-b');
|
||||
expect(app._tileGrid.cells).toEqual(['s-f', 's-b', 's-c', 's-d', 's-e', 's-a']);
|
||||
expect(app._tileGrid.focusedId).toBe('s-b');
|
||||
expect(app._tileGrid.colFr).toEqual(stored().colFr);
|
||||
});
|
||||
|
||||
it('at the count, it takes the last tile’s place: the count in total, never one more', async () => {
|
||||
const app = layoutApp();
|
||||
await arrange(app);
|
||||
app.toggleTileGrid();
|
||||
app._rememberTileGridCount(4);
|
||||
app.addSessionToTiles('s-b');
|
||||
expect(app._tileGrid.cells).toEqual(['s-f', null, 's-c', 's-d', 's-e', 's-b']);
|
||||
expect(app._tileGrid.focusedId).toBe('s-b');
|
||||
expect(stored().count).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the hover card', () => {
|
||||
it('with a stored grid larger than the window, says what fits, not "a click opens N"', () => {
|
||||
const btn = new FakeEl();
|
||||
btn.className = 'btn-icon-header btn-tile-grid';
|
||||
bySelector.set('.btn-tile-grid', btn);
|
||||
const small = { width: 1300, height: 900, top: 0, left: 0, right: 1300, bottom: 900 };
|
||||
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = () => small;
|
||||
const app = layoutApp();
|
||||
app._applyTileGridButtonVisibility(true);
|
||||
const card = body.children.find((c) => c.id === 'tileGridHint')!;
|
||||
const texts = () => card.children.map((c) => c.children.at(-1)?.textContent ?? c.textContent);
|
||||
// Nothing stored: the click opens what fits.
|
||||
expect(texts()).toContain('This window fits 4 tiles: a click opens 4');
|
||||
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: SIX, focused: 's-a' }));
|
||||
app._renderTileHint();
|
||||
expect(texts()).toContain('This window fits 4 tiles');
|
||||
expect(texts().join('\n')).not.toContain('a click opens');
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,9 @@ describe('sanitizeTileGridState', () => {
|
||||
open: true,
|
||||
ids: ['a', 'b'],
|
||||
cells: ['a', 'b'],
|
||||
// Nothing freed; no stored count: the sessions it names.
|
||||
freed: [],
|
||||
count: 2,
|
||||
focused: 'b',
|
||||
zoomed: 'a',
|
||||
colFr: [1, 2],
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
*
|
||||
* - `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 tab order 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.
|
||||
* 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.
|
||||
@@ -44,6 +45,7 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
||||
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(
|
||||
@@ -59,9 +61,9 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('c: else the open sessions in tab order up to the limit, the active one focused', () => {
|
||||
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: 'tabs',
|
||||
source: 'ranked',
|
||||
ids: order.slice(0, 6),
|
||||
focusedId: 't3',
|
||||
});
|
||||
@@ -69,7 +71,7 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
||||
|
||||
it('c: an active session past the limit still comes, with the first ones before it', () => {
|
||||
expect(T.tileGridOpenSet({ ...base, activeId: 't9' })).toEqual({
|
||||
source: 'tabs',
|
||||
source: 'ranked',
|
||||
ids: ['t1', 't2', 't3', 't4', 't5', 't9'],
|
||||
focusedId: 't9',
|
||||
});
|
||||
@@ -171,7 +173,7 @@ describe('opening at once, in the app', () => {
|
||||
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||
expect(app._tilesOwnTerminal()).toBe(true);
|
||||
pick(4);
|
||||
// Its two first, then the open sessions in tab order.
|
||||
// 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() });
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @fileoverview Which sessions the tile grid takes when nobody said which
|
||||
* (owner request: "prefer to load in tiles that are working and then the most
|
||||
* recent, so the oldest don't get opened").
|
||||
*
|
||||
* - `rankTileSessions` (constants.js, pure): WORKING first, the most recently
|
||||
* started turn first (keyed off `lastSubmitAt` only: a working pane's
|
||||
* last-activity stamp is always "now"); then the ones that NEED INPUT (the
|
||||
* red and yellow tab alerts: `needs` and `waiting`), most recent first; then
|
||||
* every other one, most recently active first. A missing stamp (0) sorts
|
||||
* last in its group, tab order breaks ties, so the order never shuffles.
|
||||
* - `tileGridOpenSet` case c opens the ranking's best up to the limit, the
|
||||
* active session always among them and focused, detached ones never.
|
||||
* - In the app (`_tileGridRanking`, the home screens' `_mobileOverviewState`
|
||||
* and stamps): the Tiles button with nothing stored, a count picked in its
|
||||
* menu, Ctrl/Cmd+click and an open split's fill all take the ranking; a stale
|
||||
* page without the classifier falls back to tab order.
|
||||
*
|
||||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeEl, bySelector, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js';
|
||||
|
||||
type Row = { id: string; state: string; lastActivityAt?: number; lastSubmitAt?: number; orderIndex?: number };
|
||||
type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null;
|
||||
type Helpers = {
|
||||
rankTileSessions(rows: unknown): string[];
|
||||
tileGridOpenSet(p: Record<string, unknown>): OpenSet;
|
||||
TILE_GRID_MAX: number;
|
||||
};
|
||||
|
||||
const T = windowStub.CodemanTileGrid as Helpers;
|
||||
const rank = (rows: Row[]) => T.rankTileSessions(rows.map((r, orderIndex) => ({ orderIndex, ...r })));
|
||||
|
||||
describe('rankTileSessions', () => {
|
||||
it('working first, then needing input (red and yellow), then everything else', () => {
|
||||
expect(
|
||||
rank([
|
||||
{ id: 'idle', state: 'idle', lastActivityAt: 900 },
|
||||
{ id: 'done', state: 'done', lastActivityAt: 800 },
|
||||
{ id: 'waiting', state: 'waiting', lastActivityAt: 100 },
|
||||
{ id: 'error', state: 'error', lastActivityAt: 950 },
|
||||
{ id: 'working', state: 'working', lastSubmitAt: 10 },
|
||||
{ id: 'needs', state: 'needs', lastActivityAt: 50 },
|
||||
])
|
||||
).toEqual(['working', 'waiting', 'needs', 'error', 'idle', 'done']);
|
||||
});
|
||||
|
||||
it('working: the most recently started turn first', () => {
|
||||
expect(
|
||||
rank([
|
||||
{ id: 'old', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 9_000 },
|
||||
{ id: 'new', state: 'working', lastSubmitAt: 5_000, lastActivityAt: 9_000 },
|
||||
{ id: 'mid', state: 'working', lastSubmitAt: 3_000, lastActivityAt: 9_000 },
|
||||
])
|
||||
).toEqual(['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
it('working keys off lastSubmitAt only: a pane that never submitted does not take the head on its repaint stamp', () => {
|
||||
expect(
|
||||
rank([
|
||||
{ id: 'never-submitted', state: 'working', lastSubmitAt: 0, lastActivityAt: 99_999 },
|
||||
{ id: 'submitted-long-ago', state: 'working', lastSubmitAt: 1, lastActivityAt: 99_998 },
|
||||
])
|
||||
).toEqual(['submitted-long-ago', 'never-submitted']);
|
||||
});
|
||||
|
||||
it('needing input is one group, most recent first, whichever alert it raised', () => {
|
||||
expect(
|
||||
rank([
|
||||
{ id: 'red-old', state: 'needs', lastActivityAt: 100 },
|
||||
{ id: 'yellow-new', state: 'waiting', lastActivityAt: 300 },
|
||||
{ id: 'red-new', state: 'needs', lastActivityAt: 200 },
|
||||
])
|
||||
).toEqual(['yellow-new', 'red-new', 'red-old']);
|
||||
});
|
||||
|
||||
it('the rest: most recently active first, the oldest last', () => {
|
||||
expect(
|
||||
rank([
|
||||
{ id: 'yesterday', state: 'idle', lastActivityAt: 1_000 },
|
||||
{ id: 'just-now', state: 'done', lastActivityAt: 9_000 },
|
||||
{ id: 'an-hour-ago', state: 'error', lastActivityAt: 5_000 },
|
||||
])
|
||||
).toEqual(['just-now', 'an-hour-ago', 'yesterday']);
|
||||
});
|
||||
|
||||
it('no stamp sorts last in its group; equal stamps keep tab order, so it never shuffles', () => {
|
||||
const rows: Row[] = [
|
||||
{ id: 'a', state: 'idle', orderIndex: 0 },
|
||||
{ id: 'b', state: 'idle', lastActivityAt: 10, orderIndex: 1 },
|
||||
{ id: 'c', state: 'idle', orderIndex: 2 },
|
||||
{ id: 'd', state: 'idle', lastActivityAt: 10, orderIndex: 3 },
|
||||
{ id: 'w', state: 'working', orderIndex: 4 },
|
||||
{ id: 'v', state: 'working', lastSubmitAt: 5, orderIndex: 5 },
|
||||
];
|
||||
const expected = ['v', 'w', 'b', 'd', 'a', 'c'];
|
||||
expect(T.rankTileSessions(rows)).toEqual(expected);
|
||||
expect(T.rankTileSessions([...rows].reverse())).toEqual(expected);
|
||||
});
|
||||
|
||||
it('an unknown state counts as "everything else"', () => {
|
||||
expect(
|
||||
rank([
|
||||
{ id: 'odd', state: 'mystery', lastActivityAt: 9 },
|
||||
{ id: 'idle', state: 'idle', lastActivityAt: 5 },
|
||||
{ id: 'busy', state: 'working', lastSubmitAt: 1 },
|
||||
])
|
||||
).toEqual(['busy', 'odd', 'idle']);
|
||||
});
|
||||
|
||||
it('drops malformed rows and repeats, and never sorts its input in place', () => {
|
||||
const rows = [
|
||||
{ id: 'b', state: 'idle', lastActivityAt: 1, orderIndex: 1 },
|
||||
null,
|
||||
{ id: '', state: 'working' },
|
||||
{ id: 'a', state: 'working', lastSubmitAt: 1, orderIndex: 0 },
|
||||
{ id: 'b', state: 'idle', lastActivityAt: 1, orderIndex: 2 },
|
||||
];
|
||||
const copy = JSON.stringify(rows);
|
||||
expect(T.rankTileSessions(rows)).toEqual(['a', 'b']);
|
||||
expect(JSON.stringify(rows)).toBe(copy);
|
||||
expect(T.rankTileSessions(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tileGridOpenSet with a ranking (case c)', () => {
|
||||
const order = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8'];
|
||||
const sessions = new Map(order.map((id) => [id, { id }]));
|
||||
const ranked = ['t8', 't6', 't1', 't3', 't7', 't2', 't5', 't4'];
|
||||
const base = { sessions, sessionOrder: order, ranked, limit: 4 };
|
||||
|
||||
it('opens the best of the ranking, not the first tabs, the active one focused', () => {
|
||||
expect(T.tileGridOpenSet({ ...base, activeId: 't6' })).toEqual({
|
||||
source: 'ranked',
|
||||
ids: ['t8', 't6', 't1', 't3'],
|
||||
focusedId: 't6',
|
||||
});
|
||||
});
|
||||
|
||||
it('an active session the ranking puts past the limit still comes, in the last place', () => {
|
||||
expect(T.tileGridOpenSet({ ...base, activeId: 't4' })?.ids).toEqual(['t8', 't6', 't1', 't4']);
|
||||
});
|
||||
|
||||
it('never a detached session or one that no longer exists', () => {
|
||||
const out = T.tileGridOpenSet({
|
||||
...base,
|
||||
ranked: ['gone', ...ranked],
|
||||
detachedIds: new Set(['t6']),
|
||||
activeId: null,
|
||||
});
|
||||
expect(out?.ids).toEqual(['t8', 't1', 't3', 't7']);
|
||||
expect(out?.focusedId).toBe('t8');
|
||||
});
|
||||
|
||||
it('without a ranking it is tab order, as before', () => {
|
||||
expect(T.tileGridOpenSet({ sessions, sessionOrder: order, limit: 3, activeId: null })?.ids).toEqual([
|
||||
't1',
|
||||
't2',
|
||||
't3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in the app', () => {
|
||||
// Eight sessions, tab order s-1 .. s-8 (plus the harness's s-other, first and quiet).
|
||||
const IDS = ['s-1', 's-2', 's-3', 's-4', 's-5', 's-6', 's-7', 's-8'];
|
||||
|
||||
/** Statuses, hooks and stamps: two working, one red, one yellow, four quiet. */
|
||||
function rankedApp(): GridApp {
|
||||
const app = makeGridApp(IDS);
|
||||
const set = (id: string, fields: Record<string, unknown>) => Object.assign(app.sessions.get(id), fields);
|
||||
set('s-other', { status: 'idle', lastActivityAt: 1_000 });
|
||||
set('s-1', { status: 'idle', lastActivityAt: 2_000 }); // the oldest quiet one
|
||||
set('s-2', { status: 'busy', lastSubmitAt: 5_000, lastActivityAt: 99_000 }); // working, older turn
|
||||
set('s-3', { status: 'idle', lastActivityAt: 8_000 });
|
||||
set('s-4', { status: 'idle', lastActivityAt: 7_000 }); // red: a permission dialog
|
||||
set('s-5', { status: 'stopped', lastActivityAt: 3_000 });
|
||||
set('s-6', { status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 99_000 }); // working, newest turn
|
||||
set('s-7', { status: 'idle', lastActivityAt: 6_000 }); // yellow: a finished turn not seen
|
||||
set('s-8', { status: 'idle', lastActivityAt: 9_500 });
|
||||
app.pendingHooks.set('s-4', new Set(['permission_prompt']));
|
||||
app.pendingHooks.set('s-7', new Set(['idle_prompt']));
|
||||
app.activeSessionId = null;
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetGridHarness();
|
||||
bySelector.set('.terminal-wrap', new FakeEl());
|
||||
});
|
||||
|
||||
it('ranks with the home screens’ classification and stamps', () => {
|
||||
expect(rankedApp()._tileGridRanking()).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-3', 's-5', 's-1', 's-other']);
|
||||
});
|
||||
|
||||
it('the Tiles button with nothing stored opens working, then needing input, then the most recent; the oldest stay out', () => {
|
||||
const app = rankedApp();
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-3']);
|
||||
expect(app._tileGrid.ids).not.toContain('s-1');
|
||||
expect(app._tileGrid.ids).not.toContain('s-other');
|
||||
// No active session: the best ranked takes focus.
|
||||
expect(app._tileGrid.focusedId).toBe('s-6');
|
||||
});
|
||||
|
||||
it('the active session always comes and takes focus, even ranked last', () => {
|
||||
const app = rankedApp();
|
||||
app.activeSessionId = 's-1';
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-1']);
|
||||
expect(app._tileGrid.focusedId).toBe('s-1');
|
||||
});
|
||||
|
||||
it('the remembered count takes that many from the top; a detached session is never tiled', () => {
|
||||
const app = rankedApp();
|
||||
app.detachedSessions.add('s-2');
|
||||
app._rememberTileGridCount(4);
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(['s-6', 's-4', 's-7', 's-8']);
|
||||
});
|
||||
|
||||
it('a count picked in the menu fills an open grid from the ranking', () => {
|
||||
const app = rankedApp();
|
||||
app.openTileGrid(['s-1', 's-5'], { focusedId: 's-5' });
|
||||
app._pickTileCount(4);
|
||||
expect(app._tileGrid.cells).toEqual(['s-1', 's-5', 's-6', 's-2']);
|
||||
expect(app._tileGrid.focusedId).toBe('s-5');
|
||||
});
|
||||
|
||||
it('Ctrl/Cmd+click with nothing stored: the clicked one plus the best of the ranking, the count in total', () => {
|
||||
const app = rankedApp();
|
||||
app._rememberTileGridCount(4);
|
||||
app.addSessionToTiles('s-5');
|
||||
expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-5']);
|
||||
expect(app._tileGrid.focusedId).toBe('s-5');
|
||||
});
|
||||
|
||||
it('an open split comes first (case b), the rest from the ranking', () => {
|
||||
const app = rankedApp();
|
||||
app.activeSessionId = 's-1';
|
||||
app._splitPane = {};
|
||||
app._splitSessionId = 's-5';
|
||||
app.closeSplitPane = vi.fn(() => {
|
||||
app._splitPane = null;
|
||||
});
|
||||
app._rememberTileGridCount(4);
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(['s-1', 's-5', 's-6', 's-2']);
|
||||
expect(app._tileGrid.focusedId).toBe('s-1');
|
||||
});
|
||||
|
||||
it('a page without the classifier (a stale cached mobile-overview.js) falls back to tab order', () => {
|
||||
const app = rankedApp();
|
||||
app._mobileOverviewState = undefined;
|
||||
expect(app._tileGridRanking()).toEqual(['s-other', ...IDS]);
|
||||
});
|
||||
});
|
||||
+114
-30
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* @fileoverview The grid survives a page reload, per device (`codeman:tile-grid`).
|
||||
*
|
||||
* - What is stored: ids, focus, a zoom the user chose and the divider
|
||||
* fractions, never content. Closing the grid (Tiles, a pick outside it)
|
||||
* keeps it remembered as `open: false` for one-click return; the last tile
|
||||
* leaving forgets it. Never written or read in a solo window.
|
||||
* - What is stored: ids (the cells), the tile count, focus, a zoom the user
|
||||
* chose and the divider fractions, never content. Closing the grid (Tiles,
|
||||
* a pick outside it, the last tile leaving) keeps it remembered as
|
||||
* `open: false` for one-click return, and the toggle brings it back exactly
|
||||
* (owner request, superseding decision 10's "the count wins over a
|
||||
* remembered grid's size"; the full set of close paths is in
|
||||
* tile-grid-layout-memory.test.ts). Never written or read in a solo window.
|
||||
* - The restore runs INSIDE handleInit, in place of its single-view
|
||||
* `selectSession(restoreId, { auto: true })`: with a stored open grid the
|
||||
* main terminal never loads on that page load (no select, no socket, no
|
||||
* capture), deleted / detached / duplicate ids are dropped, and the stored
|
||||
* fractions and zoom come back. A narrow window keeps the single view.
|
||||
* capture), a deleted or detached session frees its cell for the ranking
|
||||
* to fill (on status and stamps: pending approvals are seeded after, and
|
||||
* never re-form the restored grid), duplicate ids are dropped, and the
|
||||
* stored fractions and zoom come back. A narrow window keeps the single view.
|
||||
* - A `#session=` link on load wins, and leaves the stored grid remembered
|
||||
* but closed.
|
||||
* - Leaving the grid invalidates the main terminal's cached content for every
|
||||
@@ -25,7 +30,15 @@ const KEY = 'codeman:tile-grid';
|
||||
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
||||
|
||||
/** A fresh page: the app as handleInit leaves it on its FIRST run (gen 1). */
|
||||
function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
|
||||
function pageLoad(
|
||||
liveIds: string[],
|
||||
setup: (app: GridApp) => void = () => {},
|
||||
{
|
||||
sessions = liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
|
||||
sessionOrder,
|
||||
realOrder = false,
|
||||
}: { sessions?: Array<Record<string, unknown>>; sessionOrder?: string[]; realOrder?: boolean } = {}
|
||||
) {
|
||||
const app = makeGridApp(IDS);
|
||||
app._initGeneration = 0;
|
||||
app.activeSessionId = null;
|
||||
@@ -35,7 +48,7 @@ function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
|
||||
for (const name of [
|
||||
'_clearTimer',
|
||||
'_updateCjkInputState',
|
||||
'syncSessionOrder',
|
||||
...(realOrder ? [] : ['syncSessionOrder']),
|
||||
'_loadTabLayout',
|
||||
'cleanupAllFloatingWindows',
|
||||
'startSystemStatsPolling',
|
||||
@@ -46,10 +59,7 @@ function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
|
||||
}
|
||||
app.$ = () => null;
|
||||
setup(app);
|
||||
app.handleInit({
|
||||
sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
|
||||
scheduledRuns: [],
|
||||
});
|
||||
app.handleInit({ sessions, scheduledRuns: [], ...(sessionOrder ? { sessionOrder } : {}) });
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -58,13 +68,14 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('what is stored', () => {
|
||||
it('opening the grid stores ids, focus and fractions, nothing else', () => {
|
||||
it('opening the grid stores ids, its count, focus and fractions, nothing else', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS, { focusedId: 's-b' });
|
||||
expect(stored()).toEqual({
|
||||
v: 1,
|
||||
open: true,
|
||||
ids: IDS,
|
||||
count: 4,
|
||||
focused: 's-b',
|
||||
zoomed: null,
|
||||
colFr: [1, 1],
|
||||
@@ -94,7 +105,7 @@ describe('what is stored', () => {
|
||||
expect(stored().zoomed).toBeNull();
|
||||
});
|
||||
|
||||
it('closing keeps it remembered (open: false); the last tile leaving forgets it', () => {
|
||||
it('closing keeps it remembered (open: false); the last tile leaving keeps it too', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.selectSession = vi.fn();
|
||||
app.openTileGrid(IDS);
|
||||
@@ -103,7 +114,7 @@ describe('what is stored', () => {
|
||||
|
||||
app.openTileGrid(['s-a']);
|
||||
app.removeTile('s-a');
|
||||
expect(localStore.has(KEY)).toBe(false);
|
||||
expect(stored()).toMatchObject({ open: false, ids: ['s-a'], count: 1 });
|
||||
});
|
||||
|
||||
it('a solo window never writes', () => {
|
||||
@@ -134,12 +145,81 @@ describe('page load with a stored open grid', () => {
|
||||
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a session that no longer exists, and stores the cleaned list', () => {
|
||||
it('a session that no longer exists frees its cell, which the ranking fills; that is stored', () => {
|
||||
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 'gone' });
|
||||
const app = pageLoad(IDS);
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
|
||||
// Every session quiet and unstamped here: the ranking is tab order (s-c first).
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-b']);
|
||||
expect(app.activeSessionId).toBe('s-a');
|
||||
expect(stored().ids).toEqual(['s-a', 's-b']);
|
||||
expect(stored().ids).toEqual(['s-a', 's-c', 's-b']);
|
||||
});
|
||||
|
||||
it('the reload fill ranks with the init payload: its states, stamps and synced tab order', () => {
|
||||
// The tab order comes from the server snapshot (synced before the restore
|
||||
// runs); the states from the session payload. Pending approvals arrive
|
||||
// later (seedApprovals is async), so only status and stamps rank here.
|
||||
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 's-a' });
|
||||
const now = 1_000_000;
|
||||
const app = pageLoad(IDS, () => {}, {
|
||||
realOrder: true,
|
||||
sessionOrder: ['s-c', 's-b', 's-a', 's-d'],
|
||||
sessions: [
|
||||
{ id: 's-a', name: 's-a', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now },
|
||||
{ id: 's-b', name: 's-b', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now },
|
||||
{ id: 's-c', name: 's-c', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 10 },
|
||||
{ id: 's-d', name: 's-d', mode: 'claude', pid: 1, status: 'busy', lastSubmitAt: now - 500 },
|
||||
],
|
||||
});
|
||||
expect(app.sessionOrder).toEqual(['s-c', 's-b', 's-a', 's-d']);
|
||||
// s-d is working: it takes the freed cell, though s-c comes first in the tab order.
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-d', 's-b']);
|
||||
});
|
||||
|
||||
it('pending approvals seeded after the reload never re-form the restored grid (known limit of the reload fill)', async () => {
|
||||
// seedApprovals is async (GET /api/approvals), so the freed cell is filled
|
||||
// from status and stamps alone. A needs-input session the seed reveals a
|
||||
// moment later does not take a tile from the grid already back on screen:
|
||||
// a late fill would reshape it (fewer tiles opened is another shape) and
|
||||
// move the user's tiles a second after the reload.
|
||||
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 's-a' });
|
||||
const now = 1_000_000;
|
||||
let release!: () => void;
|
||||
const app = pageLoad(
|
||||
IDS,
|
||||
(a) => {
|
||||
a.tabAlerts = new Map();
|
||||
a.seedApprovals = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
a.setPendingHook('s-d', 'permission_prompt');
|
||||
resolve();
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
{
|
||||
sessions: [
|
||||
{ id: 's-a', name: 's-a', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now },
|
||||
{ id: 's-b', name: 's-b', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now },
|
||||
{ id: 's-c', name: 's-c', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 10 },
|
||||
{ id: 's-d', name: 's-d', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 500 },
|
||||
],
|
||||
}
|
||||
);
|
||||
expect(app.seedApprovals).toHaveBeenCalledTimes(1);
|
||||
// Before the seed lands: all quiet, the most recent (s-c) takes the freed cell.
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-b']);
|
||||
const before = stored();
|
||||
release();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// The seed made s-d need input: the ranking now puts it first...
|
||||
expect(app._tileGridRanking()[0]).toBe('s-d');
|
||||
app._renderTileChrome();
|
||||
// ...but the restored grid and the stored layout stay as they came back.
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-b']);
|
||||
expect(stored()).toEqual(before);
|
||||
});
|
||||
|
||||
it('brings back the fractions (same layout only) and a zoom the user chose', () => {
|
||||
@@ -179,26 +259,30 @@ 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").
|
||||
it('the toggle brings a stored grid back exactly, never filled to the remembered count', () => {
|
||||
// Owner request ("always keep what the last setting was"), superseding
|
||||
// decision 10's answer that the count wins over a remembered grid's size.
|
||||
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._tileGrid.cells).toEqual(['s-b', 's-c']);
|
||||
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'));
|
||||
it('a stored hole stays a hole on the toggle; a count picked in the menu fills it first', () => {
|
||||
// A 3x2 of five with the second cell empty; the remembered count is six.
|
||||
const cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
|
||||
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: cells, focused: 's-b' }));
|
||||
const app = pageLoad([...IDS, 's-e', 's-f']);
|
||||
// (syncSessionOrder is stubbed here: the tab order the ranking reads.)
|
||||
app.sessionOrder = [...IDS, 's-e', 's-f'];
|
||||
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']);
|
||||
expect(app._tileGrid.cells).toEqual(cells);
|
||||
app.closeTileGrid({ reselect: false });
|
||||
app._pickTileCount(6);
|
||||
// s-f joins in the hole (packing would have put it last).
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-f', 's-b', 's-c', 's-d', 's-e']);
|
||||
});
|
||||
|
||||
it('a reload brings back exactly the stored grid, whatever the count', () => {
|
||||
|
||||
@@ -243,14 +243,15 @@ describe('the capture-phase handler', () => {
|
||||
});
|
||||
|
||||
describe('the actions', () => {
|
||||
it('the toggle brings back the grid this tab last left, focus included, filled to the count', () => {
|
||||
it('the toggle brings back exactly the grid this tab last left, focus included, never filled to the count', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.selectSession = vi.fn();
|
||||
app.openTileGrid(IDS, { focusedId: 's-c' });
|
||||
app.closeTileGrid({ reselect: false });
|
||||
app.toggleTileGrid();
|
||||
// Its three first, then the open sessions in tab order (the default count, 6).
|
||||
expect(app._tileGrid.ids).toEqual([...IDS, 's-other']);
|
||||
// Its three, as left (owner request: "always keep what the last setting
|
||||
// was"), though the default count is 6 and s-other is free.
|
||||
expect(app._tileGrid.ids).toEqual(IDS);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
});
|
||||
|
||||
@@ -261,7 +262,7 @@ describe('the actions', () => {
|
||||
app.sessions.delete('s-b');
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).not.toContain('s-b');
|
||||
// Its cell stays where it was, and the count fills it first (s-other, tab order).
|
||||
// Its cell stays where it was, and the ranking fills it (s-other, the only one left).
|
||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-c']);
|
||||
});
|
||||
|
||||
|
||||
@@ -89,8 +89,9 @@ describe('opening the grid over an open split', () => {
|
||||
|
||||
expect(paneB.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(app._splitPane).toBeNull();
|
||||
// Filled from tab order (s-other first), not seeded with the split's two.
|
||||
expect(app._tileGrid.ids).toEqual(['s-c', 's-other']);
|
||||
// Exactly as stored (owner request: the last arrangement comes back), not
|
||||
// seeded with the split's two, nor filled to the count.
|
||||
expect(app._tileGrid.ids).toEqual(['s-c']);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
// Pane A (s-a) is not a tile: the split's closing resize gave it its full width back.
|
||||
expect(app.sendResize.mock.calls).toEqual([['s-a', { force: true }]]);
|
||||
|
||||
Reference in New Issue
Block a user