Files
Codeman/test/tile-grid-close-fallback.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

205 lines
8.3 KiB
TypeScript

/**
* @fileoverview Closing or deleting a tiled session keeps the grid open and
* moves focus to the NEIGHBOURING tile.
*
* `closeSession()` normally falls back to the first remaining `sessionOrder`
* entry with `auto: true`, and that entry is often NOT tiled: an app-driven
* pick that, with the grid open, would be refused (auto never collapses the
* grid) and leave nothing focused. So the fallback is grid-aware, and it lives
* IN closeSession: the delete broadcast routinely lands while the request is in
* flight, and the delete handlers skip ids in `_closingSessions`. The neighbour
* is captured BEFORE the await, like `wasActive`, because that broadcast may
* already have removed the tile.
*
* - closing the focused tile: next tile in grid order, else the previous one;
* `s-other` is FIRST in sessionOrder and never tiled, so the old pick would
* have collapsed the grid;
* - the same with the broadcast arriving mid-request and after it;
* - closing the last tile closes the grid and falls back to the normal pick;
* - a tiled session deleted ELSEWHERE: its tile goes, a neighbour takes focus
* with `auto` (no idle alert spent); the last one leaves the welcome screen.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeTile, flushFrames, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
/** A grid on `ids` focused on `focus`, with the DELETE request held open until `finish()`. */
function setup(ids = IDS, focus = ids[0]) {
const app = makeGridApp(ids);
app.openTileGrid(ids, { focusedId: focus });
app.selectSession = vi.fn();
app.markIdleAlertSeen.mockClear();
let finish: () => void = () => {};
app._apiDelete = vi.fn(() => new Promise<void>((r) => (finish = r)));
// The real cleanup touches a lot of panels; what the fallback reads is the session list.
app._cleanupSessionData = vi.fn((id: string) => {
app.sessions.delete(id);
app.sessionOrder = app.sessionOrder.filter((s: string) => s !== id);
});
return { app: app as GridApp, finish: () => finish() };
}
async function settle() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
beforeEach(() => {
resetGridHarness();
});
describe('closeSession on the focused tile', () => {
it('keeps the grid open and focuses the next tile, never the untiled first sessionOrder entry', async () => {
const { app, finish } = setup(IDS, 's-a');
const closing = app.closeSession('s-a');
finish();
await closing;
expect(app._tilesOwnTerminal()).toBe(true);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app.selectSession).not.toHaveBeenCalled();
// The app chose the neighbour: no idle alert spent.
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('the last tile in grid order hands focus back to the previous one', async () => {
const { app, finish } = setup(IDS, 's-c');
const closing = app.closeSession('s-c');
finish();
await closing;
expect(app.activeSessionId).toBe('s-b');
});
it('the delete broadcast arriving DURING the request changes nothing about the outcome', async () => {
const { app, finish } = setup(IDS, 's-b');
const closing = app.closeSession('s-b');
await settle();
app._onSessionDeleted({ id: 's-b' });
// Only the tile went; closeSession owns the follow-up (as the split's
// wrapper does for ids in _closingSessions), so focus has not moved yet.
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app.showWelcome).not.toHaveBeenCalled();
finish();
await closing;
expect(app._tilesOwnTerminal()).toBe(true);
expect(app.activeSessionId).toBe('s-c');
expect(app.showWelcome).not.toHaveBeenCalled();
});
it('the delete broadcast arriving AFTER the request is a no-op for the grid', async () => {
const { app, finish } = setup(IDS, 's-a');
const closing = app.closeSession('s-a');
finish();
await closing;
app._onSessionDeleted({ id: 's-a' });
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
});
it('closing the LAST tile closes the grid and falls back to the normal pick', async () => {
const { app, finish } = setup(['s-a'], 's-a');
const closing = app.closeSession('s-a');
finish();
await closing;
expect(app._tilesOwnTerminal()).toBe(false);
expect(FakeTile.all[0].destroy).toHaveBeenCalledTimes(1);
expect(app.selectSession).toHaveBeenCalledWith('s-other', { auto: true });
});
it('closing a tile that is NOT focused removes it and leaves focus alone', async () => {
const { app, finish } = setup(IDS, 's-a');
const closing = app.closeSession('s-c');
finish();
await closing;
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-a');
});
});
describe('a tiled session deleted elsewhere', () => {
it('removes its tile and moves focus to the neighbour with `auto`', () => {
const { app } = setup(IDS, 's-b');
app._onSessionDeleted({ id: 's-b' });
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
expect(app.showWelcome).not.toHaveBeenCalled();
});
it('a deleted tile that was not focused just goes', () => {
const { app } = setup(IDS, 's-a');
app._onSessionDeleted({ id: 's-c' });
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-a');
});
it('the last tile deleted closes the grid and lands on the welcome screen, as in the single view', () => {
const { app } = setup(['s-a'], 's-a');
app._onSessionDeleted({ id: 's-a' });
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.activeSessionId).toBeNull();
expect(app.showWelcome).toHaveBeenCalled();
});
});
// The handoffs above are the APP's choice: focus moves (activeSessionId, the
// focus paint) but the keyboard does not. Moving DOM focus into the
// neighbour's xterm sent whatever the user was still typing, Enter included,
// into another session's agent; the single view sends it nowhere. Only a
// removal the user made (the x button, Remove Focused Tile) carries the
// keyboard along.
describe('an app-driven refocus never moves the keyboard into another session', () => {
const live = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!;
const focusCalls = () => FakeTile.all.reduce((n, t) => n + t.terminal.focus.mock.calls.length, 0);
/** A grid on IDS focused on s-b, every terminal built, no focus recorded yet. */
function built() {
const app = makeGridApp(IDS);
app.openTileGrid(IDS, { focusedId: 's-b' });
flushFrames();
for (const t of FakeTile.all) t.terminal.focus.mockClear();
return app;
}
it('a remote delete of the focused tile hands focus to the neighbour, DOM focus untouched', () => {
const app = built();
app._onSessionDeleted({ id: 's-b' });
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app._tileGrid.focusedId).toBe('s-c');
expect(focusCalls()).toBe(0);
// No deferred focus left to land later either.
expect(app._tileGrid.focusOnConnect ?? null).toBeNull();
});
it.each([4003, 4004, 4010])('a socket the server closed (%i) removes the tile, DOM focus untouched', (code) => {
const app = built();
app._onTileExit('s-b', live('s-b'), code);
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(focusCalls()).toBe(0);
});
it('a reconcile that finds the focused session gone moves focus, DOM focus untouched', () => {
const app = built();
app.sessions.delete('s-b');
app._reconcileTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-a');
expect(focusCalls()).toBe(0);
});
it('a removal the user made still carries the keyboard to the neighbour', () => {
const app = built();
app.removeFocusedTile();
expect(app.activeSessionId).toBe('s-c');
expect(live('s-c').terminal.focus).toHaveBeenCalledTimes(1);
});
});