Files
Codeman/test/tile-grid-select-branch.test.ts
T
Codeman maintainer 18c8b5c280 fix(tiles): file drops, focus handoffs, pop-out fallback, split merge, tablet default
- A file dragged onto the tile grid navigated the browser away: the single
  view's drop handler sits on #terminalContainer, hidden while tiles are
  open. The #tileGrid section now cancels every file dragover and drop
  (bubble phase, so tab and tile drags stay with _acceptTabDrops), and a
  drop on a tile uploads its images to THAT tile's session through
  _uploadAndInsertImages, with the same "Only image files are supported"
  toast as image-input.js (now in the zh-CN table).
- App-driven refocus no longer moves DOM focus into another session's
  xterm: a remote delete of the focused tile, _reconcileTileGrid and a
  socket closed with 4003/4004/4010 (_onTileExit) pass focus: false.
  removeTile gains a focus option; user-initiated removes keep focusing.
- Popping out the last tile left the parked terminal's stale content under
  the popped-out tab (and snapshotted it on the next switch).
  _selectAfterTileGrid treats a detached session as unusable for both the
  focused id and the fallback.
- "Open group as tiles" and Ctrl/Cmd+click with the grid closed pass
  mergeSplit: false, so an open split no longer adds its two sessions on
  top of a set already sized to the group, the count and the window.
- Touch-primary devices (primary pointer coarse: iPad, Android tablets)
  default the Tiles button OFF in getDefaultSettings(); touchscreen
  laptops (fine primary pointer) keep the desktop default ON. The button,
  the App Settings chip and the Ctrl+Shift+G gate all resolve an absent key
  through these defaults, so they agree. CLAUDE.md and the invariants doc
  say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:44:52 +02:00

242 lines
9.8 KiB
TypeScript

