mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
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:
@@ -805,7 +805,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
### 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 })`.
|
||||
|
||||
|
||||
@@ -153,6 +153,8 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'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).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
|
||||
@@ -3549,7 +3549,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
// Desktop defaults - rely on ?? operators in apply functions
|
||||
// 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() {
|
||||
|
||||
+87
-20
@@ -253,16 +253,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
const wrap = document.querySelector('.terminal-wrap');
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
* 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
|
||||
* selection (it acknowledges that session's idle alert). An open split
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* handhelds): with it off the chord is inert and reaches the terminal like
|
||||
* any unbound key (owner decision 6 in docs/tile-grid-plan.md). An absent key
|
||||
* handhelds and touch-primary tablets): with it off the chord is inert and
|
||||
* 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
|
||||
* (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
|
||||
@@ -1140,10 +1190,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// replayed fresh (forceReload drops the stale snapshot and nulls
|
||||
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
|
||||
// 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),
|
||||
// or undefined for the welcome screen.
|
||||
// A popped-out session counts as gone in both: selectSession would only
|
||||
// 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) {
|
||||
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 });
|
||||
}
|
||||
this.activeSessionId = null;
|
||||
@@ -1152,7 +1206,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {
|
||||
/* 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 });
|
||||
this.terminal?.clear();
|
||||
this.showWelcome();
|
||||
@@ -1213,11 +1267,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Removes one tile; the session keeps running. Its cell becomes empty where
|
||||
* it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus`
|
||||
* moves focus to the neighbouring tile (next in grid order, else previous),
|
||||
* as the app's choice (`auto`: no idle alert is spent). The last tile
|
||||
* 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
|
||||
* 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 entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
||||
if (!entry) return false;
|
||||
@@ -1243,7 +1300,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyTileLayout();
|
||||
this._scheduleTileGridRefit();
|
||||
this.renderSessionTabs?.();
|
||||
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true });
|
||||
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true, focus });
|
||||
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
|
||||
* own; an empty cell takes any). A session it does not accept
|
||||
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
|
||||
* drop follows). Any other drag (a file) is left alone.
|
||||
* drop follows). Any other drag (a file) is left to the grid section's own
|
||||
* guard (_installTileFileDrop).
|
||||
*/
|
||||
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
|
||||
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 base = this._tileGridOpenSet()?.ids || [];
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -1567,7 +1627,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeTileGrid({ keepStored: false, reselect: false });
|
||||
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. */
|
||||
@@ -2036,7 +2098,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* 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
|
||||
* 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) {
|
||||
if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return;
|
||||
@@ -2045,7 +2109,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._renderTileOverlay(sessionId);
|
||||
return;
|
||||
}
|
||||
this.removeTile(sessionId);
|
||||
this.removeTile(sessionId, { focus: false });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2560,8 +2624,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!grid.open) return false;
|
||||
// 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),
|
||||
// which the single view's reconnect never does.
|
||||
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true });
|
||||
// which the single view's reconnect never does. The app's choice, so DOM
|
||||
// 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();
|
||||
return true;
|
||||
},
|
||||
@@ -2569,7 +2634,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
@@ -2582,7 +2649,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
|
||||
this.removeTile(data.id, { refocus: false });
|
||||
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);
|
||||
|
||||
@@ -375,6 +375,10 @@ export function resetGridHarness() {
|
||||
// The CLI catalog the server injects (labels for the harness logos); a test sets its own.
|
||||
delete windowStub.__codemanCliCatalog;
|
||||
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';
|
||||
// A split a test left open moved .terminal-wrap into its container, with
|
||||
// Pane A's header strip in it.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* 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 { 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'];
|
||||
|
||||
@@ -148,3 +148,57 @@ describe('a tiled session deleted elsewhere', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -215,6 +215,14 @@ async function exercise() {
|
||||
harvestAll(app, 'zoomed');
|
||||
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.
|
||||
app._joinTileGridFromRun('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',
|
||||
'This group has no session to show as tiles',
|
||||
'No sessions to show as tiles',
|
||||
'Only image files are supported',
|
||||
'Split: unavailable while tiles are open',
|
||||
'Tiles: show several sessions side by side (right-click for how many)',
|
||||
'Tiles: back to a single session (right-click for how many tiles)',
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// Port: none (pure static analysis, runs in CI, no browser/server).
|
||||
//
|
||||
// `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
|
||||
// history): in settings-ui.js's displayKeys merge policy, stripped out of the
|
||||
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
|
||||
@@ -14,7 +15,9 @@ import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { makeGridApp } from './mocks/tile-grid-vm.js';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
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', () => {
|
||||
// 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.
|
||||
|
||||
@@ -201,6 +201,35 @@ describe('moving focus between tiles', () => {
|
||||
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', () => {
|
||||
const app = openGrid();
|
||||
app.goHome();
|
||||
|
||||
@@ -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.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', () => {
|
||||
|
||||
Reference in New Issue
Block a user