mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(tiles): selections with the grid open focus tiles, and never collapse it by themselves
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>
This commit is contained in:
+48
-2
@@ -1471,7 +1471,9 @@ class CodemanApp {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this._retireUrlSession();
|
this._retireUrlSession();
|
||||||
this.selectSession(id, { auto: true });
|
// Following a link is navigation: a tiled id focuses its tile, any other
|
||||||
|
// leaves the tile grid for the single view (the grid is remembered).
|
||||||
|
this.selectSession(id, { auto: true, leaveTiles: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1610,6 +1612,9 @@ class CodemanApp {
|
|||||||
* Full re-renders re-apply the class from this.detachedSessions. */
|
* Full re-renders re-apply the class from this.detachedSessions. */
|
||||||
_markDetached(id, on) {
|
_markDetached(id, on) {
|
||||||
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
||||||
|
// A popped-out session's window owns its PTY size now, so it leaves the
|
||||||
|
// tile grid (one place per session in this browser tab).
|
||||||
|
if (on && this._tileGrid?.has(id)) this.removeTile(id, { refocus: true, auto: true });
|
||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||||
if (tab) tab.classList.toggle('detached', on);
|
if (tab) tab.classList.toggle('detached', on);
|
||||||
@@ -7973,6 +7978,20 @@ class CodemanApp {
|
|||||||
if (userInitiated) this.markIdleAlertSeen(sessionId);
|
if (userInitiated) this.markIdleAlertSeen(sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Tile grid open (tile-grid.js): a tiled session is FOCUSED in its tile and
|
||||||
|
// never loaded into the parked main terminal. Decision 1: only a USER-
|
||||||
|
// initiated pick of a session that is not tiled (or an explicit
|
||||||
|
// `leaveTiles`, a followed link) leaves the grid for the single view, the
|
||||||
|
// grid remembered for one-click return. An app-driven pick (`auto`) never
|
||||||
|
// collapses it.
|
||||||
|
if (this._tileGrid?.open) {
|
||||||
|
if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options);
|
||||||
|
if (options?.auto === true && !options?.leaveTiles) return;
|
||||||
|
this.closeTileGrid({ keepStored: true, reselect: false });
|
||||||
|
// The parked terminal still holds what it showed before the grid opened;
|
||||||
|
// with no active id, the switch below snapshots none of it.
|
||||||
|
this.activeSessionId = null;
|
||||||
|
}
|
||||||
if (this.activeSessionId === sessionId && forceReload) {
|
if (this.activeSessionId === sessionId && forceReload) {
|
||||||
this.terminalBufferCache?.delete(sessionId);
|
this.terminalBufferCache?.delete(sessionId);
|
||||||
this._xtermSnapshots?.delete(sessionId);
|
this._xtermSnapshots?.delete(sessionId);
|
||||||
@@ -8828,12 +8847,24 @@ class CodemanApp {
|
|||||||
// next session or dumped you on the home screen, depending on which path
|
// next session or dumped you on the home screen, depending on which path
|
||||||
// won the race (both outcomes measured on one build, 2026-08-17).
|
// won the race (both outcomes measured on one build, 2026-08-17).
|
||||||
const wasActive = this.activeSessionId === sessionId;
|
const wasActive = this.activeSessionId === sessionId;
|
||||||
|
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
|
||||||
|
// sessionOrder entry (often not tiled, which would collapse the grid).
|
||||||
|
// Captured here for the same reason as wasActive: the SSE delete can remove
|
||||||
|
// the tile while the request is still in flight.
|
||||||
|
const grid = this._tileGrid;
|
||||||
|
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
|
||||||
this._closingSessions.add(sessionId);
|
this._closingSessions.add(sessionId);
|
||||||
try {
|
try {
|
||||||
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
||||||
this._cleanupSessionData(sessionId);
|
this._cleanupSessionData(sessionId);
|
||||||
|
// The last tile leaving closes the grid (no reselect): the pick below runs.
|
||||||
|
if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
|
||||||
|
|
||||||
if (wasActive) {
|
if (wasActive && grid?.open) {
|
||||||
|
// `auto`: the app chose this tile because the previous one went away.
|
||||||
|
const target = grid.has(tileNeighborId) ? tileNeighborId : grid.ids[0];
|
||||||
|
this._selectTiledSession(target, { auto: true });
|
||||||
|
} else if (wasActive) {
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||||
// Next tab in the user's own order, skipping ids the cleanup has not
|
// Next tab in the user's own order, skipping ids the cleanup has not
|
||||||
@@ -8918,6 +8949,12 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextSession() {
|
nextSession() {
|
||||||
|
// With the tile grid open, Ctrl+Tab and Alt+] cycle through the tiles.
|
||||||
|
if (this._tileGrid?.open) {
|
||||||
|
const id = window.CodemanTileGrid.cycleTile(this._tileGrid.ids, this.activeSessionId, 1);
|
||||||
|
if (id) this.selectSession(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.sessionOrder.length <= 1) return;
|
if (this.sessionOrder.length <= 1) return;
|
||||||
|
|
||||||
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
||||||
@@ -8926,6 +8963,11 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevSession() {
|
prevSession() {
|
||||||
|
if (this._tileGrid?.open) {
|
||||||
|
const id = window.CodemanTileGrid.cycleTile(this._tileGrid.ids, this.activeSessionId, -1);
|
||||||
|
if (id) this.selectSession(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.sessionOrder.length <= 1) return;
|
if (this.sessionOrder.length <= 1) return;
|
||||||
|
|
||||||
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
||||||
@@ -8941,6 +8983,8 @@ class CodemanApp {
|
|||||||
// Going Home is choosing something else, so a `#session=<id>` link still
|
// Going Home is choosing something else, so a `#session=<id>` link still
|
||||||
// waiting for its session must not take the screen later.
|
// waiting for its session must not take the screen later.
|
||||||
this._retireUrlSession();
|
this._retireUrlSession();
|
||||||
|
// Home is a choice to leave the grid too; it is remembered for Tiles.
|
||||||
|
this.closeTileGrid?.({ keepStored: true, reselect: false });
|
||||||
// Deselect active session and show welcome screen
|
// Deselect active session and show welcome screen
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||||
@@ -8991,6 +9035,8 @@ class CodemanApp {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this._apiDelete('/api/sessions');
|
await this._apiDelete('/api/sessions');
|
||||||
|
// Every tiled session is gone: nothing left to remember or reselect.
|
||||||
|
this.closeTileGrid?.({ keepStored: false, reselect: false });
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
this.terminalBufferCache.clear();
|
this.terminalBufferCache.clear();
|
||||||
|
|||||||
@@ -309,6 +309,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'tile-body';
|
body.className = 'tile-body';
|
||||||
el.appendChild(body);
|
el.appendChild(body);
|
||||||
|
// Pressing a tile is a human selection: it focuses the tile and
|
||||||
|
// acknowledges its idle alert (the already-focused tile hits
|
||||||
|
// selectSession's early return, which acknowledges too). pointerdown, not
|
||||||
|
// click, so focus moves before the press reaches xterm, and never
|
||||||
|
// preventDefault: xterm's own mousedown focuses its textarea and starts
|
||||||
|
// selections.
|
||||||
|
el.addEventListener('pointerdown', () => {
|
||||||
|
if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId);
|
||||||
|
});
|
||||||
this._tileGridSection().appendChild(el);
|
this._tileGridSection().appendChild(el);
|
||||||
const tile = new window.TerminalTile(sessionId, body, {
|
const tile = new window.TerminalTile(sessionId, body, {
|
||||||
mode: session.mode,
|
mode: session.mode,
|
||||||
@@ -479,3 +488,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
|
||||||
|
// the neighbouring tile takes it (`auto`: the app chose, so no idle alert is
|
||||||
|
// spent). Done BEFORE the original handler, so activeSessionId no longer names
|
||||||
|
// the deleted id and its welcome-screen handoff stays out of it. The last tile
|
||||||
|
// closes the grid without a reselect, and the original handler then shows the
|
||||||
|
// welcome screen as in the single view. A close started from this tab
|
||||||
|
// (closeSession, in _closingSessions) owns its own follow-up: only the tile goes.
|
||||||
|
const _tileGridOriginalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
|
||||||
|
CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||||
|
const grid = this._tileGrid;
|
||||||
|
if (grid?.has(data.id)) {
|
||||||
|
const wasFocused = grid.focusedId === data.id;
|
||||||
|
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
|
||||||
|
this.removeTile(data.id, { refocus: false });
|
||||||
|
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
|
||||||
|
this._selectTiledSession(neighbor, { auto: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _tileGridOriginalOnSessionDeleted.call(this, data);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
|
||||||
|
* app around it: constants.js + app.js + terminal-ui.js + tile-grid.js in one
|
||||||
|
* context, a small fake DOM (just what the grid touches) and a fake
|
||||||
|
* TerminalTile that records what the grid asks of it.
|
||||||
|
*
|
||||||
|
* `makeGridApp()` returns an app instance with everything around the grid that
|
||||||
|
* a test does not exercise stubbed as `vi.fn()`; a test overrides what it needs
|
||||||
|
* (or deletes a stub to run the real method from the prototype).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */
|
||||||
|
export class FakeEl {
|
||||||
|
id = '';
|
||||||
|
className = '';
|
||||||
|
dataset: Record<string, string> = {};
|
||||||
|
style: Record<string, string> = {};
|
||||||
|
children: FakeEl[] = [];
|
||||||
|
parentElement: FakeEl | null = null;
|
||||||
|
attrs: Record<string, string> = {};
|
||||||
|
listeners: Record<string, Array<(ev: unknown) => void>> = {};
|
||||||
|
classList = {
|
||||||
|
add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)),
|
||||||
|
remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)),
|
||||||
|
toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)),
|
||||||
|
contains: (n: string) => this.className.split(/\s+/).includes(n),
|
||||||
|
};
|
||||||
|
_setClass(name: string, on: boolean) {
|
||||||
|
const set = new Set(this.className.split(/\s+/).filter(Boolean));
|
||||||
|
if (on) set.add(name);
|
||||||
|
else set.delete(name);
|
||||||
|
this.className = [...set].join(' ');
|
||||||
|
return on;
|
||||||
|
}
|
||||||
|
appendChild(child: FakeEl) {
|
||||||
|
child.remove();
|
||||||
|
child.parentElement = this;
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
insertBefore(child: FakeEl, ref: FakeEl | null) {
|
||||||
|
child.remove();
|
||||||
|
child.parentElement = this;
|
||||||
|
const i = ref ? this.children.indexOf(ref) : -1;
|
||||||
|
if (i === -1) this.children.push(child);
|
||||||
|
else this.children.splice(i, 0, child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
get nextSibling() {
|
||||||
|
const siblings = this.parentElement?.children ?? [];
|
||||||
|
return siblings[siblings.indexOf(this) + 1] ?? null;
|
||||||
|
}
|
||||||
|
remove() {
|
||||||
|
if (!this.parentElement) return;
|
||||||
|
const siblings = this.parentElement.children;
|
||||||
|
siblings.splice(siblings.indexOf(this), 1);
|
||||||
|
this.parentElement = null;
|
||||||
|
}
|
||||||
|
setAttribute(k: string, v: string) {
|
||||||
|
this.attrs[k] = v;
|
||||||
|
}
|
||||||
|
getAttribute(k: string) {
|
||||||
|
return this.attrs[k] ?? null;
|
||||||
|
}
|
||||||
|
addEventListener(type: string, fn: (ev: unknown) => void) {
|
||||||
|
(this.listeners[type] ||= []).push(fn);
|
||||||
|
}
|
||||||
|
removeEventListener() {}
|
||||||
|
dispatch(type: string, ev: unknown = {}) {
|
||||||
|
for (const fn of this.listeners[type] ?? []) fn(ev);
|
||||||
|
}
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 };
|
||||||
|
}
|
||||||
|
querySelector() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A TerminalTile stand-in: records what the grid asks of it. */
|
||||||
|
export class FakeTile {
|
||||||
|
static all: FakeTile[] = [];
|
||||||
|
_wsReady = false;
|
||||||
|
_stoppedCode: number | null = null;
|
||||||
|
_destroyed = false;
|
||||||
|
fontSize: number | null;
|
||||||
|
terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record<string, unknown> };
|
||||||
|
connect = vi.fn(async () => {});
|
||||||
|
reconnectNow = vi.fn();
|
||||||
|
fit = vi.fn();
|
||||||
|
destroy = vi.fn(() => {
|
||||||
|
this._destroyed = true;
|
||||||
|
});
|
||||||
|
onExit: ((code: number) => void) | null;
|
||||||
|
constructor(
|
||||||
|
public sessionId: string,
|
||||||
|
public mountEl: FakeEl,
|
||||||
|
public opts: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
this.fontSize = (opts.fontSize as number) ?? null;
|
||||||
|
this.onExit = (opts.onExit as (code: number) => void) ?? null;
|
||||||
|
FakeTile.all.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const main = new FakeEl();
|
||||||
|
main.className = 'main';
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
wrap.className = 'terminal-wrap';
|
||||||
|
main.appendChild(wrap);
|
||||||
|
export const section = new FakeEl();
|
||||||
|
section.id = 'tileGrid';
|
||||||
|
section.className = 'tile-grid';
|
||||||
|
main.appendChild(section);
|
||||||
|
|
||||||
|
export const localStore = new Map<string, string>();
|
||||||
|
/** What the code under test deferred with requestIdleCallback; a test runs them. */
|
||||||
|
export const idleCallbacks: Array<() => void> = [];
|
||||||
|
export const windowStub: Record<string, unknown> = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
CodemanBase: { base: '' },
|
||||||
|
innerWidth: 2400,
|
||||||
|
innerHeight: 1200,
|
||||||
|
};
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8');
|
||||||
|
const context = vm.createContext({
|
||||||
|
console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
performance: { now: () => 100_000 },
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
navigator: { onLine: true },
|
||||||
|
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
|
||||||
|
history: { replaceState: vi.fn(), state: null },
|
||||||
|
document: {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
createElement: () => new FakeEl(),
|
||||||
|
getElementById: (id: string) => (id === 'tileGrid' ? section : null),
|
||||||
|
querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
},
|
||||||
|
localStorage: {
|
||||||
|
getItem: (k: string) => localStore.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => localStore.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => localStore.delete(k),
|
||||||
|
},
|
||||||
|
window: windowStub,
|
||||||
|
VoiceInput: { cleanup: vi.fn() },
|
||||||
|
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` +
|
||||||
|
'globalThis.__CodemanApp = CodemanApp;',
|
||||||
|
context
|
||||||
|
);
|
||||||
|
windowStub.TerminalTile = FakeTile;
|
||||||
|
windowStub.TileLoadQueue = class {
|
||||||
|
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
|
||||||
|
return run();
|
||||||
|
}
|
||||||
|
drop() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodemanApp = (context as unknown as { __CodemanApp: { prototype: Record<string, unknown> } }).__CodemanApp;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type GridApp = Record<string, any>;
|
||||||
|
|
||||||
|
/** Stubbed around the grid: panels, tabs, the main terminal's own select path. */
|
||||||
|
const STUBS = [
|
||||||
|
'_cleanupPreviousSession',
|
||||||
|
'hideWelcome',
|
||||||
|
'showWelcome',
|
||||||
|
'markIdleAlertSeen',
|
||||||
|
'renderSessionTabs',
|
||||||
|
'renderRalphStatePanel',
|
||||||
|
'renderProjectInsightsPanel',
|
||||||
|
'_updateActiveTabImmediate',
|
||||||
|
'_refreshSessionPanels',
|
||||||
|
'_updateSseSubscription',
|
||||||
|
'_updateConnectionIndicator',
|
||||||
|
'_activateFileBrowserSession',
|
||||||
|
'_hideWebviewLayer',
|
||||||
|
'closeSessionSidebarOnHandheld',
|
||||||
|
'updateAttachmentHistoryBadge',
|
||||||
|
'refreshHostWakeBanner',
|
||||||
|
'_connectWs',
|
||||||
|
'sendResize',
|
||||||
|
'showToast',
|
||||||
|
'_cleanupSessionData',
|
||||||
|
'stopSystemStatsPolling',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An app with sessions `ids` plus one more (`s-other`, FIRST in sessionOrder and
|
||||||
|
* never tiled), `s-a` active, nothing open.
|
||||||
|
*/
|
||||||
|
export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp {
|
||||||
|
const app = Object.create(CodemanApp.prototype) as GridApp;
|
||||||
|
app.sessions = new Map(
|
||||||
|
[...ids, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }])
|
||||||
|
);
|
||||||
|
app.sessionOrder = ['s-other', ...ids];
|
||||||
|
app.detachedSessions = new Set();
|
||||||
|
app.isSoloWindow = false;
|
||||||
|
app.activeSessionId = ids[0];
|
||||||
|
app._selectGeneration = 0;
|
||||||
|
app._xtermSnapshots = new Map();
|
||||||
|
app.terminalBufferCache = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._closingSessions = new Set();
|
||||||
|
app.pendingHooks = new Map();
|
||||||
|
app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } };
|
||||||
|
for (const name of STUBS) app[name] = vi.fn();
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets the shared fake DOM and tile registry between tests. */
|
||||||
|
export function resetGridHarness() {
|
||||||
|
FakeTile.all = [];
|
||||||
|
idleCallbacks.length = 0;
|
||||||
|
localStore.clear();
|
||||||
|
windowStub.innerWidth = 2400;
|
||||||
|
section.children = [];
|
||||||
|
main.className = 'main';
|
||||||
|
}
|
||||||
@@ -110,6 +110,60 @@ describe('selectSession acknowledgement gate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with the tile grid open (tile-grid.js)', () => {
|
||||||
|
// A plain stand-in for the grid: the branch reads only `open` and `has(id)`.
|
||||||
|
function withGrid(app: ReturnType<typeof makeApp>, tiled: string[]) {
|
||||||
|
const a = app as Record<string, unknown>;
|
||||||
|
a._tileGrid = { open: true, has: (id: string) => tiled.includes(id) };
|
||||||
|
a._selectTiledSession = vi.fn();
|
||||||
|
a.closeTileGrid = vi.fn();
|
||||||
|
a._connectWs = vi.fn();
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a tiled session goes to its tile, never the main terminal, and the tile decides the ack', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), [SID]);
|
||||||
|
await (app.selectSession as (id: string) => Promise<void>)(SID);
|
||||||
|
expect(app._selectTiledSession).toHaveBeenCalledWith(SID, {});
|
||||||
|
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
|
||||||
|
expect(app._connectWs).not.toHaveBeenCalled();
|
||||||
|
// The branch returns before the main path's own acknowledgement line.
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes `auto` through, so an app-driven focus stays unacknowledged', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), [SID]);
|
||||||
|
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, { auto: true });
|
||||||
|
expect(app._selectTiledSession).toHaveBeenCalledWith(SID, { auto: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an `auto` selection of a session that is not tiled leaves the grid open and acknowledges nothing', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), []);
|
||||||
|
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, { auto: true });
|
||||||
|
expect(app.closeTileGrid).not.toHaveBeenCalled();
|
||||||
|
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user-initiated one leaves the grid (remembered) and switches normally, acknowledging', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), []);
|
||||||
|
await (app.selectSession as (id: string) => Promise<void>)(SID).catch(() => {});
|
||||||
|
expect(app.closeTileGrid).toHaveBeenCalledWith({ keepStored: true, reselect: false });
|
||||||
|
expect(app._cleanupPreviousSession).toHaveBeenCalled();
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`leaveTiles` leaves it too, without acknowledging an `auto` pick', async () => {
|
||||||
|
const app = withGrid(makeApp('other'), []);
|
||||||
|
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, {
|
||||||
|
auto: true,
|
||||||
|
leaveTiles: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
expect(app.closeTileGrid).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the call sites the app drives itself', () => {
|
describe('the call sites the app drives itself', () => {
|
||||||
// Source guard: these call sites are the reason the flag exists. If a refactor
|
// Source guard: these call sites are the reason the flag exists. If a refactor
|
||||||
// moves or reformats them, fail loudly rather than silently going back to
|
// moves or reformats them, fail loudly rather than silently going back to
|
||||||
@@ -119,7 +173,7 @@ describe('selectSession acknowledgement gate', () => {
|
|||||||
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
||||||
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
||||||
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
||||||
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true });'],
|
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true, leaveTiles: true });'],
|
||||||
])('%s passes auto: true', (_label, call) => {
|
])('%s passes auto: true', (_label, call) => {
|
||||||
expect(APP_SOURCE).toContain(call);
|
expect(APP_SOURCE).toContain(call);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* @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 { FakeTile, idleCallbacks, makeGridApp, resetGridHarness, section, type GridApp } 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);
|
||||||
|
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('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(app._tileGridRemembered).toEqual({ ids: IDS, focusedId: '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 = section.children.find((el) => el.dataset.sessionId === '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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('going Home leaves the grid, remembered', () => {
|
||||||
|
const app = openGrid();
|
||||||
|
app.goHome();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
expect(app._tileGridRemembered?.ids).toEqual(IDS);
|
||||||
|
expect(app.activeSessionId).toBeNull();
|
||||||
|
expect(app.showWelcome).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -136,7 +136,7 @@ describe('dashboard handling of a #session=<id> link', () => {
|
|||||||
const app = make(['a']);
|
const app = make(['a']);
|
||||||
app._urlSessionId = 'a';
|
app._urlSessionId = 'a';
|
||||||
expect(app._selectUrlSession()).toBe(true);
|
expect(app._selectUrlSession()).toBe(true);
|
||||||
expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true });
|
expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true, leaveTiles: true });
|
||||||
expect(app._urlSessionId).toBeNull();
|
expect(app._urlSessionId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ describe('dashboard handling of a #session=<id> link', () => {
|
|||||||
app._onSessionCreated({ id: 'other', name: 'other' });
|
app._onSessionCreated({ id: 'other', name: 'other' });
|
||||||
expect(app.selectSession).not.toHaveBeenCalled();
|
expect(app.selectSession).not.toHaveBeenCalled();
|
||||||
app._onSessionCreated({ id: 'new', name: 'new' });
|
app._onSessionCreated({ id: 'new', name: 'new' });
|
||||||
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
|
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true, leaveTiles: true });
|
||||||
expect(app._urlSessionId).toBeNull();
|
expect(app._urlSessionId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ describe('a #session=<id> link that is still waiting', () => {
|
|||||||
app._selectUrlSession();
|
app._selectUrlSession();
|
||||||
vi.advanceTimersByTime(waitMs - 1);
|
vi.advanceTimersByTime(waitMs - 1);
|
||||||
app._onSessionCreated({ id: 'new', name: 'new' });
|
app._onSessionCreated({ id: 'new', name: 'new' });
|
||||||
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
|
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true, leaveTiles: true });
|
||||||
expect(app._urlSessionWaitTimer).toBeNull();
|
expect(app._urlSessionWaitTimer).toBeNull();
|
||||||
vi.advanceTimersByTime(waitMs);
|
vi.advanceTimersByTime(waitMs);
|
||||||
expect(app.showToast).not.toHaveBeenCalled();
|
expect(app.showToast).not.toHaveBeenCalled();
|
||||||
|
|||||||
Reference in New Issue
Block a user