Files
Codeman/test/tab-layout-focus.test.ts
Aamer Akhter cde90a465b feat(tabs): pin tabs to a Focus section on the vertical rail
A session or web-tab ref in the owner tab layout may now carry
`focus: true`. "Add to Focus" in a row's menu (right-click on a web tab)
sets it; the vertical rail then draws a shortcut to that tab in a pinned,
collapsible "Focus" section at its top, while the row itself stays where
it is. A click on a shortcut selects its tab, a right-click or the menu
key offers "Remove from Focus".

Model: `TabRef.focus?: true`, parsed strictly (only `true` or absent) and
carried through normalization, every pure edit and the legacy-order
projection; `setTabFocus()` is the pure helper. TabLayoutService needed
no change and no route was added: the flag rides the existing
`PUT /api/tab-layout`. Browser: `copyRef`/`normalizeLayout` keep `focus`
the way they keep `placement` (the browser writes whole layouts, so a
dropped field would be erased on every save), `setFocus` is a named
operation through `createEditCoordinator` and replays on a 409, and
`focusRefs()` (also on `project()`) lists what the rail can draw.

The section is its own element in #tabRail above #sessionTabs, so it
works on the flat rail (no named groups) as well as the grouped one, and
shortcuts are never rows: no Alt+N badge, no tree item, no lineage
anchor, no drag target. It is a list with one tab stop and
Up/Down/Home/End between shortcuts. Collapse is per-device localStorage
under its own key. A shortcut whose session closed or whose web tab is
not open drops out; the server already prunes deleted refs. zh-CN
strings added.
2026-10-10 13:43:20 -04:00

359 lines
15 KiB
TypeScript

