mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +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' }],
|
||||
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',
|
||||
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
|
||||
// disables (App Settings → Shortcuts) take effect. Every dispatchable
|
||||
// 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;
|
||||
}
|
||||
|
||||
/* 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
|
||||
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
|
||||
|
||||
@@ -46,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the old exact-node check below) bubbled straight through to
|
||||
// `document` and self-closed the menu it just opened.
|
||||
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) {
|
||||
this.closeSplitPane();
|
||||
return;
|
||||
@@ -139,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
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
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// the home screen still created the container and connected Pane B, just
|
||||
|
||||
@@ -208,6 +208,10 @@
|
||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -590,6 +590,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
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
|
||||
// ^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
|
||||
|
||||
+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
|
||||
// window drag sends each tile's PTY one resize, not one per frame.
|
||||
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. */
|
||||
class TileGridModel {
|
||||
@@ -137,8 +147,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
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 });
|
||||
requested = [...seed, ...requested];
|
||||
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||||
}
|
||||
const wanted = [];
|
||||
for (const id of ids || []) {
|
||||
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);
|
||||
@@ -175,6 +195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._installTileGridWidthGate();
|
||||
this._selectTiledSession(focus, { auto });
|
||||
this._updateConnectionIndicator?.();
|
||||
this._updateSplitButtonForTiles();
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -230,9 +251,95 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminalBufferCache?.delete(id);
|
||||
}
|
||||
this._updateConnectionIndicator?.();
|
||||
this._updateSplitButtonForTiles();
|
||||
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,
|
||||
// replayed fresh (forceReload drops the stale snapshot and nulls
|
||||
// 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
|
||||
* app around it: constants.js + app.js + terminal-ui.js + tile-grid.js in one
|
||||
* context, a small fake DOM (just what the grid touches) and a fake
|
||||
* app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
|
||||
* 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.
|
||||
*
|
||||
* `makeGridApp()` returns an app instance with everything around the grid that
|
||||
@@ -57,7 +57,8 @@ export class FakeEl {
|
||||
remove() {
|
||||
if (!this.parentElement) return;
|
||||
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;
|
||||
}
|
||||
setAttribute(k: string, v: string) {
|
||||
@@ -76,7 +77,15 @@ export class FakeEl {
|
||||
getBoundingClientRect() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -117,6 +126,11 @@ section.id = 'tileGrid';
|
||||
section.className = 'tile-grid';
|
||||
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>();
|
||||
/** What the code under test deferred with requestIdleCallback; a test runs them. */
|
||||
export const idleCallbacks: Array<() => void> = [];
|
||||
@@ -145,11 +159,13 @@ const context = vm.createContext({
|
||||
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
|
||||
history: { replaceState: vi.fn(), state: null },
|
||||
document: {
|
||||
addEventListener: vi.fn(),
|
||||
addEventListener: documentAddEventListener,
|
||||
documentElement: { dataset: {} },
|
||||
createElement: () => new FakeEl(),
|
||||
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: () => [],
|
||||
},
|
||||
localStorage: {
|
||||
@@ -162,7 +178,8 @@ const context = vm.createContext({
|
||||
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||
});
|
||||
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;',
|
||||
context
|
||||
);
|
||||
@@ -237,4 +254,10 @@ export function resetGridHarness() {
|
||||
windowStub.innerWidth = 2400;
|
||||
section.children = [];
|
||||
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