diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 1a954439..40156702 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -289,15 +289,17 @@ Object.assign(CodemanApp.prototype, { const section = this._tileGridSection(); this.hideWelcome(); // Mount every tile and lay the grid out BEFORE any tile connects, so each - // first fit measures its real cell; the focused tile connects first, so - // its capture is the one the queue starts with. Each tile enters in + // first fit measures its real cell. Each tile enters in // reading order (opacity and transform only: the fit measures the final // cell, so the animation adds no resize). wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k })); // Packed from the first cell (_applyTileLayout pads the shape with empty cells). grid.cells = wanted.filter((id) => grid.tiles.has(id)); this._applyTileLayout(); - for (const id of [focus, ...wanted.filter((id) => id !== focus)]) this._connectTile(id); + // The tiles' frames paint first; their terminals are built one per frame + // after it, the focused tile's first, so its capture is the one the queue + // starts with (_connectTilesPaced). + this._connectTilesPaced([focus, ...wanted.filter((id) => id !== focus)]); if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') { // The main terminal's observer watches a node that is now hidden; this one // catches window resizes, sidebar toggles and rail drags for the grid. @@ -1397,6 +1399,7 @@ Object.assign(CodemanApp.prototype, { const hadKeyboard = this._focusedTile === entry.tile; this._destroyTerminalTile(entry.tile); entry.tile = this._newTerminalTile(sessionId, entry.body); + entry.connected = false; this._connectTile(sessionId); if (hadKeyboard) this._noteFocusedTile(entry.tile); }, @@ -1779,13 +1782,46 @@ Object.assign(CodemanApp.prototype, { input.select?.(); }, + /** + * Starts a tile's terminal and socket (TerminalTile.connect builds the xterm + * synchronously, then loads through the grid's queue). Once per tile. A + * focus that selected this tile before its terminal existed lands now. + */ _connectTile(sessionId) { - this._tileGrid?.tiles - .get(sessionId) - ?.tile.connect() - .catch(() => { - /* Best-effort, as the split's Pane B: live output arrives once the socket opens. */ - }); + const grid = this._tileGrid; + const entry = grid?.tiles.get(sessionId); + if (!entry || entry.connected) return; + entry.connected = true; + entry.tile.connect().catch(() => { + /* Best-effort, as the split's Pane B: live output arrives once the socket opens. */ + }); + if (grid.focusOnConnect === sessionId) { + grid.focusOnConnect = null; + entry.tile.terminal?.focus(); + } + }, + + /** + * Opening the grid: the tiles' terminals are built one per animation frame, + * in `order` (the focused tile first), instead of all of them inside the + * click. Building six xterms took about 100 ms of the click's 140 ms before + * the first frame; paced, the click paints its empty frames in about 20 ms + * and the entrance plays while the terminals are built. Nothing waits for + * it: the load queue serves one capture at a time anyway, so only the + * focused tile's connect is on its critical path, one frame later. Each + * tile still fits once, at its final size (laid out before any connect), + * and sends one PTY resize. A tile removed, replaced or already connected + * meanwhile is skipped; a grid closed (or opened again) meanwhile stops it. + */ + _connectTilesPaced(order) { + const grid = this._tileGrid; + const run = (grid.paceRun = (grid.paceRun || 0) + 1); + const step = (k) => { + if (!grid.open || grid.paceRun !== run || k >= order.length) return; + this._connectTile(order[k]); + requestAnimationFrame(() => step(k + 1)); + }; + requestAnimationFrame(() => step(0)); }, /** @@ -2143,8 +2179,14 @@ Object.assign(CodemanApp.prototype, { const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16); idleCb(() => this._refreshSessionPanels(sessionId, selectGen)); // The keyboard follows focus: shortcuts, voice and paste act on this tile. + // A tile whose terminal is not built yet (the grid just opened, paced) + // takes it as soon as it is (_connectTile). this._noteFocusedTile(entry.tile); - if (options.focus !== false) entry.tile.terminal?.focus(); + grid.focusOnConnect = null; + if (options.focus !== false) { + if (entry.connected) entry.tile.terminal?.focus(); + else grid.focusOnConnect = sessionId; + } this._persistTileGrid(); }, diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 9e95b7df..c0332449 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -222,6 +222,13 @@ export const clockNow = () => clock; export const perfObserverCallbacks: Array<(list: { getEntries(): unknown[] }) => void> = []; /** Animation-frame callbacks the code under test queued (id = index + 1); a test runs them. */ export const rafCallbacks: Array<() => void> = []; +/** + * Runs every queued animation frame, and the frames those queue in turn (the + * grid builds its tiles' terminals one per frame), until none is left. + */ +export function flushFrames(limit = 100) { + for (let n = 0; n < limit && rafCallbacks.length; n++) for (const cb of rafCallbacks.splice(0)) cb(); +} /** What the code under test deferred with requestIdleCallback; a test runs them. */ export const idleCallbacks: Array<() => void> = []; export const windowStub: Record = { diff --git a/test/tile-grid-count-menu.test.ts b/test/tile-grid-count-menu.test.ts index 5c802b7b..05726b5b 100644 --- a/test/tile-grid-count-menu.test.ts +++ b/test/tile-grid-count-menu.test.ts @@ -35,6 +35,7 @@ import { activeElement, body, bySelector, + flushFrames, localStore, makeGridApp, resetGridHarness, @@ -370,6 +371,7 @@ describe('re-forming an open grid', () => { it('2 to 6 keeps both in their cells and fills from tab order; nothing remounts', () => { const app = gridApp(); app.openTileGrid(['s-c', 's-a']); + flushFrames(); const before = new Map(FakeTile.all.map((t) => [t.sessionId, t])); open(app); item(6).dispatch('click', { stopPropagation: vi.fn() }); diff --git a/test/tile-grid-dividers.test.ts b/test/tile-grid-dividers.test.ts index 1e3e8fbe..b1e547ba 100644 --- a/test/tile-grid-dividers.test.ts +++ b/test/tile-grid-dividers.test.ts @@ -17,6 +17,7 @@ import { FakeEl, FakeTile, body, + flushFrames, makeGridApp, rafCallbacks, resetGridHarness, @@ -32,6 +33,8 @@ const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id) as Fak function grid(ids: string[]): GridApp { const app = makeGridApp(ids); app.openTileGrid(ids); + // The tiles' terminals are built one per frame (_connectTilesPaced). + flushFrames(); for (const t of FakeTile.all) { t.fit.mockClear(); t.localFit.mockClear(); diff --git a/test/tile-grid-motion.test.ts b/test/tile-grid-motion.test.ts index fd331ffe..d2f1985a 100644 --- a/test/tile-grid-motion.test.ts +++ b/test/tile-grid-motion.test.ts @@ -30,6 +30,7 @@ import { FakeTile, body, bySelector, + flushFrames, main, makeGridApp, resetGridHarness, @@ -134,6 +135,7 @@ describe('opening', () => { reducedMotion(!motion); const app = gridApp(); app.openTileGrid(IDS); + flushFrames(); vi.advanceTimersByTime(1000); return FakeTile.all.map((t) => [t.connect.mock.calls.length, t.fit.mock.calls.length, t.localFit.mock.calls.length]); }; diff --git a/test/tile-grid-paced-connect.test.ts b/test/tile-grid-paced-connect.test.ts new file mode 100644 index 00000000..1b4f4403 --- /dev/null +++ b/test/tile-grid-paced-connect.test.ts @@ -0,0 +1,191 @@ +/** + * @fileoverview Opening the grid paints the tiles first and builds their + * terminals one per frame (owner answer: "paced connect: in"). + * + * - `openTileGrid` connects no tile inside the click: every tile is mounted + * and the grid laid out first, then one tile connects per animation frame, + * the focused one first, then reading order. Each still connects once, into + * its final cell (one fit, one PTY resize). + * - The keyboard goes to the focused tile's terminal as soon as it exists + * (`focusOnConnect`): not before, never for a `focus: false` selection, and + * to the newly focused tile when focus moved while they were being built. + * - A grid closed (or opened again) meanwhile stops the old run; a tile + * removed before its turn is skipped; a tile already connected (one a + * re-form added) is never connected twice; a remount after Attach connects + * its new terminal. + * + * 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 { + FakeEl, + FakeTile, + bySelector, + flushFrames, + makeGridApp, + rafCallbacks, + resetGridHarness, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c', 's-d']; +const connected = () => FakeTile.all.filter((t) => t.connect.mock.calls.length > 0).map((t) => t.sessionId); +const tileOf = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!; +/** Runs the frames queued so far, once (one step of the paced connect). */ +const oneFrame = () => { + for (const cb of rafCallbacks.splice(0)) cb(); +}; + +beforeEach(() => { + resetGridHarness(); + const btn = new FakeEl(); + btn.className = 'btn-icon-header btn-tile-grid'; + bySelector.set('.btn-tile-grid', btn); +}); + +function gridApp(ids = IDS): GridApp { + const app = makeGridApp(ids); + app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {})); + return app; +} + +describe('opening paints first, then one terminal per frame', () => { + it('nothing connects inside the click; then the focused tile first, then reading order, one per frame', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-c' }); + expect(app._tilesOwnTerminal()).toBe(true); + expect(FakeTile.all).toHaveLength(4); + expect(connected()).toEqual([]); + oneFrame(); + expect(connected()).toEqual(['s-c']); + oneFrame(); + expect(connected()).toEqual(['s-a', 's-c']); + oneFrame(); + oneFrame(); + expect(connected().sort()).toEqual(IDS); + oneFrame(); + expect(rafCallbacks).toHaveLength(0); + expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true); + }); + + it('every tile connects into its final cell: the grid was laid out before the first', () => { + const app = gridApp(); + app.openTileGrid(IDS); + const seen: string[] = []; + for (const t of FakeTile.all) { + t.connect.mockImplementation(async () => { + seen.push(`${t.sessionId}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(t.sessionId)}`); + }); + } + flushFrames(); + expect(seen).toEqual(['s-a:4:0', 's-b:4:1', 's-c:4:2', 's-d:4:3']); + }); +}); + +describe('the keyboard', () => { + it('lands in the focused tile once its terminal exists, not before', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-b' }); + expect(tileOf('s-b').terminal.focus).not.toHaveBeenCalled(); + oneFrame(); + expect(tileOf('s-b').terminal.focus).toHaveBeenCalledTimes(1); + flushFrames(); + expect(tileOf('s-b').terminal.focus).toHaveBeenCalledTimes(1); + expect(tileOf('s-a').terminal.focus).not.toHaveBeenCalled(); + }); + + it('follows a focus moved while the terminals are being built', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-a' }); + app._selectTiledSession('s-d', {}); + flushFrames(); + expect(tileOf('s-d').terminal.focus).toHaveBeenCalled(); + expect(tileOf('s-a').terminal.focus).not.toHaveBeenCalled(); + }); + + it('never for a selection that asked for no focus', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-a' }); + app._selectTiledSession('s-a', { focus: false }); + flushFrames(); + expect(FakeTile.all.some((t) => t.terminal.focus.mock.calls.length > 0)).toBe(false); + }); + + it('a tile already built takes the keyboard at once', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-a' }); + flushFrames(); + app._selectTiledSession('s-c', {}); + expect(tileOf('s-c').terminal.focus).toHaveBeenCalledTimes(1); + }); +}); + +describe('a grid that changes meanwhile', () => { + it('closed: nothing connects any more, and no frame is asked for after it', () => { + const app = gridApp(); + app.openTileGrid(IDS); + oneFrame(); + app.closeTileGrid({ reselect: false }); + oneFrame(); + expect(rafCallbacks).toHaveLength(0); + flushFrames(); + expect(connected()).toEqual(['s-a']); + }); + + it('opened again: the old run stops at once, the new one starts with its own focused tile', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-a' }); + oneFrame(); + app.closeTileGrid({ reselect: false }); + app.activeSessionId = null; + app.openTileGrid(IDS, { focusedId: 's-d' }); + oneFrame(); + const live = () => FakeTile.all.filter((t) => !t._destroyed && t.connect.mock.calls.length).map((t) => t.sessionId); + expect(live()).toEqual(['s-d']); + }); + + it('closed and opened again: the old run stops, the new one connects each tile once', () => { + const app = gridApp(); + app.openTileGrid(IDS); + oneFrame(); + app.closeTileGrid({ reselect: false }); + app.activeSessionId = null; + app.openTileGrid(IDS); + flushFrames(); + const live = FakeTile.all.filter((t) => !t._destroyed); + expect(live).toHaveLength(4); + expect(live.every((t) => t.connect.mock.calls.length === 1)).toBe(true); + }); + + it('a tile removed before its turn is skipped; one a re-form connected is not connected again', () => { + const app = gridApp(['s-a', 's-b', 's-c', 's-d', 's-e']); + app.openTileGrid(['s-a', 's-b', 's-c']); + app.removeTile('s-b'); + app.addTile('s-e'); + expect(tileOf('s-e').connect).toHaveBeenCalledTimes(1); + flushFrames(); + expect(FakeTile.all.find((t) => t.sessionId === 's-b')!.connect).not.toHaveBeenCalled(); + expect(tileOf('s-e').connect).toHaveBeenCalledTimes(1); + expect(tileOf('s-a').connect).toHaveBeenCalledTimes(1); + expect(tileOf('s-c').connect).toHaveBeenCalledTimes(1); + }); + + it('a tile remounted before its turn (Attach) is connected once, not again by the paced run', () => { + const app = gridApp(); + app.openTileGrid(IDS); + app._remountTile('s-c'); + flushFrames(); + expect(tileOf('s-c').connect).toHaveBeenCalledTimes(1); + }); + + it('a remount after Attach connects its new terminal', () => { + const app = gridApp(); + app.openTileGrid(IDS); + flushFrames(); + const old = tileOf('s-b'); + app._remountTile('s-b'); + const fresh = tileOf('s-b'); + expect(fresh).not.toBe(old); + expect(fresh.connect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/tile-grid-park-guards.test.ts b/test/tile-grid-park-guards.test.ts index f9565a7f..82484793 100644 --- a/test/tile-grid-park-guards.test.ts +++ b/test/tile-grid-park-guards.test.ts @@ -36,6 +36,7 @@ import { FakeTile, advanceClock, clockNow, + flushFrames, localStore, main, makeGridApp, @@ -118,6 +119,8 @@ describe('parking the main terminal', () => { expect(app._cleanupPreviousSession).toHaveBeenCalledTimes(1); expect(main.classList.contains('tiles-active')).toBe(true); expect(FakeTile.all.map((t) => t.sessionId)).toEqual(IDS); + // Built one per frame after the click (_connectTilesPaced), each once. + flushFrames(); expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true); expect(app.activeSessionId).toBe('s-b'); expect(app._tilesOwnTerminal()).toBe(true); diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts index 14c9b115..671accaa 100644 --- a/test/tile-grid-select-branch.test.ts +++ b/test/tile-grid-select-branch.test.ts @@ -22,6 +22,7 @@ import { FakeEl, FakeTile, bySelector, + flushFrames, idleCallbacks, localStore, makeGridApp, @@ -36,6 +37,8 @@ const IDS = ['s-a', 's-b', 's-c']; 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();