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
+2
View File
@@ -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': '调整窗格列宽',
+8 -1
View File
@@ -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
View File
@@ -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);