mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
selectSession gets the tile branch, right after its "already active" early return: a tiled session is focused in its tile (_selectTiledSession: an activeSessionId change, the shared _refreshSessionPanels and xterm.focus(), no cleanup, replay, resize or socket of the parked main terminal). 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; an app-driven pick (auto) never collapses it. A followed #session= link passes leaveTiles (navigation). App-driven paths pick a tile instead of the first sessionOrder entry: - closeSession on the focused tile focuses the neighbouring tile (next in grid order, else previous), captured before the await like wasActive, since the delete broadcast may already have removed the tile; the last tile closes the grid and falls back to the normal pick. - A tiled session deleted elsewhere loses its tile and a neighbour takes focus with auto (the last one lands on the welcome screen as before); a close from this tab only drops the tile and leaves the follow-up to closeSession. - A tiled session popped out to its own window leaves the grid. Focus rules: pressing a tile is a human selection (pointerdown, never preventDefault); Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles; only a human selection acknowledges an idle alert. Home leaves the grid (remembered); killing every session closes it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
151 lines
6.0 KiB
TypeScript
151 lines
6.0 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, 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();
|
|
});
|
|
});
|