/**
* @fileoverview The Focus flag on owner tab layout refs, server side.
*
* A session or web-tab ref may carry `focus: true` (src/tab-layout.ts): the
* browser then draws a shortcut to it in a pinned "Focus" section at the top of
* the vertical rail, while the ref itself stays where it is stored. These tests
* pin that the flag is parsed strictly, and that nothing on the server drops it:
* normalization, the pure edit helpers, TabLayoutService (PUT, legacy order,
* deletions, reconciliation), the legacy-order projection, and StateStore's
* persistence across a reload. Multi-user owner scoping is unchanged.
*
* Port: none (Fastify inject, temp state file).
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify from 'fastify';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StateStore } from '../src/state-store.js';
import { applyLegacySessionRank } from '../src/tab-layout-legacy-order.js';
import { normalizeOrMigrateOwnerTabLayout } from '../src/tab-layout-persistence.js';
import { TabLayoutService } from '../src/tab-layout-service.js';
import {
deleteGroup,
followParent,
materializeOrphans,
moveRef,
normalizeTabLayout,
setTabFocus,
TabLayoutValidationError,
validateTabLayout,
type TabLayout,
type TabRefMetadata,
} from '../src/tab-layout.js';
import { installRouteErrorHandler } from '../src/web/route-error-handler.js';
import { registerTabLayoutRoutes } from '../src/web/routes/tab-layout-routes.js';
const at = '2026-10-09T00:00:00.000Z';
const session = (id: string, extra: Record<string, unknown> = {}) => ({ kind: 'session' as const, id, ...extra });
const webview = (id: string, extra: Record<string, unknown> = {}) => ({ kind: 'webview' as const, id, ...extra });
const meta = (
id: string,
order: number,
extra: Partial<TabRefMetadata> = {},
kind: 'session' | 'webview' = 'session'
): TabRefMetadata => ({ kind, id, ownerValid: true, visible: true, order, ...extra });
/** Every ref that carries the flag, as `kind:id`, in stored order. */
const focused = (layout: TabLayout): string[] =>
[...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]
.filter((ref) => ref.focus === true)
.map((ref) => `${ref.kind}:${ref.id}`);
const base = (): TabLayout => ({
version: 3,
updatedAt: at,
groups: [
{ id: 'g1', name: 'Core', refs: [session('a', { focus: true }), webview('w', { focus: true })] },
{ id: 'g2', name: 'Ops', refs: [session('b')] },
],
ungrouped: [session('c', { placement: 'manual', focus: true }), session('d')],
});
const metadata: TabRefMetadata[] = [
meta('a', 0),
meta('b', 1),
meta('c', 2),
meta('d', 3),
meta('w', 4, {}, 'webview'),
];
describe('model: parsing the focus flag', () => {
it('keeps focus: true on session and webview refs, beside placement', () => {
const parsed = validateTabLayout(base());
expect(parsed.groups[0].refs).toEqual([session('a', { focus: true }), webview('w', { focus: true })]);
expect(parsed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual', focus: true });
expect(parsed.ungrouped[1]).toEqual({ kind: 'session', id: 'd' });
});
it.each([false, 'true', 1, null, {}])('rejects focus: %j (only true or absent is valid)', (value) => {
const input = { ...base(), ungrouped: [session('d', { focus: value })] };
expect(() => validateTabLayout(input)).toThrow(TabLayoutValidationError);
expect(() => validateTabLayout(input)).toThrow(/focus/);
});
it('a ref with no focus key gets no focus key back (absent, never false)', () => {
const parsed = validateTabLayout({ ...base(), groups: [], ungrouped: [session('d')] });
expect(Object.keys(parsed.ungrouped[0]).sort()).toEqual(['id', 'kind']);
});
});
describe('model: the flag survives every pure edit', () => {
it('normalizeTabLayout keeps focus, including on lineage-reordered children', () => {
const layout: TabLayout = {
version: 1,
updatedAt: at,
groups: [],
ungrouped: [session('child', { focus: true }), session('parent')],
};
const normalized = normalizeTabLayout(layout, [meta('parent', 0), meta('child', 1, { parentSessionId: 'parent' })]);
// The child follows its parent now, and still carries the flag.
expect(normalized.ungrouped).toEqual([session('parent'), session('child', { focus: true })]);
});
it('normalizeTabLayout prunes a focused ref whose session is known to be gone', () => {
const normalized = normalizeTabLayout(base(), [...metadata, meta('a', 0, { ownerValid: false })].slice(1));
expect(focused(normalized)).toEqual(['webview:w', 'session:c']);
});
it('moveRef across groups keeps the moved ref and its followers focused', () => {
const withChild: TabLayout = {
...base(),
groups: [
{ id: 'g1', name: 'Core', refs: [session('a', { focus: true }), session('kid', { focus: true })] },
{ id: 'g2', name: 'Ops', refs: [session('b')] },
],
ungrouped: [],
};
const facts = [meta('a', 0), meta('kid', 1, { parentSessionId: 'a' }), meta('b', 2)];
const moved = moveRef(withChild, session('a'), { groupId: 'g2', index: 1 }, facts);
expect(moved.groups[1].refs).toEqual([
session('b'),
session('a', { focus: true }),
session('kid', { focus: true }),
]);
});
it('followParent clears manual placement but keeps focus', () => {
const layout: TabLayout = {
version: 1,
updatedAt: at,
groups: [],
ungrouped: [session('parent'), session('other'), session('kid', { placement: 'manual', focus: true })],
};
const facts = [meta('parent', 0), meta('other', 1), meta('kid', 2, { parentSessionId: 'parent' })];
const result = followParent(layout, session('kid'), facts);
expect(result.ungrouped).toEqual([session('parent'), session('kid', { focus: true }), session('other')]);
});
it('deleteGroup and materializeOrphans keep focus on the refs they move or pin', () => {
expect(focused(deleteGroup(base(), 'g1'))).toEqual(['session:c', 'session:a', 'webview:w']);
const orphaned = materializeOrphans(
{ ...base(), ungrouped: [session('p'), session('kid', { focus: true })] },
['p'],
[...metadata, meta('p', 5), meta('kid', 6, { parentSessionId: 'p' })]
);
expect(orphaned.ungrouped).toEqual([session('kid', { placement: 'manual', focus: true })]);
});
it('setTabFocus adds and removes the flag on one ref without moving anything', () => {
const added = setTabFocus(base(), session('b'), true);
expect(focused(added)).toEqual(['session:a', 'webview:w', 'session:b', 'session:c']);
const removed = setTabFocus(added, session('c'), false);
expect(removed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual' });
expect(removed.groups.map((group) => group.refs.map((ref) => ref.id))).toEqual([['a', 'w'], ['b']]);
// Idempotent both ways.
expect(setTabFocus(added, session('b'), true)).toEqual(added);
expect(setTabFocus(base(), session('d'), false)).toEqual(validateTabLayout(base()));
expect(() => setTabFocus(base(), session('nope'), true)).toThrow(/unknown ref/);
});
});
describe('legacy order projection', () => {
it('applyLegacySessionRank re-ranks focused sessions without dropping the flag', () => {
const ranked = applyLegacySessionRank(base(), ['d', 'c', 'b', 'a'], metadata);
expect(ranked.ungrouped).toEqual([session('d'), session('c', { placement: 'manual', focus: true })]);
expect(focused(ranked)).toEqual(['session:a', 'webview:w', 'session:c']);
});
it('migration of an existing layout keeps focus', () => {
const result = normalizeOrMigrateOwnerTabLayout({
owner: '@single',
layouts: { '@single': base() },
sessionOrder: [],
persistedSessions: [],
liveSessions: ['a', 'b', 'c', 'd'].map((id, index) => ({ id, createdAt: index })),
webviews: [{ id: 'w' }],
updatedAt: at,
});
expect(focused(result.layout)).toEqual(['session:a', 'webview:w', 'session:c']);
});
});
type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string };
function createService(
options: {
layouts?: Record<string, TabLayout>;
live?: SessionFact[];
webviews?: Array<{ id: string; owner?: string }>;
} = {}
) {
const layouts = structuredClone(options.layouts ?? {});
const order: string[] = [];
const webviews = [...(options.webviews ?? [])];
const live = new Map((options.live ?? []).map((record) => [record.id, record]));
const store = {
getTabLayout: (owner: string) => structuredClone(layouts[owner] ?? null),
getTabLayouts: () => structuredClone(layouts),
getSessions: () => ({}),
getSessionOrder: () => [...order],
commitTabLayoutProjection: (
updates: Readonly<Record<string, TabLayout>>,
project: (latest: readonly string[]) => readonly string[]
) => {
const projected = [...project([...order])];
Object.assign(layouts, structuredClone(updates));
order.splice(0, order.length, ...projected);
return { layouts: structuredClone(updates), sessionOrder: [...order] };
},
};
const service = new TabLayoutService({
store: store as never,
sessions: live as never,
readWebviews: async () => webviews,
broadcast: vi.fn(),
broadcastSessionOrder: vi.fn(),
now: () => at,
});
service.markRestorationComplete();
return { service, layouts, live, webviews };
}
const liveFacts = (owner?: string): SessionFact[] =>
['a', 'b', 'c', 'd'].map((id, index) => ({ id, owner, createdAt: index }));
describe('TabLayoutService keeps focus', () => {
it('stores focus from a PUT and returns it from GET', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
const current = await h.service.get('@single');
expect(focused(current)).toEqual(['session:a', 'webview:w', 'session:c']);
const result = await h.service.put('@single', setTabFocus(current, session('d'), true), current.version);
expect(result.status).toBe('updated');
expect(focused(await h.service.get('@single'))).toEqual(['session:a', 'webview:w', 'session:c', 'session:d']);
});
it('keeps focus on a ref a PUT moves into another group', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
const current = await h.service.get('@single');
// The browser's moveRef: `a` (focused) leaves g1 for the end of g2.
const desired: TabLayout = {
...current,
groups: [
{ ...current.groups[0], refs: [webview('w', { focus: true })] },
{ ...current.groups[1], refs: [session('b'), session('a', { focus: true })] },
],
};
const result = await h.service.put('@single', desired, current.version);
expect(result.layout.groups[1].refs).toEqual([session('b'), session('a', { focus: true })]);
expect(focused(h.layouts['@single'])).toEqual(['webview:w', 'session:a', 'session:c']);
});
it('keeps focus through a legacy session-order PUT (reordering)', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
await h.service.putLegacyOrder({ owner: '@single', isAdmin: true }, ['d', 'c', 'b', 'a']);
expect(focused(h.layouts['@single'])).toEqual(['session:a', 'webview:w', 'session:c']);
expect(h.layouts['@single'].ungrouped.map((ref) => ref.id)).toEqual(['d', 'c']);
});
it('a deleted session takes its focus flag with it and leaves the others alone', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
await h.service.get('@single');
await h.service.runSessionDeletion([{ id: 'a' }], async () => {
h.live.delete('a');
});
expect(focused(h.layouts['@single'])).toEqual(['webview:w', 'session:c']);
});
it('a deleted web tab takes its focus flag with it', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
await h.service.get('@single');
h.webviews.splice(0);
await h.service.webviewDeleted('@single', 'w');
expect(focused(h.layouts['@single'])).toEqual(['session:a', 'session:c']);
});
it('multi-user: focus is per owner, and a PUT naming a foreign ref is still refused', async () => {
const aliceLayout: TabLayout = {
version: 1,
updatedAt: at,
groups: [],
ungrouped: [session('a', { focus: true })],
};
const h = createService({
layouts: { alice: aliceLayout },
live: [
{ id: 'a', owner: 'alice', createdAt: 0 },
{ id: 'b', owner: 'bob', createdAt: 1 },
],
});
const bob = await h.service.get('bob');
expect(focused(bob)).toEqual([]);
expect(focused(await h.service.get('alice'))).toEqual(['session:a']);
await expect(
h.service.put('bob', { ...bob, ungrouped: [session('b'), session('a', { focus: true })] }, bob.version)
).rejects.toThrow(/not owned/);
await h.service.put('bob', setTabFocus(bob, session('b'), true), bob.version);
expect(focused(h.layouts.bob)).toEqual(['session:b']);
expect(focused(h.layouts.alice)).toEqual(['session:a']);
});
});
describe('persistence and the HTTP route', () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it('PUT /api/tab-layout with focus round-trips through GET and a StateStore reload', async () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-focus-'));
dirs.push(dir);
const file = join(dir, 'state.json');
writeFileSync(file, JSON.stringify({}));
const store = new StateStore(file);
const live = new Map(liveFacts().map((record) => [record.id, record]));
const service = new TabLayoutService({
store,
sessions: live,
readWebviews: async () => [],
broadcast: () => undefined,
broadcastSessionOrder: () => undefined,
now: () => at,
});
service.markRestorationComplete();
const app = Fastify({ logger: false });
registerTabLayoutRoutes(app, { tabLayouts: service } as never);
installRouteErrorHandler(app);
await app.ready();
vi.stubEnv('CODEMAN_MULTIUSER', '0');
try {
const read = await app.inject({ method: 'GET', url: '/api/tab-layout' });
const layout = read.json().data.layout as TabLayout;
const put = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion: layout.version, layout: setTabFocus(layout, session('b'), true) },
});
expect(put.statusCode).toBe(200);
expect(focused(put.json().data.layout)).toEqual(['session:b']);
const refused = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: {
baseVersion: put.json().data.layout.version,
layout: { ...put.json().data.layout, ungrouped: [session('a', { focus: false })] },
},
});
expect(refused.statusCode).toBe(400);
store.saveNow();
} finally {
await app.close();
vi.unstubAllEnvs();
}
// A fresh store over the same file: what a restart would read.
expect(focused(new StateStore(file).getTabLayout('@single')!)).toEqual(['session:b']);
});
});