diff --git a/CLAUDE.md b/CLAUDE.md index c3fcc81c..56e3dbdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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 ` 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) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6c0f7347..a477ed80 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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 ` 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 diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index c2b2b563..0727a237 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -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 diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index f289513a..9928e84e 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -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. | diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index c42a79c2..31d4b9ba 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -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 diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 3e9e03fa..e0cb2f48 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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': '调整窗格列宽', diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index b7d2c1ea..b3699c8a 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -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. */ } diff --git a/test/tile-grid-cells.test.ts b/test/tile-grid-cells.test.ts new file mode 100644 index 00000000..bb67a918 --- /dev/null +++ b/test/tile-grid-cells.test.ts @@ -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) { + app._tileGrid.cells = cells.slice(); + app._applyTileLayout(); +} + +beforeEach(() => { + resetGridHarness(); + narrow(); +}); +afterEach(() => { + delete (section as unknown as Record).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) => ({ + 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 }); + }); +}); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 4ee26d44..dcc470fd 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -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', diff --git a/test/tile-grid-move.test.ts b/test/tile-grid-move.test.ts index ba8dbfb3..80aba2e0 100644 --- a/test/tile-grid-move.test.ts +++ b/test/tile-grid-move.test.ts @@ -177,52 +177,87 @@ describe('dragging a tile by its header', () => { delete (section as unknown as Record).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 = 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> } @@ -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 = 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);