/**
* @fileoverview The tile branch of `selectSession`, and focus moving between tiles.
*
* With the tile grid open the main terminal is parked, so a selection must
* never reach the half of selectSession that cleans it up, replays into it or
* reconnects its socket. A TILED session is focused in its tile
* (`_selectTiledSession`: an `activeSessionId` change plus `xterm.focus()`, the
* same panel refresh as a normal switch). Decision 1: a USER-initiated pick of
* a session that is NOT tiled leaves the grid for the single view (the grid is
* remembered), and so does an explicit `leaveTiles` (a followed link); an
* app-driven pick (`auto: true`) never collapses the grid.
*
* Focus and alert rules: only a human selection acknowledges an idle alert.
* Pressing a tile, Ctrl+Tab / Alt+[ ] (which cycle through the tiles) and a tab
* click are human; the delete fallback and a popped-out tile are the app's.
*
* Real code: constants.js + app.js + terminal-ui.js + tile-grid.js (the shared
* vm harness in test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
bySelector,
flushFrames,
idleCallbacks,
localStore,
makeGridApp,
resetGridHarness,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
/** A grid open on IDS (focus s-a), with selectSession REAL and the main terminal's select path tripwired. */
function openGrid(): GridApp {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
// The tiles' terminals are built one per frame (_connectTilesPaced).
flushFrames();
delete app.selectSession; // the prototype's, for real
app._cleanupPreviousSession.mockClear();
app.markIdleAlertSeen.mockClear();
// Anything past the tile branch: the main terminal's own select path.
app._shouldFocusTerminalForTabSwitch = vi.fn(() => false);
app._setTerminalLoadState = vi.fn(() => {
throw new Error('the main terminal select path ran');
});
return app;
}
beforeEach(() => {
resetGridHarness();
});
describe('selectSession with the grid open', () => {
it('focuses a tiled session in its tile and never touches the main terminal', async () => {
const app = openGrid();
await app.selectSession('s-b');
expect(app.activeSessionId).toBe('s-b');
expect(app._tileGrid.focusedId).toBe('s-b');
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
expect(app._connectWs).not.toHaveBeenCalled();
expect(app.sendResize).not.toHaveBeenCalled();
expect(FakeTile.all.find((t) => t.sessionId === 's-b')?.terminal.focus).toHaveBeenCalled();
expect(app._focusedPane().sessionId).toBe('s-b');
});
it('focus changes leave at most one glow listener on a tab, and the glow runs again once it ended', async () => {
// On every skin but OG the glow is `animation: none`: animationend never
// fires, and every focus used to add one more once-listener to the tab.
const app = openGrid();
const tab = new FakeEl();
tab.className = 'session-tab active';
bySelector.set('.session-tab.active[data-id="s-b"]', tab);
for (let i = 0; i < 5; i++) {
await app.selectSession('s-b');
await app.selectSession('s-a');
}
expect(tab.classList.contains('tab-glow')).toBe(true);
expect(tab.listeners.animationend).toHaveLength(1);
// OG: the animation ends, the class goes, and the next focus glows again.
tab.dispatch('animationend');
tab.listeners.animationend = [];
expect(tab.classList.contains('tab-glow')).toBe(false);
await app.selectSession('s-b');
expect(tab.classList.contains('tab-glow')).toBe(true);
expect(tab.listeners.animationend).toHaveLength(1);
});
it('a user-initiated focus acknowledges the idle alert; an `auto` one does not', async () => {
const app = openGrid();
await app.selectSession('s-b');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b');
app.markIdleAlertSeen.mockClear();
await app.selectSession('s-c', { auto: true });
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('runs the same deferred panel refresh as a normal switch, tagged with its own generation', async () => {
const app = openGrid();
idleCallbacks.length = 0;
await app.selectSession('s-b');
const genB = app._selectGeneration;
expect(app._refreshSessionPanels).not.toHaveBeenCalled(); // deferred
for (const cb of idleCallbacks.splice(0)) cb();
expect(app._refreshSessionPanels).toHaveBeenCalledWith('s-b', genB);
});
it('an `auto` selection of a session that is NOT tiled leaves the grid open and changes nothing', async () => {
const app = openGrid();
await app.selectSession('s-other', { auto: true });
expect(app._tilesOwnTerminal()).toBe(true);
expect(app.activeSessionId).toBe('s-a');
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
});
it('a user-initiated selection of a session that is NOT tiled leaves the grid, remembered', async () => {
const app = openGrid();
let activeAtCleanup: unknown = 'unset';
app._cleanupPreviousSession = vi.fn(() => {
activeAtCleanup = app.activeSessionId;
});
app._setTerminalLoadState = vi.fn();
app._clearTerminalLoadState = vi.fn();
app._renderHistoryTruncationBanner = vi.fn();
app._isStaleSelect = vi.fn(() => true); // stop right after the switch itself
await app.selectSession('s-other').catch(() => {});
expect(app._tilesOwnTerminal()).toBe(false);
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true);
expect(JSON.parse(localStore.get('codeman:tile-grid')!)).toMatchObject({ open: false, ids: IDS, focused: 's-a' });
// The parked terminal's stale content must not be saved as s-a's snapshot.
expect(activeAtCleanup).toBeNull();
expect(app.activeSessionId).toBe('s-other');
});
it('`leaveTiles` makes even an `auto` selection leave the grid (a followed link is navigation)', async () => {
const app = openGrid();
app._setTerminalLoadState = vi.fn();
app._clearTerminalLoadState = vi.fn();
app._renderHistoryTruncationBanner = vi.fn();
app._isStaleSelect = vi.fn(() => true);
await app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {});
expect(app._tilesOwnTerminal()).toBe(false);
});
it('a followed `#session=` link passes leaveTiles, and a tiled one just focuses its tile', async () => {
const app = openGrid();
app._urlSessionId = 's-c';
app._selectUrlSession();
await Promise.resolve();
expect(app._tilesOwnTerminal()).toBe(true);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
});
describe('moving focus between tiles', () => {
it('pressing a tile is a human selection of its session', async () => {
const app = openGrid();
app.selectSession = vi.fn();
const tileB = tileEl('s-b');
tileB?.dispatch('pointerdown');
expect(app.selectSession).toHaveBeenCalledWith('s-b');
});
it('Ctrl+Tab / Alt+] and Alt+[ cycle through the tiles (wrapping), user-initiated, and only the tiles', () => {
const app = openGrid();
app.selectSession = vi.fn();
// From the last tile, the next one wraps to the first tile; the tab order
// would have wrapped to s-other (first in sessionOrder, not tiled).
app.activeSessionId = 's-c';
app.nextSession();
expect(app.selectSession.mock.calls).toEqual([['s-a']]);
app.selectSession.mockClear();
app.activeSessionId = 's-a';
app.prevSession();
expect(app.selectSession.mock.calls).toEqual([['s-c']]);
});
it('without the grid they walk the tab order as before', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.nextSession();
expect(app.selectSession).toHaveBeenLastCalledWith('s-b');
});
it('a tiled session popped out to its own window leaves the grid; a neighbour takes focus', () => {
const app = openGrid();
app.$ = () => null;
app._markDetached('s-a', true);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
// The last tile popped out closes the grid. The single view must never try
// to show that session: selectSession only raises its window, and the parked
// terminal's stale pre-grid content would stay up under its tab (and be
// saved as its snapshot on the next switch).
it('popping out the LAST tile falls through to the next session, never the popped-out one', () => {
const app = makeGridApp(['s-a']);
app.openTileGrid(['s-a']);
app.$ = () => null;
app.selectSession = vi.fn();
app._markDetached('s-a', true);
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession.mock.calls).toEqual([['s-other', { auto: true }]]);
// Nulled before the selection, so the parked buffer is not snapshotted as s-a's.
expect(app.activeSessionId).toBeNull();
});
it('popping out the last tile with every other session popped out too: the welcome screen, the parked terminal cleared', () => {
const app = makeGridApp(['s-a']);
app.detachedSessions.add('s-other');
app.openTileGrid(['s-a']);
app.$ = () => null;
app.selectSession = vi.fn();
app._markDetached('s-a', true);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.activeSessionId).toBeNull();
expect(app.terminal.clear).toHaveBeenCalled();
expect(app.showWelcome).toHaveBeenCalled();
});
it('going Home leaves the grid, remembered', () => {
const app = openGrid();
app.goHome();
expect(app._tilesOwnTerminal()).toBe(false);
expect(JSON.parse(localStore.get('codeman:tile-grid')!)).toMatchObject({ open: false, ids: IDS });
expect(app.activeSessionId).toBeNull();
expect(app.showWelcome).toHaveBeenCalled();
});
});