Files
Codeman/test/tile-grid-entry-points.test.ts
T
Codeman maintainer b7fafb1c16 feat(tiles): the Tiles button opens the grid at once; the picker is on right-click
Owner decision 8 ("when I hit the tiles button, open the tiles already!").
A click with the grid closed now opens it straight away, and Ctrl+Shift+G
runs the same function (toggleTileGrid), so the two cannot drift. What
opens comes from one pure helper, tileGridOpenSet (constants.js):
  a. the grid this tab last had, if any of its sessions survive, opened
     exactly (an open split closes and its sessions do not join);
  b. else an open split's two sessions, Pane A focused;
  c. else the open sessions in tab order (the picker's list: no detached
     ones), up to what the grid takes here (the cap of 6, fewer when the
     window fits fewer), the active session always among them and focused.
A click with the grid open still closes it.

The picker moved to right-click (oncontextmenu, browser menu suppressed).
With the grid open it is preselected with the current tiles, and Open
replaces them. Ctrl/Cmd+click on a tab with the grid closed opens the
toggle's set plus that session. The button's title, the Help modal and
the wiki say right-click chooses which sessions.

Docs: decision 8 and an as-built entry in the spec (Entry points too),
CLAUDE.md, the invariants (#tile-grid, Opening), the wiki's Tile Grid and
Keyboard Shortcuts pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 23:23:55 +02:00

224 lines
8.2 KiB
TypeScript

/**
* @fileoverview The ways into the grid besides the Tiles button.
*
* - Drag a tab from the strip onto a tile: a session not yet tiled replaces it
* (same place, the replaced session keeps running); one already tiled swaps
* places. Onto an empty slot (3 tiles in a 2x2): it joins there. The tile
* handles the drag in the CAPTURE phase and stops it, because the payload is
* the session id as text and xterm's helper textarea would type it into the
* PTY; a drag that is not a tab (a file) is left alone.
* - Ctrl/Cmd+click on a tab: the session joins the grid (opening it on what
* Tiles would bring back) and takes focus, as a human selection; on a window
* too narrow for the grid it is an ordinary click.
* - "Open group as tiles" in the grouped rail's group menu: the group's live
* sessions become the grid.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl;
const slots = () => section.children.filter((el) => el.className === 'tile-slot');
const dragEvent = () => ({ preventDefault: vi.fn(), stopPropagation: vi.fn(), dataTransfer: { dropEffect: '' } });
function dropOn(el: FakeEl) {
const over = dragEvent();
el.dispatch('dragover', over);
const drop = dragEvent();
el.dispatch('drop', drop);
return { over, drop };
}
beforeEach(() => {
resetGridHarness();
});
describe('dragging a tab onto a tile', () => {
it('a session not yet tiled replaces the tile in place; the replaced one keeps running', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.markIdleAlertSeen.mockClear();
const replaced = FakeTile.all.find((t) => t.sessionId === 's-b')!;
app.draggedTabId = 's-other';
const { over, drop } = dropOn(tileEl('s-b'));
expect(over.preventDefault).toHaveBeenCalled();
expect(drop.stopPropagation).toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(['s-a', 's-other', 's-c']);
expect(replaced.destroy).toHaveBeenCalledTimes(1);
expect(app.sessions.has('s-b')).toBe(true);
expect(tileEl('s-other').style.gridColumn).toBe('3');
expect(app.activeSessionId).toBe('s-other');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-other');
});
it('a session already tiled swaps places with the target', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.draggedTabId = 's-c';
dropOn(tileEl('s-a'));
expect(app._tileGrid.ids).toEqual(['s-c', 's-b', 's-a']);
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
expect(tileEl('s-c').style.gridColumn).toBe('1');
expect(app.activeSessionId).toBe('s-c');
});
it('a drag that is not a tab (a file) is left alone', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.draggedTabId = null;
const { over, drop } = dropOn(tileEl('s-b'));
expect(over.preventDefault).not.toHaveBeenCalled();
expect(drop.stopPropagation).not.toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(IDS);
});
it('is handled in the capture phase, before xterm sees the drop', () => {
makeGridApp(IDS).openTileGrid(IDS);
expect(tileEl('s-a').captureFlags.dragover).toEqual([true]);
expect(tileEl('s-a').captureFlags.drop).toEqual([true]);
});
});
describe('dragging a tab onto an empty slot', () => {
// Under 1800px wide three tiles take a 2x2 (over it, they sit side by side).
beforeEach(() => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
});
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
it('3 tiles in a 2x2 leave one slot, placed after the tiles', () => {
makeGridApp(IDS).openTileGrid(IDS);
expect(slots()).toHaveLength(1);
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '3']);
});
it('a session dropped there joins the grid and takes focus', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.draggedTabId = 's-other';
dropOn(slots()[0]);
expect(app._tileGrid.ids).toEqual([...IDS, 's-other']);
expect(slots()).toHaveLength(0);
expect(app.activeSessionId).toBe('s-other');
});
it('a full layout has no slot', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b']);
expect(slots()).toHaveLength(0);
});
});
describe('Ctrl/Cmd+click on a tab', () => {
const click = (mods: Record<string, boolean>) => ({ preventDefault: vi.fn(), ...mods });
it('with the grid closed: opens what the Tiles toggle would, plus the clicked one, focusing it', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-a';
// Nothing remembered, no split: the open sessions in tab order (the clicked one last).
app.handleSessionTabClick(click({ ctrlKey: true }), 's-c');
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
});
it('with the grid open: adds it and focuses it (Cmd works the same)', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b']);
app.markIdleAlertSeen.mockClear();
app.handleSessionTabClick(click({ metaKey: true }), 's-c');
expect(app._tileGrid.ids).toEqual(IDS);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
});
it('on a tiled one: just focuses its tile', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.handleSessionTabClick(click({ ctrlKey: true }), 's-b');
expect(app._tileGrid.ids).toEqual(IDS);
expect(app.activeSessionId).toBe('s-b');
});
it('a plain click is still a plain selection', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.handleSessionTabClick(click({}), 's-b');
expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true });
expect(app._tileGrid?.open ?? false).toBe(false);
});
it('on a window too narrow for the grid it is an ordinary click', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
windowStub.innerWidth = 1100;
app.handleSessionTabClick(click({ ctrlKey: true }), 's-b');
expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true });
expect(app._tileGrid?.open ?? false).toBe(false);
});
});
describe('"Open group as tiles"', () => {
function withGroups(app: GridApp) {
app.tabLayout = {
groups: [
{
id: 'g1',
refs: [
{ kind: 'session', id: 's-c' },
// A web tab is never a tile, even one whose id a session also has.
{ kind: 'webview', id: 's-b' },
{ kind: 'session', id: 'gone' },
{ kind: 'session', id: 's-a' },
],
},
],
};
let actions: Array<{ label: string; run: () => void }> = [];
app._openTabLayoutMenu = vi.fn((_e: unknown, _k: string, _l: string, a: typeof actions) => {
actions = a;
return true;
});
return () => actions;
}
it('is in the group menu, and opens the group live sessions as tiles', () => {
const app = makeGridApp(IDS);
const actions = withGroups(app);
app.openTabGroupMenu({}, 'g1');
const open = actions().find((a) => a.label === 'Open group as tiles');
expect(open).toBeTruthy();
open!.run();
expect(app._tileGrid.ids).toEqual(['s-c', 's-a']);
});
it('replaces an open grid', () => {
const app = makeGridApp(IDS);
withGroups(app);
app.openTileGrid(['s-b']);
app.openGroupAsTiles('g1');
expect(app._tileGrid.ids).toEqual(['s-c', 's-a']);
});
it('is not offered where the grid cannot open', () => {
const app = makeGridApp(IDS);
const actions = withGroups(app);
windowStub.innerWidth = 1100;
app.openTabGroupMenu({}, 'g1');
expect(actions().some((a) => a.label === 'Open group as tiles')).toBe(false);
});
});