mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
feat(tiles): the grid holds at most 6 tiles (owner decision 7)
Six was tested smooth on the owner's desktop; nine missed the headless frame bar and is untested on real hardware. TILE_GRID_MAX (constants.js) is now 6 and stays the one cap every limit reads; the layout table gets its own bound, TILE_LAYOUT_MAX = 9, so the 7 to 9 layouts keep working (unreachable) and going back to nine is that one line. Every way in stops at the cap: opening, addTile, a tile's +, a session Run makes, Ctrl/Cmd+click, the picker, "Open group as tiles", and a stored grid with more ids (it comes back as its first six, focus kept only if it survives, a dropped zoom cleared, row fractions that no longer match the 3x2 reset). The limits now go through one helper, _tileGridLimit(), whose texts say which limit binds: "Up to 6 tiles" / "The grid holds at most 6 tiles" when it is the cap, "This window fits N" when it is the window. Docs: decision 7 and an as-built entry in the spec, CLAUDE.md, the invariants, the wiki's Tile Grid and Dashboard pages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||||
|
|
||||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 9 live sessions side by side, each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid)
|
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid)
|
||||||
|
|
||||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||||
|
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
|||||||
|
|
||||||
### Tile grid
|
### Tile grid
|
||||||
|
|
||||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, 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 9 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, 3x3; `fits` against a 480x240 minimum tile), `tileGridCapacity`, `sanitizeTileGridState`, `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000). 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 OFF, 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.
|
||||||
|
|
||||||
⚠️ **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 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 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 })`.
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ or settled a question the spec left open. The invariants as built are in
|
|||||||
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps
|
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps
|
||||||
its last frame.
|
its last frame.
|
||||||
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
|
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
|
||||||
|
- **The grid holds at most 6 tiles** (owner decision 7). `TILE_GRID_MAX` in constants.js is
|
||||||
|
the one cap every limit reads; the layout table keeps 7 to 9 (`TILE_LAYOUT_MAX`), unreachable,
|
||||||
|
so going back to nine is that one line. A stored grid with more ids comes back as its first
|
||||||
|
six. Where this spec says nine, read six.
|
||||||
- **"+ / New session in this case"** runs the normal Run for that case and joins through
|
- **"+ / New session in this case"** runs the normal Run for that case and joins through
|
||||||
the same auto-join as any Run from this tab.
|
the same auto-join as any Run from this tab.
|
||||||
- **A tile that joins before its pane exists resends its size when the pid appears**
|
- **A tile that joins before its pane exists resends its size when the pid appears**
|
||||||
@@ -869,6 +873,12 @@ exits green. Use the browser runner for those files and read the file count.
|
|||||||
chord is inert while `showTileGridButton` is off (it passes through like any
|
chord is inert while `showTileGridButton` is off (it passes through like any
|
||||||
unbound key) and toggles the grid while it is on, so one setting governs both
|
unbound key) and toggles the grid while it is on, so one setting governs both
|
||||||
the button and the chord.
|
the button and the chord.
|
||||||
|
7. **The tile cap.** Decided by the owner: at most 6 tiles for now. Six was
|
||||||
|
tested and is smooth on the owner's desktop; nine missed the headless frame
|
||||||
|
bar (p95 33 ms at 6 and 9 tiles under load, 16.8 ms at 4) and is untested on
|
||||||
|
real hardware. The cap is one constant (`TILE_GRID_MAX`), the layout table
|
||||||
|
keeps 7 to 9 working but unreachable, and the user-facing texts say "at most
|
||||||
|
6 tiles" when the cap, not the window, is what limits the grid.
|
||||||
|
|
||||||
## Code anchors
|
## Code anchors
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
| Cron ⏰ | Off | Scheduled jobs. |
|
| Cron ⏰ | Off | Scheduled jobs. |
|
||||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||||
| Tiles | Off, desktop only | Up to nine live sessions side by side. See [Tile Grid](Tile-Grid). |
|
| Tiles | Off, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
|
||||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||||
| Admin panel | Multi-user only | User administration. |
|
| Admin panel | Multi-user only | User administration. |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Tile Grid
|
# Tile Grid
|
||||||
|
|
||||||
Watch and drive up to nine sessions at once, side by side in one window. Each tile is a
|
Watch and drive up to six sessions at once, side by side in one window. Each tile is a
|
||||||
full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether
|
full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether
|
||||||
its agent is working, idle, or waiting on you.
|
its agent is working, idle, or waiting on you.
|
||||||
|
|
||||||
@@ -32,7 +32,8 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`.
|
|||||||
joins it. Sessions started elsewhere (an agent, another device, a cron job) do not.
|
joins it. Sessions started elsewhere (an agent, another device, a cron job) do not.
|
||||||
|
|
||||||
The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a
|
The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a
|
||||||
2x2 with one empty slot), 2x2, 3x2, 3x3.
|
2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the
|
||||||
|
window is too small for six; the picker and a tile's + say which limit applies.
|
||||||
|
|
||||||
## A tile
|
## A tile
|
||||||
|
|
||||||
|
|||||||
@@ -1636,11 +1636,19 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
|||||||
// ── Tile grid (tile-grid.js) ───────────────────────────────────────────────
|
// ── Tile grid (tile-grid.js) ───────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md):
|
// Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md):
|
||||||
// 1 to 9 live sessions side by side, each in its own TerminalTile. Desktop
|
// 1 to TILE_GRID_MAX live sessions side by side, each in its own TerminalTile.
|
||||||
// only, behind the same 1180px gate as the split pane.
|
// Desktop only, behind the same 1180px gate as the split pane.
|
||||||
|
|
||||||
/** Hard cap on tiles in one grid. */
|
/**
|
||||||
const TILE_GRID_MAX = 9;
|
* Hard cap on tiles in one grid: the ONE place it is set (owner decision 7 in
|
||||||
|
* docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
|
||||||
|
* the headless frame bar and is untested on hardware. Everything that limits
|
||||||
|
* the grid reads this, and the layout table still covers up to TILE_LAYOUT_MAX,
|
||||||
|
* so raising the cap is this one line.
|
||||||
|
*/
|
||||||
|
const TILE_GRID_MAX = 6;
|
||||||
|
/** The largest count the layout table covers (3x3). Never a cap by itself. */
|
||||||
|
const TILE_LAYOUT_MAX = 9;
|
||||||
// The smallest tile worth showing: about 60 columns and a dozen rows at the
|
// The smallest tile worth showing: about 60 columns and a dozen rows at the
|
||||||
// default tile font. Bounds how many tiles a window can hold.
|
// default tile font. Bounds how many tiles a window can hold.
|
||||||
const TILE_MIN_W = 480;
|
const TILE_MIN_W = 480;
|
||||||
@@ -1648,7 +1656,7 @@ const TILE_MIN_H = 240;
|
|||||||
// Three tiles go side by side (3x1) only when each still gets ~600px;
|
// Three tiles go side by side (3x1) only when each still gets ~600px;
|
||||||
// otherwise they take three cells of a 2x2.
|
// otherwise they take three cells of a 2x2.
|
||||||
const TILE_GRID_WIDE_3X1 = 1800;
|
const TILE_GRID_WIDE_3X1 = 1800;
|
||||||
// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: nine DOM
|
// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: a grid of DOM
|
||||||
// renderers at 50k lines each is a real memory cost, and a tile's load is a
|
// renderers at 50k lines each is a real memory cost, and a tile's load is a
|
||||||
// bounded 1 MiB window anyway, so more scrollback only fills with live output.
|
// bounded 1 MiB window anyway, so more scrollback only fills with live output.
|
||||||
const TILE_SCROLLBACK = 10000;
|
const TILE_SCROLLBACK = 10000;
|
||||||
@@ -1664,7 +1672,7 @@ const TILE_FONT_SIZE_DEFAULT = 13;
|
|||||||
* @returns {{cols: number, rows: number, fits: boolean}}
|
* @returns {{cols: number, rows: number, fits: boolean}}
|
||||||
*/
|
*/
|
||||||
function computeTileLayout({ count, width = Infinity, height = Infinity, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) {
|
function computeTileLayout({ count, width = Infinity, height = Infinity, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) {
|
||||||
const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_GRID_MAX);
|
const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX);
|
||||||
let cols;
|
let cols;
|
||||||
let rows;
|
let rows;
|
||||||
if (n === 0) return { cols: 0, rows: 0, fits: true };
|
if (n === 0) return { cols: 0, rows: 0, fits: true };
|
||||||
@@ -2109,6 +2117,7 @@ if (typeof window !== 'undefined') {
|
|||||||
tileInDirection,
|
tileInDirection,
|
||||||
cycleTile,
|
cycleTile,
|
||||||
TILE_GRID_MAX,
|
TILE_GRID_MAX,
|
||||||
|
TILE_LAYOUT_MAX,
|
||||||
TILE_MIN_W,
|
TILE_MIN_W,
|
||||||
TILE_MIN_H,
|
TILE_MIN_H,
|
||||||
TILE_SCROLLBACK,
|
TILE_SCROLLBACK,
|
||||||
|
|||||||
+34
-16
@@ -338,6 +338,25 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many tiles the grid takes here and now: what the window fits, at most
|
||||||
|
* the cap (TILE_GRID_MAX), never less than one. The texts say which of the
|
||||||
|
* two binds, so a large monitor never reads "this window fits 6".
|
||||||
|
*/
|
||||||
|
_tileGridLimit() {
|
||||||
|
const max = window.CodemanTileGrid.TILE_GRID_MAX;
|
||||||
|
const fits = this._tileGridCapacityNow();
|
||||||
|
const capacity = Math.max(1, Math.min(fits, max));
|
||||||
|
const byCap = fits >= max;
|
||||||
|
return {
|
||||||
|
capacity,
|
||||||
|
hint: byCap ? `Up to ${max} tiles` : `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`,
|
||||||
|
full: byCap
|
||||||
|
? `The grid holds at most ${max} tiles`
|
||||||
|
: `The grid already holds what this window fits (${capacity})`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Tiles button: with the grid open it closes it (back to the single view
|
* The Tiles button: with the grid open it closes it (back to the single view
|
||||||
* of the focused session); otherwise it opens a picker with a checkbox per
|
* of the focused session); otherwise it opens a picker with a checkbox per
|
||||||
@@ -359,7 +378,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
if (!this.canOpenTileGrid()) return;
|
if (!this.canOpenTileGrid()) return;
|
||||||
const T = window.CodemanTileGrid;
|
const T = window.CodemanTileGrid;
|
||||||
const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX));
|
const limit = this._tileGridLimit();
|
||||||
|
const capacity = limit.capacity;
|
||||||
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
|
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
|
||||||
const remembered = (this._readStoredTileGrid()?.ids || []).filter((id) => candidates.some((c) => c.id === id));
|
const remembered = (this._readStoredTileGrid()?.ids || []).filter((id) => candidates.some((c) => c.id === id));
|
||||||
const seed = remembered.length
|
const seed = remembered.length
|
||||||
@@ -394,7 +414,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
footer.className = 'tile-picker-footer';
|
footer.className = 'tile-picker-footer';
|
||||||
const hint = document.createElement('span');
|
const hint = document.createElement('span');
|
||||||
hint.className = 'tile-picker-hint';
|
hint.className = 'tile-picker-hint';
|
||||||
hint.textContent = `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`;
|
hint.textContent = limit.hint;
|
||||||
const open = document.createElement('button');
|
const open = document.createElement('button');
|
||||||
open.type = 'button';
|
open.type = 'button';
|
||||||
open.className = 'tile-picker-open';
|
open.className = 'tile-picker-open';
|
||||||
@@ -413,7 +433,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const count = boxes.filter((b) => b.checked).length;
|
const count = boxes.filter((b) => b.checked).length;
|
||||||
for (const b of boxes) {
|
for (const b of boxes) {
|
||||||
b.disabled = !b.checked && count >= capacity;
|
b.disabled = !b.checked && count >= capacity;
|
||||||
b.title = b.disabled ? `This window fits ${capacity} tiles` : '';
|
b.title = b.disabled ? limit.full : '';
|
||||||
}
|
}
|
||||||
open.disabled = count === 0;
|
open.disabled = count === 0;
|
||||||
};
|
};
|
||||||
@@ -477,8 +497,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
this.closeTileAddMenu();
|
this.closeTileAddMenu();
|
||||||
const T = window.CodemanTileGrid;
|
const T = window.CodemanTileGrid;
|
||||||
const capacity = Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX);
|
const limit = this._tileGridLimit();
|
||||||
const full = grid.ids.length >= Math.max(capacity, 1);
|
const full = grid.ids.length >= limit.capacity;
|
||||||
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions, grid.tiles);
|
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions, grid.tiles);
|
||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'tab-rail-action-menu tile-add-menu';
|
menu.className = 'tab-rail-action-menu tile-add-menu';
|
||||||
@@ -497,7 +517,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
item.setAttribute('data-i18n-skip', '');
|
item.setAttribute('data-i18n-skip', '');
|
||||||
item.textContent = c.label;
|
item.textContent = c.label;
|
||||||
item.disabled = full;
|
item.disabled = full;
|
||||||
if (full) item.title = `The grid already holds what this window fits (${capacity})`;
|
if (full) item.title = limit.full;
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
this.closeTileAddMenu();
|
this.closeTileAddMenu();
|
||||||
if (this.addTile(c.id)) this.selectSession(c.id);
|
if (this.addTile(c.id)) this.selectSession(c.id);
|
||||||
@@ -515,7 +535,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
create.textContent = 'New session in this case';
|
create.textContent = 'New session in this case';
|
||||||
create.disabled = full || !theCase;
|
create.disabled = full || !theCase;
|
||||||
if (!theCase) create.title = 'This session is not in a case';
|
if (!theCase) create.title = 'This session is not in a case';
|
||||||
else if (full) create.title = `The grid already holds what this window fits (${capacity})`;
|
else if (full) create.title = limit.full;
|
||||||
create.addEventListener('click', () => {
|
create.addEventListener('click', () => {
|
||||||
this.closeTileAddMenu();
|
this.closeTileAddMenu();
|
||||||
if (theCase) void this.runInCaseForTiles(theCase.name);
|
if (theCase) void this.runInCaseForTiles(theCase.name);
|
||||||
@@ -570,10 +590,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_joinTileGridFromRun(sessionId) {
|
_joinTileGridFromRun(sessionId) {
|
||||||
const grid = this._tileGrid;
|
const grid = this._tileGrid;
|
||||||
if (!grid?.open || grid.tiles.has(sessionId) || !this.sessions.has(sessionId)) return false;
|
if (!grid?.open || grid.tiles.has(sessionId) || !this.sessions.has(sessionId)) return false;
|
||||||
const T = window.CodemanTileGrid;
|
const limit = this._tileGridLimit();
|
||||||
const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX));
|
if (grid.ids.length >= limit.capacity) {
|
||||||
if (grid.ids.length >= capacity) {
|
this.showToast?.(`${limit.full}: the new session opens on its own`, 'info');
|
||||||
this.showToast?.(`The grid holds what this window fits (${capacity}): the new session opens on its own`, 'info');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Run starts the session right after creating it: no Attach overlay
|
// Run starts the session right after creating it: no Attach overlay
|
||||||
@@ -899,13 +918,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
|
if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const T = window.CodemanTileGrid;
|
const limit = this._tileGridLimit();
|
||||||
const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX));
|
const capacity = limit.capacity;
|
||||||
const grid = this._tileGrid;
|
const grid = this._tileGrid;
|
||||||
if (grid?.open) {
|
if (grid?.open) {
|
||||||
if (!grid.tiles.has(sessionId)) {
|
if (!grid.tiles.has(sessionId)) {
|
||||||
if (grid.ids.length >= capacity) {
|
if (grid.ids.length >= capacity) {
|
||||||
this.showToast?.(`The grid already holds what this window fits (${capacity})`, 'info');
|
this.showToast?.(limit.full, 'info');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.addTile(sessionId);
|
this.addTile(sessionId);
|
||||||
@@ -929,8 +948,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
openGroupAsTiles(groupId) {
|
openGroupAsTiles(groupId) {
|
||||||
const group = (this.tabLayout?.groups || []).find((g) => g.id === groupId);
|
const group = (this.tabLayout?.groups || []).find((g) => g.id === groupId);
|
||||||
if (!group || !this.canOpenTileGrid()) return false;
|
if (!group || !this.canOpenTileGrid()) return false;
|
||||||
const T = window.CodemanTileGrid;
|
const capacity = this._tileGridLimit().capacity;
|
||||||
const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX));
|
|
||||||
const ids = (group.refs || [])
|
const ids = (group.refs || [])
|
||||||
.filter((ref) => ref.kind === 'session')
|
.filter((ref) => ref.kind === 'session')
|
||||||
.map((ref) => ref.id)
|
.map((ref) => ref.id)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The grid holds at most TILE_GRID_MAX tiles: 6, owner decision 7
|
||||||
|
* (docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
|
||||||
|
* the headless frame bar. The layout table still covers 7 to 9 (unreachable).
|
||||||
|
*
|
||||||
|
* Every way into the grid stops at the cap even where the window would fit
|
||||||
|
* nine (the harness window is 2400x1200): opening, adding, a tile's +, a
|
||||||
|
* session Run makes, Ctrl/Cmd+click, the picker and "Open group as tiles".
|
||||||
|
* The texts say which limit binds: "at most 6" for the cap, "what this window
|
||||||
|
* fits" for a smaller window.
|
||||||
|
*
|
||||||
|
* 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, body, bySelector, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`);
|
||||||
|
const SIX = EIGHT.slice(0, 6);
|
||||||
|
const AT_MOST = 'The grid holds at most 6 tiles';
|
||||||
|
|
||||||
|
let wrapRect = { width: 2400, height: 1200 };
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
|
||||||
|
bySelector.set('.terminal-wrap', wrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fullGrid(): GridApp {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
app.openTileGrid(EIGHT);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the cap', () => {
|
||||||
|
it('is 6, worded as the cap where the window would fit more', () => {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
expect(app._tileGridLimit()).toEqual({ capacity: 6, hint: 'Up to 6 tiles', full: AT_MOST });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a smaller window is worded by the window', () => {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
expect(app._tileGridLimit()).toEqual({
|
||||||
|
capacity: 4,
|
||||||
|
hint: 'This window fits 4 tiles',
|
||||||
|
full: 'The grid already holds what this window fits (4)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening on eight sessions shows the first six', () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
expect(app._tileGrid.tiles.size).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a full grid takes no more tiles', () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
expect(app.addTile('s-7')).toBe(false);
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('every way in stops at the cap', () => {
|
||||||
|
it("a tile's + offers the rest, disabled, and says why", () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1');
|
||||||
|
const menu = body.children.find((c) => c.className.includes('tile-add-menu'))!;
|
||||||
|
const items = menu.children.filter((c) => c.textContent === 's-7' || c.textContent === 's-8');
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.disabled).toBe(true);
|
||||||
|
expect(item.title).toBe(AT_MOST);
|
||||||
|
}
|
||||||
|
expect(menu.children.find((c) => c.className === 'tile-add-new')!.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session Run makes opens on its own, with a toast', () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
expect(app._joinTileGridFromRun('s-7')).toBe(false);
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(`${AT_MOST}: the new session opens on its own`, 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl/Cmd+click on another tab says the grid is full', () => {
|
||||||
|
const app = fullGrid();
|
||||||
|
expect(app.addSessionToTiles('s-7')).toBe(true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(AT_MOST, 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the picker greys out the seventh box', () => {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
app.openTilePicker({ stopPropagation: vi.fn() });
|
||||||
|
const picker = body.children.find((c) => c.id === 'tilePickerMenu')!;
|
||||||
|
const boxes = picker.children[0].children.map((row) => row.children[0]);
|
||||||
|
expect(picker.children[1].children[0].textContent).toBe('Up to 6 tiles');
|
||||||
|
for (const box of boxes) {
|
||||||
|
// A disabled box cannot be ticked (the browser ignores the click).
|
||||||
|
if (!EIGHT.includes(box.value) || box.checked || box.disabled) continue;
|
||||||
|
box.checked = true;
|
||||||
|
box.dispatch('change');
|
||||||
|
}
|
||||||
|
const checked = boxes.filter((b) => b.checked).map((b) => b.value);
|
||||||
|
expect(checked).toHaveLength(6);
|
||||||
|
const left = boxes.filter((b) => !b.checked);
|
||||||
|
expect(left.length).toBeGreaterThan(0);
|
||||||
|
for (const box of left) {
|
||||||
|
expect(box.disabled).toBe(true);
|
||||||
|
expect(box.title).toBe(AT_MOST);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Open group as tiles" shows the first six of a larger group', () => {
|
||||||
|
const app = makeGridApp(EIGHT);
|
||||||
|
app.tabLayout = { groups: [{ id: 'g', name: 'G', refs: EIGHT.map((id) => ({ kind: 'session', id })) }] };
|
||||||
|
app.openGroupAsTiles('g');
|
||||||
|
expect(app._tileGrid.ids).toEqual(SIX);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ type TileGrid = {
|
|||||||
tileInDirection(ids: string[], focused: string, dir: string, cols: number): string | null;
|
tileInDirection(ids: string[], focused: string, dir: string, cols: number): string | null;
|
||||||
cycleTile(ids: string[], focused: string, delta: number): string | null;
|
cycleTile(ids: string[], focused: string, delta: number): string | null;
|
||||||
TILE_GRID_MAX: number;
|
TILE_GRID_MAX: number;
|
||||||
|
TILE_LAYOUT_MAX: number;
|
||||||
TILE_MIN_W: number;
|
TILE_MIN_W: number;
|
||||||
TILE_MIN_H: number;
|
TILE_MIN_H: number;
|
||||||
TILE_SCROLLBACK: number;
|
TILE_SCROLLBACK: number;
|
||||||
@@ -61,7 +62,8 @@ describe('computeTileLayout', () => {
|
|||||||
expect(T.computeTileLayout({ count: 3, width: 1799, height: 900 })).toMatchObject({ cols: 2, rows: 2 });
|
expect(T.computeTileLayout({ count: 3, width: 1799, height: 900 })).toMatchObject({ cols: 2, rows: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps the count at 9 and treats nothing as an empty grid', () => {
|
it('lays out up to 9 (past the cap, unreachable but kept) and treats nothing as an empty grid', () => {
|
||||||
|
expect(T.TILE_LAYOUT_MAX).toBe(9);
|
||||||
expect(T.computeTileLayout({ count: 12, ...BIG })).toMatchObject({ cols: 3, rows: 3 });
|
expect(T.computeTileLayout({ count: 12, ...BIG })).toMatchObject({ cols: 3, rows: 3 });
|
||||||
expect(T.computeTileLayout({ count: 0, ...BIG })).toMatchObject({ cols: 0, rows: 0 });
|
expect(T.computeTileLayout({ count: 0, ...BIG })).toMatchObject({ cols: 0, rows: 0 });
|
||||||
});
|
});
|
||||||
@@ -75,8 +77,10 @@ describe('computeTileLayout', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('tileGridCapacity', () => {
|
describe('tileGridCapacity', () => {
|
||||||
it('holds all nine on a large monitor', () => {
|
it('never holds more than the cap of 6 (owner decision 7), even where nine would fit', () => {
|
||||||
expect(T.tileGridCapacity(BIG)).toBe(T.TILE_GRID_MAX);
|
expect(T.TILE_GRID_MAX).toBe(6);
|
||||||
|
expect(T.computeTileLayout({ count: 9, ...BIG }).fits).toBe(true);
|
||||||
|
expect(T.tileGridCapacity(BIG)).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops at the first count whose layout does not fit', () => {
|
it('stops at the first count whose layout does not fit', () => {
|
||||||
@@ -137,10 +141,18 @@ describe('sanitizeTileGridState', () => {
|
|||||||
expect(out).toMatchObject({ open: false, ids: [], focused: null });
|
expect(out).toMatchObject({ open: false, ids: [], focused: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps the list at nine tiles', () => {
|
it('caps the list at the cap (6): the extras are dropped', () => {
|
||||||
const many = Array.from({ length: 12 }, (_, i) => `s${i}`);
|
const many = Array.from({ length: 12 }, (_, i) => `s${i}`);
|
||||||
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many);
|
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many);
|
||||||
expect(out?.ids).toEqual(many.slice(0, 9));
|
expect(out?.ids).toEqual(many.slice(0, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stored 3x3 keeps its focus if it survives the cap, and loses a zoom that did not', () => {
|
||||||
|
const nine = Array.from({ length: 9 }, (_, i) => `s${i}`);
|
||||||
|
const kept = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's4', zoomed: 's7' }, nine);
|
||||||
|
expect(kept).toMatchObject({ ids: nine.slice(0, 6), focused: 's4', zoomed: null });
|
||||||
|
const lost = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's8', zoomed: 's2' }, nine);
|
||||||
|
expect(lost).toMatchObject({ focused: 's0', zoomed: 's2' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops malformed track fractions', () => {
|
it('drops malformed track fractions', () => {
|
||||||
|
|||||||
@@ -152,6 +152,20 @@ describe('page load with a stored open grid', () => {
|
|||||||
expect(app.activeSessionId).toBe('s-b');
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a stored 3x3 (before the cap of 6) comes back as its first six, focus kept, a dropped zoom cleared', () => {
|
||||||
|
const nine = Array.from({ length: 9 }, (_, i) => `n-${i + 1}`);
|
||||||
|
storeGrid({ ids: nine, focused: 'n-5', zoomed: 'n-8', colFr: [2, 1, 1], rowFr: [1, 1, 1] });
|
||||||
|
const app = pageLoad(nine);
|
||||||
|
expect(app._tileGrid.ids).toEqual(nine.slice(0, 6));
|
||||||
|
expect(FakeTile.all.filter((t) => !t._destroyed)).toHaveLength(6);
|
||||||
|
expect(app.activeSessionId).toBe('n-5');
|
||||||
|
expect(app._tileGrid.zoomedId).toBeNull();
|
||||||
|
// 3x2 now: the columns still match, the three stored rows do not.
|
||||||
|
expect(app._tileGrid.colFr).toEqual([2, 1, 1]);
|
||||||
|
expect(app._tileGrid.rowFr).toEqual([1, 1]);
|
||||||
|
expect(stored().ids).toEqual(nine.slice(0, 6));
|
||||||
|
});
|
||||||
|
|
||||||
it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => {
|
it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => {
|
||||||
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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user