mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(tiles): tile chords in the shortcut registry, and the grid never beside a split
Shortcut registry (DEFAULT_SHORTCUTS, group Tiles, all rebindable): - Toggle Tile Grid, Ctrl+Shift+G: opens the grid this tab last left (one step back after a selection outside it), else an open split as two tiles, else the active session as one tile; pressed again, back to the single view of the focused session. xterm emits nothing for a shifted Ctrl letter; the browser's find-previous is overridden only where the grid can open. - Focus Tile Left/Right/Up/Down, Alt+Shift+Arrows: a human selection of the tile in that direction. - Remove Focused Tile, unbound: the session keeps running. tileShortcutFor() decides whether a chord applies (the toggle wherever a grid could open, the rest only while one is open, so outside the grid Alt+Shift+Arrows reach the terminal untouched) and is registry-aware. The capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and before the Shift+Enter gate, so a chord that applies never reaches a PTY. Coexistence with the split pane: opening the grid over an open split closes it (no wasted resize for the pane about to park) and seeds the grid with both of its sessions, Pane A focused. While the grid is open openSplitPicker and openSplitPane refuse and the Split button reads as unavailable (aria-disabled); closing the grid never reopens a split. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -525,6 +525,56 @@ const DEFAULT_SHORTCUTS = [
|
|||||||
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
||||||
action: 'toggleSessionSidebar',
|
action: 'toggleSessionSidebar',
|
||||||
},
|
},
|
||||||
|
// Tile grid (tile-grid.js). Dispatched by tileShortcutFor()/runTileShortcut()
|
||||||
|
// and deliberately absent from SHORTCUT_ACTIONS: each applies only in some
|
||||||
|
// states (the focus chords only while the grid is open), and outside them the
|
||||||
|
// chord must reach the terminal untouched. Every xterm key handler swallows a
|
||||||
|
// chord that applies, so it never reaches a PTY. Defaults: Ctrl+Shift+G makes
|
||||||
|
// xterm emit nothing (a shifted Ctrl letter) and overrides only the browser's
|
||||||
|
// find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs.
|
||||||
|
{
|
||||||
|
id: 'toggle-tile-grid',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Toggle Tile Grid',
|
||||||
|
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }],
|
||||||
|
action: 'toggleTileGrid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus-tile-left',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Focus Tile Left',
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowLeft' }],
|
||||||
|
action: 'focusTileLeft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus-tile-right',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Focus Tile Right',
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowRight' }],
|
||||||
|
action: 'focusTileRight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus-tile-up',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Focus Tile Up',
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowUp' }],
|
||||||
|
action: 'focusTileUp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus-tile-down',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Focus Tile Down',
|
||||||
|
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
|
||||||
|
action: 'focusTileDown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remove-tile',
|
||||||
|
group: 'Tiles',
|
||||||
|
label: 'Remove Focused Tile',
|
||||||
|
// Unbound by default; the session keeps running either way.
|
||||||
|
bindings: [],
|
||||||
|
action: 'removeTile',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'previous-next-session',
|
id: 'previous-next-session',
|
||||||
group: 'Session',
|
group: 'Session',
|
||||||
@@ -1362,6 +1412,15 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
|
||||||
|
// so outside the grid Alt+Shift+Arrows still reach the terminal.
|
||||||
|
const tileShortcut = this.tileShortcutFor?.(e);
|
||||||
|
if (tileShortcut) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.runTileShortcut(tileShortcut);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Match against the shortcut registry so user rebinds and per-shortcut
|
// Match against the shortcut registry so user rebinds and per-shortcut
|
||||||
// disables (App Settings → Shortcuts) take effect. Every dispatchable
|
// disables (App Settings → Shortcuts) take effect. Every dispatchable
|
||||||
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
|
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
|
||||||
|
|||||||
@@ -2510,6 +2510,12 @@ html[data-tab-orientation='vertical'].tab-rail-compact
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The tile grid is open (tile-grid.js): the split cannot open beside it. */
|
||||||
|
.btn-split.btn-split--blocked {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* While a split is open, the button's own click closes it instead of opening
|
/* While a split is open, the button's own click closes it instead of opening
|
||||||
the picker — the accent fill is what tells the user which of its two
|
the picker — the accent fill is what tells the user which of its two
|
||||||
behaviours the next click will get (aria-pressed carries the same state
|
behaviours the next click will get (aria-pressed carries the same state
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the old exact-node check below) bubbled straight through to
|
// the old exact-node check below) bubbled straight through to
|
||||||
// `document` and self-closed the menu it just opened.
|
// `document` and self-closed the menu it just opened.
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
|
// The tile grid and the split are never open together (tile-grid.js); the
|
||||||
|
// Split button shows as unavailable meanwhile.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
if (this._splitPane) {
|
if (this._splitPane) {
|
||||||
this.closeSplitPane();
|
this.closeSplitPane();
|
||||||
return;
|
return;
|
||||||
@@ -139,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||||
// a CSS backstop exist.
|
// a CSS backstop exist.
|
||||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||||
|
// Never beside the tile grid: the main terminal is parked while it is open.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
// No active session means there is no `.terminal-wrap` to split against
|
// No active session means there is no `.terminal-wrap` to split against
|
||||||
// (the welcome overlay is showing) — without this, a split opened from
|
// (the welcome overlay is showing) — without this, a split opened from
|
||||||
// the home screen still created the container and connected Pane B, just
|
// the home screen still created the container and connected Pane B, just
|
||||||
|
|||||||
@@ -208,6 +208,10 @@
|
|||||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Tile grid chords (focus, toggle): acted on by the capture handler, so
|
||||||
|
// they must never reach this tile's PTY. Every event type, and before
|
||||||
|
// the Shift+Enter branch below.
|
||||||
|
if (global.app?.tileShortcutFor?.(ev)) return false;
|
||||||
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
|
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
|
||||||
// a pasted image uploads to this pane's session and its path is typed
|
// a pasted image uploads to this pane's session and its path is typed
|
||||||
// here, and pasted text goes into this xterm with its bracketed-paste
|
// here, and pasted text goes into this xterm with its bracketed-paste
|
||||||
|
|||||||
@@ -590,6 +590,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tile grid chords (Ctrl+Shift+G, Alt+Shift+Arrows): the capture handler
|
||||||
|
// has already acted on one that applies, and its preventDefault() does not
|
||||||
|
// stop xterm. Every event type, and BEFORE the Shift+Enter branch below.
|
||||||
|
if (this.tileShortcutFor?.(ev)) return false;
|
||||||
|
|
||||||
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
|
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
|
||||||
// ^C. With NO selection the branch must fall through (return true, and no
|
// ^C. With NO selection the branch must fall through (return true, and no
|
||||||
// preventDefault) or the interrupt key is lost, which is the whole reason
|
// preventDefault) or the interrupt key is lost, which is the whole reason
|
||||||
|
|||||||
+108
-1
@@ -26,6 +26,16 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
|
|||||||
// Trailing debounce for refitting tiles after the grid area changes size, so a
|
// 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.
|
// window drag sends each tile's PTY one resize, not one per frame.
|
||||||
const TILE_GRID_REFIT_MS = 150;
|
const TILE_GRID_REFIT_MS = 150;
|
||||||
|
// 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.
|
||||||
|
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' },
|
||||||
|
'remove-tile': { needsOpen: true },
|
||||||
|
};
|
||||||
|
|
||||||
/** The grid's state. `has(id)` answers only while it is open. */
|
/** The grid's state. `has(id)` answers only while it is open. */
|
||||||
class TileGridModel {
|
class TileGridModel {
|
||||||
@@ -137,8 +147,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!this.canOpenTileGrid()) return false;
|
if (!this.canOpenTileGrid()) return false;
|
||||||
const grid = (this._tileGrid ||= new TileGridModel());
|
const grid = (this._tileGrid ||= new TileGridModel());
|
||||||
const max = window.CodemanTileGrid.TILE_GRID_MAX;
|
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 });
|
||||||
|
requested = [...seed, ...requested];
|
||||||
|
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||||||
|
}
|
||||||
const wanted = [];
|
const wanted = [];
|
||||||
for (const id of ids || []) {
|
for (const id of requested) {
|
||||||
if (typeof id !== 'string' || wanted.includes(id)) continue;
|
if (typeof id !== 'string' || wanted.includes(id)) continue;
|
||||||
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue;
|
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue;
|
||||||
wanted.push(id);
|
wanted.push(id);
|
||||||
@@ -175,6 +195,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._installTileGridWidthGate();
|
this._installTileGridWidthGate();
|
||||||
this._selectTiledSession(focus, { auto });
|
this._selectTiledSession(focus, { auto });
|
||||||
this._updateConnectionIndicator?.();
|
this._updateConnectionIndicator?.();
|
||||||
|
this._updateSplitButtonForTiles();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -230,9 +251,95 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.terminalBufferCache?.delete(id);
|
this.terminalBufferCache?.delete(id);
|
||||||
}
|
}
|
||||||
this._updateConnectionIndicator?.();
|
this._updateConnectionIndicator?.();
|
||||||
|
this._updateSplitButtonForTiles();
|
||||||
if (reselect) this._selectAfterTileGrid(focusedId);
|
if (reselect) this._selectAfterTileGrid(focusedId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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
|
||||||
|
* wherever a grid could open (or is open), the focus and remove chords only
|
||||||
|
* while it is open, so outside the grid they reach the terminal untouched.
|
||||||
|
* 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.needsOpen ? open : open || this.canOpenTileGrid()) return shortcut.id;
|
||||||
|
return 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 (spec.direction) this.focusTileInDirection(spec.direction);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the grid, or closes it to the single view of the focused session.
|
||||||
|
* Opening brings back the grid this tab last left (decision 1: one step back
|
||||||
|
* after a selection outside it), else an open split as two tiles, else the
|
||||||
|
* active session as one tile.
|
||||||
|
*/
|
||||||
|
toggleTileGrid() {
|
||||||
|
if (this._tilesOwnTerminal()) {
|
||||||
|
this.closeTileGrid({ keepStored: true, reselect: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.canOpenTileGrid()) return;
|
||||||
|
const remembered = this._tileGridRemembered;
|
||||||
|
const ids = (remembered?.ids || []).filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id));
|
||||||
|
if (ids.length > 0) {
|
||||||
|
this.openTileGrid(ids, { focusedId: remembered.focusedId, auto: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.activeSessionId) this.openTileGrid([this.activeSessionId], { focusedId: this.activeSessionId });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
|
||||||
|
focusTileInDirection(direction) {
|
||||||
|
const grid = this._tileGrid;
|
||||||
|
if (!grid?.open) return;
|
||||||
|
const id = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols);
|
||||||
|
if (id) this.selectSession(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 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, { refocus: true, auto: true });
|
||||||
|
},
|
||||||
|
|
||||||
// The single view after the grid closes: the session the grid was focused on,
|
// The single view after the grid closes: the session the grid was focused on,
|
||||||
// replayed fresh (forceReload drops the stale snapshot and nulls
|
// replayed fresh (forceReload drops the stale snapshot and nulls
|
||||||
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
|
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
|
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
|
||||||
* app around it: constants.js + app.js + terminal-ui.js + tile-grid.js in one
|
* app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
|
||||||
* context, a small fake DOM (just what the grid touches) and a fake
|
* tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake
|
||||||
* TerminalTile that records what the grid asks of it.
|
* TerminalTile that records what the grid asks of it.
|
||||||
*
|
*
|
||||||
* `makeGridApp()` returns an app instance with everything around the grid that
|
* `makeGridApp()` returns an app instance with everything around the grid that
|
||||||
@@ -57,7 +57,8 @@ export class FakeEl {
|
|||||||
remove() {
|
remove() {
|
||||||
if (!this.parentElement) return;
|
if (!this.parentElement) return;
|
||||||
const siblings = this.parentElement.children;
|
const siblings = this.parentElement.children;
|
||||||
siblings.splice(siblings.indexOf(this), 1);
|
const i = siblings.indexOf(this);
|
||||||
|
if (i !== -1) siblings.splice(i, 1);
|
||||||
this.parentElement = null;
|
this.parentElement = null;
|
||||||
}
|
}
|
||||||
setAttribute(k: string, v: string) {
|
setAttribute(k: string, v: string) {
|
||||||
@@ -76,7 +77,15 @@ export class FakeEl {
|
|||||||
getBoundingClientRect() {
|
getBoundingClientRect() {
|
||||||
return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 };
|
return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 };
|
||||||
}
|
}
|
||||||
querySelector() {
|
/** `.class` selectors only: the first descendant carrying that class. */
|
||||||
|
querySelector(sel: string): FakeEl | null {
|
||||||
|
if (!sel.startsWith('.') || /[\s[>:]/.test(sel)) return null;
|
||||||
|
const cls = sel.slice(1);
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (child.classList.contains(cls)) return child;
|
||||||
|
const deeper = child.querySelector(sel);
|
||||||
|
if (deeper) return deeper;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +126,11 @@ section.id = 'tileGrid';
|
|||||||
section.className = 'tile-grid';
|
section.className = 'tile-grid';
|
||||||
main.appendChild(section);
|
main.appendChild(section);
|
||||||
|
|
||||||
|
/** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */
|
||||||
|
export const bySelector = new Map<string, FakeEl>();
|
||||||
|
export const body = new FakeEl();
|
||||||
|
/** `document.addEventListener`, so a test can find a listener the app installed. */
|
||||||
|
export const documentAddEventListener = vi.fn();
|
||||||
export const localStore = new Map<string, string>();
|
export const localStore = new Map<string, string>();
|
||||||
/** What the code under test deferred with requestIdleCallback; a test runs them. */
|
/** What the code under test deferred with requestIdleCallback; a test runs them. */
|
||||||
export const idleCallbacks: Array<() => void> = [];
|
export const idleCallbacks: Array<() => void> = [];
|
||||||
@@ -145,11 +159,13 @@ const context = vm.createContext({
|
|||||||
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
|
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
|
||||||
history: { replaceState: vi.fn(), state: null },
|
history: { replaceState: vi.fn(), state: null },
|
||||||
document: {
|
document: {
|
||||||
addEventListener: vi.fn(),
|
addEventListener: documentAddEventListener,
|
||||||
documentElement: { dataset: {} },
|
documentElement: { dataset: {} },
|
||||||
createElement: () => new FakeEl(),
|
createElement: () => new FakeEl(),
|
||||||
getElementById: (id: string) => (id === 'tileGrid' ? section : null),
|
getElementById: (id: string) => (id === 'tileGrid' ? section : null),
|
||||||
querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
|
body,
|
||||||
|
querySelector: (sel: string) =>
|
||||||
|
sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : (bySelector.get(sel) ?? null),
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
},
|
},
|
||||||
localStorage: {
|
localStorage: {
|
||||||
@@ -162,7 +178,8 @@ const context = vm.createContext({
|
|||||||
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||||
});
|
});
|
||||||
vm.runInContext(
|
vm.runInContext(
|
||||||
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` +
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
|
||||||
|
`${read('tile-grid.js')}\n` +
|
||||||
'globalThis.__CodemanApp = CodemanApp;',
|
'globalThis.__CodemanApp = CodemanApp;',
|
||||||
context
|
context
|
||||||
);
|
);
|
||||||
@@ -237,4 +254,10 @@ export function resetGridHarness() {
|
|||||||
windowStub.innerWidth = 2400;
|
windowStub.innerWidth = 2400;
|
||||||
section.children = [];
|
section.children = [];
|
||||||
main.className = 'main';
|
main.className = 'main';
|
||||||
|
// A split a test left open moved .terminal-wrap into its container.
|
||||||
|
main.children = [];
|
||||||
|
main.appendChild(wrap);
|
||||||
|
main.appendChild(section);
|
||||||
|
bySelector.clear();
|
||||||
|
body.children = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The tile grid's chords: registry entries, when they apply, and
|
||||||
|
* that a chord which applies never reaches a PTY.
|
||||||
|
*
|
||||||
|
* Defaults (all rebindable in App Settings, Shortcuts): Ctrl+Shift+G toggles
|
||||||
|
* the grid, Alt+Shift+Arrows move focus between tiles, Remove Focused Tile is
|
||||||
|
* unbound. The toggle applies wherever a grid could open; the focus and remove
|
||||||
|
* chords only while the grid is open, so outside it Alt+Shift+Arrows reach the
|
||||||
|
* terminal untouched. The capture-phase handler (app.js) dispatches a chord
|
||||||
|
* that applies; its preventDefault() does not stop xterm, so every xterm key
|
||||||
|
* handler (the main terminal's and TerminalTile's) returns false for it too,
|
||||||
|
* for every event type and before the Shift+Enter gate (which would otherwise
|
||||||
|
* send S-Enter for Alt+Shift+Enter style chords).
|
||||||
|
*
|
||||||
|
* Real code: the grid harness (test/mocks/tile-grid-vm.ts) for the registry,
|
||||||
|
* the capture handler and the actions; a second `vm` context with the real
|
||||||
|
* TerminalTile for its key handler; the main terminal's handler, which needs a
|
||||||
|
* real xterm, is pinned at the source. Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
documentAddEventListener,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
windowStub as gridWindow,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
|
||||||
|
const chord = (overrides: Record<string, unknown>) => ({
|
||||||
|
type: 'keydown',
|
||||||
|
key: '',
|
||||||
|
code: '',
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
target: { closest: () => null },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true };
|
||||||
|
const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registry', () => {
|
||||||
|
it('ships the tile chords, rebindable, with Remove Focused Tile unbound', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const byId = Object.fromEntries(app.getShortcutRegistry().map((s: { id: string }) => [s.id, s])) as Record<
|
||||||
|
string,
|
||||||
|
{ bindings: Array<Record<string, unknown>>; group: string }
|
||||||
|
>;
|
||||||
|
expect(byId['toggle-tile-grid'].bindings).toEqual([{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }]);
|
||||||
|
for (const dir of ['Left', 'Right', 'Up', 'Down']) {
|
||||||
|
expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([
|
||||||
|
{ modifiers: ['alt', 'shift'], key: `Arrow${dir}` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
expect(byId['remove-tile'].bindings).toEqual([]);
|
||||||
|
expect(byId['toggle-tile-grid'].group).toBe('Tiles');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when a chord applies', () => {
|
||||||
|
it('the toggle applies wherever a grid could open, and while one is open', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the toggle does not apply in a narrow window or a solo window', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
gridWindow.innerWidth = 1000;
|
||||||
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
||||||
|
gridWindow.innerWidth = 2400;
|
||||||
|
app.isSoloWindow = true;
|
||||||
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the focus chords apply only while the grid is open', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
expect(app.tileShortcutFor(chord(RIGHT))).toBeNull();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plain typing and unrelated chords never match', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
expect(app.tileShortcutFor(chord({ key: 'g', code: 'KeyG' }))).toBeNull();
|
||||||
|
expect(app.tileShortcutFor(chord({ key: 'ArrowRight', altKey: true }))).toBeNull();
|
||||||
|
expect(app.tileShortcutFor(chord({ key: 'G', code: 'KeyG', ctrlKey: true }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a disable and a rebind from App Settings', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.loadAppSettingsFromStorage = () => ({
|
||||||
|
shortcutOverrides: {
|
||||||
|
'toggle-tile-grid': { disabled: true },
|
||||||
|
'focus-tile-right': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'l', code: 'KeyL' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
expect(app.tileShortcutFor(chord(RIGHT))).toBeNull();
|
||||||
|
expect(app.tileShortcutFor(chord({ key: 'l', code: 'KeyL', ctrlKey: true, altKey: true }))).toBe(
|
||||||
|
'focus-tile-right'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the capture-phase handler', () => {
|
||||||
|
function handlerFor(app: GridApp) {
|
||||||
|
app.$ = () => null;
|
||||||
|
app.setupColorPicker = vi.fn();
|
||||||
|
const before = (documentAddEventListener.mock.calls as unknown[]).length;
|
||||||
|
app.setupEventListeners();
|
||||||
|
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
|
||||||
|
const keydown = added.find(([type, , capture]) => type === 'keydown' && capture === true);
|
||||||
|
if (!keydown) throw new Error('no capture-phase keydown listener');
|
||||||
|
return keydown[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
const onKeydown = handlerFor(app);
|
||||||
|
const open = chord(TOGGLE);
|
||||||
|
onKeydown(open);
|
||||||
|
expect(open.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a']);
|
||||||
|
|
||||||
|
onKeydown(chord(TOGGLE));
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(app.selectSession).toHaveBeenCalledWith('s-a', { forceReload: true, auto: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Alt+Shift+Right moves focus to the tile on the right, as a human selection', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.markIdleAlertSeen.mockClear();
|
||||||
|
// The real selectSession routes a tiled id to the tile branch.
|
||||||
|
delete app.selectSession;
|
||||||
|
const onKeydown = handlerFor(app);
|
||||||
|
const e = chord(RIGHT);
|
||||||
|
onKeydown(e);
|
||||||
|
expect(e.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outside the grid Alt+Shift+Right is left alone for the terminal', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const onKeydown = handlerFor(app);
|
||||||
|
const e = chord(RIGHT);
|
||||||
|
onKeydown(e);
|
||||||
|
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the actions', () => {
|
||||||
|
it('the toggle brings back the grid this tab last left, focus included', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-c' });
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(IDS);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a remembered tile whose session is gone is not brought back', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
app.sessions.delete('s-b');
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with every remembered session gone, it opens on the active session instead', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(['s-b', 's-c']);
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
app.sessions.delete('s-b');
|
||||||
|
app.sessions.delete('s-c');
|
||||||
|
app.activeSessionId = 's-a';
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Remove Focused Tile removes it (the session keeps running) and a neighbour takes focus', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS, { focusedId: 's-b' });
|
||||||
|
app._apiDelete = vi.fn();
|
||||||
|
app.removeFocusedTile();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
expect(app.sessions.has('s-b')).toBe(true);
|
||||||
|
expect(app._apiDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('xterm key handlers swallow a chord that applies', () => {
|
||||||
|
it("the main terminal's handler checks the chord before the Shift+Enter gate, for every event type", () => {
|
||||||
|
// That handler lives in initTerminal's closure over a real xterm, so the
|
||||||
|
// rule is pinned at the source, as terminal-copy-clean.test.ts does.
|
||||||
|
const src = read('terminal-ui.js');
|
||||||
|
const handler = src.slice(src.indexOf('this.terminal.attachCustomKeyEventHandler((ev) => {'));
|
||||||
|
const gate = handler.indexOf('if (this.tileShortcutFor?.(ev)) return false;');
|
||||||
|
const enter = handler.indexOf("if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {");
|
||||||
|
expect(gate).toBeGreaterThan(0);
|
||||||
|
expect(gate).toBeLessThan(enter);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminalTile's handler (real tile, real tileShortcutFor)", () => {
|
||||||
|
class FakeTerminal {
|
||||||
|
static last: FakeTerminal;
|
||||||
|
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
cols = 80;
|
||||||
|
rows = 24;
|
||||||
|
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
|
||||||
|
textarea = { addEventListener() {}, removeEventListener() {} };
|
||||||
|
constructor(options: Record<string, unknown>) {
|
||||||
|
this.options = options;
|
||||||
|
FakeTerminal.last = this;
|
||||||
|
}
|
||||||
|
loadAddon() {}
|
||||||
|
open() {}
|
||||||
|
onData() {}
|
||||||
|
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
|
||||||
|
this.keyHandler = fn;
|
||||||
|
}
|
||||||
|
registerLinkProvider() {}
|
||||||
|
write() {}
|
||||||
|
clear() {}
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { terminalBuffer: '' } }) }));
|
||||||
|
const windowStub: Record<string, unknown> = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
CodemanBase: { base: '' },
|
||||||
|
innerWidth: 2400,
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: class {
|
||||||
|
static OPEN = 1;
|
||||||
|
send() {}
|
||||||
|
close() {}
|
||||||
|
},
|
||||||
|
Terminal: FakeTerminal,
|
||||||
|
FitAddon: {
|
||||||
|
FitAddon: class {
|
||||||
|
fit() {}
|
||||||
|
proposeDimensions() {
|
||||||
|
return { cols: 80, rows: 24 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch: fetchMock,
|
||||||
|
location: { protocol: 'http:', host: 'codeman.test' },
|
||||||
|
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
||||||
|
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: windowStub,
|
||||||
|
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||||
|
`${read('terminal-split.js')}\n${read('tile-grid.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
const App = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||||
|
const TerminalTile = windowStub.TerminalTile as new (
|
||||||
|
id: string,
|
||||||
|
mount: unknown,
|
||||||
|
opts: object
|
||||||
|
) => { connect(): Promise<void>; destroy(): void };
|
||||||
|
|
||||||
|
async function tileHandler(gridOpen: boolean) {
|
||||||
|
const app = Object.create(App.prototype) as GridApp;
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app.isSoloWindow = false;
|
||||||
|
app._tileGrid = { open: gridOpen };
|
||||||
|
windowStub.app = app;
|
||||||
|
const tile = new TerminalTile('s1', { addEventListener() {}, removeEventListener() {} }, { mode: 'claude' });
|
||||||
|
await tile.connect();
|
||||||
|
const handler = FakeTerminal.last.keyHandler!;
|
||||||
|
tile.destroy();
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(['keydown', 'keypress', 'keyup'])(
|
||||||
|
'returns false for Alt+Shift+Right (%s) while the grid is open',
|
||||||
|
async (type) => {
|
||||||
|
const handler = await tileHandler(true);
|
||||||
|
expect(handler({ ...chord(RIGHT), type })).toBe(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('lets Alt+Shift+Right through to the PTY when no grid is open', async () => {
|
||||||
|
const handler = await tileHandler(false);
|
||||||
|
expect(handler(chord(RIGHT))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for the toggle chord', async () => {
|
||||||
|
const handler = await tileHandler(true);
|
||||||
|
expect(handler(chord(TOGGLE))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The tile grid and the split pane are never open together.
|
||||||
|
*
|
||||||
|
* Both run on TerminalTile, and the split's wrappers (`selectSession`,
|
||||||
|
* `_onSessionDeleted` in terminal-split.js) key on `this._splitPane`, so they
|
||||||
|
* would fight the grid over the same terminal area if both were ever up:
|
||||||
|
*
|
||||||
|
* - opening the grid while a split is open closes the split first and seeds
|
||||||
|
* the grid with both of its sessions, Pane A focused and Pane B beside it;
|
||||||
|
* - while the grid is open, `openSplitPicker` and `openSplitPane` refuse, and
|
||||||
|
* the Split button says so (`aria-disabled`, a title);
|
||||||
|
* - closing the grid never reopens a split;
|
||||||
|
* - the split's wrappers stay inert while the grid is open.
|
||||||
|
*
|
||||||
|
* Real code: constants.js + app.js + terminal-ui.js + terminal-split.js +
|
||||||
|
* tile-grid.js (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
FakeTile,
|
||||||
|
body,
|
||||||
|
bySelector,
|
||||||
|
main,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c'];
|
||||||
|
|
||||||
|
function makeSplitButton() {
|
||||||
|
const btn = new FakeEl();
|
||||||
|
btn.className = 'btn-icon-header btn-split';
|
||||||
|
bySelector.set('.btn-split', btn);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** s-a in the main pane, s-b in Pane B (the split's real openSplitPane). */
|
||||||
|
function openSplit(app: GridApp) {
|
||||||
|
app.openSplitPane('s-b');
|
||||||
|
expect(app._splitSessionId).toBe('s-b');
|
||||||
|
// closeSplitPane finds its container by selector.
|
||||||
|
const container = main.querySelector('.terminal-split-container');
|
||||||
|
if (container) bySelector.set('.terminal-split-container', container);
|
||||||
|
return FakeTile.all.at(-1) as FakeTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('opening the grid over an open split', () => {
|
||||||
|
it('closes the split and seeds the grid with both of its sessions, Pane A focused', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const paneB = openSplit(app);
|
||||||
|
|
||||||
|
app.toggleTileGrid();
|
||||||
|
|
||||||
|
expect(paneB.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._splitPane).toBeNull();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
// Pane A is about to park: no closing resize for it.
|
||||||
|
expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an explicit open over a split keeps both split sessions first', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
openSplit(app);
|
||||||
|
app.openTileGrid(['s-c']);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('while the grid is open', () => {
|
||||||
|
it('the split refuses to open, from the picker or directly', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
const before = FakeTile.all.length;
|
||||||
|
|
||||||
|
app.openSplitPicker({ stopPropagation: vi.fn() });
|
||||||
|
app.openSplitPane('s-b');
|
||||||
|
|
||||||
|
expect(app._splitPane ?? null).toBeNull();
|
||||||
|
expect(FakeTile.all.length).toBe(before);
|
||||||
|
// The picker itself never opened either.
|
||||||
|
expect(body.children).toHaveLength(0);
|
||||||
|
expect(app._splitPickerDismissHandlers ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Split button is marked unavailable, and back to normal once the grid closes', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const btn = makeSplitButton();
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
expect(btn.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
expect(btn.classList.contains('btn-split--blocked')).toBe(true);
|
||||||
|
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
expect(btn.getAttribute('aria-disabled')).toBe('false');
|
||||||
|
expect(btn.classList.contains('btn-split--blocked')).toBe(false);
|
||||||
|
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the split's wrappers do nothing (no split to close or promote)", () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
app.closeSplitPane = vi.fn();
|
||||||
|
app._onSessionDeleted({ id: 's-c' });
|
||||||
|
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closing the grid', () => {
|
||||||
|
it('never reopens the split it replaced', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
openSplit(app);
|
||||||
|
app.toggleTileGrid();
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
expect(app._splitPane).toBeNull();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user