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:
Codeman maintainer
2026-10-06 16:31:29 +02:00
parent 1a04a75c3d
commit 6adf750c50
9 changed files with 672 additions and 8 deletions
+124
View File
@@ -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);
});
});