mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Owner decision 10 ("give me then the option to choose only HOW many
tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives:
"Click opens 6"). The click still opens the grid at once; right-click,
Shift+F10 or the Menu key on the button opens a small menu of three
counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the
remembered one checked. It replaces the session picker, which is gone
(method, markup hook, CSS, zh-CN entries).
- A pick is remembered per device in codeman:tile-count (default 6;
codeman:tile-grid stays ids only) and opens that many tiles. The click
and Ctrl+Shift+G then open with it, at most what the window fits.
- Which sessions: the rule the click already had (the grid this tab
last had, else an open split's two, else tab order, the active one
included and focused), trimmed from the end (the focused one kept) or
filled from tab order to the count. A remembered grid comes back with
its tiles first, in their cells, holes filled first, then tab order
(owner answer, superseding decision 8's "exactly the stored set"). A
page-load restore still brings back exactly what was stored.
- With the grid open a pick re-forms it: a count change is a shape
change under the cell model's rule (reformTileCells), the focused tile
always kept, every joining tile mounted and laid out before any of
them connects, so each fits once and sends one PTY resize.
- Ctrl/Cmd+click on a tab with the grid closed opens the count in total,
that session among them and focused (owner answer: N, not N+1).
- A count the window cannot fit is greyed out with the reason; a
remembered one stays checked, the keyboard starts on the largest that
fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone
(the global handler gives it the key first, like the tab-group menu)
and puts the keyboard back on the Tiles button; Tab and a click
elsewhere close it.
- zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help
modal row); the i18n test harvests the menu now, with a session named
"6 tiles" as the user-text trap.
Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts
(the button checks kept, every picker check carried over to the menu,
plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch
connect); the open-set, cap, restore, shortcuts, split-coexistence and
i18n expectations follow the count; the Help modal test escapes its
label (the new one has parentheses). The vm harness tracks
document.activeElement and makes SVG elements.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2218 lines
94 KiB
JavaScript
2218 lines
94 KiB
JavaScript
// src/web/public/tile-grid.js
|
||
|
||
/**
|
||
* @fileoverview The tile grid: 1 to TILE_GRID_MAX (6) live sessions side by
|
||
* side in one window, each in its own TerminalTile (terminal-tile.js), laid
|
||
* out by count (window.CodemanTileGrid, constants.js). Desktop only; see
|
||
* docs/tile-grid-plan.md.
|
||
*
|
||
* While the grid is open the main terminal (terminal-ui.js) is PARKED: hidden,
|
||
* its socket closed, and every path that would write to it, fetch for it or
|
||
* reconnect it stands aside (`_tilesOwnTerminal()`). `activeSessionId` always
|
||
* names the FOCUSED tile's session, so everything keyed on it (files panel,
|
||
* respawn and Ralph panels, subagent windows, voice, image paste, the tab
|
||
* highlight) follows focus without knowing tiles exist.
|
||
*
|
||
* Every capture a tile fetches goes through the grid's one TileLoadQueue,
|
||
* because each is a synchronous tmux call on the server.
|
||
*
|
||
* @dependency terminal-tile.js (window.TerminalTile, window.TileLoadQueue)
|
||
* @dependency constants.js (window.CodemanTileGrid, SPLIT_PANE_MIN_WIDTH)
|
||
* @loadorder 7.6 of 16, loaded after terminal-split.js and before respawn-ui.js
|
||
*/
|
||
|
||
// Per-device tile font size (a tile is a fraction of the screen).
|
||
const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
|
||
// The grid this device last had, ids only (sanitizeTileGridState, constants.js):
|
||
// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, `ids` being the cells
|
||
// in reading order with `null` for an empty one. `open: false` keeps it
|
||
// remembered for one-click return; restored on reload inside handleInit.
|
||
const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
|
||
// How many tiles a click on Tiles opens: the count last picked in its
|
||
// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so
|
||
// the stored grid above stays ids only.
|
||
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
|
||
// Trailing debounce for refitting tiles after the grid area changes size, so a
|
||
// window drag sends each tile's PTY one resize, not one per frame.
|
||
const TILE_GRID_REFIT_MS = 150;
|
||
// Width of the draggable column and row dividers (their own grid tracks), and
|
||
// the grid section's padding (styles.css .tile-grid), for the drag math.
|
||
const TILE_DIVIDER_PX = 6;
|
||
const TILE_GRID_PADDING_PX = 4;
|
||
|
||
/** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
|
||
function tileGridTracks(fr) {
|
||
return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `);
|
||
}
|
||
// Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each
|
||
// needs the grid open. The toggle applies wherever a grid could open. The arrow
|
||
// chords (`direction`, `move`) leave a text field its keys (tileShortcutFor).
|
||
const TILE_SHORTCUTS = {
|
||
'toggle-tile-grid': { needsOpen: false },
|
||
'focus-tile-left': { needsOpen: true, direction: 'left' },
|
||
'focus-tile-right': { needsOpen: true, direction: 'right' },
|
||
'focus-tile-up': { needsOpen: true, direction: 'up' },
|
||
'focus-tile-down': { needsOpen: true, direction: 'down' },
|
||
'move-tile-left': { needsOpen: true, move: 'left' },
|
||
'move-tile-right': { needsOpen: true, move: 'right' },
|
||
'move-tile-up': { needsOpen: true, move: 'up' },
|
||
'move-tile-down': { needsOpen: true, move: 'down' },
|
||
'remove-tile': { needsOpen: true },
|
||
'zoom-tile': { needsOpen: true },
|
||
};
|
||
|
||
/**
|
||
* A text field other than a terminal's own input (xterm's helper textarea):
|
||
* the rename input, the file editor, a settings field. Shifted arrows select
|
||
* there (Ctrl+Shift by word; Option+Shift by word on macOS), so the arrow
|
||
* chords, focus and move, leave it alone (owner: best practice).
|
||
*/
|
||
function isTextFieldTarget(target) {
|
||
if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false;
|
||
const tag = String(target.tagName || '').toUpperCase();
|
||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true;
|
||
}
|
||
|
||
/**
|
||
* The count menu's picture of `n` tiles: the grid's own layout for that count
|
||
* (2x1, 2x2, 3x2) as small rounded cells.
|
||
*/
|
||
function tileCountGlyph(n) {
|
||
const { cols, rows } = window.CodemanTileGrid.computeTileLayout({ count: n });
|
||
const NS = 'http://www.w3.org/2000/svg';
|
||
const svg = document.createElementNS(NS, 'svg');
|
||
svg.setAttribute('class', 'tile-count-glyph');
|
||
svg.setAttribute('viewBox', '0 0 30 20');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
const gap = 3;
|
||
const w = (30 - gap * (cols - 1)) / cols;
|
||
const h = (20 - gap * (rows - 1)) / rows;
|
||
for (let r = 0; r < rows; r++) {
|
||
for (let c = 0; c < cols; c++) {
|
||
const rect = document.createElementNS(NS, 'rect');
|
||
rect.setAttribute('x', String(c * (w + gap)));
|
||
rect.setAttribute('y', String(r * (h + gap)));
|
||
rect.setAttribute('width', String(w));
|
||
rect.setAttribute('height', String(h));
|
||
rect.setAttribute('rx', '1.5');
|
||
svg.appendChild(rect);
|
||
}
|
||
}
|
||
return svg;
|
||
}
|
||
|
||
/** The grid's state. `has(id)` answers only while it is open. */
|
||
class TileGridModel {
|
||
constructor() {
|
||
this.open = false;
|
||
// The grid's cells in reading order (row-major), cols x rows of them: a
|
||
// session id, or null for an empty cell. THE source of truth for where
|
||
// each tile is (owner: an empty cell can be any cell); `ids` derives from it.
|
||
this.cells = [];
|
||
// id -> { tile: TerminalTile, el: HTMLElement }
|
||
this.tiles = new Map();
|
||
this.focusedId = null;
|
||
// The tile filling the grid (tmux zoom), or null. `autoZoom`: zoomed by the
|
||
// grid itself because the window cannot fit the tiles; it follows focus and
|
||
// lifts once the window fits again.
|
||
this.zoomedId = null;
|
||
this.autoZoom = false;
|
||
// Track fractions (grid-template fr values) set by the dividers; equal
|
||
// again whenever the column or row count changes.
|
||
this.colFr = [];
|
||
this.rowFr = [];
|
||
// 'col-<i>' / 'row-<i>' -> the divider element between track i and i+1.
|
||
this.dividers = new Map();
|
||
this.cols = 0;
|
||
this.rows = 0;
|
||
this.queue = null;
|
||
this.resizeObserver = null;
|
||
this.refitTimer = null;
|
||
}
|
||
|
||
has(id) {
|
||
return this.open && this.tiles.has(id);
|
||
}
|
||
|
||
/** The tiled sessions in reading order, holes skipped (a fresh array: change `cells`, never this). */
|
||
get ids() {
|
||
return this.cells.filter(Boolean);
|
||
}
|
||
}
|
||
|
||
Object.assign(CodemanApp.prototype, {
|
||
/**
|
||
* True while the grid owns the terminal area and the main terminal is parked.
|
||
* Every main-terminal path that would write, fetch, resize or reconnect checks
|
||
* this and stands aside.
|
||
*/
|
||
_tilesOwnTerminal() {
|
||
return !!this._tileGrid?.open;
|
||
},
|
||
|
||
/** The open grid's TerminalTile for a session, or null. */
|
||
_tileFor(sessionId) {
|
||
return this._tileGrid?.open ? (this._tileGrid.tiles.get(sessionId)?.tile ?? null) : null;
|
||
},
|
||
|
||
/** Desktop only, never in a solo (popped-out) window; same gate as the split. */
|
||
canOpenTileGrid() {
|
||
return !this.isSoloWindow && window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
||
},
|
||
|
||
_tileGridFontSize() {
|
||
let saved = NaN;
|
||
try {
|
||
saved = parseInt(localStorage.getItem(TILE_GRID_FONT_KEY), 10);
|
||
} catch {
|
||
/* Storage unavailable: the default below. */
|
||
}
|
||
return saved >= 10 && saved <= 24 ? saved : window.CodemanTileGrid.TILE_FONT_SIZE_DEFAULT;
|
||
},
|
||
|
||
/** Ctrl +/- while the grid is open: every tile, then each tile's PTY (a font change is a size change, #464). */
|
||
setTileFontSize(size) {
|
||
try {
|
||
localStorage.setItem(TILE_GRID_FONT_KEY, String(size));
|
||
} catch {
|
||
/* Per-device convenience only. */
|
||
}
|
||
for (const { tile } of this._tileGrid?.tiles.values() || []) {
|
||
tile.fontSize = size;
|
||
if (!tile.terminal) continue;
|
||
tile.terminal.options.fontSize = size;
|
||
tile.fit();
|
||
}
|
||
},
|
||
|
||
_tileLoadQueue() {
|
||
const grid = this._tileGrid;
|
||
if (!grid.queue) {
|
||
grid.queue = new window.TileLoadQueue({
|
||
// The focused tile first, then reading order.
|
||
rank: (tile) => (tile.sessionId === grid.focusedId ? -1 : grid.ids.indexOf(tile.sessionId)),
|
||
// A quiet "loading" state on a tile until its capture has landed.
|
||
onChange: (tile, state) => {
|
||
const entry = grid.tiles.get(tile.sessionId);
|
||
if (entry?.tile === tile) entry.el.classList.toggle('tile--loading', state !== 'idle');
|
||
},
|
||
});
|
||
}
|
||
return grid.queue;
|
||
},
|
||
|
||
_tileGridSection() {
|
||
let section = document.getElementById('tileGrid');
|
||
if (!section) {
|
||
section = document.createElement('section');
|
||
section.id = 'tileGrid';
|
||
section.className = 'tile-grid';
|
||
section.setAttribute('aria-label', 'Tiled sessions');
|
||
const wrap = document.querySelector('.terminal-wrap');
|
||
wrap?.parentElement?.insertBefore(section, wrap.nextSibling);
|
||
}
|
||
return section;
|
||
},
|
||
|
||
/**
|
||
* 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).
|
||
*
|
||
* Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
|
||
* its snapshot of the session it shows is still right, and closes its socket.
|
||
*
|
||
* @returns {boolean} whether the grid is open afterwards
|
||
*/
|
||
openTileGrid(ids, { focusedId = null, auto = true, mergeSplit = true } = {}) {
|
||
if (!this.canOpenTileGrid()) return false;
|
||
const grid = (this._tileGrid ||= new TileGridModel());
|
||
const max = window.CodemanTileGrid.TILE_GRID_MAX;
|
||
// The grid and the split are never open together. An open split becomes the
|
||
// grid's first two tiles (Pane A focused, Pane B beside it), so "split, then
|
||
// want more" is one step. No closing resize for Pane A: it is about to park.
|
||
let requested = ids || [];
|
||
if (this._splitPane) {
|
||
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
|
||
this.closeSplitPane({ skipPrimaryResize: true });
|
||
if (mergeSplit) {
|
||
requested = [...seed, ...requested];
|
||
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||
}
|
||
}
|
||
const wanted = [];
|
||
for (const id of requested) {
|
||
if (typeof id !== 'string' || wanted.includes(id)) continue;
|
||
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue;
|
||
wanted.push(id);
|
||
if (wanted.length === max) break;
|
||
}
|
||
if (wanted.length === 0) return grid.open;
|
||
const focus = wanted.includes(focusedId) ? focusedId : wanted[0];
|
||
|
||
if (grid.open) {
|
||
for (const id of wanted) this.addTile(id);
|
||
if (grid.has(focus)) this._selectTiledSession(focus, { auto });
|
||
return true;
|
||
}
|
||
|
||
// No snapshot of a session that becomes a tile: closing the grid drops the
|
||
// main terminal's snapshot of every tiled id (stale by then), so taking one
|
||
// here (a 1000-line serialize and up to 256 KB of localStorage for a
|
||
// non-shell session) only ever produced a copy that was thrown away.
|
||
this._cleanupPreviousSession(focus, { skipSnapshot: wanted.includes(this.activeSessionId) });
|
||
grid.open = true;
|
||
grid.cells = [];
|
||
grid.cols = 0;
|
||
grid.rows = 0;
|
||
grid.focusedId = focus;
|
||
document.querySelector('.main')?.classList.add('tiles-active');
|
||
const section = this._tileGridSection();
|
||
this.hideWelcome();
|
||
// Mount every tile and lay the grid out BEFORE any tile connects, so each
|
||
// first fit measures its real cell; the focused tile connects first, so
|
||
// its capture is the one the queue starts with.
|
||
for (const id of wanted) this._mountTile(id);
|
||
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
|
||
grid.cells = wanted.filter((id) => grid.tiles.has(id));
|
||
this._applyTileLayout();
|
||
for (const id of [focus, ...wanted.filter((id) => id !== focus)]) this._connectTile(id);
|
||
if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') {
|
||
// The main terminal's observer watches a node that is now hidden; this one
|
||
// catches window resizes, sidebar toggles and rail drags for the grid.
|
||
grid.resizeObserver = new ResizeObserver(() => this._scheduleTileGridRefit());
|
||
grid.resizeObserver.observe(section);
|
||
}
|
||
this._installTileGridWidthGate();
|
||
this._selectTiledSession(focus, { auto });
|
||
this._updateConnectionIndicator?.();
|
||
this._updateSplitButtonForTiles();
|
||
this._updateTileGridButtonState();
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* Leaves the grid: every tile destroyed (sockets closed, xterms disposed,
|
||
* queued loads dropped), the main terminal unparked.
|
||
*
|
||
* `keepStored` remembers the grid for one-click return (toggleTileGrid).
|
||
* `reselect` shows the focused session in the single view through a forced
|
||
* reload; pass false when the caller selects something itself.
|
||
*
|
||
* The main terminal's cached content for EVERY tiled id is invalidated: it was
|
||
* written before the grid opened, possibly hours ago, and selectSession paints
|
||
* a snapshot as its first frame.
|
||
*/
|
||
closeTileGrid({ keepStored = true, reselect = true } = {}) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) return;
|
||
const focusedId = grid.focusedId;
|
||
const ids = grid.ids.slice();
|
||
// Remembered (closed) for one-click return, or forgotten.
|
||
if (keepStored) this._persistTileGrid({ open: false });
|
||
else this._forgetStoredTileGrid();
|
||
// A divider drag or a header drag in progress ends with the grid.
|
||
this._tileDividerDragTeardown?.();
|
||
this._endTileMoveDrag();
|
||
// Closed BEFORE the tiles go: each destroy() updates the header's connection
|
||
// state, which must read the main terminal again, not half-destroyed tiles.
|
||
grid.open = false;
|
||
clearTimeout(grid.refitTimer);
|
||
grid.refitTimer = null;
|
||
grid.resizeObserver?.disconnect();
|
||
grid.resizeObserver = null;
|
||
for (const { tile, el } of grid.tiles.values()) {
|
||
this._destroyTerminalTile(tile);
|
||
el.remove();
|
||
}
|
||
grid.tiles.clear();
|
||
for (const el of grid.dividers.values()) el.remove();
|
||
grid.dividers.clear();
|
||
for (const slot of grid.slots || []) slot.remove();
|
||
grid.slots = [];
|
||
grid.colFr = [];
|
||
grid.rowFr = [];
|
||
grid.cells = [];
|
||
grid.cols = 0;
|
||
grid.rows = 0;
|
||
grid.focusedId = null;
|
||
grid.zoomedId = null;
|
||
grid.autoZoom = false;
|
||
document.querySelector('.main')?.classList.remove('tiles-active');
|
||
const section = document.getElementById('tileGrid');
|
||
if (section) {
|
||
section.style.gridTemplateColumns = '';
|
||
section.style.gridTemplateRows = '';
|
||
section.classList.remove('tile-grid--zoomed');
|
||
}
|
||
// As _redock does: the tiles sized these PTYs, so the main terminal's
|
||
// record of the last size it sent no longer describes them.
|
||
this._lastResizeDims = null;
|
||
for (const id of ids) {
|
||
this._xtermSnapshots?.delete(id);
|
||
try {
|
||
localStorage.removeItem(`codeman-xs-${id}`);
|
||
} catch {
|
||
/* Nothing stored. */
|
||
}
|
||
this.terminalBufferCache?.delete(id);
|
||
}
|
||
this._updateConnectionIndicator?.();
|
||
this._updateSplitButtonForTiles();
|
||
this._updateTileGridButtonState();
|
||
// The tabs drop their .in-tiles marker.
|
||
this.renderSessionTabs?.();
|
||
if (reselect) this._selectAfterTileGrid(focusedId);
|
||
},
|
||
|
||
/**
|
||
* The header Tiles button: shown when its per-device setting is on AND the
|
||
* window is desktop-wide (a JS check plus a live media listener, the same
|
||
* pair as the Split button; the CSS `@media (max-width: 1179px)` rule is the
|
||
* backstop that hides it even if this never runs).
|
||
*/
|
||
_applyTileGridButtonVisibility(enabled) {
|
||
this._tileGridButtonSettingEnabled = !!enabled;
|
||
const btn = document.querySelector('.btn-tile-grid');
|
||
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
||
btn?.classList.toggle('btn-tile-grid--hidden', !enabled || !wide || !!this.isSoloWindow);
|
||
if (!this._tileGridButtonWidthListener && window.matchMedia) {
|
||
this._tileGridButtonWidthListener = true;
|
||
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
|
||
mq.addEventListener('change', () => this._applyTileGridButtonVisibility(this._tileGridButtonSettingEnabled));
|
||
}
|
||
},
|
||
|
||
// Open grid: the button's click closes it, and says so.
|
||
_updateTileGridButtonState() {
|
||
const btn = document.querySelector('.btn-tile-grid');
|
||
if (!btn) return;
|
||
const open = this._tilesOwnTerminal();
|
||
btn.classList.toggle('tiles-open', open);
|
||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||
const title = open
|
||
? 'Tiles: back to a single session (right-click for how many tiles)'
|
||
: 'Tiles: show several sessions side by side (right-click for how many)';
|
||
btn.title = title;
|
||
btn.setAttribute('aria-label', title);
|
||
},
|
||
|
||
/**
|
||
* How many tiles the grid takes here and now: what the terminal area fits
|
||
* (the grid section, or the single view it would replace), at most the cap
|
||
* (TILE_GRID_MAX), never less than one. `full` says which of the two binds,
|
||
* so a large monitor never reads "this window fits 6".
|
||
*/
|
||
_tileGridLimit() {
|
||
const T = window.CodemanTileGrid;
|
||
const max = T.TILE_GRID_MAX;
|
||
const el = this._tilesOwnTerminal() ? this._tileGridSection() : document.querySelector('.terminal-wrap');
|
||
const rect = el?.getBoundingClientRect?.() || { width: 0, height: 0 };
|
||
const fits = T.tileGridCapacity({
|
||
width: rect.width || window.innerWidth,
|
||
height: rect.height || window.innerHeight,
|
||
});
|
||
const capacity = Math.max(1, Math.min(fits, max));
|
||
const byCap = fits >= max;
|
||
return {
|
||
capacity,
|
||
full: byCap
|
||
? `The grid holds at most ${max} tiles`
|
||
: `The grid already holds what this window fits (${capacity})`,
|
||
};
|
||
},
|
||
|
||
/** The tile count a click on Tiles opens (the menu's last pick, per device), default 6. */
|
||
_tileGridCount() {
|
||
let raw = null;
|
||
try {
|
||
raw = localStorage.getItem(TILE_GRID_COUNT_KEY);
|
||
} catch {
|
||
/* Storage unavailable: the default. */
|
||
}
|
||
return window.CodemanTileGrid.sanitizeTileCount(raw);
|
||
},
|
||
|
||
_rememberTileGridCount(count) {
|
||
try {
|
||
localStorage.setItem(TILE_GRID_COUNT_KEY, String(window.CodemanTileGrid.sanitizeTileCount(count)));
|
||
} catch {
|
||
/* Per-device convenience only. */
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Right-click on the Tiles button (or Shift+F10 / the Menu key on it, which
|
||
* fire the same contextmenu event; its click opens the grid at once, see
|
||
* toggleTileGrid): how many tiles, 2, 4 or 6 (owner decision 10), each with
|
||
* its shape, the remembered count checked. A count the window cannot fit is
|
||
* greyed out, and the menu says why; the keyboard then starts on the largest
|
||
* that fits (the count a click opens here). Picking one opens the grid with
|
||
* that many tiles, or re-forms the open grid to it, and remembers it for the
|
||
* click. Arrows move, Enter or Space picks, Escape closes and gives the
|
||
* keyboard back to the Tiles button, Tab closes.
|
||
*/
|
||
openTileCountMenu(event) {
|
||
// The right-click: the browser's own context menu stays away.
|
||
event?.preventDefault?.();
|
||
if (this._tileCountMenu) {
|
||
this.closeTileCountMenu();
|
||
return;
|
||
}
|
||
if (!this.canOpenTileGrid()) return;
|
||
const T = window.CodemanTileGrid;
|
||
const capacity = this._tileGridLimit().capacity;
|
||
const fitsText = `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`;
|
||
const current = this._tileGridCount();
|
||
const menu = document.createElement('div');
|
||
menu.id = 'tileCountMenu';
|
||
menu.className = 'tile-count-menu';
|
||
menu.setAttribute('role', 'menu');
|
||
menu.setAttribute('aria-label', 'How many tiles');
|
||
const heading = document.createElement('div');
|
||
heading.className = 'tile-count-heading';
|
||
heading.setAttribute('aria-hidden', 'true');
|
||
heading.textContent = 'Tiles';
|
||
menu.appendChild(heading);
|
||
const items = [];
|
||
for (const n of T.TILE_GRID_COUNTS) {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'tile-count-item';
|
||
item.setAttribute('role', 'menuitemradio');
|
||
item.setAttribute('aria-checked', n === current ? 'true' : 'false');
|
||
item.tabIndex = -1;
|
||
item.dataset.count = String(n);
|
||
if (n > capacity) {
|
||
item.disabled = true;
|
||
item.setAttribute('aria-disabled', 'true');
|
||
item.title = fitsText;
|
||
}
|
||
const label = document.createElement('span');
|
||
label.className = 'tile-count-label';
|
||
label.textContent = `${n} tiles`;
|
||
const check = document.createElement('span');
|
||
check.className = 'tile-count-check';
|
||
check.setAttribute('aria-hidden', 'true');
|
||
check.textContent = n === current ? '\u2713' : '';
|
||
item.append(tileCountGlyph(n), label, check);
|
||
item.addEventListener('click', (e) => {
|
||
e.stopPropagation?.();
|
||
if (!item.disabled) this._pickTileCount(n);
|
||
});
|
||
item.addEventListener('pointerenter', () => {
|
||
if (!item.disabled) item.focus?.();
|
||
});
|
||
menu.appendChild(item);
|
||
items.push(item);
|
||
}
|
||
if (capacity < T.TILE_GRID_MAX) {
|
||
const hint = document.createElement('div');
|
||
hint.className = 'tile-count-hint';
|
||
hint.textContent = fitsText;
|
||
menu.appendChild(hint);
|
||
}
|
||
|
||
document.body.appendChild(menu);
|
||
const btn = document.querySelector('.btn-tile-grid');
|
||
if (btn?.getBoundingClientRect) {
|
||
// Under the Tiles button (.tile-count-menu is position: fixed).
|
||
const rect = btn.getBoundingClientRect();
|
||
menu.style.top = `${rect.bottom + 6}px`;
|
||
menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
|
||
}
|
||
const enabled = () => items.filter((i) => !i.disabled);
|
||
const move = (delta) => {
|
||
const list = enabled();
|
||
if (list.length === 0) return;
|
||
const at = list.indexOf(document.activeElement);
|
||
const next = at === -1 ? (delta > 0 ? 0 : list.length - 1) : (at + delta + list.length) % list.length;
|
||
list[next].focus?.();
|
||
};
|
||
// A click elsewhere closes it. A click on the Tiles button needs no
|
||
// exception: its own handler (toggleTileGrid) closes the menu first.
|
||
const onOutside = (e) => {
|
||
if (menu.contains?.(e.target)) return;
|
||
this.closeTileCountMenu();
|
||
};
|
||
// The menu's keys, in the capture phase while it is open. Escape normally
|
||
// reaches the app's global handler first, which closes the menu alone and
|
||
// gives the keyboard back (app.js); this one covers the rest.
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault?.();
|
||
this.closeTileCountMenu({ refocus: true });
|
||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||
e.preventDefault?.();
|
||
move(1);
|
||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
||
e.preventDefault?.();
|
||
move(-1);
|
||
} else if (e.key === 'Home' || e.key === 'End') {
|
||
e.preventDefault?.();
|
||
const list = enabled();
|
||
list[e.key === 'Home' ? 0 : list.length - 1]?.focus?.();
|
||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||
const item = items.find((i) => i === document.activeElement);
|
||
if (!item) return;
|
||
// Not the button's own activation too.
|
||
e.preventDefault?.();
|
||
if (!item.disabled) this._pickTileCount(Number(item.dataset.count));
|
||
} else if (e.key === 'Tab') {
|
||
this.closeTileCountMenu();
|
||
}
|
||
};
|
||
this._tileCountMenu = { menu, onOutside, onKey };
|
||
document.addEventListener('click', onOutside);
|
||
document.addEventListener('keydown', onKey, true);
|
||
// The checked count, or when the window cannot fit it, the largest that fits.
|
||
const start = items.find((i) => !i.disabled && i.getAttribute('aria-checked') === 'true') || enabled().at(-1);
|
||
(start || menu).focus?.();
|
||
},
|
||
|
||
/**
|
||
* Idempotent: the global Escape handler may call it whether or not the menu
|
||
* is open. The keyboard goes back to the Tiles button when it was in the
|
||
* menu (`refocus` unset), or when asked (Escape); a click elsewhere has
|
||
* already moved it, and a pick passes `refocus: false`.
|
||
*/
|
||
closeTileCountMenu({ refocus } = {}) {
|
||
const menu = this._tileCountMenu;
|
||
if (!menu) return;
|
||
if (refocus === undefined) refocus = !!menu.menu.contains?.(document.activeElement);
|
||
this._tileCountMenu = null;
|
||
document.removeEventListener('click', menu.onOutside);
|
||
document.removeEventListener('keydown', menu.onKey, true);
|
||
menu.menu.remove();
|
||
if (refocus) document.querySelector('.btn-tile-grid')?.focus?.();
|
||
},
|
||
|
||
/**
|
||
* A count picked in the menu: remembered for the click, then the grid opens
|
||
* with that many tiles (what the click opens), or the open grid is re-formed
|
||
* to it (_reformTileGrid). Never more than the window fits; fewer open
|
||
* sessions than the count give fewer tiles.
|
||
*/
|
||
_pickTileCount(count) {
|
||
this.closeTileCountMenu({ refocus: false });
|
||
this._rememberTileGridCount(count);
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) {
|
||
this.toggleTileGrid();
|
||
return;
|
||
}
|
||
const T = window.CodemanTileGrid;
|
||
const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity);
|
||
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
|
||
this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId));
|
||
},
|
||
|
||
/**
|
||
* The open grid re-formed to the tiles `target` (a count picked in the
|
||
* menu): a count change is a shape change, so the cell model's rule applies
|
||
* (reformTileCells: the tiles that stay keep their row and column when all
|
||
* fit, else they pack in reading order), and the tiles that join fill the
|
||
* empty cells in reading order, holes first. The focused tile is always
|
||
* among those that stay (tileGridSetForCount). Every tile that joins is
|
||
* mounted and the grid laid out ONCE before any of them connects (as in
|
||
* openTileGrid): a tile connected into an intermediate layout would fit that
|
||
* cell first and resize its PTY twice. A zoom the user chose ends: the grid
|
||
* they asked for is meant to be seen.
|
||
*/
|
||
_reformTileGrid(target) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) return;
|
||
const T = window.CodemanTileGrid;
|
||
const leaving = grid.ids.filter((id) => !target.includes(id));
|
||
const joining = target.filter((id) => !grid.tiles.has(id));
|
||
if (leaving.length === 0 && joining.length === 0) return;
|
||
// A divider or header drag in progress measured the old grid.
|
||
this._tileDividerDragTeardown?.();
|
||
this._endTileMoveDrag();
|
||
for (const id of leaving) {
|
||
const entry = grid.tiles.get(id);
|
||
this._destroyTerminalTile(entry.tile);
|
||
entry.el.remove();
|
||
grid.tiles.delete(id);
|
||
}
|
||
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
||
const staying = grid.ids.filter((id) => grid.tiles.has(id));
|
||
const mounted = joining.filter((id) => this._mountTile(id));
|
||
const { cols, rows } = this._tileShapeFor(grid.tiles.size);
|
||
grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows);
|
||
grid.cols = cols;
|
||
grid.rows = rows;
|
||
this._applyTileLayout();
|
||
for (const id of mounted) this._connectTile(id);
|
||
this._scheduleTileGridRefit();
|
||
this.renderSessionTabs?.();
|
||
},
|
||
|
||
/**
|
||
* A session THIS tab's Run just created (session-ui.js
|
||
* _ensureCreatedSessionVisible, reached only from the Run paths): with the
|
||
* grid open it joins the next free slot, and Run's own selectSession then
|
||
* focuses it through the tile branch. Sessions created elsewhere (agents,
|
||
* other devices, cron) arrive only by session:created and never join. A grid
|
||
* already holding what the window fits does not take it; Run's selection
|
||
* then shows it in the single view (decision 1), and a hint says why.
|
||
*/
|
||
_joinTileGridFromRun(sessionId) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open || grid.tiles.has(sessionId) || !this.sessions.has(sessionId)) return false;
|
||
const limit = this._tileGridLimit();
|
||
if (grid.ids.length >= limit.capacity) {
|
||
this.showToast?.(`${limit.full}: the new session opens on its own`, 'info');
|
||
return false;
|
||
}
|
||
// Run starts the session right after creating it: no Attach overlay
|
||
// meanwhile for a pane that is about to exist.
|
||
(this._tileAttachPending ||= new Map()).set(sessionId, Date.now());
|
||
return this.addTile(sessionId);
|
||
},
|
||
|
||
// The Split button cannot act while the grid is open (openSplitPicker and
|
||
// openSplitPane refuse), so it says so: aria-disabled plus a title, the same
|
||
// refusal the split already gives for web tabs and the welcome screen.
|
||
_updateSplitButtonForTiles() {
|
||
const btn = document.querySelector('.btn-split');
|
||
if (!btn) return;
|
||
const blocked = this._tilesOwnTerminal();
|
||
btn.classList.toggle('btn-split--blocked', blocked);
|
||
btn.setAttribute('aria-disabled', blocked ? 'true' : 'false');
|
||
if (blocked) {
|
||
btn.title = 'Split: unavailable while tiles are open';
|
||
btn.setAttribute('aria-label', btn.title);
|
||
} else {
|
||
this._updateSplitButtonState?.(!!this._splitPane);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 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: with it off (the default)
|
||
* the chord is inert and reaches the terminal like any unbound key (owner
|
||
* decision 6 in docs/tile-grid-plan.md). The focus, move,
|
||
* zoom and remove chords apply only while the grid is open, however it was
|
||
* opened (a move chord also while a tile is zoomed, as a no-op, so its keys
|
||
* never reach the CLI). The arrow chords never apply in a text field, whose
|
||
* keys they are.
|
||
* Registry-aware (rebinds and disables in App Settings, Shortcuts).
|
||
* The capture handler (app.js) dispatches it; every xterm key handler returns
|
||
* false for it, so a chord that applies never reaches a PTY.
|
||
*
|
||
* @returns {string|null} the registry id
|
||
*/
|
||
tileShortcutFor(e) {
|
||
if (!e || (!e.ctrlKey && !e.metaKey && !e.altKey)) return null;
|
||
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return null;
|
||
const open = this._tilesOwnTerminal();
|
||
for (const shortcut of this.getShortcutRegistry()) {
|
||
const spec = TILE_SHORTCUTS[shortcut.id];
|
||
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
|
||
if ((spec.direction || spec.move) && isTextFieldTarget(e.target)) continue;
|
||
if (spec.needsOpen) return open ? shortcut.id : null;
|
||
const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true;
|
||
return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null;
|
||
}
|
||
return null;
|
||
},
|
||
|
||
/** Runs a chord tileShortcutFor() matched. */
|
||
runTileShortcut(id) {
|
||
const spec = TILE_SHORTCUTS[id];
|
||
if (!spec) return;
|
||
if (id === 'toggle-tile-grid') this.toggleTileGrid();
|
||
else if (id === 'remove-tile') this.removeFocusedTile();
|
||
else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId);
|
||
else if (spec.direction) this.focusTileInDirection(spec.direction);
|
||
else if (spec.move) this.moveTileInDirection(spec.move);
|
||
},
|
||
|
||
/**
|
||
* The Tiles button's click and Ctrl+Shift+G, one function so the two never
|
||
* drift (owner decision 8): opens the grid at once, no menu in the way, or
|
||
* closes it to the single view of the focused session. What opens is the
|
||
* remembered count of tiles (the right-click menu's last pick, default 6,
|
||
* at most what the window fits; owner decision 10), chosen by
|
||
* `tileGridOpenSet` (constants.js): the grid this tab last had, else an open
|
||
* split's two sessions, else the open sessions in tab order, the active one
|
||
* focused; then trimmed or filled to the count (the focused one kept). A
|
||
* remembered grid comes back with its tiles in their cells, the ones the
|
||
* count adds filling its empty cells first (_openStoredTileGrid).
|
||
*/
|
||
toggleTileGrid() {
|
||
this.closeTileCountMenu();
|
||
if (this._tilesOwnTerminal()) {
|
||
this.closeTileGrid({ keepStored: true, reselect: true });
|
||
return;
|
||
}
|
||
if (!this.canOpenTileGrid()) return;
|
||
const stored = this._readStoredTileGrid();
|
||
const set = this._tileGridOpenSet(stored);
|
||
if (!set) {
|
||
this.showToast?.('No sessions to show as tiles', 'info');
|
||
return;
|
||
}
|
||
if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids);
|
||
else this.openTileGrid(set.ids, { focusedId: set.focusedId });
|
||
},
|
||
|
||
/**
|
||
* What the toggle would open now (see toggleTileGrid): `count` tiles (the
|
||
* remembered count), or as many as the window fits; `stored` saves a
|
||
* second read.
|
||
*/
|
||
_tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) {
|
||
const T = window.CodemanTileGrid;
|
||
const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity));
|
||
const set = T.tileGridOpenSet({
|
||
stored,
|
||
split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
|
||
sessions: this.sessions,
|
||
sessionOrder: this.sessionOrder,
|
||
detachedIds: this.detachedSessions,
|
||
activeId: this.activeSessionId,
|
||
limit: n,
|
||
});
|
||
if (!set) return null;
|
||
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
|
||
return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) };
|
||
},
|
||
|
||
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
|
||
focusTileInDirection(direction) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) return;
|
||
// Over the cells: focus skips an empty one, never lands on it.
|
||
const id = window.CodemanTileGrid.tileInDirection(grid.cells, grid.focusedId, direction, grid.cols);
|
||
if (id) this.selectSession(id);
|
||
},
|
||
|
||
/**
|
||
* Ctrl+Tab / Alt+] (delta 1) and Alt+[ (delta -1) while the grid is open: a
|
||
* human selection of the next tile in reading order, wrapping. Returns
|
||
* whether the grid took the chord (it is open), so the tab walk is skipped.
|
||
*/
|
||
_cycleTileFocus(delta) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) return false;
|
||
const id = window.CodemanTileGrid.cycleTile(grid.ids, this.activeSessionId, delta);
|
||
if (id) this.selectSession(id);
|
||
return true;
|
||
},
|
||
|
||
/** Removes the focused tile (the session keeps running); a neighbour takes focus. */
|
||
removeFocusedTile() {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open || !grid.focusedId) return;
|
||
this.removeTile(grid.focusedId);
|
||
},
|
||
|
||
// The single view after the grid closes: the session the grid was focused on,
|
||
// 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.
|
||
_selectAfterTileGrid(sessionId) {
|
||
if (sessionId && this.sessions.has(sessionId)) {
|
||
this.selectSession(sessionId, { forceReload: true, auto: true });
|
||
return;
|
||
}
|
||
this.activeSessionId = null;
|
||
try {
|
||
localStorage.removeItem('codeman-active-session');
|
||
} catch {
|
||
/* Nothing stored. */
|
||
}
|
||
const next = this.sessionOrder.find((id) => this.sessions.has(id));
|
||
if (next) {
|
||
this.selectSession(next, { auto: true });
|
||
} else {
|
||
this.terminal?.clear();
|
||
this.showWelcome();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Adds one session as a tile (open grid only), into `cell` when that cell is
|
||
* empty (a tab dropped on it), else the first empty cell in reading order
|
||
* (_placeTile). Returns whether it was added.
|
||
*/
|
||
addTile(sessionId, { cell = -1 } = {}) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open || grid.tiles.has(sessionId)) return false;
|
||
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
||
if (!this._mountTile(sessionId)) return false;
|
||
this._placeTile(sessionId, cell);
|
||
// A tile added while one is zoomed by hand is meant to be seen.
|
||
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
||
this._applyTileLayout();
|
||
this._connectTile(sessionId);
|
||
this._scheduleTileGridRefit();
|
||
this.renderSessionTabs?.();
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* Puts a tile just mounted into a cell. The shape for one more tile comes
|
||
* first (fitTileCells: the same shape keeps every cell; 2x2 growing to 3x2
|
||
* keeps each tile where it is), then `cell` if it is empty there, else the
|
||
* first empty cell in reading order.
|
||
*/
|
||
_placeTile(sessionId, cell = -1) {
|
||
const grid = this._tileGrid;
|
||
const { cols, rows } = this._tileShapeFor(grid.ids.length + 1);
|
||
grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows);
|
||
grid.cols = cols;
|
||
grid.rows = rows;
|
||
const k = Number.isInteger(cell) && grid.cells[cell] === null ? cell : grid.cells.indexOf(null);
|
||
// (No empty cell cannot happen: the shape for n tiles has at least n cells.
|
||
// Appended, the next layout packs it in.)
|
||
if (k === -1) grid.cells.push(sessionId);
|
||
else grid.cells[k] = sessionId;
|
||
},
|
||
|
||
/** Columns x rows (and whether they fit) for `count` tiles in the grid area as it is now. */
|
||
_tileShapeFor(count) {
|
||
const rect = this._tileGridSection().getBoundingClientRect?.() || { width: 0, height: 0 };
|
||
return window.CodemanTileGrid.computeTileLayout({
|
||
count,
|
||
width: rect.width || window.innerWidth,
|
||
height: rect.height || window.innerHeight,
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Removes one tile; the session keeps running. Its cell becomes empty where
|
||
* it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus`
|
||
* moves focus to the neighbouring tile (next in grid order, else previous),
|
||
* as the app's choice (`auto`: no idle alert is spent). The last tile
|
||
* leaving closes the grid: with `refocus` the single view then shows that
|
||
* session, without it the caller decides what comes next.
|
||
*/
|
||
removeTile(sessionId, { refocus = true } = {}) {
|
||
const grid = this._tileGrid;
|
||
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
||
if (!entry) return false;
|
||
if (grid.ids.length === 1) {
|
||
this.closeTileGrid({ keepStored: false, reselect: refocus });
|
||
return true;
|
||
}
|
||
const wasFocused = grid.focusedId === sessionId;
|
||
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId);
|
||
// A divider drag in progress was measured against this tile; a header
|
||
// drag of this tile has nothing left to drop.
|
||
this._tileDividerDragTeardown?.();
|
||
if (this._draggedTileId === sessionId) this._endTileMoveDrag();
|
||
// The zoomed tile leaving restores the grid (an automatic zoom moves to
|
||
// the neighbour with focus, below).
|
||
if (grid.zoomedId === sessionId) grid.zoomedId = grid.autoZoom && refocus ? neighbor : null;
|
||
this._destroyTerminalTile(entry.tile);
|
||
entry.el.remove();
|
||
grid.tiles.delete(sessionId);
|
||
grid.cells[grid.cells.indexOf(sessionId)] = null;
|
||
// Before the layout, which may zoom the focused tile on a small window.
|
||
if (wasFocused) grid.focusedId = null;
|
||
this._applyTileLayout();
|
||
this._scheduleTileGridRefit();
|
||
this.renderSessionTabs?.();
|
||
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true });
|
||
return true;
|
||
},
|
||
|
||
_mountTile(sessionId) {
|
||
const grid = this._tileGrid;
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session || this.detachedSessions?.has(sessionId)) return false;
|
||
const el = document.createElement('div');
|
||
el.className = 'tile';
|
||
el.dataset.sessionId = sessionId;
|
||
// Header and body are siblings: the chrome is refreshed in place
|
||
// (_renderTileHeader), never by rewriting the tile, which would take the
|
||
// xterm in the body with it.
|
||
const header = this._buildTileHeader(sessionId);
|
||
const body = document.createElement('div');
|
||
body.className = 'tile-body';
|
||
el.append(header.el, body);
|
||
// Pressing a tile is a human selection: it focuses the tile and
|
||
// acknowledges its idle alert (the already-focused tile hits
|
||
// selectSession's early return, which acknowledges too). In the body on
|
||
// pointerdown, not click, so focus moves before the press reaches xterm,
|
||
// and never preventDefault: xterm's own mousedown focuses its textarea and
|
||
// starts selections. In the header on click instead (below): a press there
|
||
// may become a drag, and a drag that is cancelled changes nothing, focus
|
||
// included (owner: best practice). A drag never ends in a click.
|
||
el.addEventListener('pointerdown', (e) => {
|
||
if (header.el.contains?.(e?.target)) return;
|
||
if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId);
|
||
});
|
||
header.el.addEventListener('click', () => {
|
||
if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId);
|
||
});
|
||
// A tile takes any session but its own (_acceptTabDrops).
|
||
this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, sessionId), {
|
||
accepts: (id) => id !== sessionId,
|
||
});
|
||
this._tileGridSection().appendChild(el);
|
||
const tile = this._newTerminalTile(sessionId, body);
|
||
grid.tiles.set(sessionId, {
|
||
tile,
|
||
el,
|
||
body,
|
||
overlay: null,
|
||
header: header.el,
|
||
dot: header.dot,
|
||
harness: header.harness,
|
||
name: header.name,
|
||
model: header.model,
|
||
modelName: header.modelName,
|
||
zoomBtn: header.zoomBtn,
|
||
renaming: false,
|
||
// The pid this tile last saw, so a pane that starts later is noticed.
|
||
pid: this.sessions.get(sessionId)?.pid ?? null,
|
||
});
|
||
// Where it goes is the caller's (grid.cells).
|
||
this._renderTileHeader(sessionId);
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* Makes `el` a drop target for a session tab dragged from the strip (the
|
||
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
|
||
* (`_draggedTileId`, _installTileMoveDrag). Capture phase, with the event
|
||
* stopped: a tab drag carries the session id as text, and xterm's helper
|
||
* textarea would otherwise accept that drop and type the id into a PTY.
|
||
* `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.
|
||
*/
|
||
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
|
||
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
|
||
el.addEventListener(
|
||
'dragover',
|
||
(e) => {
|
||
const id = dragged();
|
||
if (!id) return;
|
||
e.preventDefault?.();
|
||
e.stopPropagation?.();
|
||
const ok = accepts(id);
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none';
|
||
if (ok) el.classList.add('tile--drop-target');
|
||
},
|
||
true
|
||
);
|
||
el.addEventListener('dragleave', (e) => {
|
||
if (!el.contains?.(e.relatedTarget)) el.classList.remove('tile--drop-target');
|
||
});
|
||
el.addEventListener(
|
||
'drop',
|
||
(e) => {
|
||
el.classList.remove('tile--drop-target');
|
||
const id = dragged();
|
||
if (!id) return;
|
||
e.preventDefault?.();
|
||
e.stopPropagation?.();
|
||
if (accepts(id)) onDrop(id);
|
||
},
|
||
true
|
||
);
|
||
},
|
||
|
||
/** Whether tiles can be moved now: more than one, and none zoomed (moving is off while one is). */
|
||
_tilesMovable() {
|
||
const grid = this._tileGrid;
|
||
return !!grid?.open && !grid.zoomedId && grid.ids.length > 1;
|
||
},
|
||
|
||
/**
|
||
* Puts the tiles in new cells (the same sessions, the same shape): the one
|
||
* path every move takes, a header drag, the tab of a tiled session dropped
|
||
* on a tile or an empty cell, and the Move Tile chords. Nothing is
|
||
* remounted, reconnected or reloaded, and no session joins or leaves.
|
||
* Divider sizes belong to the cells, so a moved tile takes its new cell's
|
||
* size: each tile whose cell size changed fits once (its xterm and one PTY
|
||
* resize together, #464), every other tile is left alone. Refused while a
|
||
* tile is zoomed (moving is off then).
|
||
*
|
||
* @param {(string|null)[]} cells - the new cells
|
||
* @returns {boolean} false when refused, true otherwise (also when nothing moved)
|
||
*/
|
||
_reorderTiles(cells) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open || grid.zoomedId || cells.length !== grid.cells.length) return false;
|
||
const tiles = cells.filter(Boolean);
|
||
if (tiles.length !== grid.tiles.size || new Set(tiles).size !== tiles.length) return false;
|
||
if (tiles.some((id) => !grid.tiles.has(id))) return false;
|
||
if (cells.every((id, k) => (id || null) === grid.cells[k])) return true;
|
||
// A divider drag in progress measured the tiles at their old places.
|
||
this._tileDividerDragTeardown?.();
|
||
const cell = (k) => `${grid.colFr[k % grid.cols]}x${grid.rowFr[Math.floor(k / grid.cols)]}`;
|
||
const before = new Map();
|
||
grid.cells.forEach((id, k) => id && before.set(id, cell(k)));
|
||
grid.cells = cells.map((id) => id || null);
|
||
this._applyTileLayout();
|
||
// Synchronous: the fit's measurement forces the new placement's layout.
|
||
grid.cells.forEach((id, k) => {
|
||
if (id && before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit();
|
||
});
|
||
return true;
|
||
},
|
||
|
||
/** Two tiles trade cells (_reorderTiles). */
|
||
_swapTiles(a, b) {
|
||
const cells = this._tileGrid.cells.slice();
|
||
const i = cells.indexOf(a);
|
||
const j = cells.indexOf(b);
|
||
if (i === -1 || j === -1) return false;
|
||
cells[i] = b;
|
||
cells[j] = a;
|
||
return this._reorderTiles(cells);
|
||
},
|
||
|
||
/**
|
||
* A tile moves into empty cell `k`, leaving its own cell empty: nothing else
|
||
* moves (_reorderTiles). False when `k` is not an empty cell, or moving is off.
|
||
*/
|
||
_moveTileToCell(sessionId, k) {
|
||
const cells = this._tileGrid.cells.slice();
|
||
const from = cells.indexOf(sessionId);
|
||
if (from === -1 || !Number.isInteger(k) || cells[k] !== null) return false;
|
||
cells[from] = null;
|
||
cells[k] = sessionId;
|
||
return this._reorderTiles(cells);
|
||
},
|
||
|
||
/**
|
||
* Move Tile Left/Right/Up/Down: the focused tile goes to the cell next to it
|
||
* in that direction (tileCellInDirection, never jumping a cell): into it
|
||
* when it is empty, trading places when a tile is there. Focus stays on the
|
||
* moved tile. Nothing while a tile is zoomed, or at an edge.
|
||
*/
|
||
moveTileInDirection(direction) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open || grid.zoomedId || !grid.focusedId) return;
|
||
const from = grid.cells.indexOf(grid.focusedId);
|
||
const to = window.CodemanTileGrid.tileCellInDirection(from, direction, grid.cols, grid.cells.length);
|
||
if (to === -1) return;
|
||
if (grid.cells[to]) this._swapTiles(grid.focusedId, grid.cells[to]);
|
||
else this._moveTileToCell(grid.focusedId, to);
|
||
},
|
||
|
||
/**
|
||
* A tab dropped on a tile, or a tile dragged there by its header: a session
|
||
* not yet tiled REPLACES that tile (same place; the replaced session keeps
|
||
* running); one already tiled swaps places with it (_swapTiles, refused while
|
||
* a tile is zoomed). Either way the dropped session takes focus (a human
|
||
* selection).
|
||
*/
|
||
dropSessionOnTile(draggedId, targetId) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open || draggedId === targetId || !grid.tiles.has(targetId)) return;
|
||
if (!this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return;
|
||
if (grid.tiles.has(draggedId)) {
|
||
if (!this._swapTiles(draggedId, targetId)) return;
|
||
} else {
|
||
this._tileDividerDragTeardown?.();
|
||
if (!this._mountTile(draggedId)) return;
|
||
// It takes the replaced tile's cell.
|
||
grid.cells[grid.cells.indexOf(targetId)] = draggedId;
|
||
const old = grid.tiles.get(targetId);
|
||
this._destroyTerminalTile(old.tile);
|
||
old.el.remove();
|
||
grid.tiles.delete(targetId);
|
||
if (grid.zoomedId === targetId) grid.zoomedId = grid.autoZoom ? draggedId : null;
|
||
if (grid.focusedId === targetId) grid.focusedId = null;
|
||
this._applyTileLayout();
|
||
this._connectTile(draggedId);
|
||
this._scheduleTileGridRefit();
|
||
this.renderSessionTabs?.();
|
||
}
|
||
this.selectSession(draggedId);
|
||
},
|
||
|
||
/**
|
||
* Something dropped on empty cell `cell`: a tab of a session not tiled yet
|
||
* joins the grid in that cell; a tiled session (its tab, or the tile dragged
|
||
* by its header) moves into it, leaving its own cell empty
|
||
* (_moveTileToCell, refused while a tile is zoomed). Either way the dropped
|
||
* session takes focus (a human selection).
|
||
*/
|
||
dropSessionOnSlot(draggedId, cell) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return;
|
||
if (grid.tiles.has(draggedId)) {
|
||
if (!this._moveTileToCell(draggedId, cell)) return;
|
||
} else if (!this.addTile(draggedId, { cell })) {
|
||
return;
|
||
}
|
||
this.selectSession(draggedId);
|
||
},
|
||
|
||
/**
|
||
* Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
|
||
* human selection: the user clicked its tab). With the grid closed it opens
|
||
* what the Tiles toggle would, with this session among the tiles and
|
||
* focused: the remembered count in total (owner answer to decision 10's
|
||
* questions), never one more. Returns false
|
||
* when the grid cannot open here (narrow or solo window), so the click is an
|
||
* ordinary one.
|
||
*/
|
||
addSessionToTiles(sessionId) {
|
||
if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
|
||
return false;
|
||
}
|
||
const limit = this._tileGridLimit();
|
||
const capacity = limit.capacity;
|
||
const grid = this._tileGrid;
|
||
if (grid?.open) {
|
||
if (!grid.tiles.has(sessionId)) {
|
||
if (grid.ids.length >= capacity) {
|
||
this.showToast?.(limit.full, 'info');
|
||
return true;
|
||
}
|
||
this.addTile(sessionId);
|
||
}
|
||
this.selectSession(sessionId);
|
||
return true;
|
||
}
|
||
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 });
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* "Open group as tiles" (the tab-group menu of the grouped rail): the
|
||
* group's live sessions, as many as the window fits, become the grid,
|
||
* replacing whatever it showed. Opening the grid is the app's choice of
|
||
* focus, so no idle alert is spent.
|
||
*/
|
||
openGroupAsTiles(groupId) {
|
||
const group = (this.tabLayout?.groups || []).find((g) => g.id === groupId);
|
||
if (!group || !this.canOpenTileGrid()) return false;
|
||
const capacity = this._tileGridLimit().capacity;
|
||
const ids = (group.refs || [])
|
||
.filter((ref) => ref.kind === 'session')
|
||
.map((ref) => ref.id)
|
||
.filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id))
|
||
.slice(0, capacity);
|
||
if (ids.length === 0) {
|
||
this.showToast?.('This group has no session to show as tiles', 'info');
|
||
return false;
|
||
}
|
||
return this._replaceTileGrid(ids);
|
||
},
|
||
|
||
/**
|
||
* Opens the grid on `ids` in place of whatever it shows ("Open group as
|
||
* tiles"): the session in focus keeps focus when `ids` holds
|
||
* it, otherwise the first one takes it. Chosen BEFORE an open grid closes,
|
||
* because the close drops activeSessionId: as in selectSession's tile
|
||
* branch, the parked terminal still holds what it showed before the grid,
|
||
* and re-parking must not snapshot it.
|
||
*
|
||
* @returns {boolean} whether the grid is open afterwards
|
||
*/
|
||
_replaceTileGrid(ids) {
|
||
const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
|
||
if (this._tilesOwnTerminal()) {
|
||
this.closeTileGrid({ keepStored: false, reselect: false });
|
||
this.activeSessionId = null;
|
||
}
|
||
return this.openTileGrid(ids, { focusedId: focus });
|
||
},
|
||
|
||
/** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */
|
||
_newTerminalTile(sessionId, body) {
|
||
const session = this.sessions.get(sessionId);
|
||
const tile = new window.TerminalTile(sessionId, body, {
|
||
mode: session?.mode,
|
||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||
detachedSessions: this.detachedSessions,
|
||
scheduleLoad: (t, kind, run) => this._tileLoadQueue().schedule(t, kind, run),
|
||
scrollback: window.CodemanTileGrid.TILE_SCROLLBACK,
|
||
fontSize: this._tileGridFontSize(),
|
||
boundedLoad: true,
|
||
onExit: (code) => this._onTileExit(sessionId, tile, code),
|
||
});
|
||
return tile;
|
||
},
|
||
|
||
/**
|
||
* A grid tile's TerminalTile goes for good: its loads still waiting in the
|
||
* grid's queue are dropped first (resolved, never run, its loading state
|
||
* cleared), then the tile itself (socket, xterm, listeners). Its element is
|
||
* the caller's.
|
||
*/
|
||
_destroyTerminalTile(tile) {
|
||
this._tileGrid.queue?.drop(tile);
|
||
tile.destroy();
|
||
},
|
||
|
||
/**
|
||
* Replaces a tile's TerminalTile with a fresh one in the same place (after
|
||
* Attach: a tile whose socket stopped for good cannot reconnect, and a fresh
|
||
* one loads the new pane from scratch). Keeps the keyboard if it had it.
|
||
*/
|
||
_remountTile(sessionId) {
|
||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||
if (!entry) return;
|
||
const hadKeyboard = this._focusedTile === entry.tile;
|
||
this._destroyTerminalTile(entry.tile);
|
||
entry.tile = this._newTerminalTile(sessionId, entry.body);
|
||
this._connectTile(sessionId);
|
||
if (hadKeyboard) this._noteFocusedTile(entry.tile);
|
||
},
|
||
|
||
/**
|
||
* What the tile's body should say instead of a terminal, or null for none: a
|
||
* session with no PTY attached (pid null) or a socket the server closed
|
||
* because the session exited (4009), both of which Attach can start again;
|
||
* or an agent that exited in a live pane (paneExit), which it cannot: the
|
||
* attach and shell routes refuse while the pane's tmux client still runs
|
||
* ("already has a running process"), and the single view has no restart for
|
||
* it either, so the tile says so and points at Close session instead. Attach
|
||
* was just pressed: nothing, while the server catches up.
|
||
*
|
||
* @returns {{text: string, attachable: boolean}|null}
|
||
*/
|
||
_tileAttachReason(sessionId, tile) {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return null;
|
||
const pending = this._tileAttachPending?.get(sessionId);
|
||
if (pending && Date.now() - pending < 15000) return null;
|
||
const exited = typeof paneExitLabel === 'function' ? paneExitLabel(session.paneExit) : '';
|
||
if (exited) return { text: `The agent ${exited}`, attachable: false };
|
||
if (session.pid === null) return { text: 'Not attached', attachable: true };
|
||
if (tile?._stoppedCode === 4009) return { text: 'The session ended', attachable: true };
|
||
return null;
|
||
},
|
||
|
||
/**
|
||
* The Attach overlay over a tile's body (absolute, so the body and its xterm
|
||
* keep their size): why there is no terminal, and an Attach button.
|
||
*/
|
||
_renderTileOverlay(sessionId) {
|
||
const entry = this._tileGrid?.tiles.get(sessionId);
|
||
if (!entry?.body) return;
|
||
const session = this.sessions.get(sessionId);
|
||
if (session && session.pid !== null && !session.paneExit) this._tileAttachPending?.delete(sessionId);
|
||
const reason = this._tileAttachReason(sessionId, entry.tile);
|
||
const busy = !!this._tileAttachInFlight?.has(sessionId);
|
||
if (!reason && !busy) {
|
||
if (entry.overlay) entry.overlay.hidden = true;
|
||
return;
|
||
}
|
||
if (!entry.overlay) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'tile-attach';
|
||
const text = document.createElement('span');
|
||
text.className = 'tile-attach-text';
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'tile-attach-btn';
|
||
btn.textContent = 'Attach';
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
void this.attachTileSession(sessionId);
|
||
});
|
||
const hint = document.createElement('span');
|
||
hint.className = 'tile-attach-hint';
|
||
hint.textContent = 'It cannot be restarted in place: close it from \u22EF (Close session).';
|
||
overlay.append(text, btn, hint);
|
||
entry.body.appendChild(overlay);
|
||
entry.overlay = overlay;
|
||
entry.overlayText = text;
|
||
entry.overlayBtn = btn;
|
||
entry.overlayHint = hint;
|
||
}
|
||
entry.overlay.hidden = false;
|
||
const text = busy ? 'Attaching\u2026' : reason.text;
|
||
// Compared with the last English text set, never the DOM: with the zh-CN
|
||
// translator on, the DOM holds the translation and would never match, so
|
||
// every refresh would rewrite English for it to translate again.
|
||
if (entry.overlayLabel !== text) {
|
||
entry.overlayLabel = text;
|
||
entry.overlayText.textContent = text;
|
||
}
|
||
const attachable = busy || reason.attachable;
|
||
entry.overlayBtn.hidden = !attachable;
|
||
entry.overlayHint.hidden = attachable;
|
||
entry.overlayBtn.disabled = busy;
|
||
},
|
||
|
||
/**
|
||
* Attach: starts the session's CLI in its pane, exactly as the single view's
|
||
* automatic re-attach does: `POST /interactive` (or `/shell` for a shell)
|
||
* with NO body, at most one in flight per session (the route has no guard of
|
||
* its own). A session whose PTY-exit breaker tripped goes through the same
|
||
* confirm the single view asks before `clearBreaker: true`; nothing automatic
|
||
* ever sends that. On success the tile is remounted onto the new pane.
|
||
*/
|
||
async attachTileSession(sessionId) {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return false;
|
||
// An agent that exited in a live pane cannot be started again in place.
|
||
if (this._tileAttachReason(sessionId, this._tileFor(sessionId))?.attachable === false) return false;
|
||
this._tileAttachInFlight ||= new Set();
|
||
if (this._tileAttachInFlight.has(sessionId)) return false;
|
||
let url = `/api/sessions/${sessionId}/${session.mode === 'shell' ? 'shell' : 'interactive'}`;
|
||
let init = { method: 'POST' };
|
||
if (session.respawnBlocked) {
|
||
const label = session.name || 'Session';
|
||
if (!window.confirm(`${label} was stopped after crashing repeatedly. Restart it?`)) return false;
|
||
url = `/api/sessions/${sessionId}/interactive`;
|
||
init = {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ clearBreaker: true }),
|
||
};
|
||
}
|
||
this._tileAttachInFlight.add(sessionId);
|
||
this._renderTileOverlay(sessionId);
|
||
let ok = false;
|
||
try {
|
||
const res = await fetch(url, init);
|
||
// The routes report a refusal in the envelope of a 200.
|
||
const body = await res?.json?.().catch(() => null);
|
||
ok = !!res?.ok && body?.success !== false;
|
||
} catch {
|
||
ok = false;
|
||
} finally {
|
||
this._tileAttachInFlight.delete(sessionId);
|
||
}
|
||
if (ok) {
|
||
if (init.body) session.respawnBlocked = false;
|
||
session.status = 'busy';
|
||
(this._tileAttachPending ||= new Map()).set(sessionId, Date.now());
|
||
this._remountTile(sessionId);
|
||
} else {
|
||
this.showToast?.('Could not attach the session', 'error');
|
||
}
|
||
this._renderTileOverlay(sessionId);
|
||
return ok;
|
||
},
|
||
|
||
/**
|
||
* `● [logo] name · model ..... ⋯ ⤢ ×`: the status dot (the six-state
|
||
* classifier the tab rows and both home screens share), the harness logo, the
|
||
* session name (double-click renames), the model it runs when known, the
|
||
* session menu (the tab rail's own), zoom and remove-tile. Its
|
||
* buttons stop pointerdown, so acting on a tile that is not focused does not
|
||
* also focus it (and spend its idle alert). The rest of it is the handle that
|
||
* moves the tile (_installTileMoveDrag).
|
||
*/
|
||
_buildTileHeader(sessionId) {
|
||
const el = document.createElement('div');
|
||
el.className = 'tile-header';
|
||
const dot = document.createElement('span');
|
||
dot.className = 'tile-dot home-sessions-dot home-sessions-dot--idle';
|
||
dot.setAttribute('aria-hidden', 'true');
|
||
// The harness's logo: PR #532's `run-mode-dot <cliId>` slot, so the logos,
|
||
// the skins and the plain dot for an id without one stay in styles.css. Its
|
||
// tooltip and accessible name carry the harness and the model in full
|
||
// (_paintSessionHarness).
|
||
const harness = document.createElement('span');
|
||
harness.className = 'tile-harness run-mode-dot';
|
||
harness.setAttribute('role', 'img');
|
||
const title = document.createElement('span');
|
||
title.className = 'tile-title';
|
||
const name = document.createElement('span');
|
||
name.className = 'tile-name';
|
||
// A session literally named like a UI string ("Sessions") must not be translated.
|
||
name.setAttribute('data-i18n-skip', '');
|
||
name.addEventListener('dblclick', (e) => {
|
||
e.stopPropagation();
|
||
this.startTileRename(sessionId);
|
||
});
|
||
// The model the session runs, when the server knows it: the name itself is
|
||
// never translated (data-i18n-skip on the inner span), the tooltip on the
|
||
// outer one may be. Hidden from screen readers: the logo's accessible name
|
||
// already says it.
|
||
const model = document.createElement('span');
|
||
model.className = 'tile-model';
|
||
model.setAttribute('aria-hidden', 'true');
|
||
model.hidden = true;
|
||
const modelName = document.createElement('span');
|
||
modelName.setAttribute('data-i18n-skip', '');
|
||
model.appendChild(modelName);
|
||
title.append(name, model);
|
||
const actions = document.createElement('span');
|
||
actions.className = 'tile-actions';
|
||
const button = (cls, label, glyph, onClick) => {
|
||
const b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = `tile-btn ${cls}`;
|
||
b.title = label;
|
||
b.setAttribute('aria-label', label);
|
||
b.textContent = glyph;
|
||
b.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||
b.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
onClick(e);
|
||
});
|
||
return b;
|
||
};
|
||
const zoomBtn = button('tile-zoom', 'Zoom this tile', '\u2922', () => this.zoomTile(sessionId));
|
||
zoomBtn.setAttribute('aria-pressed', 'false');
|
||
actions.append(
|
||
button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)),
|
||
zoomBtn,
|
||
// (No + here, owner decision 9: tiles are added from the Tiles button
|
||
// and its count menu, Ctrl/Cmd+click, a dragged tab, a tab group or Run.)
|
||
// Removes the tile ONLY: the session keeps running. Killing it stays
|
||
// behind the menu's Close session and its confirm.
|
||
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId))
|
||
);
|
||
el.append(dot, harness, title, actions);
|
||
this._installTileMoveDrag(sessionId, el, actions);
|
||
return { el, dot, harness, name, model, modelName, zoomBtn };
|
||
},
|
||
|
||
/**
|
||
* The header moves its tile: dragged onto another tile the two trade places,
|
||
* onto an empty cell it moves there and leaves its own cell empty
|
||
* (dropSessionOnTile / dropSessionOnSlot, the path a dragged tab takes,
|
||
* through the same capture-phase drop targets, _acceptTabDrops). A native drag, so Escape and a drop anywhere else are the
|
||
* browser's own cancel: nothing moves, and dragend clears what the drag
|
||
* painted. The drag carries a type of its own and never text, so no text
|
||
* field or terminal, in this page or another application, can take it as
|
||
* typing; and it is not `draggedTabId`, so the tab strip ignores it.
|
||
*
|
||
* The handle is the header's free area: a press on a button or the rename
|
||
* input starts no drag. dragstart's target is the header whatever was
|
||
* pressed, so where the press landed is noted in the capture phase, ahead of
|
||
* the buttons' own stopPropagation. `draggable` is off while moving is
|
||
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress).
|
||
*/
|
||
_installTileMoveDrag(sessionId, header, actions) {
|
||
let pressedOnControl = false;
|
||
header.addEventListener(
|
||
'pointerdown',
|
||
(e) => {
|
||
const target = e.target;
|
||
pressedOnControl = !!actions.contains?.(target) || /^(INPUT|BUTTON)$/i.test(target?.tagName || '');
|
||
},
|
||
true
|
||
);
|
||
header.addEventListener('dragstart', (e) => {
|
||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||
if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
|
||
e.preventDefault?.();
|
||
return;
|
||
}
|
||
this._draggedTileId = sessionId;
|
||
entry.el.classList.add('tile--dragging');
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
// Firefox starts no drag without data.
|
||
e.dataTransfer.setData('application/x-codeman-tile', sessionId);
|
||
}
|
||
});
|
||
header.addEventListener('dragend', () => this._endTileMoveDrag());
|
||
},
|
||
|
||
/**
|
||
* A header drag is over (dropped, cancelled with Escape, dropped outside),
|
||
* or its tile or the grid went mid-drag: forget it and clear what it
|
||
* painted. The drop targets' highlight too: a cancelled drag does not
|
||
* reliably send dragleave.
|
||
*/
|
||
_endTileMoveDrag() {
|
||
this._draggedTileId = null;
|
||
const grid = this._tileGrid;
|
||
for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target');
|
||
for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target');
|
||
},
|
||
|
||
/**
|
||
* The header as a handle: `draggable` while the tile can move (not while a
|
||
* tile is zoomed, alone, or being renamed), and its tooltip, the state and
|
||
* how long ("working 3m") plus, while it can move, that it drags. Diffs on
|
||
* the last English text, never the DOM (translated in zh-CN; see
|
||
* _renderTileOverlay).
|
||
*/
|
||
_paintTileHandle(entry) {
|
||
const movable = this._tilesMovable();
|
||
const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n');
|
||
if (entry.headerLabel !== title) {
|
||
entry.headerLabel = title;
|
||
entry.header.title = title;
|
||
}
|
||
const draggable = movable && !entry.renaming ? 'true' : 'false';
|
||
if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
|
||
},
|
||
|
||
/**
|
||
* Refreshes one tile's header from the session: dot state, harness logo,
|
||
* name, model, the hover label ("working 3m") and the `needs` border. Diffs on existing nodes only,
|
||
* and cheap: it runs on every tab render (every status change).
|
||
*/
|
||
_renderTileHeader(sessionId) {
|
||
const entry = this._tileGrid?.tiles.get(sessionId);
|
||
const session = this.sessions.get(sessionId);
|
||
if (!entry?.header || !session) return;
|
||
const row = this._sidebarRichRow?.(sessionId, session) || null;
|
||
const state = row?.state || 'idle';
|
||
const dotClass = `tile-dot home-sessions-dot home-sessions-dot--${row?.exited ? 'done' : state}`;
|
||
if (entry.dot.className !== dotClass) entry.dot.className = dotClass;
|
||
const since = row?.since?.at ? this._mobileOverviewStampText?.(row.since.at, 'for') : '';
|
||
entry.stateLabel = row ? [row.pill, since].filter(Boolean).join(' ') : '';
|
||
this._paintTileHandle(entry);
|
||
// The input of a rename in progress has taken the name's place in the
|
||
// header, so updating the detached name never touches what is being typed.
|
||
// A rename still in flight shows as already done, as on the tab.
|
||
const name =
|
||
this._inlineRenamePending?.get(sessionId) || this.getSessionName?.(session) || session.name || 'Session';
|
||
if (entry.name.textContent !== name) entry.name.textContent = name;
|
||
this._paintSessionHarness(entry, session, 'tile-harness');
|
||
// A permission prompt is visible across the room.
|
||
entry.el.classList.toggle('tile--needs', state === 'needs');
|
||
this._renderTileOverlay(sessionId);
|
||
},
|
||
|
||
/** Every tile's header (after a tab render, i.e. any session change). */
|
||
_renderTileChrome() {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) return;
|
||
for (const [id, entry] of grid.tiles) {
|
||
this._renderTileHeader(id);
|
||
// A pane that started after the tile connected (a session Run made
|
||
// while the grid is open, an Attach from elsewhere) was spawned at the
|
||
// server's default size: the tile's own resize went out before there
|
||
// was a PTY to take it, and Run's resize measures the parked main
|
||
// terminal (nothing). Keyed on the sessions map, so a handleInit after
|
||
// an SSE drop is seen too; a render skipped during an inline tab rename
|
||
// is caught up by the rename's own render.
|
||
const pid = this.sessions.get(id)?.pid ?? null;
|
||
if (pid !== null && pid !== entry.pid) entry.tile.paneStarted?.();
|
||
entry.pid = pid;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Double-click on a tile's name: an input in its place, Enter or leaving it
|
||
* renames through the tab rename's own write queue, Escape cancels. The
|
||
* header refresh leaves the name alone meanwhile.
|
||
*/
|
||
startTileRename(sessionId) {
|
||
const entry = this._tileGrid?.tiles.get(sessionId);
|
||
const session = this.sessions.get(sessionId);
|
||
if (!entry || !session || entry.renaming) return;
|
||
entry.renaming = true;
|
||
// No drag from the header while its input is in it (_paintTileHandle).
|
||
this._paintTileHandle(entry);
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'tile-rename-input';
|
||
input.setAttribute('aria-label', 'Session name');
|
||
// A rename still in flight is the user's last word.
|
||
input.value = this._inlineRenamePending?.get(sessionId) ?? session.name ?? '';
|
||
let settled = false;
|
||
const finish = (commit) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
input.replaceWith(entry.name);
|
||
entry.renaming = false;
|
||
const value = input.value.trim();
|
||
if (commit && value && value !== session.name && this.sessions.has(sessionId)) {
|
||
entry.name.textContent = value;
|
||
void this._queueInlineSessionName?.(sessionId, value);
|
||
}
|
||
this._renderTileHeader(sessionId);
|
||
};
|
||
input.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||
// A click in the input is not the header's click, which would focus the
|
||
// terminal away from it.
|
||
input.addEventListener('click', (e) => e.stopPropagation());
|
||
input.addEventListener('keydown', (e) => {
|
||
// Enter and Escape during an IME composition belong to the IME.
|
||
if (e.isComposing || e.keyCode === 229) return;
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
finish(true);
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
finish(false);
|
||
}
|
||
});
|
||
input.addEventListener('blur', () => finish(true));
|
||
entry.name.replaceWith(input);
|
||
input.focus();
|
||
input.select?.();
|
||
},
|
||
|
||
_connectTile(sessionId) {
|
||
this._tileGrid?.tiles
|
||
.get(sessionId)
|
||
?.tile.connect()
|
||
.catch(() => {
|
||
/* Best-effort, as the split's Pane B: live output arrives once the socket opens. */
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
_onTileExit(sessionId, tile, code) {
|
||
if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return;
|
||
// The session exited: the tile stays, with the Attach overlay over it.
|
||
if (code === 4009) {
|
||
this._renderTileOverlay(sessionId);
|
||
return;
|
||
}
|
||
this.removeTile(sessionId);
|
||
},
|
||
|
||
/**
|
||
* Columns x rows for the current tile count, applied to the grid section.
|
||
* The shape comes from the count alone; within it each tile sits in its
|
||
* cell (grid.cells) and any cell may be empty. When the shape changes, the
|
||
* cells follow fitTileCells (each tile keeps its row and column if all fit,
|
||
* else the tiles pack in reading order).
|
||
*/
|
||
_applyTileLayout() {
|
||
const grid = this._tileGrid;
|
||
const section = this._tileGridSection();
|
||
const { cols, rows, fits } = this._tileShapeFor(grid.ids.length);
|
||
grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows);
|
||
if (grid.colFr.length !== cols) grid.colFr = new Array(cols).fill(1);
|
||
if (grid.rowFr.length !== rows) grid.rowFr = new Array(rows).fill(1);
|
||
grid.cols = cols;
|
||
grid.rows = rows;
|
||
// A window too small for the tiles' minimum size shows the focused tile
|
||
// alone, with a hint; once it fits again the grid comes back. A zoom the
|
||
// user chose is theirs: it stays until they lift it.
|
||
if (!fits && !grid.zoomedId && grid.focusedId) {
|
||
grid.zoomedId = grid.focusedId;
|
||
grid.autoZoom = true;
|
||
this.showToast?.(`The window is too small for ${grid.ids.length} tiles: showing the focused one`, 'info');
|
||
} else if (fits && grid.autoZoom) {
|
||
grid.zoomedId = null;
|
||
grid.autoZoom = false;
|
||
}
|
||
const zoomed = grid.zoomedId && grid.tiles.has(grid.zoomedId) ? grid.zoomedId : null;
|
||
section.classList.toggle('tile-grid--zoomed', !!zoomed);
|
||
this._syncTileZoom(zoomed);
|
||
// Moving is off while a tile is zoomed or alone: the headers say so.
|
||
for (const entry of grid.tiles.values()) this._paintTileHandle(entry);
|
||
// Zoomed: one cell; the other tiles stay connected but hidden (CSS), so
|
||
// they measure nothing and send no resize. Otherwise every tile is placed
|
||
// explicitly in its cell, with a divider track between columns and between
|
||
// rows, and every empty cell holds a drop slot.
|
||
section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.colFr);
|
||
section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.rowFr);
|
||
const holes = [];
|
||
grid.cells.forEach((id, k) => {
|
||
if (!id) {
|
||
holes.push(k);
|
||
return;
|
||
}
|
||
const el = grid.tiles.get(id)?.el;
|
||
if (!el) return;
|
||
el.style.gridColumn = id === zoomed ? '1' : String(2 * (k % cols) + 1);
|
||
el.style.gridRow = id === zoomed ? '1' : String(2 * Math.floor(k / cols) + 1);
|
||
});
|
||
this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows);
|
||
this._syncTileSlots(zoomed ? [] : holes, cols);
|
||
this._persistTileGrid();
|
||
},
|
||
|
||
// Each tile's zoom state: the zoomed one is shown alone (CSS), and every ⤢
|
||
// button says what pressing it does next.
|
||
_syncTileZoom(zoomed) {
|
||
for (const [id, entry] of this._tileGrid.tiles) {
|
||
const on = id === zoomed;
|
||
entry.el.classList.toggle('tile--zoomed', on);
|
||
const zoomBtn = entry.zoomBtn;
|
||
if (!zoomBtn) continue;
|
||
zoomBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
const label = on ? 'Restore the grid' : 'Zoom this tile';
|
||
// Against the last English label, never the DOM (translated in zh-CN).
|
||
if (entry.zoomLabel !== label) {
|
||
entry.zoomLabel = label;
|
||
zoomBtn.title = label;
|
||
zoomBtn.setAttribute('aria-label', label);
|
||
}
|
||
}
|
||
},
|
||
|
||
// The empty cells of a layout that is not full (3 tiles in a 2x2, 5 in a
|
||
// 3x2), wherever they are: drop targets for a tab (it joins there) and for a
|
||
// tile (it moves there). Each slot knows its cell (`data-cell`).
|
||
_syncTileSlots(holes, cols) {
|
||
const grid = this._tileGrid;
|
||
grid.slots ||= [];
|
||
while (grid.slots.length > holes.length) grid.slots.pop().remove();
|
||
while (grid.slots.length < holes.length) {
|
||
const slot = document.createElement('div');
|
||
slot.className = 'tile-slot';
|
||
slot.textContent = 'Drop a tab or a tile here';
|
||
this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId, Number(slot.dataset.cell)));
|
||
this._tileGridSection().appendChild(slot);
|
||
grid.slots.push(slot);
|
||
}
|
||
grid.slots.forEach((slot, i) => {
|
||
const k = holes[i];
|
||
slot.dataset.cell = String(k);
|
||
slot.style.gridColumn = String(2 * (k % cols) + 1);
|
||
slot.style.gridRow = String(2 * Math.floor(k / cols) + 1);
|
||
});
|
||
},
|
||
|
||
// One divider per gap between columns and between rows, created and dropped
|
||
// as the counts change (never rebuilt while they stay, so a drag in progress
|
||
// keeps its element).
|
||
_syncTileDividers(cols, rows) {
|
||
const grid = this._tileGrid;
|
||
const section = this._tileGridSection();
|
||
const wanted = new Set();
|
||
for (let i = 0; i < cols - 1; i++) wanted.add(`col-${i}`);
|
||
for (let i = 0; i < rows - 1; i++) wanted.add(`row-${i}`);
|
||
for (const [key, el] of grid.dividers) {
|
||
if (wanted.has(key)) continue;
|
||
if (this._tileDividerDrag?.key === key) this._tileDividerDragTeardown?.();
|
||
el.remove();
|
||
grid.dividers.delete(key);
|
||
}
|
||
for (const key of wanted) {
|
||
let el = grid.dividers.get(key);
|
||
const [axis, n] = key.split('-');
|
||
const index = Number(n);
|
||
if (!el) {
|
||
el = document.createElement('div');
|
||
el.className = `tile-divider tile-divider--${axis}`;
|
||
el.setAttribute('role', 'separator');
|
||
el.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal');
|
||
el.setAttribute('aria-label', axis === 'col' ? 'Resize tile columns' : 'Resize tile rows');
|
||
el.addEventListener('pointerdown', (e) => this._startTileDividerDrag(e, axis, index, el, key));
|
||
section.appendChild(el);
|
||
grid.dividers.set(key, el);
|
||
}
|
||
el.style.gridColumn = axis === 'col' ? String(2 * index + 2) : '1 / -1';
|
||
el.style.gridRow = axis === 'col' ? '1 / -1' : String(2 * index + 2);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Drags a column or row divider: the two tracks either side trade size, each
|
||
* kept at the minimum tile size (dragTrackFractions, constants.js). The
|
||
* affected tiles reflow locally once per animation frame; their PTYs hear
|
||
* ONE resize each, at pointer-up, never per move (each one is a tmux resize
|
||
* and a SIGWINCH). Pointer capture keeps the drag on the divider whatever is
|
||
* under the pointer; closing the grid or removing a tile mid-drag tears it
|
||
* down through _tileDividerDragTeardown.
|
||
*/
|
||
_startTileDividerDrag(e, axis, index, divider, key) {
|
||
if (e.button !== undefined && e.button !== 0) return;
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) return;
|
||
e.preventDefault?.();
|
||
e.stopPropagation?.();
|
||
this._tileDividerDragTeardown?.();
|
||
const T = window.CodemanTileGrid;
|
||
const section = this._tileGridSection();
|
||
const rect = section.getBoundingClientRect();
|
||
const isCol = axis === 'col';
|
||
const count = isCol ? grid.cols : grid.rows;
|
||
const total = (isCol ? rect.width : rect.height) - 2 * TILE_GRID_PADDING_PX - TILE_DIVIDER_PX * (count - 1);
|
||
const startFr = (isCol ? grid.colFr : grid.rowFr).slice();
|
||
const start = isCol ? e.clientX : e.clientY;
|
||
const minPx = isCol ? T.TILE_MIN_W : T.TILE_MIN_H;
|
||
const affected = [];
|
||
grid.cells.forEach((id, k) => {
|
||
if (!id) return;
|
||
const track = isCol ? k % grid.cols : Math.floor(k / grid.cols);
|
||
if (track === index || track === index + 1) affected.push(grid.tiles.get(id).tile);
|
||
});
|
||
let raf = null;
|
||
let pending = start;
|
||
let capturedPointerId = null;
|
||
const apply = (pos) => {
|
||
if (!grid.open) return;
|
||
const fr = T.dragTrackFractions(startFr, index, pos - start, total, minPx);
|
||
if (isCol) grid.colFr = fr;
|
||
else grid.rowFr = fr;
|
||
section.style[isCol ? 'gridTemplateColumns' : 'gridTemplateRows'] = tileGridTracks(fr);
|
||
for (const tile of affected) tile.localFit();
|
||
};
|
||
const onMove = (ev) => {
|
||
pending = isCol ? ev.clientX : ev.clientY;
|
||
if (raf !== null) return;
|
||
raf = requestAnimationFrame(() => {
|
||
raf = null;
|
||
apply(pending);
|
||
});
|
||
};
|
||
const endDrag = () => {
|
||
divider.classList.remove('dragging');
|
||
document.body.classList.remove('tile-grid-resizing', `tile-grid-resizing--${axis}`);
|
||
if (capturedPointerId !== null) {
|
||
try {
|
||
divider.releasePointerCapture?.(capturedPointerId);
|
||
} catch {
|
||
/* Already released. */
|
||
}
|
||
capturedPointerId = null;
|
||
}
|
||
divider.removeEventListener('pointermove', onMove);
|
||
divider.removeEventListener('pointerup', onUp);
|
||
divider.removeEventListener('pointercancel', onUp);
|
||
if (raf !== null) {
|
||
cancelAnimationFrame(raf);
|
||
raf = null;
|
||
}
|
||
if (this._tileDividerDragTeardown === endDrag) {
|
||
this._tileDividerDragTeardown = null;
|
||
this._tileDividerDrag = null;
|
||
}
|
||
};
|
||
const onUp = () => {
|
||
// The last queued frame carries the final pointer position.
|
||
const queued = raf !== null;
|
||
endDrag();
|
||
if (queued) apply(pending);
|
||
for (const tile of affected) {
|
||
if (!tile._destroyed) tile.fit();
|
||
}
|
||
this._persistTileGrid();
|
||
};
|
||
divider.classList.add('dragging');
|
||
document.body.classList.add('tile-grid-resizing', `tile-grid-resizing--${axis}`);
|
||
try {
|
||
divider.setPointerCapture?.(e.pointerId);
|
||
capturedPointerId = e.pointerId ?? null;
|
||
} catch {
|
||
/* The drag still works through the listeners below. */
|
||
}
|
||
divider.addEventListener('pointermove', onMove);
|
||
divider.addEventListener('pointerup', onUp);
|
||
divider.addEventListener('pointercancel', onUp);
|
||
this._tileDividerDragTeardown = endDrag;
|
||
this._tileDividerDrag = { key };
|
||
},
|
||
|
||
/**
|
||
* Zooms a tile to fill the grid, like tmux zoom, or restores the grid when it
|
||
* is the one zoomed. A tile that is not focused is focused first (a human
|
||
* selection: the user asked to look at it). Every tile is refitted after, the
|
||
* shown ones to their new size and the zoomed one to the whole grid.
|
||
*/
|
||
zoomTile(sessionId) {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open || !grid.tiles.has(sessionId)) return;
|
||
if (grid.zoomedId === sessionId) {
|
||
grid.zoomedId = null;
|
||
grid.autoZoom = false;
|
||
} else {
|
||
if (grid.focusedId !== sessionId) this.selectSession(sessionId);
|
||
grid.zoomedId = sessionId;
|
||
grid.autoZoom = false;
|
||
}
|
||
this._applyTileLayout();
|
||
this._scheduleTileGridRefit();
|
||
},
|
||
|
||
// Refits every tile once the grid area has settled: one xterm resize and one
|
||
// PTY resize together per tile (#464), on the trailing edge.
|
||
_scheduleTileGridRefit() {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) return;
|
||
clearTimeout(grid.refitTimer);
|
||
grid.refitTimer = setTimeout(() => {
|
||
grid.refitTimer = null;
|
||
if (!grid.open) return;
|
||
// The 3-tile layout depends on the width (3x1 or 2x2).
|
||
this._applyTileLayout();
|
||
for (const { tile } of grid.tiles.values()) tile.fit();
|
||
}, TILE_GRID_REFIT_MS);
|
||
},
|
||
|
||
// Narrowing the window past the desktop gate returns to the single view of the
|
||
// focused session; the grid is remembered.
|
||
_installTileGridWidthGate() {
|
||
if (this._tileGridWidthGateInstalled || !window.matchMedia) return;
|
||
this._tileGridWidthGateInstalled = true;
|
||
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
|
||
mq.addEventListener('change', (e) => {
|
||
if (!e.matches && this._tileGrid?.open) this.closeTileGrid({ keepStored: true, reselect: true });
|
||
});
|
||
},
|
||
|
||
_paintTileFocus() {
|
||
const grid = this._tileGrid;
|
||
for (const [id, { el }] of grid?.tiles || []) el.classList.toggle('focused', id === grid.focusedId);
|
||
},
|
||
|
||
/**
|
||
* Focuses a tiled session: the tile branch of selectSession. Moving focus is
|
||
* an `activeSessionId` change plus `xterm.focus()`: no fetch, no replay. It
|
||
* runs the panel refresh a normal switch runs and skips everything bound to
|
||
* the main terminal (cleanup, replay, resize, its socket, local echo). Only a
|
||
* USER-initiated selection (`auto` not true) acknowledges the idle alert.
|
||
*
|
||
* @param {string} sessionId
|
||
* @param {{auto?: boolean, focus?: boolean}} [options] - `focus: false` leaves
|
||
* DOM focus where it is (an app-driven reconcile must not steal it)
|
||
*/
|
||
_selectTiledSession(sessionId, options = {}) {
|
||
const grid = this._tileGrid;
|
||
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
||
if (!entry) return;
|
||
const userInitiated = options.auto !== true;
|
||
// Aborts any in-flight normal select at its next _isStaleSelect check.
|
||
const selectGen = ++this._selectGeneration;
|
||
this._hideWebviewLayer?.();
|
||
this.activeSessionId = sessionId;
|
||
grid.focusedId = sessionId;
|
||
// Moving focus off a zoomed tile restores the grid, as selecting another
|
||
// pane does in tmux. An automatic zoom (the window cannot fit the tiles)
|
||
// follows focus instead: there is no grid to restore.
|
||
if (grid.zoomedId && grid.zoomedId !== sessionId) {
|
||
grid.zoomedId = grid.autoZoom ? sessionId : null;
|
||
this._applyTileLayout();
|
||
this._scheduleTileGridRefit();
|
||
}
|
||
this._activateFileBrowserSession?.(sessionId);
|
||
try {
|
||
localStorage.setItem('codeman-active-session', sessionId);
|
||
} catch {
|
||
/* Per-device convenience only. */
|
||
}
|
||
// The SSE subscription follows the focused session as in the single view;
|
||
// its terminal frames are dropped by the parking guards.
|
||
this._updateSseSubscription?.(sessionId);
|
||
this.hideWelcome();
|
||
if (userInitiated) this.markIdleAlertSeen(sessionId);
|
||
this._paintTileFocus();
|
||
this._updateActiveTabImmediate?.(sessionId);
|
||
this.closeSessionSidebarOnHandheld?.();
|
||
this.renderSessionTabs?.();
|
||
const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
|
||
// Not while it still glows: on every skin but OG the glow is `animation:
|
||
// none`, so animationend never comes and each tile focus used to leave
|
||
// another listener behind on the tab (49 after 50 focus changes).
|
||
if (activeTab && !activeTab.classList.contains('tab-glow')) {
|
||
activeTab.classList.add('tab-glow');
|
||
activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true });
|
||
}
|
||
this.updateAttachmentHistoryBadge?.();
|
||
if (this.attachmentHistoryDrawerOpen) this.loadAttachmentHistory?.(sessionId);
|
||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession();
|
||
this.refreshHostWakeBanner?.(sessionId);
|
||
this.currentSessionWorkingDir = this.sessions.get(sessionId)?.workingDir || null;
|
||
const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16);
|
||
idleCb(() => this._refreshSessionPanels(sessionId, selectGen));
|
||
// The keyboard follows focus: shortcuts, voice and paste act on this tile.
|
||
this._noteFocusedTile(entry.tile);
|
||
if (options.focus !== false) entry.tile.terminal?.focus();
|
||
this._persistTileGrid();
|
||
},
|
||
|
||
// ── Persistence (codeman:tile-grid, per device, ids only) ────────────────
|
||
|
||
/**
|
||
* Writes the open grid: ids, focus, a zoom the user chose (an automatic one
|
||
* is worked out again from the window) and the divider fractions. Never
|
||
* content. `open: false` is the closed-but-remembered state. Never in a solo
|
||
* window; a storage failure only costs the convenience.
|
||
*/
|
||
_persistTileGrid({ open = true } = {}) {
|
||
const grid = this._tileGrid;
|
||
if (this.isSoloWindow || !grid || grid.ids.length === 0) return;
|
||
if (open && !grid.open) return;
|
||
const state = {
|
||
v: 1,
|
||
open,
|
||
// The cells, null for an empty one, so a hole comes back where it was
|
||
// (sanitizeTileGridState; a build before cells drops the nulls and reads
|
||
// the tiles packed, as it always did).
|
||
ids: grid.cells.slice(),
|
||
focused: grid.focusedId,
|
||
zoomed: grid.autoZoom ? null : grid.zoomedId,
|
||
colFr: grid.colFr.slice(),
|
||
rowFr: grid.rowFr.slice(),
|
||
};
|
||
try {
|
||
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify(state));
|
||
} catch {
|
||
/* Per-device convenience only. */
|
||
}
|
||
},
|
||
|
||
_forgetStoredTileGrid() {
|
||
try {
|
||
localStorage.removeItem(TILE_GRID_STORAGE_KEY);
|
||
} catch {
|
||
/* Nothing stored. */
|
||
}
|
||
},
|
||
|
||
/** The stored grid, sanitized against the sessions this page knows now, or null. */
|
||
_readStoredTileGrid() {
|
||
if (this.isSoloWindow) return null;
|
||
let raw = null;
|
||
try {
|
||
raw = localStorage.getItem(TILE_GRID_STORAGE_KEY);
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!raw) return null;
|
||
return window.CodemanTileGrid.sanitizeTileGridState(raw, this.sessions, this.detachedSessions);
|
||
},
|
||
|
||
/**
|
||
* Opens a stored grid: its tiles and focus, then the fractions it had (only
|
||
* if they still match the layout) and a zoom the user chose. `auto`: the app
|
||
* is putting it back, so no idle alert is spent. `ids` is the set to open,
|
||
* the stored one by default (a reload); the Tiles button passes it trimmed
|
||
* or filled to the remembered count, a shape change the cell model's rule
|
||
* handles (reformTileCells).
|
||
*/
|
||
_openStoredTileGrid(stored, ids = stored.ids) {
|
||
const focus = stored.zoomed || stored.focused;
|
||
// The stored set (an open split closes without joining it, decision 8,
|
||
// case a), trimmed or filled to the count by the caller.
|
||
if (!this.openTileGrid(ids, { focusedId: focus, auto: true, mergeSplit: false })) return false;
|
||
const grid = this._tileGrid;
|
||
const T = window.CodemanTileGrid;
|
||
// openTileGrid packed the tiles from the first cell. The stored tiles go
|
||
// back to their cells (a session gone since leaves its cell empty): as
|
||
// they were when this is the stored set in the shape it was stored with,
|
||
// otherwise by the cell model's rule, the tiles the count adds filling the
|
||
// empty cells in reading order, holes first.
|
||
const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
|
||
const kept = storedCells.filter(Boolean);
|
||
const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id));
|
||
const cells = sameSet
|
||
? storedCells
|
||
: T.reformTileCells(
|
||
storedCells,
|
||
T.tileCellCols(storedCells.length),
|
||
kept,
|
||
grid.ids.filter((id) => !kept.includes(id)),
|
||
grid.cols,
|
||
grid.rows
|
||
);
|
||
if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
|
||
grid.cells = cells;
|
||
}
|
||
// openTileGrid laid the grid out with equal tracks. The stored ones go back
|
||
// on; _applyTileLayout drops them again if they do not match the column or
|
||
// row count (the window may have changed the layout since).
|
||
if (stored.colFr) grid.colFr = stored.colFr.slice();
|
||
if (stored.rowFr) grid.rowFr = stored.rowFr.slice();
|
||
if (stored.zoomed && grid.tiles.has(stored.zoomed)) {
|
||
grid.zoomedId = stored.zoomed;
|
||
grid.autoZoom = false;
|
||
}
|
||
this._applyTileLayout();
|
||
this._scheduleTileGridRefit();
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* Page load (handleInit, in place of selecting the session to restore): a
|
||
* grid stored OPEN on this device comes back, ids sanitized against the
|
||
* session list (deleted, detached and duplicate ids dropped). The main
|
||
* terminal then never loads on this page load, so no `full=1` capture is
|
||
* paid for a terminal about to be parked. Not in a solo window (nothing is
|
||
* read there), nor on a window too narrow for the grid (openTileGrid
|
||
* refuses; the stored grid waits for a wide one).
|
||
*
|
||
* @returns {boolean} whether the grid was restored
|
||
*/
|
||
_restoreTileGrid() {
|
||
if (this._tilesOwnTerminal()) return false;
|
||
const stored = this._readStoredTileGrid();
|
||
if (!stored?.open || stored.ids.length === 0) return false;
|
||
return this._openStoredTileGrid(stored);
|
||
},
|
||
|
||
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
|
||
_closeStoredTileGrid() {
|
||
const stored = this._readStoredTileGrid();
|
||
if (!stored?.open) return;
|
||
// Stored as it was read, holes included (`ids` carries the cells).
|
||
const { cells, ...rest } = stored;
|
||
try {
|
||
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false }));
|
||
} catch {
|
||
/* Per-device convenience only. */
|
||
}
|
||
},
|
||
|
||
/** Header connection state while tiles own the terminal: every live tile socket open, or not. */
|
||
_tileGridSocketState() {
|
||
for (const { tile } of this._tileGrid?.tiles.values() || []) {
|
||
// A tile stopped for good (its session exited) has nothing to reconnect.
|
||
if (tile._stoppedCode !== null && tile._stoppedCode !== undefined) continue;
|
||
if (!tile._wsReady) return 'reconnecting';
|
||
}
|
||
return 'connected';
|
||
},
|
||
|
||
/**
|
||
* handleInit (page state reloaded, or SSE back after a server restart) with
|
||
* the grid open: tiles whose sessions are gone or popped out are removed, the
|
||
* rest are KEPT (never rebuilt) and told to reconnect now instead of waiting
|
||
* out their backoff, and focus stays on a live tile.
|
||
*
|
||
* @returns {boolean} whether the grid is still open (handleInit then skips
|
||
* restoring the main terminal)
|
||
*/
|
||
_reconcileTileGrid() {
|
||
const grid = this._tileGrid;
|
||
if (!grid?.open) return false;
|
||
for (const id of grid.ids.slice()) {
|
||
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false });
|
||
}
|
||
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 });
|
||
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
|
||
return true;
|
||
},
|
||
});
|
||
|
||
// 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
|
||
// 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
|
||
// (closeSession, in _closingSessions) owns its own follow-up: only the tile goes.
|
||
const _tileGridOriginalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
|
||
CodemanApp.prototype._onSessionDeleted = function (data) {
|
||
const grid = this._tileGrid;
|
||
if (grid?.has(data.id)) {
|
||
const wasFocused = grid.focusedId === data.id;
|
||
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 });
|
||
}
|
||
}
|
||
return _tileGridOriginalOnSessionDeleted.call(this, data);
|
||
};
|
||
|
||
// Every tab render (any session change: status, hooks, name) refreshes the
|
||
// tile headers too, so a tile's dot, name and needs border follow the same
|
||
// state the tab shows.
|
||
const _tileGridOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
|
||
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
|
||
const result = _tileGridOriginalRenderSessionTabsImmediate.apply(this, args);
|
||
this._renderTileChrome?.();
|
||
return result;
|
||
};
|