mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 10:19:42 +02:00
Merge the Tiles open order and kept layout into the 1.41.0 release branch
This commit is contained in:
@@ -819,7 +819,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
|
|||||||
|
|
||||||
### Tile grid
|
### Tile grid
|
||||||
|
|
||||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
|
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives, reports the cells whose sessions went away as `freed`), `restoreTileGridCells` (a stored grid as it comes back), `rankTileSessions` (the order a fresh or filling grid takes sessions in), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
|
||||||
|
|
||||||
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
|
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
|
||||||
|
|
||||||
@@ -829,7 +829,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
|
|||||||
|
|
||||||
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select.
|
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select.
|
||||||
|
|
||||||
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy.
|
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, EXACTLY as the user left it (decision 11: `restoreTileGridCells` keeps its cells and holes, its focus and a zoom the user chose; `_openStoredTileGrid` puts back its divider sizes and count; an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), never filled to the remembered count and never trimmed to the window (`_applyTileLayout`'s automatic zoom shows the focused tile until the window fits, the arrangement kept); (b) else an open split's two sessions; (c) else the open sessions as `rankTileSessions` orders them (`_tileGridRanking`, tile-grid.js: no detached ones; WORKING first, the most recently started turn first, keyed off `lastSubmitAt` ONLY because a working pane's `lastActivityAt` is always "now"; then the ones NEEDING INPUT, `needs`/`waiting`, the red and yellow tab alerts, most recent first; then the rest by `sessionActivityAnchor`, newest first; a 0 stamp last, tab order the final tiebreak; the states are `_mobileOverviewState`'s, and a page without it falls back to tab order), the active session always included and focused; then `tileGridSetForCount` trims (b) and (c) from the end (the session to focus kept) or fills them from the ranking to the remembered count (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`). ⚠️ A stored grid's freed cells (a session gone or popped out since) are filled from the ranking first, then other empty cells, only while it holds fewer tiles than its stored `count`; a hole the user made stays. A count picked in the menu with the grid closed (`_activateTileGrid({ count })`) re-forms a stored grid to it (`reformTileCells`, the added tiles in the empty cells first). Ctrl/Cmd+click with the grid closed opens what the toggle would with the clicked session focused, never past the count: it joins while the grid holds fewer, else takes the last tile's place. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy.
|
||||||
|
|
||||||
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
|
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
|
||||||
|
|
||||||
@@ -839,7 +839,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
|
|||||||
|
|
||||||
⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session.
|
⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session.
|
||||||
|
|
||||||
⚠️ **Persistence and joining.** `codeman:tile-grid` holds `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, ids only, written as the grid changes; its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants; a restore puts the holes back only when the cell count matches the current shape, else the tiles stay packed, and `_closeStoredTileGrid` writes the cells back, never the packed list. The old packed format reads as cells with no hole; closing keeps it as `open: false` (the Tiles toggle, the picker and Ctrl/Cmd+click bring it back), the last tile leaving forgets it, a solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
|
⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
|
||||||
|
|
||||||
### Gesture control: the setting
|
### Gesture control: the setting
|
||||||
|
|
||||||
|
|||||||
+86
-26
@@ -45,20 +45,28 @@ or settled a question the spec left open. The invariants as built are in
|
|||||||
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
|
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
|
||||||
compare with the last English value, not the translated DOM.
|
compare with the last English value, not the translated DOM.
|
||||||
- **The Tiles button opens the grid at once** (owner decision 8, with the count of
|
- **The Tiles button opens the grid at once** (owner decision 8, with the count of
|
||||||
decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered
|
decision 10 and the layout memory of decision 11): a click (and `Ctrl+Shift+G`, the same
|
||||||
count of tiles (default 6, at most what the window fits). `tileGridOpenSet`
|
`toggleTileGrid`) brings back the grid this browser last had EXACTLY as the user left
|
||||||
(constants.js) picks the grid this tab last had, else an open split's two sessions, else
|
it: which session sits in which cell, holes included, its tile count, divider sizes,
|
||||||
the open sessions in tab order, the active one always included and focused, and
|
focus and a zoom the user chose (`restoreTileGridCells`, constants.js). It is never
|
||||||
`tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it
|
filled to the remembered count and never trimmed to the window (a window too small
|
||||||
(from tab order) to the count. A remembered grid comes back with its tiles first, in
|
for it shows the focused tile alone until it fits, the arrangement kept). A session
|
||||||
their cells, then sessions in tab order, to the count in total: the count is a shape
|
that no longer exists frees its cell, which the ranking fills. Only with nothing
|
||||||
change under the cell model's rule (`reformTileCells`: the tiles keep their row and
|
stored, or none of its sessions left, does `tileGridOpenSet` (constants.js) take an
|
||||||
column when all fit, else they pack in reading order) and the added tiles fill the empty
|
open split's two sessions, else the open sessions as `rankTileSessions` orders them
|
||||||
cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A
|
(owner request: "prefer to load in tiles that are working and then the most recent,
|
||||||
remembered grid still wins over an open split: the split closes and its sessions are not
|
so the oldest don't get opened"): WORKING first (the most recently started turn
|
||||||
seeded first. A page-load restore brings back exactly the stored grid, whatever the
|
first, keyed off `lastSubmitAt` only), then the ones NEEDING INPUT (the red and yellow
|
||||||
count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that
|
tab alerts), then the rest by most recent activity, tab order breaking ties; the
|
||||||
session among them and focused (owner answer: N, not N+1).
|
active one always included and focused, and `tileGridSetForCount` trims it (from the
|
||||||
|
end, the session to focus kept) or fills it (from the ranking) to the remembered count
|
||||||
|
(default 6, at most what the window fits). The states and stamps are the home
|
||||||
|
screens' own (`_mobileOverviewState`, `sessionActivityAnchor`). A remembered grid still
|
||||||
|
wins over an open split: the split closes and its sessions are not seeded first. A
|
||||||
|
page-load restore brings back the same grid as the toggle. Ctrl/Cmd+click on a tab
|
||||||
|
with the grid closed opens what the toggle would with that session among the tiles and
|
||||||
|
focused, never past the count (owner answer: N, not N+1): it joins the first empty cell
|
||||||
|
while the grid holds fewer than the count, else it takes the last tile's place.
|
||||||
- **A hover card on the Tiles button says it** (owner feedback: "give me the hover info
|
- **A hover card on the Tiles button says it** (owner feedback: "give me the hover info
|
||||||
to right click over the tile button to adjust it"). It replaces the button's native title:
|
to right click over the tile button to adjust it"). It replaces the button's native title:
|
||||||
"Tiles · N" (the remembered count, live), what a click does (open or close the grid),
|
"Tiles · N" (the remembered count, live), what a click does (open or close the grid),
|
||||||
@@ -80,9 +88,10 @@ or settled a question the spec left open. The invariants as built are in
|
|||||||
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
|
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
|
||||||
element close it (the single view a close starts focuses its terminal when its replay
|
element close it (the single view a close starts focuses its terminal when its replay
|
||||||
lands; a menu left open behind that would send its keys there). A pick is remembered per
|
lands; a menu left open behind that would send its keys there). A pick is remembered per
|
||||||
device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
|
device in `codeman:tile-count` and opens that many tiles (a stored grid re-formed to
|
||||||
tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
|
it, its tiles first in their cells); with the grid open it re-forms it
|
||||||
stays, the others leave from the end or join from tab order, filling empty cells first,
|
(`_reformTileGrid`): the focused tile always stays, the others leave from the end or
|
||||||
|
join from the ranking, filling empty cells first,
|
||||||
every joining tile mounted and laid out before any connects (one fit, one PTY resize
|
every joining tile mounted and laid out before any connects (one fit, one PTY resize
|
||||||
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
|
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
|
||||||
"Open group as tiles", Run) still add up to the cap of 6.
|
"Open group as tiles", Run) still add up to the cap of 6.
|
||||||
@@ -392,16 +401,34 @@ the harness/model request; see "As built")
|
|||||||
|
|
||||||
### Persistence
|
### Persistence
|
||||||
|
|
||||||
Decided: per device, restored on reload. Stored in localStorage key
|
Decided: per device (per browser), restored on reload when it was open, and by
|
||||||
`codeman:tile-grid`:
|
the Tiles toggle however it was closed (decision 11). Stored in localStorage key `codeman:tile-grid`, never on the
|
||||||
|
server:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
|
{ "v": 1, "open": true, "ids": ["…", null, "…"], "count": 3, "focused": "…",
|
||||||
"colFr": [1, 1, 1], "rowFr": [1, 1] }
|
"zoomed": null, "colFr": [1, 1, 1], "rowFr": [1, 1] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
|
Session ids and the layout, never content. `ids` are the CELLS in reading order,
|
||||||
duplicate ids on load. Never restored in a solo window.
|
`null` for an empty one. `count` is how many tiles the user's own last change
|
||||||
|
left (open, add, remove by hand, a count picked): a session that goes away by
|
||||||
|
itself (deleted, popped out, its socket refused) does not lower it, so the next
|
||||||
|
time the grid opens the ranking fills that place, while a hole the user made
|
||||||
|
stays. It is written on every change (a move, a divider drag at pointer-up, a
|
||||||
|
tile added or removed, a count picked, a focus, a zoom) and kept, as
|
||||||
|
`open: false`, however the grid closes: the toggle, a non-tiled tab,
|
||||||
|
`leaveTiles` or a `#session=` link (which flips `open` only, so a gone id still
|
||||||
|
frees its cell), Home, the width gate, the last tile, "Open group as tiles",
|
||||||
|
closing or killing sessions. Nothing is written while a stored grid is being
|
||||||
|
put back, so a half-built grid never overwrites it.
|
||||||
|
|
||||||
|
A pure sanitizer drops unknown, deleted, detached and duplicate ids on load,
|
||||||
|
reports the cells their sessions freed (`freed`), and derives `count` for a
|
||||||
|
value written before it existed (the number of sessions the cells name); the
|
||||||
|
old packed `ids` (no nulls) read as cells with no hole, and anything that is not
|
||||||
|
a v1 object is ignored. The format stays `v: 1`, so an older build still reads
|
||||||
|
a newer value (it ignores `count`). Never read or written in a solo window.
|
||||||
|
|
||||||
The restore runs INSIDE `handleInit`, in place of its initial
|
The restore runs INSIDE `handleInit`, in place of its initial
|
||||||
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
|
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
|
||||||
@@ -412,6 +439,17 @@ the main terminal never loads on that page load. A later `handleInit` (SSE
|
|||||||
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
|
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
|
||||||
against the live list without rebuilding tiles that are still alive.
|
against the live list without rebuilding tiles that are still alive.
|
||||||
|
|
||||||
|
A cell freed since the grid was stored is filled during that restore, from a
|
||||||
|
ranking that knows each session's status and stamps (the init payload) but not
|
||||||
|
yet its pending approvals: `seedApprovals` asks the server for them
|
||||||
|
asynchronously, and the restore has run by the time they land. So on a reload a
|
||||||
|
session waiting on a permission dialog or an unseen finished turn ranks with the
|
||||||
|
quiet ones for that one fill (working sessions still rank first). Accepted: a
|
||||||
|
fill held back for the approvals would open fewer tiles, which can be another
|
||||||
|
shape, and then reshape the grid and move the user's tiles a second after the
|
||||||
|
reload; so approvals that land later never re-form a restored grid. The Tiles
|
||||||
|
toggle, run once the page has loaded, ranks with them.
|
||||||
|
|
||||||
### Gating
|
### Gating
|
||||||
|
|
||||||
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
|
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
|
||||||
@@ -818,7 +856,8 @@ Separate follow-up PRs worth doing (see "Follow-ups").
|
|||||||
open, and an open menu owns the Escape (it closes alone and the keyboard goes
|
open, and an open menu owns the Escape (it closes alone and the keyboard goes
|
||||||
back to the Tiles button, like the tab-group menu).
|
back to the Tiles button, like the tab-group menu).
|
||||||
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
|
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
|
||||||
- **No secrets in localStorage**: the stored grid holds ids only.
|
- **No secrets in localStorage**: the stored grid holds session ids and its
|
||||||
|
layout only, never content.
|
||||||
- **Memory**: everything a tile creates is released in `destroy()`.
|
- **Memory**: everything a tile creates is released in `destroy()`.
|
||||||
|
|
||||||
## Delivery: two PRs
|
## Delivery: two PRs
|
||||||
@@ -1060,7 +1099,8 @@ exits green. Use the browser runner for those files and read the file count.
|
|||||||
is on right-click of the button (its title says so, as do the wiki and the
|
is on right-click of the button (its title says so, as do the wiki and the
|
||||||
Help modal). Superseded in part by decision 10: right-click is now the count
|
Help modal). Superseded in part by decision 10: right-click is now the count
|
||||||
menu, and a remembered grid is filled to the count instead of opening
|
menu, and a remembered grid is filled to the count instead of opening
|
||||||
exactly as stored.
|
exactly as stored; decision 11 then restored "exactly as stored" and put a
|
||||||
|
ranking in place of the tab order.
|
||||||
9. **No + in the tile header.** Decided by the owner ("remove the + button from
|
9. **No + in the tile header.** Decided by the owner ("remove the + button from
|
||||||
these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New
|
these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New
|
||||||
session in this case" went with it. Tiles are added from the Tiles button
|
session in this case" went with it. Tiles are added from the Tiles button
|
||||||
@@ -1073,12 +1113,32 @@ exits green. Use the browser runner for those files and read the file count.
|
|||||||
right-click menu offers 2, 4 and 6, remembered per device; the session
|
right-click menu offers 2, 4 and 6, remembered per device; the session
|
||||||
picker is gone, and decision 8's "picker on right-click" is superseded. The
|
picker is gone, and decision 8's "picker on right-click" is superseded. The
|
||||||
owner's answers on the details: the count wins over a remembered grid's
|
owner's answers on the details: the count wins over a remembered grid's
|
||||||
size (its tiles first, in their cells, holes filled first, then tab order);
|
size (its tiles first, in their cells, holes filled first, then tab order;
|
||||||
|
superseded by decision 11: a click brings the remembered grid back as it
|
||||||
|
was, and only a count picked in the menu re-forms it);
|
||||||
Ctrl/Cmd+click with the grid closed opens the count in total, that session
|
Ctrl/Cmd+click with the grid closed opens the count in total, that session
|
||||||
focused; shrinking keeps the focused tile; only the toggle animates the
|
focused; shrinking keeps the focused tile; only the toggle animates the
|
||||||
close; a remembered count larger than the window stays checked but greyed
|
close; a remembered count larger than the window stays checked but greyed
|
||||||
and a click opens what fits; the close keeps its dimmed still until the
|
and a click opens what fits; the close keeps its dimmed still until the
|
||||||
single view has painted (at most 700 ms); paced connect is in.
|
single view has painted (at most 700 ms); paced connect is in.
|
||||||
|
11. **The grid keeps the layout the user arranged, and a fresh one ranks by
|
||||||
|
work.** Decided by the owner ("when I hit the tiles button, it should prefer
|
||||||
|
to load in tiles that are working and then the most recent working, so the
|
||||||
|
oldest dont get opened ... 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, if there was no setting before take the working ones, that
|
||||||
|
ones needs input and then the most recent ones in order"). The layout
|
||||||
|
(cells and holes, tile count, divider sizes, focus, a zoom the user chose)
|
||||||
|
is saved per browser on every change and comes back exactly from the toggle,
|
||||||
|
however the grid closed, and from a reload when the grid was open (a grid
|
||||||
|
closed before the reload stays remembered for the toggle; the page shows the
|
||||||
|
single view); it is never filled to the remembered count nor trimmed to the
|
||||||
|
window. A session gone since frees its cell for the
|
||||||
|
ranking; with none left, the grid opens from the ranking (`rankTileSessions`:
|
||||||
|
working, then needing input, then most recent), which also fills every place
|
||||||
|
the grid fills on its own (a count picked in the menu, a freed cell, an open
|
||||||
|
split's fill). Supersedes decision 10's "the count wins over a remembered
|
||||||
|
grid's size"; the count menu itself, its counts and its other answers stay.
|
||||||
|
|
||||||
## Code anchors
|
## Code anchors
|
||||||
|
|
||||||
|
|||||||
+36
-16
@@ -16,23 +16,32 @@ It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift
|
|||||||
|
|
||||||
## Opening a grid
|
## Opening a grid
|
||||||
|
|
||||||
- **Tiles button**: one click shows the tiles straight away, as many as you last chose
|
- **Tiles button**: one click shows the tiles straight away. If you have used the grid in
|
||||||
(six until you choose; fewer if the window is too small or you have fewer sessions open).
|
this browser before, you get it back exactly as you left it: the same sessions in the
|
||||||
You get the grid you last had, its tiles where they were, topped up with your open
|
same places, an empty place where you left one, the same number of tiles, your column
|
||||||
sessions in tab order; if there is none, an open split's two first; otherwise your open
|
widths and row heights, the tile you were in, and a zoomed tile still zoomed. A session
|
||||||
sessions in tab order, with the session you are on focused. With the grid open, the same
|
closed since frees its place, which is filled the way a new grid is filled (below).
|
||||||
button closes it.
|
Otherwise, or when none of those sessions is left, you get as many tiles as you last
|
||||||
|
chose (six until you choose; fewer if the window is too small or you have fewer sessions
|
||||||
|
open): an open split's two first; otherwise the sessions that are working (the most
|
||||||
|
recently started first), then the ones waiting on you (red and yellow tabs), then the
|
||||||
|
rest, the most recently used first, so the oldest are the ones left out. The session you
|
||||||
|
are on always comes along and is focused. With the grid open, the same button closes it.
|
||||||
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
|
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
|
||||||
count it opens and what a click and a right-click do.
|
count you chose and what a click and a right-click do.
|
||||||
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
|
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
|
||||||
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
|
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered in this
|
||||||
and is what the next click opens. With the grid open, picking a count re-forms it: the
|
browser. Picking a count opens the grid with that many tiles (the grid you left, its
|
||||||
tile you are in always stays, extra tiles leave from the end, new ones join from your tab
|
tiles in their places, new ones in the empty places first), and it is what a click opens
|
||||||
order. A count the window is too small for is greyed out, with the reason.
|
when there is no grid to bring back. With the grid open, picking a count re-forms it: the
|
||||||
|
tile you are in always stays, extra tiles leave from the end, new ones join working ones
|
||||||
|
first, then the ones waiting on you, then the most recent. A count the window is too
|
||||||
|
small for is greyed out, with the reason.
|
||||||
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
|
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
|
||||||
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
|
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
|
||||||
the grid closed it opens what the Tiles button would show, with that session among them
|
the grid closed it opens what the Tiles button would show with that session added: in the
|
||||||
(still the count you chose in total). On macOS use
|
empty place while the grid has fewer tiles than the count you chose, else in place of the
|
||||||
|
last tile (never more than the count). On macOS use
|
||||||
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
|
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
|
||||||
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
|
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
|
||||||
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
|
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
|
||||||
@@ -89,7 +98,8 @@ keeps the focus.
|
|||||||
|
|
||||||
A moved tile takes the size of the place it lands in: column widths and row heights stay
|
A moved tile takes the size of the place it lands in: column widths and row heights stay
|
||||||
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
|
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
|
||||||
the empty slot included, is saved with the grid and comes back on reload.
|
the empty slot included, is saved with the grid and comes back when you turn the grid off
|
||||||
|
and on, and on a page reload while the grid is open.
|
||||||
|
|
||||||
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
|
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
|
||||||
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
|
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
|
||||||
@@ -122,8 +132,18 @@ session finder) shows that session on its own, the normal single view. The grid
|
|||||||
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
|
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
|
||||||
same. Narrowing the window below the desktop width also returns to the single view.
|
same. Narrowing the window below the desktop width also returns to the single view.
|
||||||
|
|
||||||
The grid is saved on this device and comes back when you reload the page, with its focus,
|
The grid is saved in this browser every time you change it (moving, resizing, adding or
|
||||||
zoom and column widths. A session that was closed in the meantime is simply left out.
|
removing a tile, changing the count, focusing or zooming a tile), and never sent to the
|
||||||
|
server. However you leave it (the Tiles button, another tab, Home, a link, closing its last
|
||||||
|
tile or session), the Tiles button brings it back as it was. A page reload brings it back
|
||||||
|
when the grid was open; after you left it, a reload shows the single view and the Tiles
|
||||||
|
button still brings the grid back. A session that was closed or popped out into its own
|
||||||
|
window in the meantime frees its place for another one, picked the way a new grid picks
|
||||||
|
them; the place stays empty only when no other session is left. Right after a page reload
|
||||||
|
the page does not know yet which sessions are waiting for your answer, so that pick goes by
|
||||||
|
which sessions are working and which you used last. If the window has become too small for
|
||||||
|
all the tiles, the tile you were in fills the grid until the window is wide enough again,
|
||||||
|
and the rest of the layout is kept.
|
||||||
|
|
||||||
Split shows the same logo, name and model above both of its panes.
|
Split shows the same logo, name and model above both of its panes.
|
||||||
|
|
||||||
|
|||||||
@@ -1820,8 +1820,11 @@ class CodemanApp {
|
|||||||
_markDetached(id, on) {
|
_markDetached(id, on) {
|
||||||
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
||||||
// A popped-out session's window owns its PTY size now, so it leaves the
|
// A popped-out session's window owns its PTY size now, so it leaves the
|
||||||
// tile grid (one place per session in this browser tab).
|
// tile grid (one place per session in this browser tab). `gone`: it left by
|
||||||
if (on && this._tileGrid?.has(id)) this.removeTile(id);
|
// itself, not by a tile the user removed, so the grid's count stays and the
|
||||||
|
// ranking fills that cell the next time the grid opens, as when it pops out
|
||||||
|
// with the grid closed.
|
||||||
|
if (on && this._tileGrid?.has(id)) this.removeTile(id, { gone: true });
|
||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||||
if (tab) tab.classList.toggle('detached', on);
|
if (tab) tab.classList.toggle('detached', on);
|
||||||
@@ -10065,8 +10068,10 @@ class CodemanApp {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this._apiDelete('/api/sessions');
|
await this._apiDelete('/api/sessions');
|
||||||
// Every tiled session is gone: nothing left to remember or reselect.
|
// Every tiled session is gone: nothing to reselect. The stored grid is
|
||||||
this.closeTileGrid?.({ keepStored: false, reselect: false });
|
// 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.sessions.clear();
|
||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
this.terminalBufferCache.clear();
|
this.terminalBufferCache.clear();
|
||||||
|
|||||||
+176
-28
@@ -2202,11 +2202,11 @@ function tileGridCapacity({ width, height }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
|
* The stored grid (`codeman:tile-grid`: session ids and the layout, never
|
||||||
* deleted, detached and duplicate ids are dropped, the list is capped at
|
* content) made safe to apply: unknown, deleted, detached and duplicate ids
|
||||||
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
|
* are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
|
||||||
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
|
* name a kept id, and track fractions must be 1 to 3 finite positive numbers.
|
||||||
* (or its JSON) gives null.
|
* 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
|
* 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
|
* 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,
|
* 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.
|
* 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 {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}|Iterable<string>} liveSessions - ids that exist now
|
||||||
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
|
* @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,
|
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
|
||||||
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
* focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
||||||
*/
|
*/
|
||||||
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||||
let value = raw;
|
let value = raw;
|
||||||
@@ -2229,11 +2238,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
|||||||
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
|
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
|
||||||
const ids = [];
|
const ids = [];
|
||||||
const cells = [];
|
const cells = [];
|
||||||
|
const freed = [];
|
||||||
|
const named = [];
|
||||||
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
|
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
|
||||||
const keep =
|
const isId = typeof id === 'string' && id !== '';
|
||||||
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
|
const present = isId && live.has(id) && !detachedIds?.has?.(id);
|
||||||
ids.length < TILE_GRID_MAX;
|
const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
|
||||||
if (keep) ids.push(id);
|
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.
|
// A malformed entry (not a string, not null) is a hole too.
|
||||||
cells.push(keep ? id : null);
|
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;
|
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;
|
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 {
|
return {
|
||||||
v: 1,
|
v: 1,
|
||||||
open: value.open === true && ids.length > 0,
|
open: value.open === true && ids.length > 0,
|
||||||
ids,
|
ids,
|
||||||
cells,
|
cells,
|
||||||
|
freed,
|
||||||
|
count,
|
||||||
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
|
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
|
||||||
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
|
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
|
||||||
colFr: fractions(value.colFr),
|
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
|
* 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
|
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
|
||||||
@@ -2285,7 +2352,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
|
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
|
||||||
* ones a count fills a grid with), in tab order: live ones only, never a session popped out to
|
* ones a count fills a grid with), in the order given (tab order, or the
|
||||||
|
* ranking): live ones only, never a session popped out to
|
||||||
* its own window (that window owns its PTY size). A session with no PTY
|
* its own window (that window owns its PTY size). A session with no PTY
|
||||||
* attached IS offered: its tile shows the Attach overlay.
|
* attached IS offered: its tile shows the Attach overlay.
|
||||||
*
|
*
|
||||||
@@ -2299,44 +2367,121 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
|
|||||||
for (const id of sessionOrder) {
|
for (const id of sessionOrder) {
|
||||||
if (detachedIds?.has?.(id)) continue;
|
if (detachedIds?.has?.(id)) continue;
|
||||||
const session = sessions.get(id);
|
const session = sessions.get(id);
|
||||||
if (!session) continue;
|
if (!session || result.some((r) => r.id === id)) continue;
|
||||||
result.push({ id, label: session.name || 'Session' });
|
result.push({ id, label: session.name || 'Session' });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ranking groups (rankTileSessions): working first, then the sessions waiting
|
||||||
|
// on the user, then everything else.
|
||||||
|
const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stamp a session is ranked by inside its group. A WORKING session keys
|
||||||
|
* off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
|
||||||
|
* about once a second, so its last-activity stamp is always "now", and one
|
||||||
|
* that never submitted would otherwise claim the head of the group. 0 means
|
||||||
|
* unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
|
||||||
|
* the last byte the pane printed, i.e. when it went quiet).
|
||||||
|
*/
|
||||||
|
function tileRankStamp(row) {
|
||||||
|
if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
|
||||||
|
return sessionActivityAnchor(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which open sessions the tile grid shows when nobody said which (the Tiles
|
||||||
|
* button with no stored grid to bring back, and every place the grid fills a
|
||||||
|
* tile on its own: a count picked in its menu, a freed cell), best first
|
||||||
|
* (owner request: "prefer to load in tiles that are working and then the most
|
||||||
|
* recent, so the oldest don't get opened"):
|
||||||
|
* 1. WORKING, the most recently started turn first;
|
||||||
|
* 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
|
||||||
|
* permission or question dialog; `waiting`: a finished turn not seen
|
||||||
|
* yet), most recent first;
|
||||||
|
* 3. then every other one (idle, done, error), most recently active first.
|
||||||
|
* Inside a group the newest stamp wins (tileRankStamp) and a session with no
|
||||||
|
* stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
|
||||||
|
* the result never shuffles. The states are the home screens' own
|
||||||
|
* (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
|
||||||
|
* order differs: the home screens put the longest-running turn first, the grid
|
||||||
|
* the most recent.
|
||||||
|
*
|
||||||
|
* Pure. Unit-tested in test/tile-grid-ranking.test.ts.
|
||||||
|
*
|
||||||
|
* @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
|
||||||
|
* @returns {string[]} the ids, best first, each once
|
||||||
|
*/
|
||||||
|
function rankTileSessions(rows) {
|
||||||
|
const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
|
||||||
|
const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const byGroup = group(a) - group(b);
|
||||||
|
if (byGroup !== 0) return byGroup;
|
||||||
|
const atA = tileRankStamp(a);
|
||||||
|
const atB = tileRankStamp(b);
|
||||||
|
if (atA !== atB) {
|
||||||
|
if (!atA) return 1;
|
||||||
|
if (!atB) return -1;
|
||||||
|
return atB - atA;
|
||||||
|
}
|
||||||
|
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||||
|
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
const ids = [];
|
||||||
|
for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
||||||
* (owner decision 8). In order:
|
* (owner decision 8). In order:
|
||||||
* a. the grid this tab last had (`stored`, already sanitized: live, not
|
* 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;
|
* b. else an open split's two sessions, Pane A focused;
|
||||||
* c. else the open sessions in tab order (buildTilePickerSessions: no
|
* c. else the open sessions in `ranked` order (rankTileSessions: working,
|
||||||
* detached ones), up to `limit`, the active session always among them and focused
|
* then needing input, then the most recent; tab order when no ranking is
|
||||||
* (when it sits past the limit, the first `limit - 1` others come with it).
|
* given), detached ones never, up to `limit`, the active session always
|
||||||
|
* among them and focused (when it ranks past the limit, the first
|
||||||
|
* `limit - 1` others come with it).
|
||||||
* Null when there is nothing to open.
|
* 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,
|
||||||
* split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
* 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
|
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
|
||||||
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
|
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
|
||||||
|
* focusedId: string|null}|null}
|
||||||
*/
|
*/
|
||||||
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
|
function tileGridOpenSet({
|
||||||
if (stored?.ids?.length) {
|
stored = null,
|
||||||
const focus = stored.zoomed || stored.focused;
|
split = null,
|
||||||
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
|
ranked = null,
|
||||||
}
|
sessions,
|
||||||
|
sessionOrder,
|
||||||
|
detachedIds,
|
||||||
|
activeId = null,
|
||||||
|
limit,
|
||||||
|
}) {
|
||||||
|
const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
|
||||||
|
(c) => c.id
|
||||||
|
);
|
||||||
|
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 usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
|
||||||
const pair = (split || []).filter(usable);
|
const pair = (split || []).filter(usable);
|
||||||
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
||||||
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
|
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
|
||||||
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
|
|
||||||
if (all.length === 0) return null;
|
if (all.length === 0) return null;
|
||||||
let ids = all.slice(0, max);
|
let ids = all.slice(0, max);
|
||||||
if (all.includes(activeId) && !ids.includes(activeId)) {
|
if (all.includes(activeId) && !ids.includes(activeId)) {
|
||||||
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
|
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
|
||||||
}
|
}
|
||||||
return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2356,8 +2501,9 @@ function sanitizeTileCount(raw) {
|
|||||||
* `base` (what the grid would open, or what an open grid shows, in its order)
|
* `base` (what the grid would open, or what an open grid shows, in its order)
|
||||||
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
|
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
|
||||||
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
|
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
|
||||||
* tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
|
* tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
|
||||||
* with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
|
* the app passes the ranking, rankTileSessions) with the ones not in it yet.
|
||||||
|
* Fewer sessions than `n` give fewer tiles.
|
||||||
*
|
*
|
||||||
* @param {string[]} base
|
* @param {string[]} base
|
||||||
* @param {string[]} all
|
* @param {string[]} all
|
||||||
@@ -2866,6 +3012,8 @@ if (typeof window !== 'undefined') {
|
|||||||
fitTileCells,
|
fitTileCells,
|
||||||
cycleTile,
|
cycleTile,
|
||||||
tileGridOpenSet,
|
tileGridOpenSet,
|
||||||
|
restoreTileGridCells,
|
||||||
|
rankTileSessions,
|
||||||
sanitizeTileCount,
|
sanitizeTileCount,
|
||||||
tileGridSetForCount,
|
tileGridSetForCount,
|
||||||
tileCellCols,
|
tileCellCols,
|
||||||
|
|||||||
+242
-102
@@ -23,14 +23,18 @@
|
|||||||
|
|
||||||
// Per-device tile font size (a tile is a fraction of the screen).
|
// Per-device tile font size (a tile is a fraction of the screen).
|
||||||
const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
|
const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
|
||||||
// The grid this device last had, ids only (sanitizeTileGridState, constants.js):
|
// The grid this browser last had, as the user left it (sanitizeTileGridState,
|
||||||
// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, `ids` being the cells
|
// constants.js): `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`,
|
||||||
// in reading order with `null` for an empty one. `open: false` keeps it
|
// `ids` being the cells in reading order with `null` for an empty one, `count`
|
||||||
// remembered for one-click return; restored on reload inside handleInit.
|
// 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; a grid stored open is
|
||||||
|
// restored on reload inside handleInit. Never sent to the server.
|
||||||
const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
|
const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
|
||||||
// How many tiles a click on Tiles opens: the count last picked in its
|
// The count last picked in the Tiles button's right-click menu (2, 4 or 6;
|
||||||
// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so
|
// owner decision 10), per device: what a click opens when there is no stored
|
||||||
// the stored grid above stays ids only.
|
// grid to bring back, and what a pick re-forms the grid to.
|
||||||
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
|
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
|
||||||
// The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still
|
// 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,
|
// 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
|
// 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.
|
// each tile is (owner: an empty cell can be any cell); `ids` derives from it.
|
||||||
this.cells = [];
|
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 }
|
// id -> { tile: TerminalTile, el: HTMLElement }
|
||||||
this.tiles = new Map();
|
this.tiles = new Map();
|
||||||
this.focusedId = null;
|
this.focusedId = null;
|
||||||
@@ -396,6 +405,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
|
wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
|
||||||
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
|
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
|
||||||
grid.cells = wanted.filter((id) => grid.tiles.has(id));
|
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();
|
this._applyTileLayout();
|
||||||
// The tiles' frames paint first; their terminals are built one per frame
|
// 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
|
// 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,
|
* Leaves the grid: every tile destroyed (sockets closed, xterms disposed,
|
||||||
* queued loads dropped), the main terminal unparked.
|
* queued loads dropped), the main terminal unparked.
|
||||||
*
|
*
|
||||||
* `keepStored` remembers the grid for one-click return (toggleTileGrid).
|
* `keepStored` (every caller) keeps the grid as it was, holes, count, sizes,
|
||||||
* `reselect` shows the focused session in the single view through a forced
|
* focus and zoom, closed, for the Tiles toggle to bring back (a reload
|
||||||
* reload; pass false when the caller selects something itself. `animate`
|
* restores only a grid stored open); false would forget it. `reselect` shows the focused session in the single
|
||||||
* (the Tiles toggle only, owner answer 4) leaves a still copy of the tiles
|
* view through a forced reload; pass false when the caller selects
|
||||||
* over the stage until the single view has its content (_ghostTileGrid).
|
* 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
|
* 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
|
* written before the grid opened, possibly hours ago, and selectSession paints
|
||||||
@@ -464,6 +478,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
grid.colFr = [];
|
grid.colFr = [];
|
||||||
grid.rowFr = [];
|
grid.rowFr = [];
|
||||||
grid.cells = [];
|
grid.cells = [];
|
||||||
|
grid.count = 0;
|
||||||
grid.cols = 0;
|
grid.cols = 0;
|
||||||
grid.rows = 0;
|
grid.rows = 0;
|
||||||
grid.focusedId = null;
|
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
|
* 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
|
* window fits. With the grid closed and a stored grid to bring back, the
|
||||||
* zh-CN the DOM holds the translation; see _renderTileOverlay).
|
* 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() {
|
_renderTileHint() {
|
||||||
const hint = this._tileHint;
|
const hint = this._tileHint;
|
||||||
@@ -734,10 +752,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
|
set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
|
||||||
set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
|
set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
|
||||||
const plural = capacity === 1 ? '' : 's';
|
const plural = capacity === 1 ? '' : 's';
|
||||||
|
const restoring = open ? null : this._storedTileGridSet();
|
||||||
|
const opens = restoring ? restoring.ids.length : count;
|
||||||
const fitsText =
|
const fitsText =
|
||||||
count <= capacity
|
opens <= capacity
|
||||||
? ''
|
? ''
|
||||||
: open
|
: open || restoring
|
||||||
? `This window fits ${capacity} tile${plural}`
|
? `This window fits ${capacity} tile${plural}`
|
||||||
: `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
|
: `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
|
||||||
set(hint.fits.text, 'fitsText', fitsText);
|
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
|
* 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
|
* with that many tiles (a stored grid re-formed to it, its tiles first in
|
||||||
* to it (_reformTileGrid). Never more than the window fits; fewer open
|
* their cells), or the open grid is re-formed to it (_reformTileGrid).
|
||||||
* sessions than the count give fewer tiles.
|
* 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) {
|
_pickTileCount(count) {
|
||||||
this.closeTileCountMenu({ refocus: false });
|
this.closeTileCountMenu({ refocus: false });
|
||||||
this._rememberTileGridCount(count);
|
this._rememberTileGridCount(count);
|
||||||
const grid = this._tileGrid;
|
const grid = this._tileGrid;
|
||||||
if (!grid?.open) {
|
if (!grid?.open) {
|
||||||
this.toggleTileGrid();
|
this._activateTileGrid({ count });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const T = window.CodemanTileGrid;
|
const T = window.CodemanTileGrid;
|
||||||
const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity);
|
const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity);
|
||||||
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
|
// The tiles that join are the ranking's best (_tileGridRanking).
|
||||||
this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId));
|
this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId));
|
||||||
|
grid.count = grid.ids.length;
|
||||||
|
this._persistTileGrid();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1148,14 +1171,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/**
|
/**
|
||||||
* The Tiles button's click and Ctrl+Shift+G, one function so the two never
|
* 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
|
* 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
|
* closes it to the single view of the focused session. The grid this
|
||||||
* remembered count of tiles (the right-click menu's last pick, default 6,
|
* browser last had comes back EXACTLY as the user left it (owner request:
|
||||||
* at most what the window fits; owner decision 10), chosen by
|
* "when I turn tiles off and on, always keep what the last setting was"):
|
||||||
* `tileGridOpenSet` (constants.js): the grid this tab last had, else an open
|
* its tiles in their cells, holes included, its count, divider sizes, focus
|
||||||
* split's two sessions, else the open sessions in tab order, the active one
|
* and zoom; a cell whose session no longer exists is filled from the
|
||||||
* focused; then trimmed or filled to the count (the focused one kept). A
|
* ranking (_openStoredTileGrid). With none of its sessions left (or nothing
|
||||||
* remembered grid comes back with its tiles in their cells, the ones the
|
* stored), `tileGridOpenSet` (constants.js) takes an open split's two
|
||||||
* count adds filling its empty cells first (_openStoredTileGrid).
|
* 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() {
|
toggleTileGrid() {
|
||||||
this.closeTileCountMenu();
|
this.closeTileCountMenu();
|
||||||
@@ -1163,28 +1190,43 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
|
this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.canOpenTileGrid()) return;
|
this._activateTileGrid();
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the toggle would open now (see toggleTileGrid): `count` tiles (the
|
* Opens the grid as the toggle does (toggleTileGrid), or, with `count` (a
|
||||||
* remembered count), or as many as the window fits; `stored` saves a
|
* pick in the count menu while the grid is closed), with that many tiles: a
|
||||||
* second read.
|
* 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 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({
|
const set = T.tileGridOpenSet({
|
||||||
stored,
|
stored,
|
||||||
split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
|
split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
|
||||||
|
ranked,
|
||||||
sessions: this.sessions,
|
sessions: this.sessions,
|
||||||
sessionOrder: this.sessionOrder,
|
sessionOrder: this.sessionOrder,
|
||||||
detachedIds: this.detachedSessions,
|
detachedIds: this.detachedSessions,
|
||||||
@@ -1192,8 +1234,42 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
limit: n,
|
limit: n,
|
||||||
});
|
});
|
||||||
if (!set) return null;
|
if (!set) return null;
|
||||||
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
|
// A stored grid comes back as it was: the count does not apply to it
|
||||||
return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) };
|
// unless one was asked for.
|
||||||
|
if (set.source === 'stored' && count === null) return set;
|
||||||
|
return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The open sessions the grid takes when nobody said which (the Tiles button
|
||||||
|
* with nothing stored, and every tile it fills on its own), best first:
|
||||||
|
* working (the most recently started turn first), then the ones needing
|
||||||
|
* input (the red and yellow tab alerts), then the rest by most recent
|
||||||
|
* activity, tab order breaking ties (rankTileSessions, constants.js). The
|
||||||
|
* states and stamps are the home screens' own (`_mobileOverviewState()`,
|
||||||
|
* mobile-overview.js). Detached sessions are never in it. Guarded like
|
||||||
|
* the sorted rail: without the classifier (a stale cached
|
||||||
|
* mobile-overview.js) it is plain tab order.
|
||||||
|
*
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
_tileGridRanking() {
|
||||||
|
const T = window.CodemanTileGrid;
|
||||||
|
const open = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
|
||||||
|
if (typeof this._mobileOverviewState !== 'function' || typeof T.rankTileSessions !== 'function') {
|
||||||
|
return open.map((c) => c.id);
|
||||||
|
}
|
||||||
|
const rows = open.map(({ id }, orderIndex) => {
|
||||||
|
const session = this.sessions.get(id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
state: this._mobileOverviewState(session, this.pendingHooks?.get(id)),
|
||||||
|
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||||
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||||
|
orderIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return T.rankTileSessions(rows);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
|
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
|
||||||
@@ -1264,6 +1340,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
||||||
if (!this._mountTile(sessionId, { enterIndex })) return false;
|
if (!this._mountTile(sessionId, { enterIndex })) return false;
|
||||||
this._placeTile(sessionId, cell);
|
this._placeTile(sessionId, cell);
|
||||||
|
grid.count = grid.ids.length;
|
||||||
// A tile added while one is zoomed by hand is meant to be seen.
|
// A tile added while one is zoomed by hand is meant to be seen.
|
||||||
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
||||||
this._applyTileLayout();
|
this._applyTileLayout();
|
||||||
@@ -1308,17 +1385,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* moves focus to the neighbouring tile (next in grid order, else previous),
|
* 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
|
* 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),
|
* 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
|
* so keystrokes never land in the neighbour's PTY unasked. `gone`: the
|
||||||
* leaving closes the grid: with `refocus` the single view then shows that
|
* session went away by itself (deleted, popped out, its socket refused), not
|
||||||
* session (or, popped out, the next one: _selectAfterTileGrid), without it
|
* by the user's hand, so the grid's count stays and the next time it opens
|
||||||
* the caller decides what comes next.
|
* 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 grid = this._tileGrid;
|
||||||
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
if (grid.ids.length === 1) {
|
if (grid.ids.length === 1) {
|
||||||
this.closeTileGrid({ keepStored: false, reselect: refocus });
|
this.closeTileGrid({ keepStored: true, reselect: refocus });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const wasFocused = grid.focusedId === sessionId;
|
const wasFocused = grid.focusedId === sessionId;
|
||||||
@@ -1334,6 +1414,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
entry.el.remove();
|
entry.el.remove();
|
||||||
grid.tiles.delete(sessionId);
|
grid.tiles.delete(sessionId);
|
||||||
grid.cells[grid.cells.indexOf(sessionId)] = null;
|
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.
|
// Before the layout, which may zoom the focused tile on a small window.
|
||||||
if (wasFocused) grid.focusedId = null;
|
if (wasFocused) grid.focusedId = null;
|
||||||
this._applyTileLayout();
|
this._applyTileLayout();
|
||||||
@@ -1640,10 +1721,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
|
* 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
|
* 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
|
* what the Tiles toggle would, with this session among the tiles and
|
||||||
* focused: the remembered count in total (owner answer to decision 10's
|
* focused, never past the remembered count (owner answer to decision 10's
|
||||||
* questions), never one more. Returns false
|
* questions: N, not N+1): it joins while the grid holds fewer than the
|
||||||
* when the grid cannot open here (narrow or solo window), so the click is an
|
* count (a stored grid's first empty cell), else it takes the last tile's
|
||||||
* ordinary one.
|
* 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) {
|
addSessionToTiles(sessionId) {
|
||||||
if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
|
if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
|
||||||
@@ -1664,7 +1747,26 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
|
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];
|
const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
|
||||||
// Exactly these: an open split is already in `base` (tileGridOpenSet seeds
|
// 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.
|
// it), and merging it again went past the count (N+1) and the window.
|
||||||
@@ -1707,7 +1809,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_replaceTileGrid(ids) {
|
_replaceTileGrid(ids) {
|
||||||
const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
|
const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
|
||||||
if (this._tilesOwnTerminal()) {
|
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;
|
this.activeSessionId = null;
|
||||||
}
|
}
|
||||||
// Exactly the group: an open split closes without joining it (merged, its
|
// Exactly the group: an open split closes without joining it (merged, its
|
||||||
@@ -2192,7 +2295,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._renderTileOverlay(sessionId);
|
this._renderTileOverlay(sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.removeTile(sessionId, { focus: false });
|
this.removeTile(sessionId, { focus: false, gone: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2546,17 +2649,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._persistTileGrid();
|
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
|
* Writes the open grid as it is, on every change (a move, a divider drag, a
|
||||||
* is worked out again from the window) and the divider fractions. Never
|
* tile added or removed, a count picked, a focus, a zoom): its cells, its
|
||||||
* content. `open: false` is the closed-but-remembered state. Never in a solo
|
* count, focus, a zoom the user chose (an automatic one is worked out again
|
||||||
* window; a storage failure only costs the convenience.
|
* 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 } = {}) {
|
_persistTileGrid({ open = true } = {}) {
|
||||||
const grid = this._tileGrid;
|
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;
|
if (open && !grid.open) return;
|
||||||
const state = {
|
const state = {
|
||||||
v: 1,
|
v: 1,
|
||||||
@@ -2565,6 +2671,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// (sanitizeTileGridState; a build before cells drops the nulls and reads
|
// (sanitizeTileGridState; a build before cells drops the nulls and reads
|
||||||
// the tiles packed, as it always did).
|
// the tiles packed, as it always did).
|
||||||
ids: grid.cells.slice(),
|
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,
|
focused: grid.focusedId,
|
||||||
zoomed: grid.autoZoom ? null : grid.zoomedId,
|
zoomed: grid.autoZoom ? null : grid.zoomedId,
|
||||||
colFr: grid.colFr.slice(),
|
colFr: grid.colFr.slice(),
|
||||||
@@ -2599,47 +2708,74 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a stored grid: its tiles and focus, then the fractions it had (only
|
* The stored grid as it would come back now (restoreTileGridCells): its
|
||||||
* if they still match the layout) and a zoom the user chose. `auto`: the app
|
* cells as stored, a cell whose session no longer exists filled from the
|
||||||
* is putting it back, so no idle alert is spent. `ids` is the set to open,
|
* ranking while the grid holds fewer than its count. Null when nothing is
|
||||||
* the stored one by default (a reload); the Tiles button passes it trimmed
|
* stored or none of its sessions survive.
|
||||||
* or filled to the remembered count, a shape change the cell model's rule
|
|
||||||
* handles (reformTileCells).
|
|
||||||
*/
|
*/
|
||||||
_openStoredTileGrid(stored, ids = stored.ids) {
|
_storedTileGridSet(stored = this._readStoredTileGrid()) {
|
||||||
const focus = stored.zoomed || stored.focused;
|
if (!stored) return null;
|
||||||
// The stored set (an open split closes without joining it, decision 8,
|
return window.CodemanTileGrid.restoreTileGridCells(stored, this._tileGridRanking());
|
||||||
// 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;
|
/**
|
||||||
|
* 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;
|
const T = window.CodemanTileGrid;
|
||||||
// openTileGrid packed the tiles from the first cell. The stored tiles go
|
const focus = focusedId || set.focusedId;
|
||||||
// back to their cells (a session gone since leaves its cell empty): as
|
// An open split closes without joining it (decision 8, case a).
|
||||||
// they were when this is the stored set in the shape it was stored with,
|
this._tilePersistHold = true;
|
||||||
// otherwise by the cell model's rule, the tiles the count adds filling the
|
let opened = false;
|
||||||
// empty cells in reading order, holes first.
|
try {
|
||||||
const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
|
opened = this.openTileGrid(set.ids, { focusedId: focus, auto, mergeSplit: false });
|
||||||
const kept = storedCells.filter(Boolean);
|
} finally {
|
||||||
const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id));
|
this._tilePersistHold = false;
|
||||||
const cells = sameSet
|
}
|
||||||
? storedCells
|
if (!opened) return false;
|
||||||
: T.reformTileCells(
|
const grid = this._tileGrid;
|
||||||
storedCells,
|
// openTileGrid packed the tiles from the first cell. They go back to their
|
||||||
T.tileCellCols(storedCells.length),
|
// cells (a session gone since leaves its cell empty, unless the ranking
|
||||||
kept,
|
// filled it).
|
||||||
grid.ids.filter((id) => !kept.includes(id)),
|
const base = (set.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
|
||||||
grid.cols,
|
const kept = base.filter(Boolean);
|
||||||
grid.rows
|
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) {
|
if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
|
||||||
grid.cells = cells;
|
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
|
// 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
|
// on; _applyTileLayout drops them again if they do not match the column or
|
||||||
// row count (the window may have changed the layout since).
|
// row count (the window may have changed the layout since).
|
||||||
if (stored.colFr) grid.colFr = stored.colFr.slice();
|
if (stored.colFr) grid.colFr = stored.colFr.slice();
|
||||||
if (stored.rowFr) grid.rowFr = stored.rowFr.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.zoomedId = stored.zoomed;
|
||||||
grid.autoZoom = false;
|
grid.autoZoom = false;
|
||||||
}
|
}
|
||||||
@@ -2672,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() {
|
_closeStoredTileGrid() {
|
||||||
const stored = this._readStoredTileGrid();
|
const stored = this._readStoredTileGrid();
|
||||||
if (!stored?.open) return;
|
if (!stored?.open) return;
|
||||||
// Stored as it was read, holes included (`ids` carries the cells).
|
|
||||||
const { cells, ...rest } = stored;
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
/* Per-device convenience only. */
|
/* Per-device convenience only. */
|
||||||
}
|
}
|
||||||
@@ -2708,7 +2848,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const grid = this._tileGrid;
|
const grid = this._tileGrid;
|
||||||
if (!grid?.open) return false;
|
if (!grid?.open) return false;
|
||||||
for (const id of grid.ids.slice()) {
|
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;
|
if (!grid.open) return false;
|
||||||
// Only a focus that is gone moves: re-selecting the same tile would hide an
|
// Only a focus that is gone moves: re-selecting the same tile would hide an
|
||||||
@@ -2736,7 +2876,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
|||||||
if (grid?.has(data.id)) {
|
if (grid?.has(data.id)) {
|
||||||
const wasFocused = grid.focusedId === data.id;
|
const wasFocused = grid.focusedId === data.id;
|
||||||
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, 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)) {
|
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
|
||||||
this._selectTiledSession(neighbor, { auto: true, focus: false });
|
this._selectTiledSession(neighbor, { auto: true, focus: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
|
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
|
||||||
* app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
|
* app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
|
||||||
* tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake
|
* tile-grid.js + mobile-overview.js (the session state classifier the grid's
|
||||||
|
* ranking reuses) in one context, a small fake DOM (just what the grid touches) and a fake
|
||||||
* TerminalTile that records what the grid asks of it.
|
* TerminalTile that records what the grid asks of it.
|
||||||
*
|
*
|
||||||
* `makeGridApp()` returns an app instance with everything around the grid that
|
* `makeGridApp()` returns an app instance with everything around the grid that
|
||||||
@@ -295,7 +296,7 @@ const context = vm.createContext({
|
|||||||
});
|
});
|
||||||
vm.runInContext(
|
vm.runInContext(
|
||||||
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
|
||||||
`${read('tile-grid.js')}\n` +
|
`${read('tile-grid.js')}\n${read('mobile-overview.js')}\n` +
|
||||||
'globalThis.__CodemanApp = CodemanApp;',
|
'globalThis.__CodemanApp = CodemanApp;',
|
||||||
context
|
context
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
* and Ctrl+Tab / Alt+[ ] cycle through the tiles only.
|
* and Ctrl+Tab / Alt+[ ] cycle through the tiles only.
|
||||||
* - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload
|
* - `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
|
* 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
|
* frees its cell for the ranking to fill (empty only when no other session
|
||||||
* reads unchanged. A followed `#session=` link keeps the holes.
|
* 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.
|
* - 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.
|
* 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', () => {
|
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.
|
// The page goes away; a fresh one on the same device restores the grid.
|
||||||
section.children = [];
|
section.children = [];
|
||||||
FakeTile.all = [];
|
FakeTile.all = [];
|
||||||
const app = makeGridApp(ids);
|
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);
|
expect(app._restoreTileGrid()).toBe(true);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -266,10 +272,18 @@ describe('persistence: the cells, holes included', () => {
|
|||||||
expect(slots()[0].dataset.cell).toBe('2');
|
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);
|
const app = openGrid(SIX);
|
||||||
app._tileGrid.open = false;
|
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']);
|
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);
|
const app = openGrid(FIVE);
|
||||||
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
|
||||||
app._tileGrid.open = false;
|
app._tileGrid.open = false;
|
||||||
// s-d gone: four tiles take a 2x2.
|
// 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']);
|
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']);
|
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', () => {
|
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.
|
// 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'] }));
|
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]);
|
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
|
* menu owns its Escape in the global handler, like the tab-group menu), Tab
|
||||||
* and a click elsewhere close it.
|
* and a click elsewhere close it.
|
||||||
* - A pick is remembered per device (`codeman:tile-count`; `codeman:tile-grid`
|
* - 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,
|
* holds ids and layout, never content) and opens that many tiles: the
|
||||||
* the rest from tab order. The click and Ctrl+Shift+G then open with it.
|
* 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
|
* - 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,
|
* 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
|
* 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.ids).toEqual(['s-other', 's-c']);
|
||||||
expect(app._tileGrid.focusedId).toBe('s-c');
|
expect(app._tileGrid.focusedId).toBe('s-c');
|
||||||
expect(localStore.get('codeman:tile-count')).toBe('2');
|
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')!);
|
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', () => {
|
it('a click on 4 opens four, Space works too', () => {
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The grid keeps the layout the user arranged, per browser
|
||||||
|
* (owner request: "when I moved around and modified it, save it per browser
|
||||||
|
* the layout, so when I turn tiles off and on, always keep what the last
|
||||||
|
* setting was").
|
||||||
|
*
|
||||||
|
* - Saved on every change (`codeman:tile-grid` in localStorage, never the
|
||||||
|
* server): which session sits in which cell, holes included, the tile count,
|
||||||
|
* the divider sizes, the focused tile and a zoom the user chose.
|
||||||
|
* - The Tiles toggle brings back exactly that, however the grid was closed:
|
||||||
|
* the toggle, a tab that is not tiled, a followed link (`leaveTiles`, or
|
||||||
|
* `#session=` on load), Home, the window narrowing past the desktop gate,
|
||||||
|
* the last tile removed, "Open group as tiles", closing a session, killing
|
||||||
|
* them all. A page reload brings it back only when the grid was open (a
|
||||||
|
* closed one stays remembered for the toggle). It is never filled to the
|
||||||
|
* remembered count and never trimmed to the window (a too-small window
|
||||||
|
* shows the focused tile until it fits, the arrangement kept).
|
||||||
|
* - A session that no longer exists frees its cell, and the ranking fills it
|
||||||
|
* (the hole a user left stays a hole); with none of the stored sessions left
|
||||||
|
* the grid opens from the ranking, as with nothing stored.
|
||||||
|
* - The stored format stays `v: 1`: a value written before `count` (or before
|
||||||
|
* cells) loads, and a malformed one is ignored safely.
|
||||||
|
*
|
||||||
|
* Pure helpers (sanitizeTileGridState, restoreTileGridCells) and the app via
|
||||||
|
* the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
body,
|
||||||
|
bySelector,
|
||||||
|
fetchSpy,
|
||||||
|
flushFrames,
|
||||||
|
localStore,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
section,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
type Restored = { ids: string[]; cells: Array<string | null>; focusedId: string } | null;
|
||||||
|
type Sanitized = {
|
||||||
|
ids: string[];
|
||||||
|
cells: Array<string | null>;
|
||||||
|
freed: number[];
|
||||||
|
count: number;
|
||||||
|
open: boolean;
|
||||||
|
focused: string | null;
|
||||||
|
zoomed: string | null;
|
||||||
|
colFr: number[] | null;
|
||||||
|
} | null;
|
||||||
|
type Helpers = {
|
||||||
|
sanitizeTileGridState(raw: unknown, live: Iterable<string>, detached?: Set<string>): Sanitized;
|
||||||
|
restoreTileGridCells(stored: unknown, ranked: string[]): Restored;
|
||||||
|
};
|
||||||
|
|
||||||
|
const T = windowStub.CodemanTileGrid as Helpers;
|
||||||
|
const KEY = 'codeman:tile-grid';
|
||||||
|
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||||
|
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
||||||
|
|
||||||
|
describe('sanitizeTileGridState: freed cells and the count', () => {
|
||||||
|
const live = ['a', 'b', 'c', 'd'];
|
||||||
|
|
||||||
|
it('marks the cells whose session went away (deleted or popped out), never a hole the user left', () => {
|
||||||
|
const out = T.sanitizeTileGridState(
|
||||||
|
{ v: 1, open: true, ids: ['a', null, 'gone', 'b', 'c', 'x'] },
|
||||||
|
live,
|
||||||
|
new Set(['c'])
|
||||||
|
);
|
||||||
|
expect(out?.cells).toEqual(['a', null, null, 'b', null, null]);
|
||||||
|
expect(out?.freed).toEqual([2, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeat or a malformed entry is a plain hole, not freed', () => {
|
||||||
|
const out = T.sanitizeTileGridState({ v: 1, ids: ['a', 'a', 7, '', 'gone', 'gone'] }, live);
|
||||||
|
expect(out?.cells).toEqual(['a', null, null, null, null, null]);
|
||||||
|
expect(out?.freed).toEqual([4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a stored count; one written before it existed is the number of sessions the cells name', () => {
|
||||||
|
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', null, 'b'], count: 4 }, live)?.count).toBe(4);
|
||||||
|
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', null, 'gone', 'b'] }, live)?.count).toBe(3);
|
||||||
|
// The old packed format of nine (before the cap) reads as the cap.
|
||||||
|
const nine = Array.from({ length: 9 }, (_, i) => `n${i}`);
|
||||||
|
expect(T.sanitizeTileGridState({ v: 1, ids: nine }, nine)?.count).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[0], [7], [2.5], ['4'], [null], [-1]])('a malformed count (%j) is ignored', (count) => {
|
||||||
|
expect(T.sanitizeTileGridState({ v: 1, ids: ['a', 'b'], count }, live)?.count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreTileGridCells', () => {
|
||||||
|
const sanitize = (value: Record<string, unknown>, live: string[]) =>
|
||||||
|
T.sanitizeTileGridState({ v: 1, open: false, ...value }, live);
|
||||||
|
|
||||||
|
it('gives back the cells exactly, holes included, focused on the zoomed tile, else the focused one', () => {
|
||||||
|
const s = sanitize({ ids: ['a', null, 'b', 'c', 'd', 'e'], focused: 'c', zoomed: 'd' }, [
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
'c',
|
||||||
|
'd',
|
||||||
|
'e',
|
||||||
|
'z',
|
||||||
|
]);
|
||||||
|
expect(T.restoreTileGridCells(s, ['z'])).toEqual({
|
||||||
|
ids: ['a', 'b', 'c', 'd', 'e'],
|
||||||
|
cells: ['a', null, 'b', 'c', 'd', 'e'],
|
||||||
|
focusedId: 'd',
|
||||||
|
});
|
||||||
|
const t = sanitize({ ids: ['a', 'b'], focused: 'b' }, ['a', 'b']);
|
||||||
|
expect(T.restoreTileGridCells(t, [])?.focusedId).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills a freed cell from the ranking, best first, never one already in the grid; the user hole stays', () => {
|
||||||
|
const s = sanitize({ ids: ['a', null, 'gone', 'b', 'c', 'd'], count: 5 }, ['a', 'b', 'c', 'd', 'x', 'y']);
|
||||||
|
expect(T.restoreTileGridCells(s, ['b', 'y', 'x'])?.cells).toEqual(['a', null, 'y', 'b', 'c', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills freed cells first, then other empty cells only up to the count', () => {
|
||||||
|
// A hand-made value: cell 3 freed (its session gone), cell 1 a plain hole, a count of six.
|
||||||
|
const s = sanitize({ ids: ['a', null, 'b', 'gone', 'c', 'd'], count: 6 }, ['a', 'b', 'c', 'd', 'x', 'y', 'z']);
|
||||||
|
expect(T.restoreTileGridCells(s, ['x', 'y', 'z'])?.cells).toEqual(['a', 'y', 'b', 'x', 'c', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a freed cell stays empty only when no session is left to place', () => {
|
||||||
|
const s = sanitize({ ids: ['a', 'gone', 'b'] }, ['a', 'b']);
|
||||||
|
expect(T.restoreTileGridCells(s, [])).toEqual({ ids: ['a', 'b'], cells: ['a', null, 'b'], focusedId: 'a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('more to place than the stored cells hold: they join after them (the shape grows when laid out)', () => {
|
||||||
|
// Four stored cells (2x2), the count says six: two sessions closed while the grid was open packed it.
|
||||||
|
const s = sanitize({ ids: ['a', 'b', 'c', 'd'], count: 6 }, ['a', 'b', 'c', 'd', 'x', 'y', 'z']);
|
||||||
|
expect(T.restoreTileGridCells(s, ['x', 'y', 'z'])?.ids).toEqual(['a', 'b', 'c', 'd', 'x', 'y']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when none of its sessions survive', () => {
|
||||||
|
expect(T.restoreTileGridCells(sanitize({ ids: ['gone', null] }, ['x']), ['x'])).toBeNull();
|
||||||
|
expect(T.restoreTileGridCells(null, ['x'])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── In the app ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The real selectSession (tile branch, leaving the grid); the main terminal's select path bails at once. */
|
||||||
|
function layoutApp(ids: string[] = SIX): GridApp {
|
||||||
|
const app = makeGridApp(ids);
|
||||||
|
app._setTerminalLoadState = vi.fn();
|
||||||
|
app._clearTerminalLoadState = vi.fn();
|
||||||
|
app._renderHistoryTruncationBanner = vi.fn();
|
||||||
|
app._isStaleSelect = vi.fn(() => true);
|
||||||
|
app._retireUrlSession = vi.fn();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl;
|
||||||
|
|
||||||
|
/** A column divider dragged from `from` to `to` (px), released. */
|
||||||
|
function dragColumn(app: GridApp, key: string, from: number, to: number) {
|
||||||
|
const d = divider(app, key);
|
||||||
|
d.dispatch('pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
pointerId: 3,
|
||||||
|
clientX: from,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
});
|
||||||
|
d.dispatch('pointermove', { clientX: to });
|
||||||
|
flushFrames();
|
||||||
|
d.dispatch('pointerup', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Six tiles opened, then arranged by hand: s-b removed (its cell left empty),
|
||||||
|
* s-a and s-f swapped, the first column divider dragged, s-c focused and
|
||||||
|
* zoomed. [ f _ c / d e a ].
|
||||||
|
*/
|
||||||
|
async function arrange(app: GridApp) {
|
||||||
|
app.openTileGrid(SIX, { focusedId: 's-a' });
|
||||||
|
flushFrames();
|
||||||
|
app.removeTile('s-b');
|
||||||
|
app._swapTiles('s-a', 's-f');
|
||||||
|
dragColumn(app, 'col-0', 800, 1000);
|
||||||
|
await app.selectSession('s-c');
|
||||||
|
app.zoomTile('s-c');
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutOf = (app: GridApp) => ({
|
||||||
|
cells: app._tileGrid.cells.slice(),
|
||||||
|
count: app._tileGrid.count,
|
||||||
|
colFr: app._tileGrid.colFr.slice(),
|
||||||
|
rowFr: app._tileGrid.rowFr.slice(),
|
||||||
|
focused: app._tileGrid.focusedId,
|
||||||
|
zoomed: app._tileGrid.zoomedId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ARRANGED_CELLS = ['s-f', null, 's-c', 's-d', 's-e', 's-a'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
bySelector.set('.terminal-wrap', new FakeEl());
|
||||||
|
fetchSpy.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saved on every change', () => {
|
||||||
|
it('a move, a removal, a divider drag, a focus and a zoom are each written as they happen', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
app.openTileGrid(SIX, { focusedId: 's-a' });
|
||||||
|
flushFrames();
|
||||||
|
expect(stored()).toMatchObject({ open: true, ids: SIX, count: 6, focused: 's-a', colFr: [1, 1, 1] });
|
||||||
|
|
||||||
|
app.removeTile('s-b');
|
||||||
|
expect(stored()).toMatchObject({ ids: ['s-a', null, 's-c', 's-d', 's-e', 's-f'], count: 5 });
|
||||||
|
|
||||||
|
app._swapTiles('s-a', 's-f');
|
||||||
|
expect(stored().ids).toEqual(ARRANGED_CELLS);
|
||||||
|
|
||||||
|
dragColumn(app, 'col-0', 800, 1000);
|
||||||
|
expect(stored().colFr[0]).toBeGreaterThan(1);
|
||||||
|
expect(stored().colFr).toEqual(app._tileGrid.colFr);
|
||||||
|
|
||||||
|
await app.selectSession('s-c');
|
||||||
|
expect(stored().focused).toBe('s-c');
|
||||||
|
|
||||||
|
app.zoomTile('s-c');
|
||||||
|
expect(stored().zoomed).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adding a tile (a tab dropped on the empty cell) and picking a count are written, the count with them', () => {
|
||||||
|
const app = layoutApp([...SIX, 's-g']);
|
||||||
|
app.openTileGrid(SIX.slice(0, 5));
|
||||||
|
flushFrames();
|
||||||
|
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
|
||||||
|
app._applyTileLayout();
|
||||||
|
app.dropSessionOnSlot('s-g', 1);
|
||||||
|
expect(stored()).toMatchObject({ ids: ['s-a', 's-g', 's-b', 's-c', 's-d', 's-e'], count: 6 });
|
||||||
|
app._pickTileCount(4);
|
||||||
|
expect(stored().count).toBe(4);
|
||||||
|
expect(stored().ids.filter(Boolean)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is never sent to the server', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
const calls = JSON.stringify(fetchSpy.mock.calls);
|
||||||
|
expect(calls).not.toContain('tile');
|
||||||
|
expect(calls).not.toContain('colFr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the Tiles toggle (and a reload while it is open) brings back exactly what was arranged', () => {
|
||||||
|
it('off and on: the cells and the hole, the count, the divider sizes, the focus and the zoom', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
const before = layoutOf(app);
|
||||||
|
expect(before.cells).toEqual(ARRANGED_CELLS);
|
||||||
|
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5, focused: 's-c', zoomed: 's-c' });
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(layoutOf(app)).toEqual(before);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never filled to the remembered count, though more sessions are free', async () => {
|
||||||
|
const app = layoutApp([...SIX, 's-g', 's-h']);
|
||||||
|
await arrange(app);
|
||||||
|
app._rememberTileGridCount(6);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(ARRANGED_CELLS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reload with the grid open: the same layout on the next page', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
const before = layoutOf(app);
|
||||||
|
// The page goes away; a fresh one on the same browser.
|
||||||
|
section.children = [];
|
||||||
|
FakeTile.all = [];
|
||||||
|
const next = layoutApp();
|
||||||
|
expect(next._restoreTileGrid()).toBe(true);
|
||||||
|
expect(layoutOf(next)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reload after the grid was closed: the single view, and the Tiles toggle brings it back exactly', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
const before = layoutOf(app);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
section.children = [];
|
||||||
|
FakeTile.all = [];
|
||||||
|
const next = layoutApp();
|
||||||
|
// The reload leaves it closed (handleInit then selects the single view)...
|
||||||
|
expect(next._restoreTileGrid()).toBe(false);
|
||||||
|
expect(next._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS });
|
||||||
|
// ...and remembered: the toggle puts it back as it was.
|
||||||
|
next.toggleTileGrid();
|
||||||
|
expect(layoutOf(next)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never trimmed to a smaller window: the focused tile shows alone, and the arrangement is kept for a wider one', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
app.openTileGrid(SIX, { focusedId: 's-c' });
|
||||||
|
flushFrames();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
// A window that fits four (3x2 needs 1440px across).
|
||||||
|
const small = { width: 1300, height: 900, top: 0, left: 0, right: 1300, bottom: 900 };
|
||||||
|
section.getBoundingClientRect = () => small;
|
||||||
|
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = () => small;
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
expect(app._tileGrid.zoomedId).toBe('s-c');
|
||||||
|
expect(app._tileGrid.autoZoom).toBe(true);
|
||||||
|
expect(stored().ids).toEqual(SIX);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = FakeEl.prototype.getBoundingClientRect;
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
expect(app._tileGrid.zoomedId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('every way the grid closes keeps it', () => {
|
||||||
|
const closers: Array<[string, (app: GridApp) => unknown]> = [
|
||||||
|
['the Tiles toggle', (app) => app.toggleTileGrid()],
|
||||||
|
['picking a tab that is not tiled', (app) => app.selectSession('s-b').catch(() => {})],
|
||||||
|
[
|
||||||
|
'a followed link (leaveTiles)',
|
||||||
|
(app) => app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {}),
|
||||||
|
],
|
||||||
|
['Home', (app) => app.goHome()],
|
||||||
|
['"Open group as tiles" (its grid opens, then that one is kept)', () => {}],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(closers.slice(0, 4))('%s', async (_label, close) => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
const before = layoutOf(app);
|
||||||
|
await close(app);
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5 });
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(layoutOf(app)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the window narrowing past the desktop gate', async () => {
|
||||||
|
const gates: Array<(e: { matches: boolean }) => void> = [];
|
||||||
|
windowStub.matchMedia = (q: string) => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: (_t: string, cb: (e: { matches: boolean }) => void) => {
|
||||||
|
if (q.includes('min-width')) gates.push(cb);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
const before = layoutOf(app);
|
||||||
|
for (const gate of gates) gate({ matches: false });
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(layoutOf(app)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Open group as tiles" replaces it with the group, and THAT is what comes back', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
app.tabLayout = {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 'g',
|
||||||
|
refs: [
|
||||||
|
{ kind: 'session', id: 's-b' },
|
||||||
|
{ kind: 'session', id: 's-d' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
app.openGroupAsTiles('g');
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-d']);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-b', 's-d']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the last tile removed: that tile comes back', () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
app.openTileGrid(['s-c', 's-d'], { focusedId: 's-c' });
|
||||||
|
app.removeTile('s-d');
|
||||||
|
app.removeTile('s-c');
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: ['s-c'], count: 1 });
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a #session= link on load closes it, its value kept as stored (a session gone since still frees its cell)', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
const page = layoutApp();
|
||||||
|
page.sessions.delete('s-d');
|
||||||
|
page._closeStoredTileGrid();
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5 });
|
||||||
|
page.toggleTileGrid();
|
||||||
|
// s-d's cell taken by the ranking's best untiled session (all quiet here: tab order, s-other first).
|
||||||
|
expect(page._tileGrid.cells).toEqual(['s-f', null, 's-c', 's-other', 's-e', 's-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing the last tiled session: kept, and with none of its sessions left the ranking opens from scratch', () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
app.openTileGrid(['s-c'], { focusedId: 's-c' });
|
||||||
|
app._onSessionDeleted({ id: 's-c' });
|
||||||
|
app.sessions.delete('s-c');
|
||||||
|
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-c');
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: ['s-c'] });
|
||||||
|
Object.assign(app.sessions.get('s-e'), { status: 'busy', lastSubmitAt: 50 });
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids[0]).toBe('s-e');
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('killing every session: kept, naming only gone sessions, so the next click ranks from scratch', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
app._apiDelete = vi.fn(async () => ({ ok: true }));
|
||||||
|
app.terminalBuffers = new Map();
|
||||||
|
app.terminalLoadStates = new Map();
|
||||||
|
// killAllSessions asks with a bare confirm(): the harness context's own global.
|
||||||
|
const contextGlobal = (app.toggleTileGrid.constructor as FunctionConstructor)('return globalThis')();
|
||||||
|
contextGlobal.confirm = () => true;
|
||||||
|
try {
|
||||||
|
await app.killAllSessions();
|
||||||
|
} finally {
|
||||||
|
delete contextGlobal.confirm;
|
||||||
|
}
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS });
|
||||||
|
// New sessions later: none of the stored ones survive.
|
||||||
|
app.sessions = new Map(['n-1', 'n-2'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1 }]));
|
||||||
|
app.sessionOrder = ['n-1', 'n-2'];
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['n-1', 'n-2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a session that went away by itself while the grid was open', () => {
|
||||||
|
// With the cap at 6 a grid has at most one empty cell (5 tiles in a 3x2, or
|
||||||
|
// 3 in a narrow 2x2), so the stored count alone says that cell is to be filled.
|
||||||
|
function six(extra: string[] = []): GridApp {
|
||||||
|
const app = layoutApp([...SIX, ...extra]);
|
||||||
|
app.openTileGrid(SIX, { focusedId: 's-a' });
|
||||||
|
flushFrames();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('closed from elsewhere: its cell empties now, and the ranking fills it the next time the grid opens', () => {
|
||||||
|
const app = six(['s-g']);
|
||||||
|
app._onSessionDeleted({ id: 's-d' });
|
||||||
|
app.sessions.delete('s-d');
|
||||||
|
app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-d');
|
||||||
|
// Nothing joins on its own while the grid is open.
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null, 's-e', 's-f']);
|
||||||
|
// The count stays what the user left: six.
|
||||||
|
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', null, 's-e', 's-f'], count: 6 });
|
||||||
|
Object.assign(app.sessions.get('s-g'), { status: 'busy', lastSubmitAt: 10 });
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-g', 's-e', 's-f']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('popped out (or its socket refused): the same, the place kept for the ranking', () => {
|
||||||
|
const app = six();
|
||||||
|
app.detachedSessions.add('s-e');
|
||||||
|
app._reconcileTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-f']);
|
||||||
|
FakeTile.all.find((t) => t.sessionId === 's-b' && !t._destroyed)!.onExit!(4010);
|
||||||
|
expect(stored().count).toBe(6);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
// Two places to fill, the two sessions left that are not popped out (all quiet: tab order).
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(6);
|
||||||
|
expect(app._tileGrid.ids).not.toContain('s-e');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('popped out while open (the pop-out button, or another tab announcing it): the count stays, the cell refills after re-dock', () => {
|
||||||
|
const app = six();
|
||||||
|
// What the real pop-out paths touch: detachSession and the window channel
|
||||||
|
// both mark through _markDetached; _redock clears it again.
|
||||||
|
app._detachOrphanStrikes = new Map();
|
||||||
|
app._detachWatchTimers = new Map();
|
||||||
|
app._redockGrace = new Map();
|
||||||
|
app.detachedWindows = new Map();
|
||||||
|
app._elemCache = {};
|
||||||
|
app._onWindowMessage({ type: 'detached', id: 's-e' });
|
||||||
|
expect(app.detachedSessions.has('s-e')).toBe(true);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-f']);
|
||||||
|
// Not the user's hand: the count stays six, the same as a pop-out with the grid closed.
|
||||||
|
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', 's-d', null, 's-f'], count: 6 });
|
||||||
|
app._redock('s-e');
|
||||||
|
expect(app.detachedSessions.has('s-e')).toBe(false);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
// The freed cell is filled from the ranking (all quiet here: tab order, s-other first).
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', 's-other', 's-f']);
|
||||||
|
expect(stored().count).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removed by hand instead: the hole stays a hole', () => {
|
||||||
|
const app = six(['s-g']);
|
||||||
|
app.removeTile('s-d');
|
||||||
|
expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', null, 's-e', 's-f'], count: 5 });
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null, 's-e', 's-f']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tile added by hand meanwhile sets the count to what is shown', () => {
|
||||||
|
const app = six(['s-g']);
|
||||||
|
app._onSessionDeleted({ id: 's-d' });
|
||||||
|
app.sessions.delete('s-d');
|
||||||
|
app._onSessionDeleted({ id: 's-e' });
|
||||||
|
app.sessions.delete('s-e');
|
||||||
|
// Four tiles now (a 2x2), count still six; then a Ctrl/Cmd+click adds one.
|
||||||
|
app.addSessionToTiles('s-g');
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(5);
|
||||||
|
expect(stored().count).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the stored format', () => {
|
||||||
|
it('a value written before the count (packed, no holes) loads, its sessions in their cells', () => {
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-c', 's-a', 's-e', 's-d'], focused: 's-e' }));
|
||||||
|
const app = layoutApp();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-e', 's-d']);
|
||||||
|
expect(app.activeSessionId).toBe('s-e');
|
||||||
|
expect(stored().count).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['another version', JSON.stringify({ v: 2, open: false, ids: SIX })],
|
||||||
|
['not JSON', '{oops'],
|
||||||
|
['an array', '[1,2]'],
|
||||||
|
['ids that are not a list', JSON.stringify({ v: 1, open: false, ids: 's-a' })],
|
||||||
|
['nothing but junk', JSON.stringify({ v: 1, open: false, ids: [1, {}, null], count: 'x', colFr: 'y' })],
|
||||||
|
])('a malformed value (%s) is ignored: the grid opens from the ranking', (_label, raw) => {
|
||||||
|
localStore.set(KEY, raw);
|
||||||
|
const app = layoutApp();
|
||||||
|
Object.assign(app.sessions.get('s-f'), { status: 'busy', lastSubmitAt: 99 });
|
||||||
|
expect(() => app.toggleTileGrid()).not.toThrow();
|
||||||
|
expect(app._tileGrid.ids[0]).toBe('s-f');
|
||||||
|
expect(app._tileGrid.ids).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a malformed divider size is dropped, the rest of the layout kept', () => {
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-a', 's-b'], colFr: [1, 'x'], rowFr: [-1] }));
|
||||||
|
const app = layoutApp();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-b']);
|
||||||
|
expect(app._tileGrid.colFr).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ctrl/Cmd+click with a stored grid', () => {
|
||||||
|
it('joins the hole while the grid holds fewer than the count, the rest kept where it was', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app._rememberTileGridCount(6);
|
||||||
|
app.addSessionToTiles('s-b');
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-f', 's-b', 's-c', 's-d', 's-e', 's-a']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-b');
|
||||||
|
expect(app._tileGrid.colFr).toEqual(stored().colFr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('at the count, it takes the last tile’s place: the count in total, never one more', async () => {
|
||||||
|
const app = layoutApp();
|
||||||
|
await arrange(app);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app._rememberTileGridCount(4);
|
||||||
|
app.addSessionToTiles('s-b');
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-f', null, 's-c', 's-d', 's-e', 's-b']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-b');
|
||||||
|
expect(stored().count).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the hover card', () => {
|
||||||
|
it('with a stored grid larger than the window, says what fits, not "a click opens N"', () => {
|
||||||
|
const btn = new FakeEl();
|
||||||
|
btn.className = 'btn-icon-header btn-tile-grid';
|
||||||
|
bySelector.set('.btn-tile-grid', btn);
|
||||||
|
const small = { width: 1300, height: 900, top: 0, left: 0, right: 1300, bottom: 900 };
|
||||||
|
(bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = () => small;
|
||||||
|
const app = layoutApp();
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
const card = body.children.find((c) => c.id === 'tileGridHint')!;
|
||||||
|
const texts = () => card.children.map((c) => c.children.at(-1)?.textContent ?? c.textContent);
|
||||||
|
// Nothing stored: the click opens what fits.
|
||||||
|
expect(texts()).toContain('This window fits 4 tiles: a click opens 4');
|
||||||
|
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: SIX, focused: 's-a' }));
|
||||||
|
app._renderTileHint();
|
||||||
|
expect(texts()).toContain('This window fits 4 tiles');
|
||||||
|
expect(texts().join('\n')).not.toContain('a click opens');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -122,6 +122,9 @@ describe('sanitizeTileGridState', () => {
|
|||||||
open: true,
|
open: true,
|
||||||
ids: ['a', 'b'],
|
ids: ['a', 'b'],
|
||||||
cells: ['a', 'b'],
|
cells: ['a', 'b'],
|
||||||
|
// Nothing freed; no stored count: the sessions it names.
|
||||||
|
freed: [],
|
||||||
|
count: 2,
|
||||||
focused: 'b',
|
focused: 'b',
|
||||||
zoomed: 'a',
|
zoomed: 'a',
|
||||||
colFr: [1, 2],
|
colFr: [1, 2],
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
*
|
*
|
||||||
* - `tileGridOpenSet` (constants.js, pure): what opens, in order: (a) the grid
|
* - `tileGridOpenSet` (constants.js, pure): what opens, in order: (a) the grid
|
||||||
* this tab last had, if any of its sessions survive; (b) else an open
|
* this tab last had, if any of its sessions survive; (b) else an open
|
||||||
* split's two sessions; (c) else the open sessions in tab order up to the
|
* split's two sessions; (c) else the open sessions in ranked order (tab
|
||||||
* limit (never past the cap of 6), the active one always among them and
|
* order when no ranking is given, as here; the ranking itself is pinned in
|
||||||
* focused. Detached sessions and ones that no longer exist are left out, as
|
* test/tile-grid-ranking.test.ts) up to the limit (never past the cap of
|
||||||
* in the picker.
|
* 6), the active one always among them and focused. Detached sessions and
|
||||||
|
* ones that no longer exist are left out, as in the picker.
|
||||||
* - The button's click and Ctrl+Shift+G are the same function
|
* - The button's click and Ctrl+Shift+G are the same function
|
||||||
* (`toggleTileGrid`); right-click (contextmenu) opens the count menu (owner
|
* (`toggleTileGrid`); right-click (contextmenu) opens the count menu (owner
|
||||||
* decision 10). With the grid open a pick re-forms it, the focused tile kept.
|
* decision 10). With the grid open a pick re-forms it, the focused tile kept.
|
||||||
@@ -44,6 +45,7 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
|||||||
expect(T.tileGridOpenSet({ ...base, stored, split: ['t1', 't9'], activeId: 't1' })).toEqual({
|
expect(T.tileGridOpenSet({ ...base, stored, split: ['t1', 't9'], activeId: 't1' })).toEqual({
|
||||||
source: 'stored',
|
source: 'stored',
|
||||||
ids: ['t4', 't2'],
|
ids: ['t4', 't2'],
|
||||||
|
cells: ['t4', 't2'],
|
||||||
focusedId: 't2',
|
focusedId: 't2',
|
||||||
});
|
});
|
||||||
expect(T.tileGridOpenSet({ ...base, stored: { ids: ['t4', 't2'], focused: 't4', zoomed: 't2' } })?.focusedId).toBe(
|
expect(T.tileGridOpenSet({ ...base, stored: { ids: ['t4', 't2'], focused: 't4', zoomed: 't2' } })?.focusedId).toBe(
|
||||||
@@ -59,9 +61,9 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('c: else the open sessions in tab order up to the limit, the active one focused', () => {
|
it('c: else the open sessions (no ranking given: tab order) up to the limit, the active one focused', () => {
|
||||||
expect(T.tileGridOpenSet({ ...base, activeId: 't3' })).toEqual({
|
expect(T.tileGridOpenSet({ ...base, activeId: 't3' })).toEqual({
|
||||||
source: 'tabs',
|
source: 'ranked',
|
||||||
ids: order.slice(0, 6),
|
ids: order.slice(0, 6),
|
||||||
focusedId: 't3',
|
focusedId: 't3',
|
||||||
});
|
});
|
||||||
@@ -69,7 +71,7 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
|||||||
|
|
||||||
it('c: an active session past the limit still comes, with the first ones before it', () => {
|
it('c: an active session past the limit still comes, with the first ones before it', () => {
|
||||||
expect(T.tileGridOpenSet({ ...base, activeId: 't9' })).toEqual({
|
expect(T.tileGridOpenSet({ ...base, activeId: 't9' })).toEqual({
|
||||||
source: 'tabs',
|
source: 'ranked',
|
||||||
ids: ['t1', 't2', 't3', 't4', 't5', 't9'],
|
ids: ['t1', 't2', 't3', 't4', 't5', 't9'],
|
||||||
focusedId: 't9',
|
focusedId: 't9',
|
||||||
});
|
});
|
||||||
@@ -171,7 +173,7 @@ describe('opening at once, in the app', () => {
|
|||||||
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
expect(app._tilesOwnTerminal()).toBe(true);
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
pick(4);
|
pick(4);
|
||||||
// Its two first, then the open sessions in tab order.
|
// Its two first, then the ranking (every session quiet and unstamped here: tab order).
|
||||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
|
||||||
expect(app.activeSessionId).toBe('s-b');
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Which sessions the tile grid takes when nobody said which
|
||||||
|
* (owner request: "prefer to load in tiles that are working and then the most
|
||||||
|
* recent, so the oldest don't get opened").
|
||||||
|
*
|
||||||
|
* - `rankTileSessions` (constants.js, pure): WORKING first, the most recently
|
||||||
|
* started turn first (keyed off `lastSubmitAt` only: a working pane's
|
||||||
|
* last-activity stamp is always "now"); then the ones that NEED INPUT (the
|
||||||
|
* red and yellow tab alerts: `needs` and `waiting`), most recent first; then
|
||||||
|
* every other one, most recently active first. A missing stamp (0) sorts
|
||||||
|
* last in its group, tab order breaks ties, so the order never shuffles.
|
||||||
|
* - `tileGridOpenSet` case c opens the ranking's best up to the limit, the
|
||||||
|
* active session always among them and focused, detached ones never.
|
||||||
|
* - In the app (`_tileGridRanking`, the home screens' `_mobileOverviewState`
|
||||||
|
* and stamps): the Tiles button with nothing stored, a count picked in its
|
||||||
|
* menu, Ctrl/Cmd+click and an open split's fill all take the ranking; a stale
|
||||||
|
* page without the classifier falls back to tab order.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FakeEl, bySelector, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
type Row = { id: string; state: string; lastActivityAt?: number; lastSubmitAt?: number; orderIndex?: number };
|
||||||
|
type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null;
|
||||||
|
type Helpers = {
|
||||||
|
rankTileSessions(rows: unknown): string[];
|
||||||
|
tileGridOpenSet(p: Record<string, unknown>): OpenSet;
|
||||||
|
TILE_GRID_MAX: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const T = windowStub.CodemanTileGrid as Helpers;
|
||||||
|
const rank = (rows: Row[]) => T.rankTileSessions(rows.map((r, orderIndex) => ({ orderIndex, ...r })));
|
||||||
|
|
||||||
|
describe('rankTileSessions', () => {
|
||||||
|
it('working first, then needing input (red and yellow), then everything else', () => {
|
||||||
|
expect(
|
||||||
|
rank([
|
||||||
|
{ id: 'idle', state: 'idle', lastActivityAt: 900 },
|
||||||
|
{ id: 'done', state: 'done', lastActivityAt: 800 },
|
||||||
|
{ id: 'waiting', state: 'waiting', lastActivityAt: 100 },
|
||||||
|
{ id: 'error', state: 'error', lastActivityAt: 950 },
|
||||||
|
{ id: 'working', state: 'working', lastSubmitAt: 10 },
|
||||||
|
{ id: 'needs', state: 'needs', lastActivityAt: 50 },
|
||||||
|
])
|
||||||
|
).toEqual(['working', 'waiting', 'needs', 'error', 'idle', 'done']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('working: the most recently started turn first', () => {
|
||||||
|
expect(
|
||||||
|
rank([
|
||||||
|
{ id: 'old', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 9_000 },
|
||||||
|
{ id: 'new', state: 'working', lastSubmitAt: 5_000, lastActivityAt: 9_000 },
|
||||||
|
{ id: 'mid', state: 'working', lastSubmitAt: 3_000, lastActivityAt: 9_000 },
|
||||||
|
])
|
||||||
|
).toEqual(['new', 'mid', 'old']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('working keys off lastSubmitAt only: a pane that never submitted does not take the head on its repaint stamp', () => {
|
||||||
|
expect(
|
||||||
|
rank([
|
||||||
|
{ id: 'never-submitted', state: 'working', lastSubmitAt: 0, lastActivityAt: 99_999 },
|
||||||
|
{ id: 'submitted-long-ago', state: 'working', lastSubmitAt: 1, lastActivityAt: 99_998 },
|
||||||
|
])
|
||||||
|
).toEqual(['submitted-long-ago', 'never-submitted']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needing input is one group, most recent first, whichever alert it raised', () => {
|
||||||
|
expect(
|
||||||
|
rank([
|
||||||
|
{ id: 'red-old', state: 'needs', lastActivityAt: 100 },
|
||||||
|
{ id: 'yellow-new', state: 'waiting', lastActivityAt: 300 },
|
||||||
|
{ id: 'red-new', state: 'needs', lastActivityAt: 200 },
|
||||||
|
])
|
||||||
|
).toEqual(['yellow-new', 'red-new', 'red-old']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the rest: most recently active first, the oldest last', () => {
|
||||||
|
expect(
|
||||||
|
rank([
|
||||||
|
{ id: 'yesterday', state: 'idle', lastActivityAt: 1_000 },
|
||||||
|
{ id: 'just-now', state: 'done', lastActivityAt: 9_000 },
|
||||||
|
{ id: 'an-hour-ago', state: 'error', lastActivityAt: 5_000 },
|
||||||
|
])
|
||||||
|
).toEqual(['just-now', 'an-hour-ago', 'yesterday']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no stamp sorts last in its group; equal stamps keep tab order, so it never shuffles', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'a', state: 'idle', orderIndex: 0 },
|
||||||
|
{ id: 'b', state: 'idle', lastActivityAt: 10, orderIndex: 1 },
|
||||||
|
{ id: 'c', state: 'idle', orderIndex: 2 },
|
||||||
|
{ id: 'd', state: 'idle', lastActivityAt: 10, orderIndex: 3 },
|
||||||
|
{ id: 'w', state: 'working', orderIndex: 4 },
|
||||||
|
{ id: 'v', state: 'working', lastSubmitAt: 5, orderIndex: 5 },
|
||||||
|
];
|
||||||
|
const expected = ['v', 'w', 'b', 'd', 'a', 'c'];
|
||||||
|
expect(T.rankTileSessions(rows)).toEqual(expected);
|
||||||
|
expect(T.rankTileSessions([...rows].reverse())).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown state counts as "everything else"', () => {
|
||||||
|
expect(
|
||||||
|
rank([
|
||||||
|
{ id: 'odd', state: 'mystery', lastActivityAt: 9 },
|
||||||
|
{ id: 'idle', state: 'idle', lastActivityAt: 5 },
|
||||||
|
{ id: 'busy', state: 'working', lastSubmitAt: 1 },
|
||||||
|
])
|
||||||
|
).toEqual(['busy', 'odd', 'idle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops malformed rows and repeats, and never sorts its input in place', () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: 'b', state: 'idle', lastActivityAt: 1, orderIndex: 1 },
|
||||||
|
null,
|
||||||
|
{ id: '', state: 'working' },
|
||||||
|
{ id: 'a', state: 'working', lastSubmitAt: 1, orderIndex: 0 },
|
||||||
|
{ id: 'b', state: 'idle', lastActivityAt: 1, orderIndex: 2 },
|
||||||
|
];
|
||||||
|
const copy = JSON.stringify(rows);
|
||||||
|
expect(T.rankTileSessions(rows)).toEqual(['a', 'b']);
|
||||||
|
expect(JSON.stringify(rows)).toBe(copy);
|
||||||
|
expect(T.rankTileSessions(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tileGridOpenSet with a ranking (case c)', () => {
|
||||||
|
const order = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8'];
|
||||||
|
const sessions = new Map(order.map((id) => [id, { id }]));
|
||||||
|
const ranked = ['t8', 't6', 't1', 't3', 't7', 't2', 't5', 't4'];
|
||||||
|
const base = { sessions, sessionOrder: order, ranked, limit: 4 };
|
||||||
|
|
||||||
|
it('opens the best of the ranking, not the first tabs, the active one focused', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, activeId: 't6' })).toEqual({
|
||||||
|
source: 'ranked',
|
||||||
|
ids: ['t8', 't6', 't1', 't3'],
|
||||||
|
focusedId: 't6',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an active session the ranking puts past the limit still comes, in the last place', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, activeId: 't4' })?.ids).toEqual(['t8', 't6', 't1', 't4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never a detached session or one that no longer exists', () => {
|
||||||
|
const out = T.tileGridOpenSet({
|
||||||
|
...base,
|
||||||
|
ranked: ['gone', ...ranked],
|
||||||
|
detachedIds: new Set(['t6']),
|
||||||
|
activeId: null,
|
||||||
|
});
|
||||||
|
expect(out?.ids).toEqual(['t8', 't1', 't3', 't7']);
|
||||||
|
expect(out?.focusedId).toBe('t8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a ranking it is tab order, as before', () => {
|
||||||
|
expect(T.tileGridOpenSet({ sessions, sessionOrder: order, limit: 3, activeId: null })?.ids).toEqual([
|
||||||
|
't1',
|
||||||
|
't2',
|
||||||
|
't3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('in the app', () => {
|
||||||
|
// Eight sessions, tab order s-1 .. s-8 (plus the harness's s-other, first and quiet).
|
||||||
|
const IDS = ['s-1', 's-2', 's-3', 's-4', 's-5', 's-6', 's-7', 's-8'];
|
||||||
|
|
||||||
|
/** Statuses, hooks and stamps: two working, one red, one yellow, four quiet. */
|
||||||
|
function rankedApp(): GridApp {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const set = (id: string, fields: Record<string, unknown>) => Object.assign(app.sessions.get(id), fields);
|
||||||
|
set('s-other', { status: 'idle', lastActivityAt: 1_000 });
|
||||||
|
set('s-1', { status: 'idle', lastActivityAt: 2_000 }); // the oldest quiet one
|
||||||
|
set('s-2', { status: 'busy', lastSubmitAt: 5_000, lastActivityAt: 99_000 }); // working, older turn
|
||||||
|
set('s-3', { status: 'idle', lastActivityAt: 8_000 });
|
||||||
|
set('s-4', { status: 'idle', lastActivityAt: 7_000 }); // red: a permission dialog
|
||||||
|
set('s-5', { status: 'stopped', lastActivityAt: 3_000 });
|
||||||
|
set('s-6', { status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 99_000 }); // working, newest turn
|
||||||
|
set('s-7', { status: 'idle', lastActivityAt: 6_000 }); // yellow: a finished turn not seen
|
||||||
|
set('s-8', { status: 'idle', lastActivityAt: 9_500 });
|
||||||
|
app.pendingHooks.set('s-4', new Set(['permission_prompt']));
|
||||||
|
app.pendingHooks.set('s-7', new Set(['idle_prompt']));
|
||||||
|
app.activeSessionId = null;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
bySelector.set('.terminal-wrap', new FakeEl());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks with the home screens’ classification and stamps', () => {
|
||||||
|
expect(rankedApp()._tileGridRanking()).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-3', 's-5', 's-1', 's-other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Tiles button with nothing stored opens working, then needing input, then the most recent; the oldest stay out', () => {
|
||||||
|
const app = rankedApp();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-3']);
|
||||||
|
expect(app._tileGrid.ids).not.toContain('s-1');
|
||||||
|
expect(app._tileGrid.ids).not.toContain('s-other');
|
||||||
|
// No active session: the best ranked takes focus.
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the active session always comes and takes focus, even ranked last', () => {
|
||||||
|
const app = rankedApp();
|
||||||
|
app.activeSessionId = 's-1';
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-1']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the remembered count takes that many from the top; a detached session is never tiled', () => {
|
||||||
|
const app = rankedApp();
|
||||||
|
app.detachedSessions.add('s-2');
|
||||||
|
app._rememberTileGridCount(4);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-6', 's-4', 's-7', 's-8']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a count picked in the menu fills an open grid from the ranking', () => {
|
||||||
|
const app = rankedApp();
|
||||||
|
app.openTileGrid(['s-1', 's-5'], { focusedId: 's-5' });
|
||||||
|
app._pickTileCount(4);
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-1', 's-5', 's-6', 's-2']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl/Cmd+click with nothing stored: the clicked one plus the best of the ranking, the count in total', () => {
|
||||||
|
const app = rankedApp();
|
||||||
|
app._rememberTileGridCount(4);
|
||||||
|
app.addSessionToTiles('s-5');
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-5']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an open split comes first (case b), the rest from the ranking', () => {
|
||||||
|
const app = rankedApp();
|
||||||
|
app.activeSessionId = 's-1';
|
||||||
|
app._splitPane = {};
|
||||||
|
app._splitSessionId = 's-5';
|
||||||
|
app.closeSplitPane = vi.fn(() => {
|
||||||
|
app._splitPane = null;
|
||||||
|
});
|
||||||
|
app._rememberTileGridCount(4);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-1', 's-5', 's-6', 's-2']);
|
||||||
|
expect(app._tileGrid.focusedId).toBe('s-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a page without the classifier (a stale cached mobile-overview.js) falls back to tab order', () => {
|
||||||
|
const app = rankedApp();
|
||||||
|
app._mobileOverviewState = undefined;
|
||||||
|
expect(app._tileGridRanking()).toEqual(['s-other', ...IDS]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+114
-30
@@ -1,15 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview The grid survives a page reload, per device (`codeman:tile-grid`).
|
* @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
|
* - What is stored: ids (the cells), the tile count, focus, a zoom the user
|
||||||
* fractions, never content. Closing the grid (Tiles, a pick outside it)
|
* chose and the divider fractions, never content. Closing the grid (Tiles,
|
||||||
* keeps it remembered as `open: false` for one-click return; the last tile
|
* a pick outside it, the last tile leaving) keeps it remembered as
|
||||||
* leaving forgets it. Never written or read in a solo window.
|
* `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
|
* - The restore runs INSIDE handleInit, in place of its single-view
|
||||||
* `selectSession(restoreId, { auto: true })`: with a stored open grid the
|
* `selectSession(restoreId, { auto: true })`: with a stored open grid the
|
||||||
* main terminal never loads on that page load (no select, no socket, no
|
* main terminal never loads on that page load (no select, no socket, no
|
||||||
* capture), deleted / detached / duplicate ids are dropped, and the stored
|
* capture), a deleted or detached session frees its cell for the ranking
|
||||||
* fractions and zoom come back. A narrow window keeps the single view.
|
* to fill (on status and stamps: pending approvals are seeded after, and
|
||||||
|
* never re-form the restored grid), duplicate ids are dropped, and the
|
||||||
|
* stored fractions and zoom come back. A narrow window keeps the single view.
|
||||||
* - A `#session=` link on load wins, and leaves the stored grid remembered
|
* - A `#session=` link on load wins, and leaves the stored grid remembered
|
||||||
* but closed.
|
* but closed.
|
||||||
* - Leaving the grid invalidates the main terminal's cached content for every
|
* - Leaving the grid invalidates the main terminal's cached content for every
|
||||||
@@ -25,7 +30,15 @@ const KEY = 'codeman:tile-grid';
|
|||||||
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
||||||
|
|
||||||
/** A fresh page: the app as handleInit leaves it on its FIRST run (gen 1). */
|
/** A fresh page: the app as handleInit leaves it on its FIRST run (gen 1). */
|
||||||
function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
|
function pageLoad(
|
||||||
|
liveIds: string[],
|
||||||
|
setup: (app: GridApp) => void = () => {},
|
||||||
|
{
|
||||||
|
sessions = liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
|
||||||
|
sessionOrder,
|
||||||
|
realOrder = false,
|
||||||
|
}: { sessions?: Array<Record<string, unknown>>; sessionOrder?: string[]; realOrder?: boolean } = {}
|
||||||
|
) {
|
||||||
const app = makeGridApp(IDS);
|
const app = makeGridApp(IDS);
|
||||||
app._initGeneration = 0;
|
app._initGeneration = 0;
|
||||||
app.activeSessionId = null;
|
app.activeSessionId = null;
|
||||||
@@ -35,7 +48,7 @@ function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
|
|||||||
for (const name of [
|
for (const name of [
|
||||||
'_clearTimer',
|
'_clearTimer',
|
||||||
'_updateCjkInputState',
|
'_updateCjkInputState',
|
||||||
'syncSessionOrder',
|
...(realOrder ? [] : ['syncSessionOrder']),
|
||||||
'_loadTabLayout',
|
'_loadTabLayout',
|
||||||
'cleanupAllFloatingWindows',
|
'cleanupAllFloatingWindows',
|
||||||
'startSystemStatsPolling',
|
'startSystemStatsPolling',
|
||||||
@@ -46,10 +59,7 @@ function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
|
|||||||
}
|
}
|
||||||
app.$ = () => null;
|
app.$ = () => null;
|
||||||
setup(app);
|
setup(app);
|
||||||
app.handleInit({
|
app.handleInit({ sessions, scheduledRuns: [], ...(sessionOrder ? { sessionOrder } : {}) });
|
||||||
sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
|
|
||||||
scheduledRuns: [],
|
|
||||||
});
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,13 +68,14 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('what is stored', () => {
|
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);
|
const app = makeGridApp(IDS);
|
||||||
app.openTileGrid(IDS, { focusedId: 's-b' });
|
app.openTileGrid(IDS, { focusedId: 's-b' });
|
||||||
expect(stored()).toEqual({
|
expect(stored()).toEqual({
|
||||||
v: 1,
|
v: 1,
|
||||||
open: true,
|
open: true,
|
||||||
ids: IDS,
|
ids: IDS,
|
||||||
|
count: 4,
|
||||||
focused: 's-b',
|
focused: 's-b',
|
||||||
zoomed: null,
|
zoomed: null,
|
||||||
colFr: [1, 1],
|
colFr: [1, 1],
|
||||||
@@ -94,7 +105,7 @@ describe('what is stored', () => {
|
|||||||
expect(stored().zoomed).toBeNull();
|
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);
|
const app = makeGridApp(IDS);
|
||||||
app.selectSession = vi.fn();
|
app.selectSession = vi.fn();
|
||||||
app.openTileGrid(IDS);
|
app.openTileGrid(IDS);
|
||||||
@@ -103,7 +114,7 @@ describe('what is stored', () => {
|
|||||||
|
|
||||||
app.openTileGrid(['s-a']);
|
app.openTileGrid(['s-a']);
|
||||||
app.removeTile('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', () => {
|
it('a solo window never writes', () => {
|
||||||
@@ -134,12 +145,81 @@ describe('page load with a stored open grid', () => {
|
|||||||
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
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' });
|
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 'gone' });
|
||||||
const app = pageLoad(IDS);
|
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(app.activeSessionId).toBe('s-a');
|
||||||
expect(stored().ids).toEqual(['s-a', 's-b']);
|
expect(stored().ids).toEqual(['s-a', 's-c', 's-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the reload fill ranks with the init payload: its states, stamps and synced tab order', () => {
|
||||||
|
// The tab order comes from the server snapshot (synced before the restore
|
||||||
|
// runs); the states from the session payload. Pending approvals arrive
|
||||||
|
// later (seedApprovals is async), so only status and stamps rank here.
|
||||||
|
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 's-a' });
|
||||||
|
const now = 1_000_000;
|
||||||
|
const app = pageLoad(IDS, () => {}, {
|
||||||
|
realOrder: true,
|
||||||
|
sessionOrder: ['s-c', 's-b', 's-a', 's-d'],
|
||||||
|
sessions: [
|
||||||
|
{ id: 's-a', name: 's-a', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now },
|
||||||
|
{ id: 's-b', name: 's-b', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now },
|
||||||
|
{ id: 's-c', name: 's-c', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 10 },
|
||||||
|
{ id: 's-d', name: 's-d', mode: 'claude', pid: 1, status: 'busy', lastSubmitAt: now - 500 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(app.sessionOrder).toEqual(['s-c', 's-b', 's-a', 's-d']);
|
||||||
|
// s-d is working: it takes the freed cell, though s-c comes first in the tab order.
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-d', 's-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pending approvals seeded after the reload never re-form the restored grid (known limit of the reload fill)', async () => {
|
||||||
|
// seedApprovals is async (GET /api/approvals), so the freed cell is filled
|
||||||
|
// from status and stamps alone. A needs-input session the seed reveals a
|
||||||
|
// moment later does not take a tile from the grid already back on screen:
|
||||||
|
// a late fill would reshape it (fewer tiles opened is another shape) and
|
||||||
|
// move the user's tiles a second after the reload.
|
||||||
|
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 's-a' });
|
||||||
|
const now = 1_000_000;
|
||||||
|
let release!: () => void;
|
||||||
|
const app = pageLoad(
|
||||||
|
IDS,
|
||||||
|
(a) => {
|
||||||
|
a.tabAlerts = new Map();
|
||||||
|
a.seedApprovals = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
release = () => {
|
||||||
|
a.setPendingHook('s-d', 'permission_prompt');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessions: [
|
||||||
|
{ id: 's-a', name: 's-a', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now },
|
||||||
|
{ id: 's-b', name: 's-b', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now },
|
||||||
|
{ id: 's-c', name: 's-c', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 10 },
|
||||||
|
{ id: 's-d', name: 's-d', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 500 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(app.seedApprovals).toHaveBeenCalledTimes(1);
|
||||||
|
// Before the seed lands: all quiet, the most recent (s-c) takes the freed cell.
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-b']);
|
||||||
|
const before = stored();
|
||||||
|
release();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
// The seed made s-d need input: the ranking now puts it first...
|
||||||
|
expect(app._tileGridRanking()[0]).toBe('s-d');
|
||||||
|
app._renderTileChrome();
|
||||||
|
// ...but the restored grid and the stored layout stay as they came back.
|
||||||
|
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-b']);
|
||||||
|
expect(stored()).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('brings back the fractions (same layout only) and a zoom the user chose', () => {
|
it('brings back the fractions (same layout only) and a zoom the user chose', () => {
|
||||||
@@ -179,26 +259,30 @@ describe('page load with a stored open grid', () => {
|
|||||||
expect(app.activeSessionId).toBe('s-c');
|
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', () => {
|
it('the toggle brings a stored grid back exactly, never filled to the remembered count', () => {
|
||||||
// Default 6 (owner answer 1, superseding decision 8's "exactly the stored set").
|
// 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' }));
|
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'));
|
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
|
||||||
app.activeSessionId = 's-a';
|
app.activeSessionId = 's-a';
|
||||||
app.toggleTileGrid();
|
app.toggleTileGrid();
|
||||||
// The two stay in the first row (2x1 to 2x2 keeps them), the rest join
|
expect(app._tileGrid.cells).toEqual(['s-b', 's-c']);
|
||||||
// in tab order: every live session, fewer than the count.
|
|
||||||
expect(app._tileGrid.cells).toEqual(['s-b', 's-c', 's-a', 's-d']);
|
|
||||||
expect(app.activeSessionId).toBe('s-c');
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a stored hole is filled first when the count needs more tiles', () => {
|
it('a stored hole stays a hole on the toggle; a count picked in the menu fills it first', () => {
|
||||||
// A 2x2 of three with the hole first; the count asks for four.
|
// A 3x2 of five with the second cell empty; the remembered count is six.
|
||||||
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: [null, 's-b', 's-c', 's-d'], focused: 's-b' }));
|
const cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
|
||||||
localStore.set('codeman:tile-count', '4');
|
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: cells, focused: 's-b' }));
|
||||||
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
|
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();
|
app.toggleTileGrid();
|
||||||
// s-a joins in the hole (packing would have put it last).
|
expect(app._tileGrid.cells).toEqual(cells);
|
||||||
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
|
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', () => {
|
it('a reload brings back exactly the stored grid, whatever the count', () => {
|
||||||
|
|||||||
@@ -243,14 +243,15 @@ describe('the capture-phase handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('the actions', () => {
|
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);
|
const app = makeGridApp(IDS);
|
||||||
app.selectSession = vi.fn();
|
app.selectSession = vi.fn();
|
||||||
app.openTileGrid(IDS, { focusedId: 's-c' });
|
app.openTileGrid(IDS, { focusedId: 's-c' });
|
||||||
app.closeTileGrid({ reselect: false });
|
app.closeTileGrid({ reselect: false });
|
||||||
app.toggleTileGrid();
|
app.toggleTileGrid();
|
||||||
// Its three first, then the open sessions in tab order (the default count, 6).
|
// Its three, as left (owner request: "always keep what the last setting
|
||||||
expect(app._tileGrid.ids).toEqual([...IDS, 's-other']);
|
// was"), though the default count is 6 and s-other is free.
|
||||||
|
expect(app._tileGrid.ids).toEqual(IDS);
|
||||||
expect(app.activeSessionId).toBe('s-c');
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,7 +262,7 @@ describe('the actions', () => {
|
|||||||
app.sessions.delete('s-b');
|
app.sessions.delete('s-b');
|
||||||
app.toggleTileGrid();
|
app.toggleTileGrid();
|
||||||
expect(app._tileGrid.ids).not.toContain('s-b');
|
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']);
|
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(paneB.destroy).toHaveBeenCalledTimes(1);
|
||||||
expect(app._splitPane).toBeNull();
|
expect(app._splitPane).toBeNull();
|
||||||
// Filled from tab order (s-other first), not seeded with the split's two.
|
// Exactly as stored (owner request: the last arrangement comes back), not
|
||||||
expect(app._tileGrid.ids).toEqual(['s-c', 's-other']);
|
// seeded with the split's two, nor filled to the count.
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-c']);
|
||||||
expect(app.activeSessionId).toBe('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.
|
// 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 }]]);
|
expect(app.sendResize.mock.calls).toEqual([['s-a', { force: true }]]);
|
||||||
|
|||||||
Reference in New Issue
Block a user