feat(tiles): the grid is cells, and an empty cell can be any cell

Owner feedback 1: "so the empty tab doesnt always have to be the last
one! so I can move freely around and the empty tab can also be tab nr
4 or 3". This replaces the slot refusal of aecada8c.

grid.cells (a session id or null per cell) is now the one source of
truth; grid.ids is a getter deriving the tiles in reading order, so
everything that only wants the tiled sessions (focus neighbour,
cycling, the load queue's order, the picker, closeSession) is
unchanged. The shape still comes from the tile count and the cap
counts tiles, never empty cells.

- A tile dragged onto an empty cell moves there and leaves its own
  cell empty, nothing else moving (_moveTileToCell, through
  _reorderTiles: no remount, reconnect or reload; only a tile whose
  cell size changed fits). A tiled session's tab does the same; a tab
  of a session not tiled yet joins in the cell it is dropped on. Each
  slot knows its cell and reads "Drop a tab or a tile here".
- Move Tile goes to the adjacent cell: into it when empty, a swap when
  a tile is there (tileCellInDirection).
- Removing a tile leaves its cell empty; adding one takes the first
  empty cell. A shape change goes through fitTileCells: each tile keeps
  its row and column when all fit (2x2 growing to 3x2), else the tiles
  pack in reading order.
- Focus never lands on an empty cell: Alt+Shift+Arrows run over the
  cells, Ctrl+Tab and Alt+[ ] over the tiles.
- codeman:tile-grid stays ids only: its ids are the cells with null for
  an empty one. A reload brings the holes back when the shape is the
  same (a session gone since leaves its cell empty), another shape
  packs, the old packed format reads unchanged, and a followed
  #session= link keeps the holes.

Docs: the spec's as-built bullet (rewritten in place), the wiki's Tile
Grid page and Keyboard Shortcuts, the invariants and CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 03:08:14 +02:00
parent e702f3c151
commit 45ca9c347b
10 changed files with 718 additions and 182 deletions
+1 -1
View File
@@ -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)
**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, harness logo, name, model, menu, zoom, ×; no +, owner decision), 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). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed, and an empty slot refuses a tiled session (owner). The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ 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. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot <cliId>` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [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, harness logo, name, model, menu, zoom, ×; no +, owner decision), 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). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ 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. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot <cliId>` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [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)
+2 -2
View File
@@ -813,13 +813,13 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
⚠️ **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.
⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own, an empty slot only a session not tiled yet, so a tile is never moved to a slot; a refused drag is still held (`dropEffect: 'none'`, no highlight). Every move (that drag, a tiled tab's drop on a tile, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile.
⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), each tile and each empty slot placed explicitly in its cell; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own (its own is held but refused, `dropEffect: 'none'`, no highlight), an empty slot anything. ⚠️ **The grid is cells** (owner: an empty cell can be any cell): `grid.cells` (a session id or `null` per cell, cols x rows of them) is the ONE source of truth and `grid.ids` a getter deriving the tiles in reading order, so never write `ids` (a leftover write in the non-strict `Object.assign` methods would be silently ignored). The shape comes from the tile count; within it a tile moved onto an empty cell goes there and leaves its own cell empty (`_moveTileToCell`), a removed tile leaves a hole, an added one takes the first hole or the cell a tab was dropped on (`_placeTile`), and a shape change goes through the pure `fitTileCells` (keep row and column when all fit, else pack). Each slot carries its cell (`data-cell`). The focus chords run `tileInDirection` over the CELLS (never onto a hole; the nearest tile in the nearest row for up/down), the Move Tile chords `tileCellInDirection` (the adjacent cell: into it when empty, a swap when not). Every move (that drag, a tiled tab's drop, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile.
⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot <cliId>` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it.
⚠️ **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; 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` 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`.
### Gesture control: the setting
+42 -24
View File
@@ -21,8 +21,8 @@ or settled a question the spec left open. The invariants as built are in
the setting off the toggle chord is inert. A grid opened another
way (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords.
- **Dividers are grid tracks.** Each gap between columns and rows is its own 6px track (the
grid gap is 0) and tiles are placed explicitly in reading order, which is also what the
empty-slot drop targets need. Fractions reset when the column or row count changes.
grid gap is 0) and every tile and every empty slot is placed explicitly in its cell
(`grid.cells`, see "Tiles move"). Fractions reset when the column or row count changes.
- **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom
(window too small for the minimum tile) follows focus instead.
- **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane
@@ -68,28 +68,46 @@ or settled a question the spec left open. The invariants as built are in
batches, so lifecycle and hook events are unaffected, and leaving the grid
re-subscribes the shown session.
- **Tiles move** (owner request: "give me the option to move the tiles around"; not a
numbered decision). A tile's header, its free area (not the buttons, not the rename
input), drags it onto another tile and the two trade places. An empty slot refuses a tile,
its header drag and its tab alike (owner's answer, "dont move the tile": a slot is always
the last cell, so a move there shifted every tile after it); a session not tiled yet still
joins there. It is a native drag through the tab drop targets (capture phase, stopped
before xterm), carrying a type of its own and never text, and it is not `draggedTabId`, so
neither a text field nor the tab strip takes it; Escape or a drop anywhere else cancels
with nothing changed, focus included: the header focuses its tile on click, not on press
(owner's answer: best practice; the body keeps press-to-focus, so focus moves before a
press reaches xterm). `Ctrl+Shift+Arrows` (Move Tile Left/Right/Up/Down, registry,
rebindable) swap the focused tile with the neighbour the focus chords pick, and focus
stays on it. Every move, a tiled tab's drop included, goes through `_reorderTiles`: no
remount, reconnect or reload; divider sizes belong to the cells, so only a tile whose cell
size changed fits (one PTY resize, #464). Moving is off while a tile is zoomed (the chords
still apply there, as a no-op, so their keys never reach the CLI; a tiled tab dropped on
the zoomed tile is refused too, as the owner confirmed) and with a single tile. Both arrow
chord families, focus and move, skip a text field, where shifted arrows select (owner's
answer: best practice). Default keys: every other two-modifier arrow chord is taken
(Ctrl+Alt switches workspaces, Ctrl+Alt+Shift moves a window to another workspace in
GNOME, Alt is back/forward, Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by
the browsers, GNOME, KDE, macOS and Claude Code, and costs only a terminal editor's word
selection inside a tile while the grid is open.
numbered decision). The grid is CELLS, not a packed list (owner: "the empty tab doesnt
always have to be the last one ... it can also be tab nr 4 or 3"): `grid.cells` holds a
session id or `null` per cell and is the one source of truth, `grid.ids` the tiles in
reading order derived from it. The shape still comes from the tile count (the layout
table), the cap counts tiles, never empty cells, and an empty cell can be any cell (in
practice one at most: a 3x2 holds 5 or 6 tiles, a 2x2 3 or 4). A tile's header, its free
area (not the buttons, not the rename input), drags it: onto another tile the two trade
places, onto an empty cell it moves there and leaves its own cell empty, nothing else
moving; a tiled session's tab does the same, and a tab of a session not tiled yet joins in
the cell it is dropped on. It is a native drag through the tab drop targets (capture
phase, stopped before xterm), carrying a type of its own and never text, and it is not
`draggedTabId`, so neither a text field nor the tab strip takes it; Escape or a drop
anywhere else cancels with nothing changed, focus included: the header focuses its tile on
click, not on press (owner's answer: best practice; the body keeps press-to-focus, so
focus moves before a press reaches xterm). `Ctrl+Shift+Arrows` (Move Tile
Left/Right/Up/Down, registry, rebindable) move the focused tile to the adjacent cell: into
it when empty, trading places when a tile is there, never jumping a cell; focus stays on
it. Every move goes through `_reorderTiles`: no remount, reconnect or reload; divider
sizes belong to the cells, so only a tile whose cell size changed fits (one PTY resize,
#464). Removing a tile leaves its cell empty where it was, and adding one takes the first
empty cell (or the one a tab was dropped on), while the shape stays; a shape change
(`fitTileCells`, constants.js) keeps each tile's row and column when all fit and otherwise
packs the tiles in reading order, which differs from plain packing only when a 2x2 grows
to a 3x2 (the four tiles stay put). Focus never lands on an empty cell: Alt+Shift+Arrows
go along the row past one, or to the nearest row with a tile (the same column, else the
nearest), and Ctrl+Tab and Alt+[ / ] cycle the tiles only. `codeman:tile-grid` stays ids
only: its `ids` are the cells, `null` for an empty one (a build before cells drops the
nulls and reads them packed); a reload brings the holes back when the shape is the same, a
session gone by then leaves its cell empty, another shape packs, and the old packed format
reads unchanged. A fresh grid (the picker's Open, Ctrl/Cmd+click with the grid closed,
"Open group as tiles") opens packed; only the toggle and the page-load restore bring holes
back. Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so
their keys never reach the CLI; a tiled tab dropped on the zoomed tile is refused too, as
the owner confirmed) and with a single tile. Both arrow chord families, focus and move,
skip a text field, where shifted arrows select (owner's answer: best practice). Default
keys: every other two-modifier arrow chord is taken (Ctrl+Alt switches workspaces,
Ctrl+Alt+Shift moves a window to another workspace in GNOME, Alt is back/forward,
Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by the browsers, GNOME, KDE,
macOS and Claude Code, and costs only a terminal editor's word selection inside a tile
while the grid is open.
- **A tile that joins before its pane exists resends its size when the pid appears**
(`TerminalTile.paneStarted()`): the server drops a resize for a session with no PTY and
spawns at 120x40, and Run's own resize measures the parked main terminal. Applying a
+2 -2
View File
@@ -45,8 +45,8 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa
| --------------------------- | ------------------------------------------------------------ |
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
| `Ctrl+Shift+Arrows` | Move the focused tile: it trades places with that neighbour. |
| Drag a tile's header | Move the tile: dropped on another tile, the two swap. |
| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. |
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
| Right-click the Tiles button | Choose which sessions to show as tiles. |
+14 -9
View File
@@ -65,18 +65,23 @@ whose agent exited inside its pane says so instead; close that session from `⋯
## Moving tiles
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
places; the dropped tile takes the focus. Empty slots are for adding sessions (drop a tab
there), so a tile cannot be dropped on one. Press `Escape` or let go anywhere else and nothing
changes, not even which tile has the focus: a header focuses its tile when you click it, not
when you press it.
places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else
moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press
`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a
header focuses its tile when you click it, not when you press it.
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile left, right, up or down: it
trades places with the tile next to it (the one `Alt+Shift+Arrows` would focus) and keeps the
focus.
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or
down: into the empty slot if that is the place, else trading places with the tile there. It
keeps the focus.
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. The new order is
saved with the grid.
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.
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
tiles to five is two columns to three), the tiles keep their places if they still fit, or line
up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot.
## Keys
+1 -1
View File
@@ -140,7 +140,7 @@
'Zoom this tile': '放大此窗格',
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab here': '将标签页拖放到此处',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
'Drag to move the tile': '拖动可移动窗格',
'Resize tile columns': '调整窗格列宽',
+173 -80
View File
@@ -24,7 +24,8 @@
// Per-device tile font size (a tile is a fraction of the screen).
const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
// The grid this device last had, ids only (sanitizeTileGridState, constants.js):
// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`. `open: false` keeps it
// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, `ids` being the cells
// in reading order with `null` for an empty one. `open: false` keeps it
// remembered for one-click return; restored on reload inside handleInit.
const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
// Trailing debounce for refitting tiles after the grid area changes size, so a
@@ -72,8 +73,10 @@ function isTextFieldTarget(target) {
class TileGridModel {
constructor() {
this.open = false;
// Session ids in reading order (row-major).
this.ids = [];
// The grid's cells in reading order (row-major), cols x rows of them: a
// session id, or null for an empty cell. THE source of truth for where
// each tile is (owner: an empty cell can be any cell); `ids` derives from it.
this.cells = [];
// id -> { tile: TerminalTile, el: HTMLElement }
this.tiles = new Map();
this.focusedId = null;
@@ -98,6 +101,11 @@ class TileGridModel {
has(id) {
return this.open && this.tiles.has(id);
}
/** The tiled sessions in reading order, holes skipped (a fresh array: change `cells`, never this). */
get ids() {
return this.cells.filter(Boolean);
}
}
Object.assign(CodemanApp.prototype, {
@@ -225,7 +233,9 @@ Object.assign(CodemanApp.prototype, {
// non-shell session) only ever produced a copy that was thrown away.
this._cleanupPreviousSession(focus, { skipSnapshot: wanted.includes(this.activeSessionId) });
grid.open = true;
grid.ids = [];
grid.cells = [];
grid.cols = 0;
grid.rows = 0;
grid.focusedId = focus;
document.querySelector('.main')?.classList.add('tiles-active');
const section = this._tileGridSection();
@@ -234,6 +244,8 @@ Object.assign(CodemanApp.prototype, {
// first fit measures its real cell; the focused tile connects first, so
// its capture is the one the queue starts with.
for (const id of wanted) this._mountTile(id);
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
grid.cells = wanted.filter((id) => grid.tiles.has(id));
this._applyTileLayout();
for (const id of [focus, ...wanted.filter((id) => id !== focus)]) this._connectTile(id);
if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') {
@@ -291,7 +303,9 @@ Object.assign(CodemanApp.prototype, {
grid.slots = [];
grid.colFr = [];
grid.rowFr = [];
grid.ids = [];
grid.cells = [];
grid.cols = 0;
grid.rows = 0;
grid.focusedId = null;
grid.zoomedId = null;
grid.autoZoom = false;
@@ -624,7 +638,8 @@ Object.assign(CodemanApp.prototype, {
focusTileInDirection(direction) {
const grid = this._tileGrid;
if (!grid?.open) return;
const id = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols);
// Over the cells: focus skips an empty one, never lands on it.
const id = window.CodemanTileGrid.tileInDirection(grid.cells, grid.focusedId, direction, grid.cols);
if (id) this.selectSession(id);
},
@@ -672,12 +687,17 @@ Object.assign(CodemanApp.prototype, {
}
},
/** Adds one session as a tile (open grid only). Returns whether it was added. */
addTile(sessionId) {
/**
* Adds one session as a tile (open grid only), into `cell` when that cell is
* empty (a tab dropped on it), else the first empty cell in reading order
* (_placeTile). Returns whether it was added.
*/
addTile(sessionId, { cell = -1 } = {}) {
const grid = this._tileGrid;
if (!grid?.open || grid.tiles.has(sessionId)) return false;
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
if (!this._mountTile(sessionId)) return false;
this._placeTile(sessionId, cell);
// A tile added while one is zoomed by hand is meant to be seen.
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
this._applyTileLayout();
@@ -688,7 +708,37 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Removes one tile; the session keeps running. When it held focus, `refocus`
* Puts a tile just mounted into a cell. The shape for one more tile comes
* first (fitTileCells: the same shape keeps every cell; 2x2 growing to 3x2
* keeps each tile where it is), then `cell` if it is empty there, else the
* first empty cell in reading order.
*/
_placeTile(sessionId, cell = -1) {
const grid = this._tileGrid;
const { cols, rows } = this._tileShapeFor(grid.ids.length + 1);
grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows);
grid.cols = cols;
grid.rows = rows;
const k = Number.isInteger(cell) && grid.cells[cell] === null ? cell : grid.cells.indexOf(null);
// (No empty cell cannot happen: the shape for n tiles has at least n cells.
// Appended, the next layout packs it in.)
if (k === -1) grid.cells.push(sessionId);
else grid.cells[k] = sessionId;
},
/** Columns x rows (and whether they fit) for `count` tiles in the grid area as it is now. */
_tileShapeFor(count) {
const rect = this._tileGridSection().getBoundingClientRect?.() || { width: 0, height: 0 };
return window.CodemanTileGrid.computeTileLayout({
count,
width: rect.width || window.innerWidth,
height: rect.height || window.innerHeight,
});
},
/**
* Removes one tile; the session keeps running. Its cell becomes empty where
* it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus`
* moves focus to the neighbouring tile (next in grid order, else previous),
* as the app's choice (`auto`: no idle alert is spent). The last tile
* leaving closes the grid: with `refocus` the single view then shows that
@@ -714,7 +764,7 @@ Object.assign(CodemanApp.prototype, {
this._destroyTerminalTile(entry.tile);
entry.el.remove();
grid.tiles.delete(sessionId);
grid.ids.splice(grid.ids.indexOf(sessionId), 1);
grid.cells[grid.cells.indexOf(sessionId)] = null;
// Before the layout, which may zoom the focused tile on a small window.
if (wasFocused) grid.focusedId = null;
this._applyTileLayout();
@@ -775,7 +825,7 @@ Object.assign(CodemanApp.prototype, {
// The pid this tile last saw, so a pane that starts later is noticed.
pid: this.sessions.get(sessionId)?.pid ?? null,
});
grid.ids.push(sessionId);
// Where it goes is the caller's (grid.cells).
this._renderTileHeader(sessionId);
return true;
},
@@ -786,8 +836,8 @@ Object.assign(CodemanApp.prototype, {
* (`_draggedTileId`, _installTileMoveDrag). Capture phase, with the event
* stopped: a tab drag carries the session id as text, and xterm's helper
* textarea would otherwise accept that drop and type the id into a PTY.
* `accepts(id)` is the target's own rule (a tile: any session but its own;
* an empty slot: only a session not tiled yet). A session it does not accept
* `accepts(id)` is the target's own rule (a tile takes any session but its
* own; an empty cell takes any). A session it does not accept
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
* drop follows). Any other drag (a file) is left alone.
*/
@@ -830,58 +880,77 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Puts the tiles in a new reading order (the same sessions): the one path
* every move takes, a header drag, the tab of a tiled session dropped on a
* tile, and the Move Tile chords. Nothing is remounted, reconnected or
* reloaded, and no session joins or leaves. Divider sizes belong to the
* cells, so a moved tile takes its new cell's size: each tile whose cell
* size changed fits once (its xterm and one PTY resize together, #464),
* every other tile is left alone. Refused while a tile is zoomed (moving is
* off then).
* Puts the tiles in new cells (the same sessions, the same shape): the one
* path every move takes, a header drag, the tab of a tiled session dropped
* on a tile or an empty cell, and the Move Tile chords. Nothing is
* remounted, reconnected or reloaded, and no session joins or leaves.
* Divider sizes belong to the cells, so a moved tile takes its new cell's
* size: each tile whose cell size changed fits once (its xterm and one PTY
* resize together, #464), every other tile is left alone. Refused while a
* tile is zoomed (moving is off then).
*
* @param {string[]} ids - the new order
* @param {(string|null)[]} cells - the new cells
* @returns {boolean} false when refused, true otherwise (also when nothing moved)
*/
_reorderTiles(ids) {
_reorderTiles(cells) {
const grid = this._tileGrid;
if (!grid?.open || grid.zoomedId) return false;
if (ids.length !== grid.ids.length || ids.some((id) => !grid.tiles.has(id))) return false;
if (ids.every((id, k) => grid.ids[k] === id)) return true;
if (!grid?.open || grid.zoomedId || cells.length !== grid.cells.length) return false;
const tiles = cells.filter(Boolean);
if (tiles.length !== grid.tiles.size || new Set(tiles).size !== tiles.length) return false;
if (tiles.some((id) => !grid.tiles.has(id))) return false;
if (cells.every((id, k) => (id || null) === grid.cells[k])) return true;
// A divider drag in progress measured the tiles at their old places.
this._tileDividerDragTeardown?.();
const cell = (k) => `${grid.colFr[k % grid.cols]}x${grid.rowFr[Math.floor(k / grid.cols)]}`;
const before = new Map(grid.ids.map((id, k) => [id, cell(k)]));
grid.ids = ids.slice();
const before = new Map();
grid.cells.forEach((id, k) => id && before.set(id, cell(k)));
grid.cells = cells.map((id) => id || null);
this._applyTileLayout();
// Synchronous: the fit's measurement forces the new placement's layout.
grid.ids.forEach((id, k) => {
if (before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit();
grid.cells.forEach((id, k) => {
if (id && before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit();
});
return true;
},
/** Two tiles trade places (_reorderTiles). */
/** Two tiles trade cells (_reorderTiles). */
_swapTiles(a, b) {
const ids = this._tileGrid.ids.slice();
const i = ids.indexOf(a);
const j = ids.indexOf(b);
const cells = this._tileGrid.cells.slice();
const i = cells.indexOf(a);
const j = cells.indexOf(b);
if (i === -1 || j === -1) return false;
ids[i] = b;
ids[j] = a;
return this._reorderTiles(ids);
cells[i] = b;
cells[j] = a;
return this._reorderTiles(cells);
},
/**
* Move Tile Left/Right/Up/Down: the focused tile trades places with its
* neighbour in that direction, the neighbour the Alt+Shift+Arrow focus
* chords pick (tileInDirection). Focus stays on the moved tile. Nothing while
* a tile is zoomed, or at an edge.
* A tile moves into empty cell `k`, leaving its own cell empty: nothing else
* moves (_reorderTiles). False when `k` is not an empty cell, or moving is off.
*/
_moveTileToCell(sessionId, k) {
const cells = this._tileGrid.cells.slice();
const from = cells.indexOf(sessionId);
if (from === -1 || !Number.isInteger(k) || cells[k] !== null) return false;
cells[from] = null;
cells[k] = sessionId;
return this._reorderTiles(cells);
},
/**
* Move Tile Left/Right/Up/Down: the focused tile goes to the cell next to it
* in that direction (tileCellInDirection, never jumping a cell): into it
* when it is empty, trading places when a tile is there. Focus stays on the
* moved tile. Nothing while a tile is zoomed, or at an edge.
*/
moveTileInDirection(direction) {
const grid = this._tileGrid;
if (!grid?.open || grid.zoomedId || !grid.focusedId) return;
const neighbor = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols);
if (neighbor) this._swapTiles(grid.focusedId, neighbor);
const from = grid.cells.indexOf(grid.focusedId);
const to = window.CodemanTileGrid.tileCellInDirection(from, direction, grid.cols, grid.cells.length);
if (to === -1) return;
if (grid.cells[to]) this._swapTiles(grid.focusedId, grid.cells[to]);
else this._moveTileToCell(grid.focusedId, to);
},
/**
@@ -899,11 +968,9 @@ Object.assign(CodemanApp.prototype, {
if (!this._swapTiles(draggedId, targetId)) return;
} else {
this._tileDividerDragTeardown?.();
const index = grid.ids.indexOf(targetId);
if (!this._mountTile(draggedId)) return;
// _mountTile appended it; it takes the replaced tile's place instead.
grid.ids.pop();
grid.ids.splice(index, 1, draggedId);
// It takes the replaced tile's cell.
grid.cells[grid.cells.indexOf(targetId)] = draggedId;
const old = grid.tiles.get(targetId);
this._destroyTerminalTile(old.tile);
old.el.remove();
@@ -919,13 +986,20 @@ Object.assign(CodemanApp.prototype, {
},
/**
* A tab dropped on an empty slot joins the grid there. A session already
* tiled is not moved to a slot (the slot refuses it, see _syncTileSlots).
* Something dropped on empty cell `cell`: a tab of a session not tiled yet
* joins the grid in that cell; a tiled session (its tab, or the tile dragged
* by its header) moves into it, leaving its own cell empty
* (_moveTileToCell, refused while a tile is zoomed). Either way the dropped
* session takes focus (a human selection).
*/
dropSessionOnSlot(draggedId) {
dropSessionOnSlot(draggedId, cell) {
const grid = this._tileGrid;
if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return;
if (grid.tiles.has(draggedId) || !this.addTile(draggedId)) return;
if (grid.tiles.has(draggedId)) {
if (!this._moveTileToCell(draggedId, cell)) return;
} else if (!this.addTile(draggedId, { cell })) {
return;
}
this.selectSession(draggedId);
},
@@ -1249,9 +1323,10 @@ Object.assign(CodemanApp.prototype, {
},
/**
* The header moves its tile: dragged onto another tile the two trade places
* (dropSessionOnTile, the path a dragged tab takes, through the same
* capture-phase drop targets, _acceptTabDrops); an empty slot refuses it. A native drag, so Escape and a drop anywhere else are the
* The header moves its tile: dragged onto another tile the two trade places,
* onto an empty cell it moves there and leaves its own cell empty
* (dropSessionOnTile / dropSessionOnSlot, the path a dragged tab takes,
* through the same capture-phase drop targets, _acceptTabDrops). A native drag, so Escape and a drop anywhere else are the
* browser's own cancel: nothing moves, and dragend clears what the drag
* painted. The drag carries a type of its own and never text, so no text
* field or terminal, in this page or another application, can take it as
@@ -1444,16 +1519,18 @@ Object.assign(CodemanApp.prototype, {
this.removeTile(sessionId);
},
/** Columns x rows for the current tile count, applied to the grid section. */
/**
* Columns x rows for the current tile count, applied to the grid section.
* The shape comes from the count alone; within it each tile sits in its
* cell (grid.cells) and any cell may be empty. When the shape changes, the
* cells follow fitTileCells (each tile keeps its row and column if all fit,
* else the tiles pack in reading order).
*/
_applyTileLayout() {
const grid = this._tileGrid;
const section = this._tileGridSection();
const rect = section.getBoundingClientRect?.() || { width: 0, height: 0 };
const { cols, rows, fits } = window.CodemanTileGrid.computeTileLayout({
count: grid.ids.length,
width: rect.width || window.innerWidth,
height: rect.height || window.innerHeight,
});
const { cols, rows, fits } = this._tileShapeFor(grid.ids.length);
grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows);
if (grid.colFr.length !== cols) grid.colFr = new Array(cols).fill(1);
if (grid.rowFr.length !== rows) grid.rowFr = new Array(rows).fill(1);
grid.cols = cols;
@@ -1476,18 +1553,23 @@ Object.assign(CodemanApp.prototype, {
for (const entry of grid.tiles.values()) this._paintTileHandle(entry);
// Zoomed: one cell; the other tiles stay connected but hidden (CSS), so
// they measure nothing and send no resize. Otherwise every tile is placed
// explicitly in reading order, with a divider track between columns and
// between rows.
// explicitly in its cell, with a divider track between columns and between
// rows, and every empty cell holds a drop slot.
section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.colFr);
section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.rowFr);
grid.ids.forEach((id, k) => {
const holes = [];
grid.cells.forEach((id, k) => {
if (!id) {
holes.push(k);
return;
}
const el = grid.tiles.get(id)?.el;
if (!el) return;
el.style.gridColumn = id === zoomed ? '1' : String(2 * (k % cols) + 1);
el.style.gridRow = id === zoomed ? '1' : String(2 * Math.floor(k / cols) + 1);
});
this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows);
this._syncTileSlots(zoomed ? 0 : cols * rows - grid.ids.length, cols);
this._syncTileSlots(zoomed ? [] : holes, cols);
this._persistTileGrid();
},
@@ -1511,26 +1593,23 @@ Object.assign(CodemanApp.prototype, {
},
// The empty cells of a layout that is not full (3 tiles in a 2x2, 5 in a
// 3x2): drop targets for a tab, after the tiles in reading order.
_syncTileSlots(count, cols) {
// 3x2), wherever they are: drop targets for a tab (it joins there) and for a
// tile (it moves there). Each slot knows its cell (`data-cell`).
_syncTileSlots(holes, cols) {
const grid = this._tileGrid;
grid.slots ||= [];
while (grid.slots.length > count) grid.slots.pop().remove();
while (grid.slots.length < count) {
while (grid.slots.length > holes.length) grid.slots.pop().remove();
while (grid.slots.length < holes.length) {
const slot = document.createElement('div');
slot.className = 'tile-slot';
slot.textContent = 'Drop a tab here';
// Only a session not tiled yet: a tile is never moved to a slot (owner:
// "dont move the tile"; a slot is always last, so a move there shifted
// every tile after it).
this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId), {
accepts: (id) => !this._tileGrid?.tiles.has(id),
});
slot.textContent = 'Drop a tab or a tile here';
this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId, Number(slot.dataset.cell)));
this._tileGridSection().appendChild(slot);
grid.slots.push(slot);
}
grid.slots.forEach((slot, i) => {
const k = grid.ids.length + i;
const k = holes[i];
slot.dataset.cell = String(k);
slot.style.gridColumn = String(2 * (k % cols) + 1);
slot.style.gridRow = String(2 * Math.floor(k / cols) + 1);
});
@@ -1596,7 +1675,8 @@ Object.assign(CodemanApp.prototype, {
const start = isCol ? e.clientX : e.clientY;
const minPx = isCol ? T.TILE_MIN_W : T.TILE_MIN_H;
const affected = [];
grid.ids.forEach((id, k) => {
grid.cells.forEach((id, k) => {
if (!id) return;
const track = isCol ? k % grid.cols : Math.floor(k / grid.cols);
if (track === index || track === index + 1) affected.push(grid.tiles.get(id).tile);
});
@@ -1799,7 +1879,10 @@ Object.assign(CodemanApp.prototype, {
const state = {
v: 1,
open,
ids: grid.ids.slice(),
// The cells, null for an empty one, so a hole comes back where it was
// (sanitizeTileGridState; a build before cells drops the nulls and reads
// the tiles packed, as it always did).
ids: grid.cells.slice(),
focused: grid.focusedId,
zoomed: grid.autoZoom ? null : grid.zoomedId,
colFr: grid.colFr.slice(),
@@ -1843,6 +1926,14 @@ Object.assign(CodemanApp.prototype, {
// Exactly the stored set: an open split closes without joining it (decision 8, case a).
if (!this.openTileGrid(stored.ids, { focusedId: focus, auto: true, mergeSplit: false })) return false;
const grid = this._tileGrid;
// openTileGrid packed the tiles from the first cell. The stored holes come
// back where they were when the shape is the same as when they were stored
// (as many cells as this layout has); a session gone since leaves its cell
// empty then. Otherwise the tiles stay packed.
const cells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
grid.cells = cells;
}
// openTileGrid laid the grid out with equal tracks. The stored ones go back
// on; _applyTileLayout drops them again if they do not match the column or
// row count (the window may have changed the layout since).
@@ -1879,8 +1970,10 @@ Object.assign(CodemanApp.prototype, {
_closeStoredTileGrid() {
const stored = this._readStoredTileGrid();
if (!stored?.open) return;
// Stored as it was read, holes included (`ids` carries the cells).
const { cells, ...rest } = stored;
try {
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...stored, open: false }));
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false }));
} catch {
/* Per-device convenience only. */
}
+342
View File
@@ -0,0 +1,342 @@
/**
* @fileoverview The grid as cells (owner: "the empty tab doesnt always have to
* be the last one ... it can also be tab nr 4 or 3"). `grid.cells` holds a
* session id or `null` per cell and is the one source of truth; `grid.ids` is
* the tiles in reading order, derived. The shape still comes from the tile
* count (the layout table), and the cap counts tiles, never empty cells.
*
* - Removing a tile without a shape change leaves its cell empty where it was.
* - Adding one without a shape change takes the first empty cell (or the cell a
* tab was dropped on: tile-grid-move.test.ts).
* - A shape change keeps each tile's row and column when all fit (2x2 growing
* to 3x2), else the tiles pack in reading order (fitTileCells).
* - Focus never lands on an empty cell: the Alt+Shift+Arrow chords skip it,
* and Ctrl+Tab / Alt+[ ] cycle through the tiles only.
* - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload
* brings the holes back when the shape is the same, a session gone by then
* leaves its cell empty, a different shape packs, and the old packed format
* reads unchanged. A followed `#session=` link keeps the holes.
* - 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.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
documentAddEventListener,
localStore,
makeGridApp,
rafCallbacks,
resetGridHarness,
section,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const FIVE = SIX.slice(0, 5);
const KEY = 'codeman:tile-grid';
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile;
const place = (id: string) => [tileEl(id).style.gridColumn, tileEl(id).style.gridRow];
// Under 1800px wide three tiles take a 2x2 (over it, 3x1).
const narrow = () => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
};
function openGrid(ids: string[], focusedId = ids[0], all: string[] = SIX): GridApp {
const app = makeGridApp(all);
app.openTileGrid(ids, { focusedId });
return app;
}
function setCells(app: GridApp, cells: Array<string | null>) {
app._tileGrid.cells = cells.slice();
app._applyTileLayout();
}
beforeEach(() => {
resetGridHarness();
narrow();
});
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
describe('the cells are the source of truth', () => {
it('ids are the tiles in reading order, derived from the cells (holes skipped)', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
expect(app._tileGrid.ids).toEqual(FIVE);
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c', 's-d', 's-e']);
// A tile sits in its cell, the slot in the empty one.
expect(place('s-b')).toEqual(['5', '1']);
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '1']);
});
it('opening packs from the first cell and pads the shape with empty cells', () => {
const app = openGrid(FIVE);
expect(app._tileGrid.cells).toEqual([...FIVE, null]);
});
});
describe('removing a tile', () => {
it('without a shape change (6 to 5 stays 3x2): its cell is left empty where it was', () => {
const app = openGrid(SIX);
app.removeTile('s-b');
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
expect(place('s-c')).toEqual(['5', '1']);
expect(slots()).toHaveLength(1);
expect(slots()[0].dataset.cell).toBe('1');
// Nothing else was remounted.
expect(FakeTile.all.filter((t) => t._destroyed).map((t) => t.sessionId)).toEqual(['s-b']);
expect(stored().ids).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
});
it('a shape change (5 to 4 is 3x2 to 2x2) keeps the tiles when all fit, else packs them', () => {
let app = openGrid(FIVE);
setCells(app, ['s-a', 's-b', null, 's-d', 's-e', 's-c']);
// Removing the only tile in the third column: the rest keep their places.
app.removeTile('s-c');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-d', 's-e']);
resetGridHarness();
narrow();
app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-c', 's-d', 's-e', 's-b']);
// A tile is left in the third column: packed in reading order.
app.removeTile('s-e');
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-d', 's-b']);
});
it('the focused tile leaving hands focus to the next tile in reading order, past the hole', () => {
const app = openGrid(SIX, 's-a');
setCells(app, ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']);
app.removeTile('s-b');
app.removeTile('s-a');
expect(app.activeSessionId).toBe('s-c');
});
});
describe('adding a tile', () => {
it('without a shape change: the first empty cell in reading order', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
app.addTile('s-f');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-f', 's-c', 's-d', 's-e']);
expect(slots()).toHaveLength(0);
});
it('Ctrl/Cmd+click on a tab and a session Run from this tab fill the first hole too', () => {
const app = openGrid(['s-a', 's-b', 's-c', 's-d'], 's-a', [...SIX, 's-g']);
// 2x2 [a b / c d]; the 5th makes it 3x2 with the four kept in place.
app.addSessionToTiles('s-e');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
app._joinTileGridFromRun('s-f');
expect(app._tileGrid.cells).toEqual(['s-a', 's-f', 's-b', 's-c', 's-d', 's-e']);
});
it('a shape change (4 to 5 is 2x2 to 3x2): every tile keeps its row and column', () => {
const app = openGrid(['s-a', 's-b', 's-c', 's-d']);
app.addTile('s-e');
// [a b / c d] -> [a b e / c d _]
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
expect(place('s-c')).toEqual(['1', '3']);
expect(place('s-e')).toEqual(['5', '1']);
});
it('into the cell named when it is empty, not just the first one (two holes, a state the cap never reaches)', () => {
const app = openGrid(['s-a', 's-b']);
// A 2x2 with two holes, set up by hand: the third tile still keeps the 2x2.
app._tileGrid.cells = ['s-a', null, 's-b', null];
app._tileGrid.cols = 2;
app._tileGrid.rows = 2;
app.addTile('s-c', { cell: 3 });
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c']);
// A cell that is taken is not: the first empty one instead.
resetGridHarness();
narrow();
const other = openGrid(['s-a', 's-b']);
other._tileGrid.cells = ['s-a', null, 's-b', null];
other._tileGrid.cols = 2;
other._tileGrid.rows = 2;
other.addTile('s-c', { cell: 2 });
expect(other._tileGrid.cells).toEqual(['s-a', 's-c', 's-b', null]);
});
it('the cap counts tiles, never empty cells', () => {
const app = openGrid(FIVE);
setCells(app, [null, 's-a', 's-b', 's-c', 's-d', 's-e']);
expect(app.addTile('s-f')).toBe(true);
expect(app._tileGrid.cells).toEqual(['s-f', 's-a', 's-b', 's-c', 's-d', 's-e']);
expect(app.addTile('s-other')).toBe(false);
});
});
describe('a shape change from the window (three tiles: 2x2 narrow, 3x1 wide)', () => {
it('positions do not map between shapes: the tiles keep their reading order, packed', () => {
const app = openGrid(['s-a', 's-b', 's-c']);
setCells(app, ['s-a', null, 's-b', 's-c']);
section.getBoundingClientRect = () => ({ width: 2400, height: 1000, top: 0, left: 0, right: 2400, bottom: 1000 });
app._applyTileLayout();
expect([app._tileGrid.cols, app._tileGrid.rows]).toEqual([3, 1]);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c']);
narrow();
app._applyTileLayout();
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
});
});
describe('focus never lands on an empty cell', () => {
function handlerFor(app: GridApp) {
app.$ = () => null;
app.setupColorPicker = vi.fn();
const before = (documentAddEventListener.mock.calls as unknown[]).length;
app.setupEventListeners();
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
return added.find(([type, , capture]) => type === 'keydown' && capture === true)![1];
}
const chord = (key: string, mods: Record<string, boolean>) => ({
type: 'keydown',
key,
code: key,
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
target: { closest: () => null },
...mods,
});
const focusKey = (dir: string) => chord(`Arrow${dir}`, { altKey: true, shiftKey: true });
it('Alt+Shift+Arrows skip a hole to the next tile, and do nothing when there is none', () => {
const app = openGrid(FIVE, 's-a');
// [a _ b / c d e]
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
delete app.selectSession;
const onKeydown = handlerFor(app);
onKeydown(focusKey('Right'));
expect(app.activeSessionId).toBe('s-b');
onKeydown(focusKey('Right'));
expect(app.activeSessionId).toBe('s-b');
app._selectTiledSession('s-d', { auto: true });
// Up from d (the hole above): the nearest tile in that row, the lower column on a tie.
onKeydown(focusKey('Up'));
expect(app.activeSessionId).toBe('s-a');
expect(app._tileGrid.focusedId).toBe('s-a');
});
it('Ctrl+Tab and Alt+[ / ] cycle through the tiles in reading order, never a hole', () => {
const app = openGrid(FIVE, 's-a');
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
delete app.selectSession;
const seen: string[] = [];
for (let i = 0; i < 5; i++) {
app._cycleTileFocus(1);
seen.push(app.activeSessionId);
}
expect(seen).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']);
app._cycleTileFocus(-1);
expect(app.activeSessionId).toBe('s-e');
});
});
describe('persistence: the cells, holes included', () => {
function reload(ids: string[] = SIX): GridApp {
// The page goes away; a fresh one on the same device restores the grid.
section.children = [];
FakeTile.all = [];
const app = makeGridApp(ids);
expect(app._restoreTileGrid()).toBe(true);
return app;
}
it('a hole comes back where it was when the shape is the same', () => {
const app = openGrid(FIVE, 's-c');
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
expect(stored().ids).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
app._tileGrid.open = false;
const again = reload();
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
expect(again.activeSessionId).toBe('s-c');
expect(slots()[0].dataset.cell).toBe('2');
});
it('a session gone by then leaves its cell empty if the shape still fits', () => {
const app = openGrid(SIX);
app._tileGrid.open = false;
const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f']);
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-f']);
});
it('otherwise (another shape) the grid packs', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
app._tileGrid.open = false;
// s-d gone: four tiles take a 2x2.
const again = reload(['s-a', 's-b', 's-c', 's-e']);
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-e']);
});
it('another shape packs even when the stored holes would fit the new one', () => {
// Stored as 3x2 [a _ b / c d e]; d and e gone: three tiles take a 2x2.
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] }));
const again = reload(['s-a', 's-b', 's-c']);
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
});
it('the old packed format reads unchanged', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: FIVE, focused: 's-b' }));
const again = reload();
expect(again._tileGrid.cells).toEqual([...FIVE, null]);
expect(again.activeSessionId).toBe('s-b');
});
it('a followed #session= link closes the stored grid with its holes kept', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
const fresh = makeGridApp(SIX);
fresh._closeStoredTileGrid();
expect(stored()).toMatchObject({ open: false, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] });
expect(stored()).not.toHaveProperty('cells');
void app;
});
});
describe('a move keeps every tile', () => {
it('cells that lose or duplicate a tile are refused, and nothing changes', () => {
const app = openGrid(FIVE);
const before = app._tileGrid.cells.slice();
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', null, null])).toBe(false);
expect(app._reorderTiles(['s-a', 's-a', 's-c', 's-d', 's-e', null])).toBe(false);
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', 's-e'])).toBe(false);
expect(app._tileGrid.cells).toEqual(before);
});
});
describe('a divider drag with a hole', () => {
it('refits only the tiles in its two columns, the hole skipped', () => {
const app = openGrid(FIVE);
// [a _ b / c d e]: column 0 holds a, c; column 1 holds d.
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
for (const t of FakeTile.all) t.fit.mockClear();
const d = app._tileGrid.dividers.get('col-0') as FakeEl;
d.dispatch('pointerdown', {
button: 0,
clientX: 500,
clientY: 100,
pointerId: 1,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
d.dispatch('pointermove', { clientX: 560, clientY: 100, pointerId: 1 });
for (const cb of rafCallbacks.splice(0)) cb();
d.dispatch('pointerup', { clientX: 560, clientY: 100, pointerId: 1 });
const fits = Object.fromEntries(FIVE.map((id) => [id, tile(id).fit.mock.calls.length]));
expect(fits).toEqual({ 's-a': 1, 's-b': 0, 's-c': 1, 's-d': 1, 's-e': 0 });
});
});
+1 -1
View File
@@ -277,7 +277,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
'This window fits 4 tiles',
'This window fits 1 tile',
'The grid already holds what this window fits (4)',
'Drop a tab here',
'Drop a tab or a tile here',
'Resize tile columns',
'Resize tile rows',
'Zoom this tile',
+140 -62
View File
@@ -177,52 +177,87 @@ describe('dragging a tile by its header', () => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
// Owner: "dont move the tile". A slot is always the last cell, so a move
// there shifted every tile after it; the slot refuses a tile instead.
it('the slot refuses it: held (never reaching anything below), no highlight, nothing moves', () => {
const app = openGrid(['s-a', 's-b', 's-c'], 's-b');
app.markIdleAlertSeen.mockClear();
localStore.delete('codeman:tile-grid');
const costsNothing = snapshotCost();
clearFits();
expect(slots()).toHaveLength(1);
startDrag('s-a');
const o = over(slots()[0]);
expect(o.preventDefault).toHaveBeenCalled();
expect(o.stopPropagation).toHaveBeenCalled();
expect(o.dataTransfer.dropEffect).toBe('none');
// Owner: an empty cell can be any cell, and a tile dragged onto one moves
// THERE, leaving its own cell empty; nothing else moves.
const LAYOUTS = [
{ name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], cells: 4 },
{ name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), cells: 6 },
];
for (const layout of LAYOUTS) {
it(`${layout.name}: from every cell into the hole wherever it is, by header drag`, () => {
const bad: string[] = [];
for (let hole = 0; hole < layout.cells; hole++) {
for (let from = 0; from < layout.cells; from++) {
if (from === hole) continue;
resetGridHarness();
section.getBoundingClientRect = () => ({
width: 1700,
height: 1000,
top: 0,
left: 0,
right: 1700,
bottom: 1000,
});
const app = openGrid(layout.ids, layout.ids[0]);
const cells: Array<string | null> = layout.ids.slice();
cells.splice(hole, 0, null);
app._tileGrid.cells = cells.slice(0, layout.cells);
app._applyTileLayout();
const moved = app._tileGrid.cells[from];
const costsNothing = snapshotCost();
expect(slots()).toHaveLength(1);
expect(slots()[0].dataset.cell).toBe(String(hole));
const { over: o } = dragTileOnto(moved, slots()[0]);
const expected = app._tileGrid.cells.slice();
const want = cells.slice(0, layout.cells);
want[hole] = moved;
want[from] = null;
const ok =
JSON.stringify(expected) === JSON.stringify(want) &&
o.dataTransfer.dropEffect === 'move' &&
app.activeSessionId === moved &&
JSON.stringify(stored().ids) === JSON.stringify(want) &&
slots().length === 1 &&
slots()[0].dataset.cell === String(from);
if (!ok) bad.push(`hole ${hole} from ${from}: got ${JSON.stringify(expected)}`);
costsNothing();
}
}
expect(bad).toEqual([]);
});
}
it('the moved tile sits in the hole (its grid place), and the slot takes its old place', () => {
const app = openGrid(SIX.slice(0, 5));
// [a b c / d e _]: c into the hole below it.
dragTileOnto('s-c', slots()[0]);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-c']);
expect([tileEl('s-c').style.gridColumn, tileEl('s-c').style.gridRow]).toEqual(['5', '3']);
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['5', '1']);
expect(slots()[0].classList.contains('tile--drop-target')).toBe(false);
// A browser sends no drop on a refused target; one that did never reaches the slot's handler.
const toSlot = vi.spyOn(app, 'dropSessionOnSlot');
drop(slots()[0]);
expect(toSlot).not.toHaveBeenCalled();
toSlot.mockRestore();
end('s-a');
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
expect(localStore.has('codeman:tile-grid')).toBe(false);
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
costsNothing();
});
it('so does the tab of a tiled session; a session not tiled yet still joins there', () => {
const app = openGrid(['s-a', 's-b', 's-c']);
app.draggedTabId = 's-a';
const o = over(slots()[0]);
expect(o.dataTransfer.dropEffect).toBe('none');
it('a tab of a tiled session moves the same way; a session not tiled yet joins in THAT cell', () => {
const app = openGrid(SIX.slice(0, 5));
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
app._applyTileLayout();
app.draggedTabId = 's-e';
drop(slots()[0]);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
// dropSessionOnSlot itself refuses a tiled session too.
app.dropSessionOnSlot('s-a');
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', null]);
app.draggedTabId = 's-other';
expect(over(slots()[0]).dataTransfer.dropEffect).toBe('move');
drop(slots()[0]);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c', 's-other']);
expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', 's-other']);
expect(app.activeSessionId).toBe('s-other');
});
it('while a tile is zoomed there is no slot, and a move into a hole is refused', () => {
const app = openGrid(SIX.slice(0, 5));
app.zoomTile('s-a');
expect(slots()).toHaveLength(0);
expect(app._moveTileToCell('s-b', 5)).toBe(false);
expect(app._tileGrid.cells).toEqual([...SIX.slice(0, 5), null]);
});
});
it('Escape, or a drop anywhere else, cancels: dragend with no drop moves nothing and clears what was painted', () => {
@@ -528,9 +563,9 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
return keydown[1];
}
// The neighbour in each direction, by cell, written out by hand (null: an
// edge). A partial last row: down from a cell above an empty one goes to the
// last tile, the rule the focus chords follow.
// The cell next to each cell in each direction, written out by hand (null:
// an edge). A move goes to that cell: a swap when a tile is there, a move
// into it when it is empty (the partial layouts below).
const TABLES: Record<
string,
{ ids: string[]; cols: number; width?: number; next: Array<Record<Dir, number | null>> }
@@ -565,28 +600,6 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
{ left: 4, right: null, up: 2, down: null },
],
},
'2x2 with 3 tiles': {
cols: 2,
ids: ['s-a', 's-b', 's-c'],
width: 1700,
next: [
{ left: null, right: 1, up: null, down: 2 },
{ left: 0, right: null, up: null, down: 2 },
{ left: null, right: null, up: 0, down: null },
],
},
'3x2 with 5 tiles': {
cols: 3,
ids: SIX.slice(0, 5),
width: 1700,
next: [
{ left: null, right: 1, up: null, down: 3 },
{ left: 0, right: 2, up: null, down: 4 },
{ left: 1, right: null, up: null, down: 4 },
{ left: null, right: 4, up: 0, down: null },
{ left: 3, right: null, up: 1, down: null },
],
},
};
afterEach(() => {
@@ -632,6 +645,71 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
});
}
// Owner: an empty cell can be any cell. With the hole in every cell in turn,
// every tile and every direction: into the hole, or a swap, or nothing.
const HOLED = [
{ name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], adjacency: TABLES['2x2'].next },
{ name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), adjacency: TABLES['3x2'].next },
];
for (const layout of HOLED) {
it(`${layout.name}: with the hole anywhere, every tile, every direction (into the hole, a swap, or nothing)`, () => {
const bad: string[] = [];
const size = layout.adjacency.length;
for (let hole = 0; hole < size; hole++) {
const cells: Array<string | null> = layout.ids.slice();
cells.splice(hole, 0, null);
for (let i = 0; i < size; i++) {
if (i === hole) continue;
for (const dir of Object.keys(ARROW) as Dir[]) {
resetGridHarness();
section.getBoundingClientRect = () => ({
width: 1700,
height: 1000,
top: 0,
left: 0,
right: 1700,
bottom: 1000,
});
const moved = cells[i] as string;
const app = openGrid(layout.ids, moved);
app._tileGrid.cells = cells.slice();
app._applyTileLayout();
const costsNothing = snapshotCost();
const e = chord(dir);
handlerFor(app)(e);
const want = cells.slice();
const j = layout.adjacency[i][dir];
if (j !== null) [want[i], want[j]] = [want[j], want[i]];
const ok =
e.preventDefault.mock.calls.length === 1 &&
JSON.stringify(app._tileGrid.cells) === JSON.stringify(want) &&
app.activeSessionId === moved &&
app._tileGrid.focusedId === moved &&
JSON.stringify(stored().ids) === JSON.stringify(want);
if (!ok) bad.push(`hole ${hole}, cell ${i} ${dir}: got ${JSON.stringify(app._tileGrid.cells)}`);
costsNothing();
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
}
}
}
expect(bad).toEqual([]);
});
}
it('a move into a hole across columns of different widths: only the moved tile fits, once', () => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
const five = SIX.slice(0, 5);
const app = openGrid(five, 's-b');
app._tileGrid.colFr = [1, 1, 2];
app._applyTileLayout();
clearFits();
// [a b c / d e _]: b (column 1) down into... e is there; first move e right into the hole.
app._selectTiledSession('s-e', { auto: true });
handlerFor(app)(chord('right'));
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-e']);
expect(fitCounts(five)).toEqual({ 's-a': 0, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 });
});
it('focus stays on the moved tile through several moves, and the order is stored', () => {
const app = openGrid(SIX, 's-a');
const onKeydown = handlerFor(app);