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>
242 lines
9.8 KiB
TypeScript
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();
|
|
});
|
|
});
|