fix(tiles): file drops, focus handoffs, pop-out fallback, split merge, tablet default

- A file dragged onto the tile grid navigated the browser away: the single
  view's drop handler sits on #terminalContainer, hidden while tiles are
  open. The #tileGrid section now cancels every file dragover and drop
  (bubble phase, so tab and tile drags stay with _acceptTabDrops), and a
  drop on a tile uploads its images to THAT tile's session through
  _uploadAndInsertImages, with the same "Only image files are supported"
  toast as image-input.js (now in the zh-CN table).
- App-driven refocus no longer moves DOM focus into another session's
  xterm: a remote delete of the focused tile, _reconcileTileGrid and a
  socket closed with 4003/4004/4010 (_onTileExit) pass focus: false.
  removeTile gains a focus option; user-initiated removes keep focusing.
- Popping out the last tile left the parked terminal's stale content under
  the popped-out tab (and snapshotted it on the next switch).
  _selectAfterTileGrid treats a detached session as unusable for both the
  focused id and the fallback.
- "Open group as tiles" and Ctrl/Cmd+click with the grid closed pass
  mergeSplit: false, so an open split no longer adds its two sessions on
  top of a set already sized to the group, the count and the window.
- Touch-primary devices (primary pointer coarse: iPad, Android tablets)
  default the Tiles button OFF in getDefaultSettings(); touchscreen
  laptops (fine primary pointer) keep the desktop default ON. The button,
  the App Settings chip and the Ctrl+Shift+G gate all resolve an absent key
  through these defaults, so they agree. CLAUDE.md and the invariants doc
  say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 08:44:52 +02:00
