mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 10:19:42 +02:00
feat(tiles): keep the arranged layout per browser across toggles and reloads
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>
This commit is contained in:
@@ -10065,8 +10065,10 @@ class CodemanApp {
|
||||
|
||||
try {
|
||||
await this._apiDelete('/api/sessions');
|
||||
// Every tiled session is gone: nothing left to remember or reselect.
|
||||
this.closeTileGrid?.({ keepStored: false, reselect: false });
|
||||
// Every tiled session is gone: nothing to reselect. The stored grid is
|
||||
// kept like every other close; it now names only gone sessions, so the
|
||||
// next Tiles click ranks the open sessions from scratch.
|
||||
this.closeTileGrid?.({ keepStored: true, reselect: false });
|
||||
this.sessions.clear();
|
||||
this.terminalBuffers.clear();
|
||||
this.terminalBufferCache.clear();
|
||||
|
||||
+87
-17
@@ -2202,11 +2202,11 @@ function tileGridCapacity({ width, height }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
|
||||
* deleted, detached and duplicate ids are dropped, the list is capped at
|
||||
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
|
||||
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
|
||||
* (or its JSON) gives null.
|
||||
* The stored grid (`codeman:tile-grid`: session ids and the layout, never
|
||||
* content) made safe to apply: unknown, deleted, detached and duplicate ids
|
||||
* are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
|
||||
* name a kept id, and track fractions must be 1 to 3 finite positive numbers.
|
||||
* Anything that is not a v1 object (or its JSON) gives null.
|
||||
*
|
||||
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
|
||||
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
|
||||
@@ -2214,11 +2214,20 @@ function tileGridCapacity({ width, height }) {
|
||||
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
|
||||
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
|
||||
*
|
||||
* `freed` names the cells whose session no longer exists (or was popped out
|
||||
* to its own window) since the grid was stored: the ranking fills those first
|
||||
* when the grid comes back (restoreTileGridCells). A hole the user left empty
|
||||
* is not freed. `count` is how many tiles the grid had after the user's own
|
||||
* last change (a session that went away by itself does not lower it), so the
|
||||
* grid comes back to that many when there are sessions to fill it; a value
|
||||
* stored before it existed, or a malformed one, reads as the number of
|
||||
* sessions the stored cells name.
|
||||
*
|
||||
* @param {unknown} raw - the parsed value, or the stored JSON string
|
||||
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
|
||||
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
|
||||
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
|
||||
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
||||
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
|
||||
* focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
||||
*/
|
||||
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
let value = raw;
|
||||
@@ -2229,11 +2238,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
|
||||
const ids = [];
|
||||
const cells = [];
|
||||
const freed = [];
|
||||
const named = [];
|
||||
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
|
||||
const keep =
|
||||
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
|
||||
ids.length < TILE_GRID_MAX;
|
||||
const isId = typeof id === 'string' && id !== '';
|
||||
const present = isId && live.has(id) && !detachedIds?.has?.(id);
|
||||
const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
|
||||
if (keep) ids.push(id);
|
||||
// Its session went away since: the cell is freed for the ranking to fill.
|
||||
if (isId && !present && !named.includes(id)) freed.push(cells.length);
|
||||
if (isId && !named.includes(id)) named.push(id);
|
||||
// A malformed entry (not a string, not null) is a hole too.
|
||||
cells.push(keep ? id : null);
|
||||
}
|
||||
@@ -2241,11 +2255,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
|
||||
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
|
||||
};
|
||||
const count =
|
||||
Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
|
||||
? value.count
|
||||
: Math.min(named.length, TILE_GRID_MAX);
|
||||
return {
|
||||
v: 1,
|
||||
open: value.open === true && ids.length > 0,
|
||||
ids,
|
||||
cells,
|
||||
freed,
|
||||
count,
|
||||
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
|
||||
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
|
||||
colFr: fractions(value.colFr),
|
||||
@@ -2253,6 +2273,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored grid as it comes back (the Tiles button, a page reload): its cells
|
||||
* exactly as stored, holes the user left included, and its focus (or the tile
|
||||
* it had zoomed). Only when it holds fewer tiles than its `count` (sessions
|
||||
* that went away since, or by themselves while it was open) does it fill, from
|
||||
* `ranked` (best first, never a session already in it): the freed cells first,
|
||||
* then the other empty cells, in reading order; more than the cells hold join
|
||||
* after them (the shape grows when the grid lays them out, reformTileCells).
|
||||
* A cell stays empty only when no other session is left to place. Never
|
||||
* trimmed to the window: a grid larger than the window fits shows its focused
|
||||
* tile alone until the window fits it again, and the arrangement stays.
|
||||
*
|
||||
* @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
|
||||
* focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
|
||||
* @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
|
||||
* @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
|
||||
*/
|
||||
function restoreTileGridCells(stored, ranked) {
|
||||
const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
|
||||
const cells = [];
|
||||
for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
|
||||
const tiles = cells.filter(Boolean);
|
||||
if (tiles.length === 0) return null;
|
||||
const focusedId =
|
||||
[stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
|
||||
const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
|
||||
const fillers = [];
|
||||
for (const id of ranked || []) {
|
||||
if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
|
||||
}
|
||||
const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
|
||||
const empty = [];
|
||||
cells.forEach((id, k) => {
|
||||
if (id === null) empty.push(k);
|
||||
});
|
||||
// Freed cells first, each group in reading order.
|
||||
empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
|
||||
let count = tiles.length;
|
||||
for (const k of empty) {
|
||||
if (count >= target || fillers.length === 0) break;
|
||||
cells[k] = fillers.shift();
|
||||
count++;
|
||||
}
|
||||
const extra = fillers.slice(0, Math.max(0, target - count));
|
||||
return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
|
||||
}
|
||||
|
||||
/**
|
||||
* New track fractions after a divider drag (grid-template `fr` values): the two
|
||||
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
|
||||
@@ -2372,7 +2439,9 @@ function rankTileSessions(rows) {
|
||||
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
||||
* (owner decision 8). In order:
|
||||
* a. the grid this tab last had (`stored`, already sanitized: live, not
|
||||
* detached, at most the cap), if any of its sessions survive;
|
||||
* detached, at most the cap), if any of its sessions survive, exactly
|
||||
* as it was (restoreTileGridCells: its cells and holes, a cell its
|
||||
* session freed filled from the ranking);
|
||||
* b. else an open split's two sessions, Pane A focused;
|
||||
* c. else the open sessions in `ranked` order (rankTileSessions: working,
|
||||
* then needing input, then the most recent; tab order when no ranking is
|
||||
@@ -2381,10 +2450,12 @@ function rankTileSessions(rows) {
|
||||
* `limit - 1` others come with it).
|
||||
* Null when there is nothing to open.
|
||||
*
|
||||
* @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
|
||||
* @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
|
||||
* focused: string|null, zoomed: string|null}|null,
|
||||
* split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
||||
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
|
||||
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], focusedId: string|null}|null}
|
||||
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
|
||||
* focusedId: string|null}|null}
|
||||
*/
|
||||
function tileGridOpenSet({
|
||||
stored = null,
|
||||
@@ -2399,10 +2470,8 @@ function tileGridOpenSet({
|
||||
const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
|
||||
(c) => c.id
|
||||
);
|
||||
if (stored?.ids?.length) {
|
||||
const focus = stored.zoomed || stored.focused;
|
||||
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
|
||||
}
|
||||
const restored = stored ? restoreTileGridCells(stored, all) : null;
|
||||
if (restored) return { source: 'stored', ...restored };
|
||||
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
|
||||
const pair = (split || []).filter(usable);
|
||||
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
||||
@@ -2943,6 +3012,7 @@ if (typeof window !== 'undefined') {
|
||||
fitTileCells,
|
||||
cycleTile,
|
||||
tileGridOpenSet,
|
||||
restoreTileGridCells,
|
||||
rankTileSessions,
|
||||
sanitizeTileCount,
|
||||
tileGridSetForCount,
|
||||
|
||||
+205
-100
@@ -23,14 +23,18 @@
|
||||
|
||||
// Per-device tile font size (a tile is a fraction of the screen).
|
||||
const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
|
||||
// The grid this device last had, ids only (sanitizeTileGridState, constants.js):
|
||||
// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, `ids` being the cells
|
||||
// in reading order with `null` for an empty one. `open: false` keeps it
|
||||
// remembered for one-click return; restored on reload inside handleInit.
|
||||
// The grid this browser last had, as the user left it (sanitizeTileGridState,
|
||||
// constants.js): `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`,
|
||||
// `ids` being the cells in reading order with `null` for an empty one, `count`
|
||||
// how many tiles the user's own last change left (a session that went away by
|
||||
// itself does not lower it). Session ids and layout only, never content.
|
||||
// Written on every change while the grid is open; `open: false` keeps it for
|
||||
// the Tiles toggle, which brings it back exactly; restored on reload inside
|
||||
// handleInit. Never sent to the server.
|
||||
const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
|
||||
// How many tiles a click on Tiles opens: the count last picked in its
|
||||
// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so
|
||||
// the stored grid above stays ids only.
|
||||
// The count last picked in the Tiles button's right-click menu (2, 4 or 6;
|
||||
// owner decision 10), per device: what a click opens when there is no stored
|
||||
// grid to bring back, and what a pick re-forms the grid to.
|
||||
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
|
||||
// The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still
|
||||
// copy of a closing grid goes even if animationend never comes (a hidden tab,
|
||||
@@ -147,6 +151,11 @@ class TileGridModel {
|
||||
// session id, or null for an empty cell. THE source of truth for where
|
||||
// each tile is (owner: an empty cell can be any cell); `ids` derives from it.
|
||||
this.cells = [];
|
||||
// How many tiles the user's own last change left (open, add, remove, a
|
||||
// count picked): a session that goes away by itself (deleted, popped out,
|
||||
// refused) does not lower it, so the next time the grid opens the ranking
|
||||
// fills that place. Stored with the grid (_persistTileGrid).
|
||||
this.count = 0;
|
||||
// id -> { tile: TerminalTile, el: HTMLElement }
|
||||
this.tiles = new Map();
|
||||
this.focusedId = null;
|
||||
@@ -396,6 +405,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
|
||||
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
|
||||
grid.cells = wanted.filter((id) => grid.tiles.has(id));
|
||||
// What the user opened is the grid they want (_openStoredTileGrid keeps a
|
||||
// stored count instead).
|
||||
grid.count = grid.ids.length;
|
||||
this._applyTileLayout();
|
||||
// The tiles' frames paint first; their terminals are built one per frame
|
||||
// after it, the focused tile's first, so its capture is the one the queue
|
||||
@@ -419,11 +431,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Leaves the grid: every tile destroyed (sockets closed, xterms disposed,
|
||||
* queued loads dropped), the main terminal unparked.
|
||||
*
|
||||
* `keepStored` remembers the grid for one-click return (toggleTileGrid).
|
||||
* `reselect` shows the focused session in the single view through a forced
|
||||
* reload; pass false when the caller selects something itself. `animate`
|
||||
* (the Tiles toggle only, owner answer 4) leaves a still copy of the tiles
|
||||
* over the stage until the single view has its content (_ghostTileGrid).
|
||||
* `keepStored` (every caller) keeps the grid as it was, holes, count, sizes,
|
||||
* focus and zoom, for the Tiles toggle and the next reload to bring back;
|
||||
* false would forget it. `reselect` shows the focused session in the single
|
||||
* view through a forced reload; pass false when the caller selects
|
||||
* something itself. `animate` (the Tiles toggle only, owner answer 4)
|
||||
* leaves a still copy of the tiles over the stage until the single view has
|
||||
* its content (_ghostTileGrid).
|
||||
*
|
||||
* The main terminal's cached content for EVERY tiled id is invalidated: it was
|
||||
* written before the grid opened, possibly hours ago, and selectSession paints
|
||||
@@ -464,6 +478,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
grid.colFr = [];
|
||||
grid.rowFr = [];
|
||||
grid.cells = [];
|
||||
grid.count = 0;
|
||||
grid.cols = 0;
|
||||
grid.rows = 0;
|
||||
grid.focusedId = null;
|
||||
@@ -717,8 +732,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* The card's text, from the remembered count, the grid's state and what the
|
||||
* window fits. Compared with the last English text set, never the DOM (in
|
||||
* zh-CN the DOM holds the translation; see _renderTileOverlay).
|
||||
* window fits. With the grid closed and a stored grid to bring back, the
|
||||
* click opens that grid as it was (never trimmed to the window), so the
|
||||
* window line only says what fits. Compared with the last English text set,
|
||||
* never the DOM (in zh-CN the DOM holds the translation; see
|
||||
* _renderTileOverlay).
|
||||
*/
|
||||
_renderTileHint() {
|
||||
const hint = this._tileHint;
|
||||
@@ -734,10 +752,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
|
||||
set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
|
||||
const plural = capacity === 1 ? '' : 's';
|
||||
const restoring = open ? null : this._storedTileGridSet();
|
||||
const opens = restoring ? restoring.ids.length : count;
|
||||
const fitsText =
|
||||
count <= capacity
|
||||
opens <= capacity
|
||||
? ''
|
||||
: open
|
||||
: open || restoring
|
||||
? `This window fits ${capacity} tile${plural}`
|
||||
: `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
|
||||
set(hint.fits.text, 'fitsText', fitsText);
|
||||
@@ -998,22 +1018,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* A count picked in the menu: remembered for the click, then the grid opens
|
||||
* with that many tiles (what the click opens), or the open grid is re-formed
|
||||
* to it (_reformTileGrid). Never more than the window fits; fewer open
|
||||
* sessions than the count give fewer tiles.
|
||||
* with that many tiles (a stored grid re-formed to it, its tiles first in
|
||||
* their cells), or the open grid is re-formed to it (_reformTileGrid).
|
||||
* Never more than the window fits; fewer open sessions than the count give
|
||||
* fewer tiles. Either way the grid's count is what the pick left.
|
||||
*/
|
||||
_pickTileCount(count) {
|
||||
this.closeTileCountMenu({ refocus: false });
|
||||
this._rememberTileGridCount(count);
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) {
|
||||
this.toggleTileGrid();
|
||||
this._activateTileGrid({ count });
|
||||
return;
|
||||
}
|
||||
const T = window.CodemanTileGrid;
|
||||
const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity);
|
||||
// The tiles that join are the ranking's best (_tileGridRanking).
|
||||
this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId));
|
||||
grid.count = grid.ids.length;
|
||||
this._persistTileGrid();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1148,16 +1171,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* The Tiles button's click and Ctrl+Shift+G, one function so the two never
|
||||
* drift (owner decision 8): opens the grid at once, no menu in the way, or
|
||||
* closes it to the single view of the focused session. What opens is the
|
||||
* remembered count of tiles (the right-click menu's last pick, default 6,
|
||||
* at most what the window fits; owner decision 10), chosen by
|
||||
* `tileGridOpenSet` (constants.js): the grid this tab last had, else an open
|
||||
* split's two sessions, else the open sessions as the ranking orders them
|
||||
* (_tileGridRanking: working, then needing input, then the most recent), the
|
||||
* active one always among them and focused; then trimmed or filled (from the
|
||||
* ranking) to the count, the focused one kept. A remembered grid comes back
|
||||
* with its tiles in their cells, the ones the count adds filling its empty
|
||||
* cells first (_openStoredTileGrid).
|
||||
* closes it to the single view of the focused session. The grid this
|
||||
* browser last had comes back EXACTLY as the user left it (owner request:
|
||||
* "when I turn tiles off and on, always keep what the last setting was"):
|
||||
* its tiles in their cells, holes included, its count, divider sizes, focus
|
||||
* and zoom; a cell whose session no longer exists is filled from the
|
||||
* ranking (_openStoredTileGrid). With none of its sessions left (or nothing
|
||||
* stored), `tileGridOpenSet` (constants.js) takes an open split's two
|
||||
* sessions, else the open sessions as the ranking orders them
|
||||
* (_tileGridRanking: working, then needing input, then the most recent),
|
||||
* the active one always among them and focused, filled to the remembered
|
||||
* count (the right-click menu's last pick, default 6, at most what the
|
||||
* window fits; owner decision 10).
|
||||
*/
|
||||
toggleTileGrid() {
|
||||
this.closeTileCountMenu();
|
||||
@@ -1165,25 +1190,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
|
||||
return;
|
||||
}
|
||||
if (!this.canOpenTileGrid()) return;
|
||||
const stored = this._readStoredTileGrid();
|
||||
const set = this._tileGridOpenSet(stored);
|
||||
if (!set) {
|
||||
this.showToast?.('No sessions to show as tiles', 'info');
|
||||
return;
|
||||
}
|
||||
if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids);
|
||||
else this.openTileGrid(set.ids, { focusedId: set.focusedId });
|
||||
this._activateTileGrid();
|
||||
},
|
||||
|
||||
/**
|
||||
* What the toggle would open now (see toggleTileGrid): `count` tiles (the
|
||||
* remembered count), or as many as the window fits; `stored` saves a
|
||||
* second read.
|
||||
* Opens the grid as the toggle does (toggleTileGrid), or, with `count` (a
|
||||
* pick in the count menu while the grid is closed), with that many tiles: a
|
||||
* stored grid re-formed to it, its tiles first in their cells, the ones the
|
||||
* count adds filling its empty cells first, the rest from the ranking.
|
||||
*
|
||||
* @returns {boolean} whether the grid opened
|
||||
*/
|
||||
_tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) {
|
||||
_activateTileGrid({ count = null } = {}) {
|
||||
if (!this.canOpenTileGrid()) return false;
|
||||
const stored = this._readStoredTileGrid();
|
||||
const set = this._tileGridOpenSet(stored, count);
|
||||
if (!set) {
|
||||
this.showToast?.('No sessions to show as tiles', 'info');
|
||||
return false;
|
||||
}
|
||||
if (set.source === 'stored') return this._openStoredTileGrid(stored, set, { keepCount: count === null });
|
||||
return this.openTileGrid(set.ids, { focusedId: set.focusedId });
|
||||
},
|
||||
|
||||
/**
|
||||
* What the toggle would open now (see toggleTileGrid): a stored grid as it
|
||||
* was, or `count` tiles (the remembered count by default), at most what the
|
||||
* window fits. With an explicit `count`, a stored grid is trimmed or filled
|
||||
* to it too. `stored` saves a second read.
|
||||
*/
|
||||
_tileGridOpenSet(stored = this._readStoredTileGrid(), count = null) {
|
||||
const T = window.CodemanTileGrid;
|
||||
const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity));
|
||||
const n = Math.max(1, Math.min(count ?? this._tileGridCount(), this._tileGridLimit().capacity));
|
||||
const ranked = this._tileGridRanking();
|
||||
const set = T.tileGridOpenSet({
|
||||
stored,
|
||||
@@ -1196,6 +1234,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
limit: n,
|
||||
});
|
||||
if (!set) return null;
|
||||
// A stored grid comes back as it was: the count does not apply to it
|
||||
// unless one was asked for.
|
||||
if (set.source === 'stored' && count === null) return set;
|
||||
return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) };
|
||||
},
|
||||
|
||||
@@ -1299,6 +1340,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
||||
if (!this._mountTile(sessionId, { enterIndex })) return false;
|
||||
this._placeTile(sessionId, cell);
|
||||
grid.count = grid.ids.length;
|
||||
// A tile added while one is zoomed by hand is meant to be seen.
|
||||
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
||||
this._applyTileLayout();
|
||||
@@ -1343,17 +1385,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
* moves focus to the neighbouring tile (next in grid order, else previous),
|
||||
* as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
|
||||
* DOM focus where it is (an app-driven removal: a socket the server closed),
|
||||
* so keystrokes never land in the neighbour's PTY unasked. The last tile
|
||||
* leaving closes the grid: with `refocus` the single view then shows that
|
||||
* session (or, popped out, the next one: _selectAfterTileGrid), without it
|
||||
* the caller decides what comes next.
|
||||
* so keystrokes never land in the neighbour's PTY unasked. `gone`: the
|
||||
* session went away by itself (deleted, popped out, its socket refused), not
|
||||
* by the user's hand, so the grid's count stays and the next time it opens
|
||||
* the ranking fills that place. The last tile leaving closes the grid, kept
|
||||
* as it was: with `refocus` the single view then shows that session (or,
|
||||
* popped out, the next one: _selectAfterTileGrid), without it the caller
|
||||
* decides what comes next.
|
||||
*/
|
||||
removeTile(sessionId, { refocus = true, focus = true } = {}) {
|
||||
removeTile(sessionId, { refocus = true, focus = true, gone = false } = {}) {
|
||||
const grid = this._tileGrid;
|
||||
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
||||
if (!entry) return false;
|
||||
if (grid.ids.length === 1) {
|
||||
this.closeTileGrid({ keepStored: false, reselect: refocus });
|
||||
this.closeTileGrid({ keepStored: true, reselect: refocus });
|
||||
return true;
|
||||
}
|
||||
const wasFocused = grid.focusedId === sessionId;
|
||||
@@ -1369,6 +1414,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
entry.el.remove();
|
||||
grid.tiles.delete(sessionId);
|
||||
grid.cells[grid.cells.indexOf(sessionId)] = null;
|
||||
if (!gone) grid.count = grid.ids.length;
|
||||
// Before the layout, which may zoom the focused tile on a small window.
|
||||
if (wasFocused) grid.focusedId = null;
|
||||
this._applyTileLayout();
|
||||
@@ -1675,10 +1721,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
|
||||
* human selection: the user clicked its tab). With the grid closed it opens
|
||||
* what the Tiles toggle would, with this session among the tiles and
|
||||
* focused: the remembered count in total (owner answer to decision 10's
|
||||
* questions), never one more. Returns false
|
||||
* when the grid cannot open here (narrow or solo window), so the click is an
|
||||
* ordinary one.
|
||||
* focused, never past the remembered count (owner answer to decision 10's
|
||||
* questions: N, not N+1): it joins while the grid holds fewer than the
|
||||
* count (a stored grid's first empty cell), else it takes the last tile's
|
||||
* place. A stored grid keeps its cells, sizes and holes around it.
|
||||
* Returns false when the grid cannot open here (narrow or solo window), so
|
||||
* the click is an ordinary one.
|
||||
*/
|
||||
addSessionToTiles(sessionId) {
|
||||
if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
|
||||
@@ -1699,7 +1747,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
}
|
||||
const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
|
||||
const base = this._tileGridOpenSet()?.ids || [];
|
||||
const stored = this._readStoredTileGrid();
|
||||
const plan = this._tileGridOpenSet(stored);
|
||||
if (plan?.source === 'stored') {
|
||||
let { ids, cells } = plan;
|
||||
if (!ids.includes(sessionId)) {
|
||||
if (ids.length < n) {
|
||||
ids = [...ids, sessionId];
|
||||
} else {
|
||||
const last = ids.at(-1);
|
||||
ids = ids.map((id) => (id === last ? sessionId : id));
|
||||
cells = cells.map((id) => (id === last ? sessionId : id));
|
||||
}
|
||||
}
|
||||
return this._openStoredTileGrid(
|
||||
stored,
|
||||
{ ids, cells, focusedId: sessionId },
|
||||
{ focusedId: sessionId, auto: false, keepCount: false }
|
||||
);
|
||||
}
|
||||
const base = plan?.ids || [];
|
||||
const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
|
||||
// Exactly these: an open split is already in `base` (tileGridOpenSet seeds
|
||||
// it), and merging it again went past the count (N+1) and the window.
|
||||
@@ -1742,7 +1809,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
_replaceTileGrid(ids) {
|
||||
const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
|
||||
if (this._tilesOwnTerminal()) {
|
||||
this.closeTileGrid({ keepStored: false, reselect: false });
|
||||
// Kept until the group's grid, opened next, takes its place.
|
||||
this.closeTileGrid({ keepStored: true, reselect: false });
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
// Exactly the group: an open split closes without joining it (merged, its
|
||||
@@ -2227,7 +2295,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._renderTileOverlay(sessionId);
|
||||
return;
|
||||
}
|
||||
this.removeTile(sessionId, { focus: false });
|
||||
this.removeTile(sessionId, { focus: false, gone: true });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2581,17 +2649,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._persistTileGrid();
|
||||
},
|
||||
|
||||
// ── Persistence (codeman:tile-grid, per device, ids only) ────────────────
|
||||
// ── Persistence (codeman:tile-grid, per browser: ids and layout, never content) ──
|
||||
|
||||
/**
|
||||
* Writes the open grid: ids, focus, a zoom the user chose (an automatic one
|
||||
* is worked out again from the window) and the divider fractions. Never
|
||||
* content. `open: false` is the closed-but-remembered state. Never in a solo
|
||||
* window; a storage failure only costs the convenience.
|
||||
* Writes the open grid as it is, on every change (a move, a divider drag, a
|
||||
* tile added or removed, a count picked, a focus, a zoom): its cells, its
|
||||
* count, focus, a zoom the user chose (an automatic one is worked out again
|
||||
* from the window) and the divider fractions. Never content, never to the
|
||||
* server. `open: false` is the closed-but-remembered state. Never in a solo
|
||||
* window, nor while a stored grid is being put back (_openStoredTileGrid
|
||||
* writes once it is); a storage failure only costs the convenience.
|
||||
*/
|
||||
_persistTileGrid({ open = true } = {}) {
|
||||
const grid = this._tileGrid;
|
||||
if (this.isSoloWindow || !grid || grid.ids.length === 0) return;
|
||||
if (this.isSoloWindow || this._tilePersistHold || !grid || grid.ids.length === 0) return;
|
||||
if (open && !grid.open) return;
|
||||
const state = {
|
||||
v: 1,
|
||||
@@ -2600,6 +2671,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (sanitizeTileGridState; a build before cells drops the nulls and reads
|
||||
// the tiles packed, as it always did).
|
||||
ids: grid.cells.slice(),
|
||||
// Never fewer than the tiles shown (a build before `count` derives it
|
||||
// from `ids`, and ignores it).
|
||||
count: Math.min(Math.max(grid.count || 0, grid.ids.length), window.CodemanTileGrid.TILE_GRID_MAX),
|
||||
focused: grid.focusedId,
|
||||
zoomed: grid.autoZoom ? null : grid.zoomedId,
|
||||
colFr: grid.colFr.slice(),
|
||||
@@ -2634,47 +2708,74 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a stored grid: its tiles and focus, then the fractions it had (only
|
||||
* if they still match the layout) and a zoom the user chose. `auto`: the app
|
||||
* is putting it back, so no idle alert is spent. `ids` is the set to open,
|
||||
* the stored one by default (a reload); the Tiles button passes it trimmed
|
||||
* or filled to the remembered count, a shape change the cell model's rule
|
||||
* handles (reformTileCells).
|
||||
* The stored grid as it would come back now (restoreTileGridCells): its
|
||||
* cells as stored, a cell whose session no longer exists filled from the
|
||||
* ranking while the grid holds fewer than its count. Null when nothing is
|
||||
* stored or none of its sessions survive.
|
||||
*/
|
||||
_openStoredTileGrid(stored, ids = stored.ids) {
|
||||
const focus = stored.zoomed || stored.focused;
|
||||
// The stored set (an open split closes without joining it, decision 8,
|
||||
// case a), trimmed or filled to the count by the caller.
|
||||
if (!this.openTileGrid(ids, { focusedId: focus, auto: true, mergeSplit: false })) return false;
|
||||
const grid = this._tileGrid;
|
||||
_storedTileGridSet(stored = this._readStoredTileGrid()) {
|
||||
if (!stored) return null;
|
||||
return window.CodemanTileGrid.restoreTileGridCells(stored, this._tileGridRanking());
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a stored grid as the user left it: `set` (the stored cells with any
|
||||
* freed cell filled, _storedTileGridSet, by default; the Tiles button may
|
||||
* pass it trimmed or filled to a picked count, or with a Ctrl/Cmd+clicked
|
||||
* session in it) goes back into its cells, holes included, a shape change
|
||||
* following the cell model's rule (reformTileCells: the tiles keep their row
|
||||
* and column when all fit, the ones that join fill the empty cells in
|
||||
* reading order); then the fractions it had (only if they still match the
|
||||
* layout) and a zoom the user chose on the focused tile. A grid larger than
|
||||
* the window fits is never trimmed: the focused tile shows alone until the
|
||||
* window fits it (_applyTileLayout), and the arrangement stays. `auto`: the
|
||||
* app is putting it back, so no idle alert is spent. `keepCount`: the
|
||||
* stored count stays the grid's (a session that went away may be filled the
|
||||
* next time); false makes it what opens (a pick, a Ctrl/Cmd+click). Nothing
|
||||
* is written until the grid is back, so a stored grid is never overwritten
|
||||
* by a half-built one.
|
||||
*/
|
||||
_openStoredTileGrid(
|
||||
stored,
|
||||
set = this._storedTileGridSet(stored),
|
||||
{ focusedId = null, auto = true, keepCount = true } = {}
|
||||
) {
|
||||
if (!set?.ids?.length) return false;
|
||||
const T = window.CodemanTileGrid;
|
||||
// openTileGrid packed the tiles from the first cell. The stored tiles go
|
||||
// back to their cells (a session gone since leaves its cell empty): as
|
||||
// they were when this is the stored set in the shape it was stored with,
|
||||
// otherwise by the cell model's rule, the tiles the count adds filling the
|
||||
// empty cells in reading order, holes first.
|
||||
const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
|
||||
const kept = storedCells.filter(Boolean);
|
||||
const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id));
|
||||
const cells = sameSet
|
||||
? storedCells
|
||||
: T.reformTileCells(
|
||||
storedCells,
|
||||
T.tileCellCols(storedCells.length),
|
||||
kept,
|
||||
grid.ids.filter((id) => !kept.includes(id)),
|
||||
grid.cols,
|
||||
grid.rows
|
||||
);
|
||||
const focus = focusedId || set.focusedId;
|
||||
// An open split closes without joining it (decision 8, case a).
|
||||
this._tilePersistHold = true;
|
||||
let opened = false;
|
||||
try {
|
||||
opened = this.openTileGrid(set.ids, { focusedId: focus, auto, mergeSplit: false });
|
||||
} finally {
|
||||
this._tilePersistHold = false;
|
||||
}
|
||||
if (!opened) return false;
|
||||
const grid = this._tileGrid;
|
||||
// openTileGrid packed the tiles from the first cell. They go back to their
|
||||
// cells (a session gone since leaves its cell empty, unless the ranking
|
||||
// filled it).
|
||||
const base = (set.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
|
||||
const kept = base.filter(Boolean);
|
||||
const cells = T.reformTileCells(
|
||||
base,
|
||||
T.tileCellCols(base.length),
|
||||
kept,
|
||||
grid.ids.filter((id) => !kept.includes(id)),
|
||||
grid.cols,
|
||||
grid.rows
|
||||
);
|
||||
if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
|
||||
grid.cells = cells;
|
||||
}
|
||||
grid.count = keepCount ? Math.min(Math.max(grid.ids.length, stored.count || 0), T.TILE_GRID_MAX) : grid.ids.length;
|
||||
// openTileGrid laid the grid out with equal tracks. The stored ones go back
|
||||
// on; _applyTileLayout drops them again if they do not match the column or
|
||||
// row count (the window may have changed the layout since).
|
||||
if (stored.colFr) grid.colFr = stored.colFr.slice();
|
||||
if (stored.rowFr) grid.rowFr = stored.rowFr.slice();
|
||||
if (stored.zoomed && grid.tiles.has(stored.zoomed)) {
|
||||
if (stored.zoomed && stored.zoomed === grid.focusedId && grid.tiles.has(stored.zoomed)) {
|
||||
grid.zoomedId = stored.zoomed;
|
||||
grid.autoZoom = false;
|
||||
}
|
||||
@@ -2707,14 +2808,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
|
||||
/**
|
||||
* A followed `#session=` link took the screen on load: the stored grid stays
|
||||
* remembered, closed. Only `open` changes: the value is written back as it
|
||||
* was stored, so a session gone since still frees its cell for the ranking
|
||||
* the next time the grid opens.
|
||||
*/
|
||||
_closeStoredTileGrid() {
|
||||
const stored = this._readStoredTileGrid();
|
||||
if (!stored?.open) return;
|
||||
// Stored as it was read, holes included (`ids` carries the cells).
|
||||
const { cells, ...rest } = stored;
|
||||
try {
|
||||
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false }));
|
||||
const raw = JSON.parse(localStorage.getItem(TILE_GRID_STORAGE_KEY));
|
||||
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...raw, open: false }));
|
||||
} catch {
|
||||
/* Per-device convenience only. */
|
||||
}
|
||||
@@ -2743,7 +2848,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) return false;
|
||||
for (const id of grid.ids.slice()) {
|
||||
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false });
|
||||
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false, gone: true });
|
||||
}
|
||||
if (!grid.open) return false;
|
||||
// Only a focus that is gone moves: re-selecting the same tile would hide an
|
||||
@@ -2771,7 +2876,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
if (grid?.has(data.id)) {
|
||||
const wasFocused = grid.focusedId === data.id;
|
||||
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
|
||||
this.removeTile(data.id, { refocus: false });
|
||||
this.removeTile(data.id, { refocus: false, gone: true });
|
||||
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
|
||||
this._selectTiledSession(neighbor, { auto: true, focus: false });
|
||||
}
|
||||
|
||||
@@ -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,576 @@
|
||||
/**
|
||||
* @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 and a page reload bring 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. 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 bring 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('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('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],
|
||||
|
||||
@@ -45,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(
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* @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, 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
|
||||
@@ -58,13 +62,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 +99,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 +108,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 +139,13 @@ 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('brings back the fractions (same layout only) and a zoom the user chose', () => {
|
||||
@@ -179,26 +185,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