mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
- 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>
205 lines
8.3 KiB
TypeScript
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);
|
|
});
|
|
});
|