parent f855b5d274
commit 18c8b5c280
12 changed files with 481 additions and 25 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -805,7 +805,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
### Tile grid ### Tile grid
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view. **Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`. ⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
+2
View File
@@ -153,6 +153,8 @@
'Restore the grid': '恢复平铺网格', 'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)', 'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处', 'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
'Only image files are supported': '仅支持图像文件',
// A tile header's tooltip while tiles can move (with the state above it: a pattern below). // A tile header's tooltip while tiles can move (with the state above it: a pattern below).
'Drag to move the tile': '拖动可移动窗格', 'Drag to move the tile': '拖动可移动窗格',
'Resize tile columns': '调整窗格列宽', 'Resize tile columns': '调整窗格列宽',
+8 -1
View File
@@ -3549,7 +3549,14 @@ Object.assign(CodemanApp.prototype, {
} }
// Desktop defaults - rely on ?? operators in apply functions // Desktop defaults - rely on ?? operators in apply functions
// This allows desktop to have different defaults without duplication // This allows desktop to have different defaults without duplication
return {}; // A touch-primary tablet (iPad, an Android tablet: not a handheld, so it
// lands here) keeps the Tiles button opt-in, as Split is: a tile has none of
// the main terminal's touch, IME and soft-keyboard handling. The PRIMARY
// pointer decides, never MobileDetection.isTouchDevice(), which is true on a
// touchscreen laptop too (fine primary pointer: the desktop default stays).
const coarsePrimaryPointer =
typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)')?.matches === true;
return coarsePrimaryPointer ? { showTileGridButton: false } : {};
}, },
loadAppSettingsFromStorage() { loadAppSettingsFromStorage() {
+87 -20
View File
@@ -253,16 +253,65 @@ Object.assign(CodemanApp.prototype, {
const wrap = document.querySelector('.terminal-wrap'); const wrap = document.querySelector('.terminal-wrap');
wrap?.parentElement?.insertBefore(section, wrap.nextSibling); wrap?.parentElement?.insertBefore(section, wrap.nextSibling);
} }
// Once per section (index.html ships it, so not only on create).
if (this._tileFileDropSection !== section) {
this._tileFileDropSection = section;
this._installTileFileDrop(section);
}
return section; return section;
}, },
/**
* A file dragged over the grid. The single view's file drop (image-input.js)
* listens on #terminalContainer, hidden while tiles are open, so nothing
* cancelled a file drag here and the browser opened the file in place of
* Codeman. Anywhere over the grid (a tile, an empty cell, a divider, the
* padding) the drag is cancelled, so the page never navigates; dropped on a
* tile, its images upload to THAT tile's session and their paths are typed
* there, as the single view does for the active one. Bubble phase, files
* only: a tab or tile drag carries none, and its target stops it in the
* capture phase anyway (_acceptTabDrops).
*/
_installTileFileDrop(section) {
const isFileDrag = (e) => {
const types = e.dataTransfer?.types;
return !!types && Array.from(types).includes('Files');
};
// The open grid's tile under `target`, or null (an empty cell, a divider, the padding).
const tileAt = (target) => {
const grid = this._tileGrid;
if (!grid?.open || !target) return null;
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(target)) return id;
return null;
};
section.addEventListener('dragover', (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
if (e.dataTransfer && tileAt(e.target)) e.dataTransfer.dropEffect = 'copy';
});
section.addEventListener('drop', (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
const sessionId = tileAt(e.target);
const files = Array.from(e.dataTransfer?.files || []);
if (!sessionId || files.length === 0) return;
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
if (images.length === 0) {
this.showToast?.('Only image files are supported', 'error');
return;
}
this._uploadAndInsertImages?.(images, { sessionId });
});
},
/** /**
* Opens the grid on `ids` (unknown, detached and duplicate ids are skipped; * Opens the grid on `ids` (unknown, detached and duplicate ids are skipped;
* at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it * at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it
* adds what is missing and moves focus. `auto: false` makes the focus a human * adds what is missing and moves focus. `auto: false` makes the focus a human
* selection (it acknowledges that session's idle alert). An open split * selection (it acknowledges that session's idle alert). An open split
* closes (the two are never open together); `mergeSplit` (default) makes its * closes (the two are never open together); `mergeSplit` (default) makes its
* two sessions the first tiles, false opens exactly `ids` (a stored grid). * two sessions the first tiles, false opens exactly `ids` (a stored grid, a
* group, a Ctrl/Cmd+click: callers that size their own set).
* *
* Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while * Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
* its snapshot of the session it shows is still right, and closes its socket. * its snapshot of the session it shows is still right, and closes its socket.
@@ -1015,8 +1064,9 @@ Object.assign(CodemanApp.prototype, {
* The tile chord `e` asks for, if it applies right now, else null. The * The tile chord `e` asks for, if it applies right now, else null. The
* toggle applies while the grid is open, or where one could open AND the * toggle applies while the grid is open, or where one could open AND the
* per-device `showTileGridButton` setting is on (the desktop default; OFF on * per-device `showTileGridButton` setting is on (the desktop default; OFF on
* handhelds): with it off the chord is inert and reaches the terminal like * handhelds and touch-primary tablets): with it off the chord is inert and
* any unbound key (owner decision 6 in docs/tile-grid-plan.md). An absent key * reaches the terminal like any unbound key (owner decision 6 in
* docs/tile-grid-plan.md). An absent key
* resolves through the device defaults exactly as the header button does * resolves through the device defaults exactly as the header button does
* (settings-ui.js), so the chord and the button can never disagree. The focus, move, * (settings-ui.js), so the chord and the button can never disagree. The focus, move,
* zoom and remove chords apply only while the grid is open, however it was * zoom and remove chords apply only while the grid is open, however it was
@@ -1140,10 +1190,14 @@ Object.assign(CodemanApp.prototype, {
// replayed fresh (forceReload drops the stale snapshot and nulls // replayed fresh (forceReload drops the stale snapshot and nulls
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
// or, if that session is gone, the same fallback as closing the active tab. // or, if that session is gone, the same fallback as closing the active tab.
// Returns the selection's promise (it settles once the replay is written), // A popped-out session counts as gone in both: selectSession would only
// or undefined for the welcome screen. // raise its window and return, leaving the parked terminal's pre-grid
// content on screen under its tab (and saved as its snapshot on the next
// switch). Returns the selection's promise (it settles once the replay is
// written), or undefined for the welcome screen.
_selectAfterTileGrid(sessionId) { _selectAfterTileGrid(sessionId) {
if (sessionId && this.sessions.has(sessionId)) { const usable = (id) => this.sessions.has(id) && !this.detachedSessions?.has(id);
if (sessionId && usable(sessionId)) {
return this.selectSession(sessionId, { forceReload: true, auto: true }); return this.selectSession(sessionId, { forceReload: true, auto: true });
} }
this.activeSessionId = null; this.activeSessionId = null;
@@ -1152,7 +1206,7 @@ Object.assign(CodemanApp.prototype, {
} catch { } catch {
/* Nothing stored. */ /* Nothing stored. */
} }
const next = this.sessionOrder.find((id) => this.sessions.has(id)); const next = this.sessionOrder.find(usable);
if (next) return this.selectSession(next, { auto: true }); if (next) return this.selectSession(next, { auto: true });
this.terminal?.clear(); this.terminal?.clear();
this.showWelcome(); this.showWelcome();
@@ -1213,11 +1267,14 @@ Object.assign(CodemanApp.prototype, {
* Removes one tile; the session keeps running. Its cell becomes empty where * 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` * 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), * 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 * as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
* DOM focus where it is (an app-driven removal: a socket the server closed),
* so keystrokes never land in the neighbour's PTY unasked. The last tile
* leaving closes the grid: with `refocus` the single view then shows that * leaving closes the grid: with `refocus` the single view then shows that
* session, without it the caller decides what comes next. * session (or, popped out, the next one: _selectAfterTileGrid), without it
* the caller decides what comes next.
*/ */
removeTile(sessionId, { refocus = true } = {}) { removeTile(sessionId, { refocus = true, focus = true } = {}) {
const grid = this._tileGrid; const grid = this._tileGrid;
const entry = grid?.open ? grid.tiles.get(sessionId) : null; const entry = grid?.open ? grid.tiles.get(sessionId) : null;
if (!entry) return false; if (!entry) return false;
@@ -1243,7 +1300,7 @@ Object.assign(CodemanApp.prototype, {
this._applyTileLayout(); this._applyTileLayout();
this._scheduleTileGridRefit(); this._scheduleTileGridRefit();
this.renderSessionTabs?.(); this.renderSessionTabs?.();
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true }); if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true, focus });
return true; return true;
}, },
@@ -1331,7 +1388,8 @@ Object.assign(CodemanApp.prototype, {
* `accepts(id)` is the target's own rule (a tile takes any session but its * `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 * own; an empty cell takes any). A session it does not accept
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no * is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
* drop follows). Any other drag (a file) is left alone. * drop follows). Any other drag (a file) is left to the grid section's own
* guard (_installTileFileDrop).
*/ */
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) { _acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null); const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
@@ -1525,7 +1583,9 @@ Object.assign(CodemanApp.prototype, {
const n = Math.max(1, Math.min(this._tileGridCount(), capacity)); const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
const base = this._tileGridOpenSet()?.ids || []; const base = this._tileGridOpenSet()?.ids || [];
const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId]; const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
this.openTileGrid(ids, { focusedId: sessionId, auto: false }); // Exactly these: an open split is already in `base` (tileGridOpenSet seeds
// it), and merging it again went past the count (N+1) and the window.
this.openTileGrid(ids, { focusedId: sessionId, auto: false, mergeSplit: false });
return true; return true;
}, },
@@ -1567,7 +1627,9 @@ Object.assign(CodemanApp.prototype, {
this.closeTileGrid({ keepStored: false, reselect: false }); this.closeTileGrid({ keepStored: false, reselect: false });
this.activeSessionId = null; this.activeSessionId = null;
} }
return this.openTileGrid(ids, { focusedId: focus }); // Exactly the group: an open split closes without joining it (merged, its
// two pushed group members out and the grid past the window's capacity).
return this.openTileGrid(ids, { focusedId: focus, mergeSplit: false });
}, },
/** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */ /** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */
@@ -2036,7 +2098,9 @@ Object.assign(CodemanApp.prototype, {
/** /**
* A tile's socket stopped for good. 4009 (the session exited) keeps the tile * A tile's socket stopped for good. 4009 (the session exited) keeps the tile
* with its "session ended" marker; 4003 (refused), 4004 (session gone) and * with its "session ended" marker; 4003 (refused), 4004 (session gone) and
* 4010 (another socket took over) remove it. * 4010 (another socket took over) remove it. Nobody here asked for that, so
* the neighbour takes focus without the keyboard (`focus: false`): what the
* user is typing never lands in another session's PTY.
*/ */
_onTileExit(sessionId, tile, code) { _onTileExit(sessionId, tile, code) {
if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return; if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return;
@@ -2045,7 +2109,7 @@ Object.assign(CodemanApp.prototype, {
this._renderTileOverlay(sessionId); this._renderTileOverlay(sessionId);
return; return;
} }
this.removeTile(sessionId); this.removeTile(sessionId, { focus: false });
}, },
/** /**
@@ -2560,8 +2624,9 @@ Object.assign(CodemanApp.prototype, {
if (!grid.open) return false; if (!grid.open) return false;
// Only a focus that is gone moves: re-selecting the same tile would hide an // Only a focus that is gone moves: re-selecting the same tile would hide an
// active web tab on every SSE blip (_selectTiledSession hides the web layer), // active web tab on every SSE blip (_selectTiledSession hides the web layer),
// which the single view's reconnect never does. // which the single view's reconnect never does. The app's choice, so DOM
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true }); // focus stays put (an open modal or text field keeps the keyboard).
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true, focus: false });
for (const { tile } of grid.tiles.values()) tile.reconnectNow(); for (const { tile } of grid.tiles.values()) tile.reconnectNow();
return true; return true;
}, },
@@ -2569,7 +2634,9 @@ Object.assign(CodemanApp.prototype, {
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus, // A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
// the neighbouring tile takes it (`auto`: the app chose, so no idle alert is // the neighbouring tile takes it (`auto`: the app chose, so no idle alert is
// spent). Done BEFORE the original handler, so activeSessionId no longer names // spent; `focus: false`: the keyboard stays put, so what the user was typing
// never goes on into the neighbour's PTY, as the single view sends it
// nowhere). Done BEFORE the original handler, so activeSessionId no longer names
// the deleted id and its welcome-screen handoff stays out of it. The last tile // the deleted id and its welcome-screen handoff stays out of it. The last tile
// closes the grid without a reselect, and the original handler then shows the // closes the grid without a reselect, and the original handler then shows the
// welcome screen as in the single view. A close started from this tab // welcome screen as in the single view. A close started from this tab
@@ -2582,7 +2649,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id); const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
this.removeTile(data.id, { refocus: false }); this.removeTile(data.id, { refocus: false });
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) { if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
this._selectTiledSession(neighbor, { auto: true }); this._selectTiledSession(neighbor, { auto: true, focus: false });
} }
} }
return _tileGridOriginalOnSessionDeleted.call(this, data); return _tileGridOriginalOnSessionDeleted.call(this, data);
+4
View File
@@ -375,6 +375,10 @@ export function resetGridHarness() {
// The CLI catalog the server injects (labels for the harness logos); a test sets its own. // The CLI catalog the server injects (labels for the harness logos); a test sets its own.
delete windowStub.__codemanCliCatalog; delete windowStub.__codemanCliCatalog;
section.children = []; section.children = [];
// The grid binds its file-drop guard on the section once per app; an earlier
// test's app must not still be listening there.
section.listeners = {};
section.captureFlags = {};
main.className = 'main'; main.className = 'main';
// A split a test left open moved .terminal-wrap into its container, with // A split a test left open moved .terminal-wrap into its container, with
// Pane A's header strip in it. // Pane A's header strip in it.
+55 -1
View File
@@ -22,7 +22,7 @@
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/ */
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeTile, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js'; import { FakeTile, flushFrames, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c']; const IDS = ['s-a', 's-b', 's-c'];
@@ -148,3 +148,57 @@ describe('a tiled session deleted elsewhere', () => {
expect(app.showWelcome).toHaveBeenCalled(); expect(app.showWelcome).toHaveBeenCalled();
}); });
}); });
// The handoffs above are the APP's choice: focus moves (activeSessionId, the
// focus paint) but the keyboard does not. Moving DOM focus into the
// neighbour's xterm sent whatever the user was still typing, Enter included,
// into another session's agent; the single view sends it nowhere. Only a
// removal the user made (the x button, Remove Focused Tile) carries the
// keyboard along.
describe('an app-driven refocus never moves the keyboard into another session', () => {
const live = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!;
const focusCalls = () => FakeTile.all.reduce((n, t) => n + t.terminal.focus.mock.calls.length, 0);
/** A grid on IDS focused on s-b, every terminal built, no focus recorded yet. */
function built() {
const app = makeGridApp(IDS);
app.openTileGrid(IDS, { focusedId: 's-b' });
flushFrames();
for (const t of FakeTile.all) t.terminal.focus.mockClear();
return app;
}
it('a remote delete of the focused tile hands focus to the neighbour, DOM focus untouched', () => {
const app = built();
app._onSessionDeleted({ id: 's-b' });
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app._tileGrid.focusedId).toBe('s-c');
expect(focusCalls()).toBe(0);
// No deferred focus left to land later either.
expect(app._tileGrid.focusOnConnect ?? null).toBeNull();
});
it.each([4003, 4004, 4010])('a socket the server closed (%i) removes the tile, DOM focus untouched', (code) => {
const app = built();
app._onTileExit('s-b', live('s-b'), code);
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(focusCalls()).toBe(0);
});
it('a reconcile that finds the focused session gone moves focus, DOM focus untouched', () => {
const app = built();
app.sessions.delete('s-b');
app._reconcileTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-a');
expect(focusCalls()).toBe(0);
});
it('a removal the user made still carries the keyboard to the neighbour', () => {
const app = built();
app.removeFocusedTile();
expect(app.activeSessionId).toBe('s-c');
expect(live('s-c').terminal.focus).toHaveBeenCalledTimes(1);
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* @fileoverview A file dragged onto the tile grid.
*
* The single view's file drop (image-input.js) listens on #terminalContainer,
* which is hidden while tiles are open. Nothing else cancelled a file drag,
* so dropping a screenshot on a tile made the browser open the file in place
* of Codeman. Now the grid section itself takes every file drag (bubble
* phase): anywhere over it (a tile, an empty cell, a divider, its padding)
* dragover and drop are cancelled, so the page never navigates, and a drop on
* a tile uploads its images to THAT tile's session, the same upload and the
* same "Only image files" toast as the single view. Tab and tile drags are
* the targets' own (_acceptTabDrops, capture phase) and stay untouched.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeEl, makeGridApp, resetGridHarness, section, tileEl, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
function fileEvent(target: FakeEl, files: Array<{ type: string; name?: string }>, types = ['Files']) {
return {
target,
dataTransfer: { types, files, dropEffect: 'none' },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
}
/** A grid on `ids`, s-a focused, uploads stubbed. Five tiles make a 3x2 with one empty cell. */
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app._uploadAndInsertImages = vi.fn();
app.openTileGrid(ids, { focusedId: 's-a' });
return app;
}
const slot = () => section.children.find((el) => el.classList.contains('tile-slot'))!;
const PNG = { type: 'image/png', name: 'shot.png' };
beforeEach(() => {
resetGridHarness();
});
describe('a file dropped on a tile', () => {
it("uploads its images to that tile's session (not the focused one), the page staying put", () => {
const app = gridApp();
// Deep inside the tile, as on xterm's rows or its helper textarea.
const inner = tileEl('s-b').querySelector('.tile-body')!;
const over = fileEvent(inner, [PNG]);
section.dispatch('dragover', over);
expect(over.preventDefault).toHaveBeenCalled();
expect(over.dataTransfer.dropEffect).toBe('copy');
const drop = fileEvent(inner, [PNG, { type: 'image/jpeg' }]);
section.dispatch('drop', drop);
expect(drop.preventDefault).toHaveBeenCalled();
expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1);
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, { type: 'image/jpeg' }], { sessionId: 's-b' });
// A drop is not a selection: focus stays where it was.
expect(app.activeSessionId).toBe('s-a');
});
it('only the images of a mixed drop are uploaded', () => {
const app = gridApp();
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG]));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG], { sessionId: 's-c' });
});
it('a drop with no image says so, as the single view does, and uploads nothing', () => {
const app = gridApp();
const drop = fileEvent(tileEl('s-b'), [{ type: 'application/pdf' }]);
section.dispatch('drop', drop);
expect(drop.preventDefault).toHaveBeenCalled();
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Only image files are supported', 'error');
});
});
describe('a file dragged anywhere else over the grid', () => {
it('an empty cell, a divider or the padding: cancelled (no navigation), nothing uploaded', () => {
const app = gridApp([...IDS, 's-d', 's-e']);
for (const target of [slot(), section]) {
expect(target).toBeTruthy();
const over = fileEvent(target, [PNG]);
section.dispatch('dragover', over);
expect(over.preventDefault).toHaveBeenCalled();
const drop = fileEvent(target, [PNG]);
section.dispatch('drop', drop);
expect(drop.preventDefault).toHaveBeenCalled();
}
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).not.toHaveBeenCalled();
});
});
describe('tab and tile drags are left to their targets', () => {
it('a drag that carries no files is not touched by the grid section', () => {
const app = gridApp();
app.draggedTabId = 's-other';
const over = fileEvent(section, [], ['text/plain']);
section.dispatch('dragover', over);
section.dispatch('drop', over);
expect(over.preventDefault).not.toHaveBeenCalled();
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
});
it("a tab dropped on a tile still replaces it through the tile's own handler", () => {
const app = gridApp();
app.draggedTabId = 's-other';
const drop = fileEvent(tileEl('s-b'), [], ['text/plain']);
tileEl('s-b').dispatch('drop', drop);
expect(app._tileGrid.ids).toContain('s-other');
expect(app._tileGrid.ids).not.toContain('s-b');
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
});
});
describe('the guard is installed once', () => {
it('opening and closing the grid again never stacks listeners', () => {
const app = gridApp();
app.closeTileGrid({ reselect: false });
app.openTileGrid(IDS);
app.closeTileGrid({ reselect: false });
app.openTileGrid(IDS);
expect(section.listeners.dragover).toHaveLength(1);
expect(section.listeners.drop).toHaveLength(1);
// Bubble phase: the tab and tile drop targets stop their own drags in capture.
expect(section.captureFlags.drop).toEqual([false]);
});
});
+9
View File
@@ -215,6 +215,14 @@ async function exercise() {
harvestAll(app, 'zoomed'); harvestAll(app, 'zoomed');
app.zoomTile('s-1'); app.zoomTile('s-1');
// A file that is not an image dropped on a tile.
section.dispatch('drop', {
target: section.children.find((el) => el.dataset.sessionId === 's-1'),
dataTransfer: { types: ['Files'], files: [{ type: 'application/pdf' }] },
preventDefault: vi.fn(),
});
harvestAll(app, 'file drop');
// The toasts of a full grid, by the cap and by the window. // The toasts of a full grid, by the cap and by the window.
app._joinTileGridFromRun('s-7'); app._joinTileGridFromRun('s-7');
app.addSessionToTiles('s-7'); app.addSessionToTiles('s-7');
@@ -314,6 +322,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
'Could not attach the session', 'Could not attach the session',
'This group has no session to show as tiles', 'This group has no session to show as tiles',
'No sessions to show as tiles', 'No sessions to show as tiles',
'Only image files are supported',
'Split: unavailable while tiles are open', 'Split: unavailable while tiles are open',
'Tiles: show several sessions side by side (right-click for how many)', 'Tiles: show several sessions side by side (right-click for how many)',
'Tiles: back to a single session (right-click for how many tiles)', 'Tiles: back to a single session (right-click for how many tiles)',
+98 -1
View File
@@ -2,7 +2,8 @@
// Port: none (pure static analysis, runs in CI, no browser/server). // Port: none (pure static analysis, runs in CI, no browser/server).
// //
// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is // `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is
// a PER-DEVICE setting (default ON on desktop, OFF on handhelds) with the same three-way rule as // a PER-DEVICE setting (default ON on desktop, OFF on handhelds and touch-primary
// tablets) with the same three-way rule as
// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the // showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the
// history): in settings-ui.js's displayKeys merge policy, stripped out of the // history): in settings-ui.js's displayKeys merge policy, stripped out of the
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending // object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
@@ -14,7 +15,9 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { join } from 'node:path'; import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { makeGridApp } from './mocks/tile-grid-vm.js';
const HERE = fileURLToPath(new URL('.', import.meta.url)); const HERE = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC = join(HERE, '../src/web/public'); const PUBLIC = join(HERE, '../src/web/public');
@@ -59,6 +62,100 @@ describe('showTileGridButton stays per-device: display key, stripped from the PU
}); });
}); });
// The real getDefaultSettings() (settings-ui.js) on each kind of device. A
// touch-primary tablet (iPad, an Android tablet: primary pointer coarse) is
// not a handheld, so it used to take the desktop defaults and get the Tiles
// button ON, although a tile has none of the main terminal's touch, IME and
// soft-keyboard handling. It defaults OFF there (opt-in, like Split); a
// touchscreen laptop (primary pointer fine) keeps the desktop default.
describe('the device default: ON on desktop, OFF on handhelds and touch-primary tablets', () => {
const SOURCE = readFileSync(join(PUBLIC, 'settings-ui.js'), 'utf8');
type Device = { handheld: boolean; touch: boolean; coarse: boolean };
function defaultsOn({ handheld, touch, coarse }: Device) {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
MobileDetection: {
isHandheldDevice: () => handheld,
isTouchDevice: () => touch,
getDeviceType: () => (handheld ? 'mobile' : 'desktop'),
},
window: {
matchMedia: (q: string) => ({
matches: q === '(pointer: coarse)' ? coarse : q === '(pointer: fine)' && !coarse,
}),
},
});
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
return app.getDefaultSettings();
}
/** What every reader resolves an absent key to: the button, the App Settings chip and the chord. */
const resolved = (d: Device) => (defaultsOn(d).showTileGridButton ?? true) === true;
const DESKTOP = { handheld: false, touch: false, coarse: false };
const TOUCH_LAPTOP = { handheld: false, touch: true, coarse: false };
const TABLET = { handheld: false, touch: true, coarse: true };
const PHONE = { handheld: true, touch: true, coarse: true };
it('a desktop and a touchscreen laptop (fine primary pointer): ON', () => {
expect(defaultsOn(DESKTOP).showTileGridButton).toBeUndefined();
expect(resolved(DESKTOP)).toBe(true);
expect(defaultsOn(TOUCH_LAPTOP).showTileGridButton).toBeUndefined();
expect(resolved(TOUCH_LAPTOP)).toBe(true);
});
it('a touch-primary tablet (coarse primary pointer, not a handheld): OFF, and only that key changes', () => {
expect(defaultsOn(TABLET)).toEqual({ showTileGridButton: false });
expect(resolved(TABLET)).toBe(false);
});
it('a handheld: OFF, as before', () => {
expect(defaultsOn(PHONE).showTileGridButton).toBe(false);
});
it('a context with no window at all still answers (the desktop default)', () => {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
MobileDetection: { isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
});
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
expect(app.getDefaultSettings()).toEqual({});
});
it('the Ctrl+Shift+G chord follows the same default: inert on a tablet with nothing stored, live on a touchscreen laptop', () => {
const TOGGLE = {
type: 'keydown',
key: 'G',
code: 'KeyG',
ctrlKey: true,
shiftKey: true,
metaKey: false,
altKey: false,
target: { closest: () => null },
};
for (const [device, applies] of [
[TABLET, false],
[TOUCH_LAPTOP, true],
] as const) {
const app = makeGridApp(['s-a', 's-b']);
app.loadAppSettingsFromStorage = () => ({});
app.getDefaultSettings = () => defaultsOn(device);
expect(app.tileShortcutFor(TOGGLE)).toBe(applies ? 'toggle-tile-grid' : null);
}
});
});
describe('App Settings search finds Split and Tiles', () => { describe('App Settings search finds Split and Tiles', () => {
// The real filter (settings-ui.js) over the real markup: it matches each chip // The real filter (settings-ui.js) over the real markup: it matches each chip
// by its own data-search and its text, so the chips carry their own keywords. // by its own data-search and its text, so the chips carry their own keywords.
+29
View File
@@ -201,6 +201,35 @@ describe('moving focus between tiles', () => {
expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
}); });
// The last tile popped out closes the grid. The single view must never try
// to show that session: selectSession only raises its window, and the parked
// terminal's stale pre-grid content would stay up under its tab (and be
// saved as its snapshot on the next switch).
it('popping out the LAST tile falls through to the next session, never the popped-out one', () => {
const app = makeGridApp(['s-a']);
app.openTileGrid(['s-a']);
app.$ = () => null;
app.selectSession = vi.fn();
app._markDetached('s-a', true);
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession.mock.calls).toEqual([['s-other', { auto: true }]]);
// Nulled before the selection, so the parked buffer is not snapshotted as s-a's.
expect(app.activeSessionId).toBeNull();
});
it('popping out the last tile with every other session popped out too: the welcome screen, the parked terminal cleared', () => {
const app = makeGridApp(['s-a']);
app.detachedSessions.add('s-other');
app.openTileGrid(['s-a']);
app.$ = () => null;
app.selectSession = vi.fn();
app._markDetached('s-a', true);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.activeSessionId).toBeNull();
expect(app.terminal.clear).toHaveBeenCalled();
expect(app.showWelcome).toHaveBeenCalled();
});
it('going Home leaves the grid, remembered', () => { it('going Home leaves the grid, remembered', () => {
const app = openGrid(); const app = openGrid();
app.goHome(); app.goHome();
+56
View File
@@ -100,6 +100,62 @@ describe('opening the grid over an open split', () => {
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-a'); expect(app.activeSessionId).toBe('s-a');
}); });
// The entry points that size their own set never get the split's two
// prepended on top of it (that went past the group, the count and the
// window's capacity, and auto-zoomed with a "too small" toast).
it("'Open group as tiles' over a split opens exactly the group, the split closed", () => {
const app = makeGridApp([...IDS, 's-d']);
app.tabLayout = {
groups: [
{
id: 'g',
name: 'G',
refs: [
{ kind: 'session', id: 's-c' },
{ kind: 'session', id: 's-d' },
],
},
],
};
const paneB = openSplit(app);
app.openGroupAsTiles('g');
expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-c', 's-d']);
expect(app.activeSessionId).toBe('s-c');
});
it('Ctrl/Cmd+click over a split opens the remembered count in total, never one more', () => {
const app = makeGridApp(IDS);
localStore.set('codeman:tile-count', '2');
openSplit(app);
app.addSessionToTiles('s-c');
expect(app._splitPane).toBeNull();
// The split's Pane A seeds the set (tileGridOpenSet), the clicked one joins: 2, not 3.
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
});
it('Ctrl/Cmd+click over a split with a remembered grid: the remembered grid wins, not the split', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-other', 's-c']);
app.closeTileGrid({ reselect: false });
app.activeSessionId = 's-a';
localStore.set('codeman:tile-count', '2');
openSplit(app);
app.addSessionToTiles('s-b');
expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-other', 's-b']);
expect(app.activeSessionId).toBe('s-b');
});
}); });
describe('while the grid is open', () => { describe('while the grid is open', () => {