From aecada8c565fb61bbad8d8070e8740f428980bc1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 01:28:20 +0200 Subject: [PATCH] fix(tiles): no tile onto an empty slot, header focus on click, arrow chords skip text fields The owner's answers on moving tiles: - "dont move the tile": an empty slot no longer takes a tile. A slot is always the last cell, so a move there shifted every tile after it. Each drop target now says what it accepts (_acceptTabDrops' `accepts`): a tile takes any session but its own, an empty slot only a session not tiled yet. A refused drag is still held (dropEffect none, no highlight), and dropSessionOnSlot refuses a tiled session too, its tab included. A session not tiled yet still joins on a slot. - A cancelled drag changes nothing, focus included (best practice): the header focuses its tile on click, never on press, so a drag that ends with Escape or outside leaves focus and the idle alert alone. The body keeps press-to-focus, so focus still moves before a press reaches xterm. The rename input stops its own clicks. - A tiled tab dropped on the zoomed tile stays refused (confirmed). - The Alt+Shift+Arrow focus chords skip a text field too (best practice), as the move chords already did: shifted arrows select there. Toggle and zoom are not text-editing keys and are unchanged. Docs: the wiki, the spec's as-built bullet (with the owner's answers), the invariants and CLAUDE.md say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 +- docs/tile-grid-plan.md | 35 +++++---- docs/wiki/Keyboard-Shortcuts.md | 2 +- docs/wiki/Tile-Grid.md | 14 ++-- src/web/public/app.js | 4 +- src/web/public/tile-grid.js | 96 +++++++++++++----------- test/tile-grid-move.test.ts | 122 ++++++++++++++++++++++++++----- test/tile-grid-shortcuts.test.ts | 22 ++++++ 9 files changed, 217 insertions(+), 84 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 29b6b20d..c3fcc81c 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, a tiled tab dropped on a tile or slot, `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`. ⚠️ 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. ⚠️ 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) **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 35209c53..6c0f7347 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -807,13 +807,13 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE its await (like `wasActive`; the delete broadcast may already have removed the tile) and focuses it with `auto`; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead. -⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI) and never in a text field other than xterm's own textarea (`isTextFieldTarget`), where Ctrl+Shift+Arrows select by word. +⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select. ⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does). ⚠️ **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. 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. +⚠️ **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. ⚠️ **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. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index fe18e725..c2b2b563 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -68,21 +68,26 @@ 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 the two trade places, onto an empty slot it moves there (the - last cell: empty slots are always last, so the tiles after it close up). 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. `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) and with a single tile, and the chords skip a text field, where - Ctrl+Shift+Arrows select by word. 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 + 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. - **A tile that joins before its pane exists resends its size when the pid appears** diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 1140095f..f289513a 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -46,7 +46,7 @@ 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: onto another tile they swap, onto an empty slot it moves there. | +| Drag a tile's header | Move the tile: dropped on another tile, the two swap. | | `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 0b13683e..c42a79c2 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -65,8 +65,10 @@ 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. Drop it on an empty slot and it moves there, to the end of the grid. Press `Escape` -or let go anywhere else and nothing moves. The dropped tile takes the focus. +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. 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 @@ -90,10 +92,10 @@ saved with the grid. All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and -`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Ctrl+Shift+Arrows` in a text -field (renaming a tile, the file editor) still selects by word; inside a tile it moves the tile, -so a terminal editor there (nano, micro, emacs) does not get it. With the Tiles setting off, -`Ctrl+Shift+G` does nothing. +`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and +`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there; +inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does +not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing. ## Leaving the grid diff --git a/src/web/public/app.js b/src/web/public/app.js index ad8b4f3a..390f64b4 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -543,7 +543,9 @@ const DEFAULT_SHORTCUTS = [ // chord must reach the terminal untouched. Every xterm key handler swallows a // chord that applies, so it never reaches a PTY. Defaults: Ctrl+Shift+G makes // xterm emit nothing (a shifted Ctrl letter) and overrides only the browser's - // find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs. + // find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs. The + // arrow chords (focus, move) never apply in a text field, where shifted + // arrows select (tileShortcutFor). { id: 'toggle-tile-grid', group: 'Tiles', diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 88ba58cf..b7d2c1ea 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -40,8 +40,8 @@ function tileGridTracks(fr) { return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `); } // Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each -// needs the grid open. The toggle applies wherever a grid could open. The move -// chords leave a text field its keys (`move`: see tileShortcutFor). +// needs the grid open. The toggle applies wherever a grid could open. The arrow +// chords (`direction`, `move`) leave a text field its keys (tileShortcutFor). const TILE_SHORTCUTS = { 'toggle-tile-grid': { needsOpen: false }, 'focus-tile-left': { needsOpen: true, direction: 'left' }, @@ -58,8 +58,9 @@ const TILE_SHORTCUTS = { /** * A text field other than a terminal's own input (xterm's helper textarea): - * the rename input, the file editor, a settings field. Ctrl+Shift+Arrows - * selects by word there, so the move chords leave it alone. + * the rename input, the file editor, a settings field. Shifted arrows select + * there (Ctrl+Shift by word; Option+Shift by word on macOS), so the arrow + * chords, focus and move, leave it alone (owner: best practice). */ function isTextFieldTarget(target) { if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false; @@ -547,7 +548,8 @@ Object.assign(CodemanApp.prototype, { * decision 6 in docs/tile-grid-plan.md). The focus, move, * zoom and remove chords apply only while the grid is open, however it was * opened (a move chord also while a tile is zoomed, as a no-op, so its keys - * never reach the CLI; and never in a text field, whose keys they are). + * never reach the CLI). The arrow chords never apply in a text field, whose + * keys they are. * Registry-aware (rebinds and disables in App Settings, Shortcuts). * The capture handler (app.js) dispatches it; every xterm key handler returns * false for it, so a chord that applies never reaches a PTY. @@ -561,7 +563,7 @@ Object.assign(CodemanApp.prototype, { for (const shortcut of this.getShortcutRegistry()) { const spec = TILE_SHORTCUTS[shortcut.id]; if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue; - if (spec.move && isTextFieldTarget(e.target)) continue; + if ((spec.direction || spec.move) && isTextFieldTarget(e.target)) continue; if (spec.needsOpen) return open ? shortcut.id : null; const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true; return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null; @@ -738,14 +740,23 @@ Object.assign(CodemanApp.prototype, { el.append(header.el, body); // Pressing a tile is a human selection: it focuses the tile and // acknowledges its idle alert (the already-focused tile hits - // selectSession's early return, which acknowledges too). pointerdown, not - // click, so focus moves before the press reaches xterm, and never - // preventDefault: xterm's own mousedown focuses its textarea and starts - // selections. - el.addEventListener('pointerdown', () => { + // selectSession's early return, which acknowledges too). In the body on + // pointerdown, not click, so focus moves before the press reaches xterm, + // and never preventDefault: xterm's own mousedown focuses its textarea and + // starts selections. In the header on click instead (below): a press there + // may become a drag, and a drag that is cancelled changes nothing, focus + // included (owner: best practice). A drag never ends in a click. + el.addEventListener('pointerdown', (e) => { + if (header.el.contains?.(e?.target)) return; if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); }); - this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, sessionId)); + header.el.addEventListener('click', () => { + if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); + }); + // A tile takes any session but its own (_acceptTabDrops). + this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, sessionId), { + accepts: (id) => id !== sessionId, + }); this._tileGridSection().appendChild(el); const tile = this._newTerminalTile(sessionId, body); grid.tiles.set(sessionId, { @@ -774,12 +785,13 @@ Object.assign(CodemanApp.prototype, { * strip's own drag sets `draggedTabId`) and for a tile dragged by its header * (`_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. Over - * the dragged session's own tile the drag is held there too, but refused - * (`dropEffect: 'none'`, so no drop follows). Any other drag (a file) is - * left alone. + * 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 + * is held there too, but refused (`dropEffect: 'none'`, no highlight, so no + * drop follows). Any other drag (a file) is left alone. */ - _acceptTabDrops(el, onDrop) { + _acceptTabDrops(el, onDrop, { accepts = () => true } = {}) { const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null); el.addEventListener( 'dragover', @@ -788,9 +800,9 @@ Object.assign(CodemanApp.prototype, { if (!id) return; e.preventDefault?.(); e.stopPropagation?.(); - const own = id === el.dataset.sessionId; - if (e.dataTransfer) e.dataTransfer.dropEffect = own ? 'none' : 'move'; - if (!own) el.classList.add('tile--drop-target'); + const ok = accepts(id); + if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none'; + if (ok) el.classList.add('tile--drop-target'); }, true ); @@ -805,7 +817,7 @@ Object.assign(CodemanApp.prototype, { if (!id) return; e.preventDefault?.(); e.stopPropagation?.(); - onDrop(id); + if (accepts(id)) onDrop(id); }, true ); @@ -819,12 +831,13 @@ Object.assign(CodemanApp.prototype, { /** * Puts the tiles in a new reading order (the same sessions): the one path - * every move takes, a header drag, a tab dragged onto a tile or a slot, 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). + * 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). * * @param {string[]} ids - the new order * @returns {boolean} false when refused, true otherwise (also when nothing moved) @@ -906,19 +919,13 @@ Object.assign(CodemanApp.prototype, { }, /** - * A tab dropped on an empty slot joins the grid there; a tiled session (its - * tab, or the tile dragged by its header) moves there (_reorderTiles). + * 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). */ dropSessionOnSlot(draggedId) { const grid = this._tileGrid; if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; - if (grid.tiles.has(draggedId)) { - // Empty slots are always the last cells in reading order: the tile goes - // last, the tiles after it close up. - if (!this._reorderTiles([...grid.ids.filter((id) => id !== draggedId), draggedId])) return; - } else if (!this.addTile(draggedId)) { - return; - } + if (grid.tiles.has(draggedId) || !this.addTile(draggedId)) return; this.selectSession(draggedId); }, @@ -1242,10 +1249,9 @@ Object.assign(CodemanApp.prototype, { }, /** - * The header moves its tile: dragged onto another tile the two trade places, - * onto an empty slot it moves there (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 + * 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 * 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 @@ -1394,6 +1400,9 @@ Object.assign(CodemanApp.prototype, { this._renderTileHeader(sessionId); }; input.addEventListener('pointerdown', (e) => e.stopPropagation()); + // A click in the input is not the header's click, which would focus the + // terminal away from it. + input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('keydown', (e) => { // Enter and Escape during an IME composition belong to the IME. if (e.isComposing || e.keyCode === 229) return; @@ -1511,7 +1520,12 @@ Object.assign(CodemanApp.prototype, { const slot = document.createElement('div'); slot.className = 'tile-slot'; slot.textContent = 'Drop a tab here'; - this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId)); + // 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), + }); this._tileGridSection().appendChild(slot); grid.slots.push(slot); } diff --git a/test/tile-grid-move.test.ts b/test/tile-grid-move.test.ts index 09b8f9ab..ba8dbfb3 100644 --- a/test/tile-grid-move.test.ts +++ b/test/tile-grid-move.test.ts @@ -1,6 +1,6 @@ /** * @fileoverview Moving tiles (owner request): a tile dragged by its header - * onto another tile trades places with it, onto an empty slot it moves there. + * onto another tile trades places with it. * * - One path for every move (`_reorderTiles`): the header drag, a tab dragged * onto a tile or a slot, the Move Tile chords. Nothing is remounted, @@ -8,6 +8,12 @@ * no session joins or leaves, the grid stays open, the order is persisted. * - Divider sizes belong to the cells: a tile whose cell size changed fits * once (one PTY resize, #464), every other tile is left alone. + * - An empty slot refuses a tile (its header drag or its tab): owner, "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. + * - The header focuses its tile on click, never on a press (the body keeps + * press-to-focus), so a drag that is cancelled changes nothing, focus and + * idle alert included (owner: best practice). * - The header drag is native: Escape or a drop anywhere else ends in a * `dragend` with no drop, which moves nothing and clears what the drag * painted. It carries a type of its own (never text) and is not @@ -22,7 +28,8 @@ * against hand-written tables for every cell and direction of 2x1, 2x2, 3x2 * and the partial 2x2 and 3x2; focus stays on the moved tile. The chords * apply (and are swallowed) only while the grid is open, zoomed included as a - * no-op, and never in a text field, where Ctrl+Shift+Arrows select by word. + * no-op, and never in a text field, where Ctrl+Shift+Arrows select by word + * (the focus chords skip text fields too: tile-grid-shortcuts.test.ts). * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ @@ -170,18 +177,51 @@ describe('dragging a tile by its header', () => { delete (section as unknown as Record).getBoundingClientRect; }); - it('it moves there, the last cell (the tiles after it close up); the slot count stays', () => { - const app = openGrid(['s-a', 's-b', 's-c']); + // 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); - dragTileOnto('s-a', slots()[0]); - expect(app._tileGrid.ids).toEqual(['s-b', 's-c', 's-a']); - expect([tileEl('s-a').style.gridColumn, tileEl('s-a').style.gridRow]).toEqual(['1', '3']); - 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'); expect(slots()[0].classList.contains('tile--drop-target')).toBe(false); - expect(app.activeSessionId).toBe('s-a'); + // 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(); - expect(stored().ids).toEqual(['s-b', 's-c', 's-a']); + }); + + 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'); + 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']); + + 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.activeSessionId).toBe('s-other'); }); }); @@ -260,6 +300,55 @@ describe('dragging a tile by its header', () => { }); }); +describe('focus: the header focuses its tile on click, so a cancelled drag changes nothing', () => { + it('a press on the header does not focus the tile; a click does (a human selection)', () => { + const app = openGrid(FOUR, 's-a'); + app.markIdleAlertSeen.mockClear(); + tileEl('s-c').dispatch('pointerdown', { target: nameOf('s-c'), button: 0 }); + tileEl('s-c').dispatch('pointerdown', { target: headerOf('s-c'), button: 0 }); + expect(app.activeSessionId).toBe('s-a'); + headerOf('s-c').dispatch('click', {}); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c'); + }); + + it('a press in the body still focuses at once (before the press reaches xterm)', () => { + const app = openGrid(FOUR, 's-a'); + tileEl('s-c').dispatch('pointerdown', { target: tileEl('s-c').children[1], button: 0 }); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('a drag of an unfocused tile that is cancelled leaves focus and its alert alone', () => { + const app = openGrid(FOUR, 's-a'); + app.markIdleAlertSeen.mockClear(); + tileEl('s-c').dispatch('pointerdown', { target: headerOf('s-c'), button: 0 }); + startDrag('s-c'); + over(tileEl('s-b')); + end('s-c'); + expect(app._tileGrid.ids).toEqual(FOUR); + expect(app.activeSessionId).toBe('s-a'); + expect(app._tileGrid.focusedId).toBe('s-a'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('a double-click on the name leaves the keyboard in the rename input, and its clicks stay in it', () => { + const app = openGrid(FOUR, 's-a'); + app._queueInlineSessionName = vi.fn(); + // The browser's sequence: click, click, dblclick. + headerOf('s-c').dispatch('click', {}); + headerOf('s-c').dispatch('click', {}); + nameOf('s-c').dispatch('dblclick', { stopPropagation: vi.fn() }); + const input = headerOf('s-c').children[2].children[0]; + expect(input.className).toBe('tile-rename-input'); + const terminalFocus = tile('s-c').terminal.focus.mock.invocationCallOrder; + expect(input.focus).toHaveBeenCalled(); + expect(input.focus.mock.invocationCallOrder.at(-1)).toBeGreaterThan(terminalFocus.at(-1) ?? 0); + const click = { stopPropagation: vi.fn() }; + input.dispatch('click', click); + expect(click.stopPropagation).toHaveBeenCalled(); + }); +}); + describe('the handle', () => { it('is the header: draggable, with a tooltip that says so under the state', () => { const app = openGrid(FOUR); @@ -383,19 +472,18 @@ describe('sizes belong to the cells: only a tile whose size changed fits, once', expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); }); - it('a move to the slot: every tile whose cell size changed fits once, the others not', () => { + it('a swap across rows of different heights: the two moved tiles fit, the rest not', () => { section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 }); try { const five = SIX.slice(0, 5); const app = openGrid(five); - app._tileGrid.colFr = [2, 1, 1]; + app._tileGrid.rowFr = [3, 1]; app._applyTileLayout(); clearFits(); - dragTileOnto('s-a', slots()[0]); - // [a b c / d e _] -> [b c d / e a _]: b (2 -> 1 wide), d (row 1 col 0 -> row 0 col 2: 2 -> 1), - // e (1 -> 2) and a (2 -> 1) changed size; c (1 -> 1) did not. - expect(app._tileGrid.ids).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']); - expect(fitCounts(five)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 1, 's-e': 1 }); + // [a b c / d e _]: a (row 0) and e (row 1) trade places and heights. + dragTileOnto('s-a', tileEl('s-e')); + expect(app._tileGrid.ids).toEqual(['s-e', 's-b', 's-c', 's-d', 's-a']); + expect(fitCounts(five)).toEqual({ 's-a': 1, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 }); } finally { delete (section as unknown as Record).getBoundingClientRect; } diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index 5cbab3f3..f260a50f 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -116,6 +116,28 @@ describe('when a chord applies', () => { expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right'); }); + it('the focus chords leave a text field its keys (shifted arrows select there); a terminal still gets them', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + for (const tagName of ['INPUT', 'TEXTAREA']) { + const target = { tagName, closest: () => null, classList: { contains: () => false } }; + expect(app.tileShortcutFor(chord({ ...RIGHT, target }))).toBeNull(); + } + expect( + app.tileShortcutFor(chord({ ...RIGHT, target: { isContentEditable: true, closest: () => null } })) + ).toBeNull(); + // xterm's own input is a textarea too, and the chord is the grid's there. + const xterm = { + tagName: 'TEXTAREA', + closest: () => null, + classList: { contains: (c: string) => c === 'xterm-helper-textarea' }, + }; + expect(app.tileShortcutFor(chord({ ...RIGHT, target: xterm }))).toBe('focus-tile-right'); + // Not an arrow chord: the toggle and zoom still apply from a field. + const input = { tagName: 'INPUT', closest: () => null, classList: { contains: () => false } }; + expect(app.tileShortcutFor(chord({ ...TOGGLE, target: input }))).toBe('toggle-tile-grid'); + }); + it('plain typing and unrelated chords never match', () => { const app = makeGridApp(IDS); app.openTileGrid(IDS);