diff --git a/src/web/public/app.js b/src/web/public/app.js index 6e896112..075664b7 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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(); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 971e3796..8a1d500f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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} 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, 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, diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 02c4d5f4..090b3061 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -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 }); } diff --git a/test/tile-grid-cells.test.ts b/test/tile-grid-cells.test.ts index bb67a918..1a65fe44 100644 --- a/test/tile-grid-cells.test.ts +++ b/test/tile-grid-cells.test.ts @@ -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]); }); diff --git a/test/tile-grid-count-menu.test.ts b/test/tile-grid-count-menu.test.ts index 49fa662b..e3b51df7 100644 --- a/test/tile-grid-count-menu.test.ts +++ b/test/tile-grid-count-menu.test.ts @@ -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', () => { diff --git a/test/tile-grid-layout-memory.test.ts b/test/tile-grid-layout-memory.test.ts new file mode 100644 index 00000000..261f42a9 --- /dev/null +++ b/test/tile-grid-layout-memory.test.ts @@ -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; focusedId: string } | null; +type Sanitized = { + ids: string[]; + cells: Array; + freed: number[]; + count: number; + open: boolean; + focused: string | null; + zoomed: string | null; + colFr: number[] | null; +} | null; +type Helpers = { + sanitizeTileGridState(raw: unknown, live: Iterable, detached?: Set): 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, 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).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).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'); + }); +}); diff --git a/test/tile-grid-layout.test.ts b/test/tile-grid-layout.test.ts index 4540b705..613033f8 100644 --- a/test/tile-grid-layout.test.ts +++ b/test/tile-grid-layout.test.ts @@ -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], diff --git a/test/tile-grid-open-set.test.ts b/test/tile-grid-open-set.test.ts index 8bb4b886..9a654cb8 100644 --- a/test/tile-grid-open-set.test.ts +++ b/test/tile-grid-open-set.test.ts @@ -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( diff --git a/test/tile-grid-restore.test.ts b/test/tile-grid-restore.test.ts index 1367d597..7712173f 100644 --- a/test/tile-grid-restore.test.ts +++ b/test/tile-grid-restore.test.ts @@ -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', () => { diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index 05c4e7c1..7abbaed6 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -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']); }); diff --git a/test/tile-grid-split-coexistence.test.ts b/test/tile-grid-split-coexistence.test.ts index 7a7ee1fa..3c5c40cd 100644 --- a/test/tile-grid-split-coexistence.test.ts +++ b/test/tile-grid-split-coexistence.test.ts @@ -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 }]]);