Files
Codeman/test/tab-focus-browser.test.ts
T
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

271 lines
12 KiB
TypeScript

/**
* @fileoverview The Focus flag in the browser's tab-layout mirror (tab-layout-browser.js).
*
* The browser writes WHOLE layouts back (`PUT /api/tab-layout`), so any field
* its defensive copy drops is erased on the next save, whoever made it. These
* tests pin that `focus: true` is carried like `placement`, that `setFocus` is a
* named operation that replays (and drops cleanly) through the edit coordinator,
* that a 409 rebase keeps both our flag and one set elsewhere, that the focused
* refs are exposed for rendering (with or without named groups), and the
* per-device collapse storage.
*
* Port: none (vm-loaded module).
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const SOURCE = readFileSync(join(process.cwd(), 'src/web/public/tab-layout-browser.js'), 'utf8');
type Ref = { kind: 'session' | 'webview'; id: string; placement?: 'manual'; focus?: true };
type Layout = {
version: number;
updatedAt: string;
groups: Array<{ id: string; name: string; refs: Ref[] }>;
ungrouped: Ref[];
};
function loadHelper() {
const context = vm.createContext({ window: {}, globalThis: {}, setTimeout, clearTimeout });
vm.runInContext(SOURCE, context, { filename: 'tab-layout-browser.js' });
return (context.window as any).CodemanTabLayout;
}
const s = (id: string, extra: Partial<Ref> = {}): Ref => ({ kind: 'session', id, ...extra });
const w = (id: string, extra: Partial<Ref> = {}): Ref => ({ kind: 'webview', id, ...extra });
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
const base = (version = 5): Layout => ({
version,
updatedAt: '2026-10-09T00:00:00.000Z',
groups: [
{ id: 'g1', name: 'Core', refs: [s('a', { focus: true }), s('b')] },
{ id: 'g2', name: 'Ops', refs: [w('web', { focus: true })] },
],
ungrouped: [s('c', { placement: 'manual', focus: true }), s('d')],
});
const focused = (layout: Layout) =>
[...layout.groups.flatMap((g) => g.refs), ...layout.ungrouped]
.filter((ref) => ref.focus === true)
.map((ref) => `${ref.kind}:${ref.id}`);
describe('the defensive copy keeps focus', () => {
const h = loadHelper();
it('normalizeLayout keeps focus: true on session and web refs, next to placement', () => {
const copy = plain(h.normalizeLayout(base()));
expect(copy.groups[0].refs[0]).toEqual({ kind: 'session', id: 'a', focus: true });
expect(copy.groups[1].refs[0]).toEqual({ kind: 'webview', id: 'web', focus: true });
expect(copy.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual', focus: true });
expect(copy.ungrouped[1]).toEqual({ kind: 'session', id: 'd' });
});
it('drops anything but a literal true (the server only stores true)', () => {
const copy = plain(
h.normalizeLayout({
...base(),
ungrouped: [s('c', { focus: 'yes' as never }), s('d', { focus: false as never })],
})
);
expect(copy.ungrouped).toEqual([
{ kind: 'session', id: 'c' },
{ kind: 'session', id: 'd' },
]);
});
it('every existing operation keeps the flag on the refs it touches', () => {
let layout = base();
layout = h.applyOperation(layout, { type: 'renameGroup', groupId: 'g1', name: 'Front' });
layout = h.applyOperation(layout, { type: 'reorderGroup', groupId: 'g2', index: 0 });
layout = h.applyOperation(layout, { type: 'moveRef', ref: s('a'), groupId: 'g2', index: 1, parents: {} });
expect(plain(layout).groups[0].refs).toEqual([w('web', { focus: true }), s('a', { focus: true })]);
layout = h.applyOperation(layout, { type: 'deleteGroup', groupId: 'g2' });
expect(focused(plain(layout))).toEqual(['session:c', 'webview:web', 'session:a']);
});
it('a moved lineage block keeps each member flag, and the head turns manual', () => {
const layout: Layout = {
...base(),
groups: [{ id: 'g1', name: 'Core', refs: [s('p', { focus: true }), s('kid', { focus: true })] }],
ungrouped: [],
};
const moved = plain(
h.applyOperation(layout, { type: 'moveRef', ref: s('p'), groupId: null, parents: { kid: 'p', p: 'gp' } })
);
expect(moved.ungrouped).toEqual([s('p', { focus: true, placement: 'manual' }), s('kid', { focus: true })]);
});
});
describe('setFocus operation', () => {
const h = loadHelper();
it('adds and removes the flag on one ref and moves nothing', () => {
const added = plain(h.applyOperation(base(), { type: 'setFocus', ref: s('d'), focused: true }));
expect(focused(added)).toEqual(['session:a', 'webview:web', 'session:c', 'session:d']);
const removed = plain(h.applyOperation(added, { type: 'setFocus', ref: s('c'), focused: false }));
expect(removed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual' });
expect(removed.groups.map((g: any) => g.refs.map((r: Ref) => r.id))).toEqual([['a', 'b'], ['web']]);
expect(h.contentKey(h.applyOperation(added, { type: 'setFocus', ref: s('d'), focused: true }))).toBe(
h.contentKey(added)
);
});
it('refuses an unknown ref, a bad ref and a non-boolean state (so a replay drops it)', () => {
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: s('gone'), focused: true })).toThrow(/unknown row/);
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: { kind: 'x', id: 'a' }, focused: true })).toThrow();
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: s('a'), focused: 'yes' })).toThrow(/Focus/);
});
});
describe('edit coordinator with focus', () => {
const h = loadHelper();
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function controlledPut() {
const calls: Array<{ request: any; resolve: (value: any) => void }> = [];
const put = vi.fn((request: any) => new Promise((resolve) => calls.push({ request: plain(request), resolve })));
return { put, calls };
}
function makeEditor(put: any, extra: Record<string, unknown> = {}) {
const applied: any[] = [];
const editor = h.createEditCoordinator({
initialLayout: base(),
put,
applyLayout: (layout: any, meta: any) => applied.push({ layout: plain(layout), meta }),
schedule: (fn: () => void) => setTimeout(fn, 0),
cancel: (handle: any) => clearTimeout(handle),
...extra,
});
return { editor, applied };
}
it('sends setFocus in the whole-layout PUT, and an unrelated edit keeps every other flag', async () => {
const { put, calls } = controlledPut();
const { editor } = makeEditor(put);
editor.enqueue({ type: 'setFocus', ref: s('b'), focused: true });
editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Web' });
await settle();
expect(calls).toHaveLength(1);
expect(focused(calls[0].request.layout)).toEqual(['session:a', 'session:b', 'webview:web', 'session:c']);
});
it('replays setFocus onto the 409 layout and keeps a flag set elsewhere', async () => {
const { put, calls } = controlledPut();
const { editor, applied } = makeEditor(put);
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
await settle();
// Elsewhere: someone unpinned `a`, pinned `b` and renamed Core (version 9).
const server: Layout = {
...base(9),
groups: [
{ id: 'g1', name: 'Front', refs: [s('a'), s('b', { focus: true })] },
{ id: 'g2', name: 'Ops', refs: [w('web', { focus: true })] },
],
};
calls[0].resolve({ ok: false, status: 409, layout: server });
await settle();
expect(calls).toHaveLength(2);
expect(calls[1].request.baseVersion).toBe(9);
expect(calls[1].request.layout.groups[0].name).toBe('Front');
expect(focused(calls[1].request.layout)).toEqual(['session:b', 'webview:web', 'session:c', 'session:d']);
calls[1].resolve({ ok: true, status: 200, layout: { ...calls[1].request.layout, version: 10 } });
await settle();
expect(focused(applied.at(-1).layout)).toEqual(['session:b', 'webview:web', 'session:c', 'session:d']);
expect(editor.hasPending()).toBe(false);
});
it('drops a setFocus whose ref vanished in the conflict, and reports it once', async () => {
const { put, calls } = controlledPut();
const reportError = vi.fn();
const { editor, applied } = makeEditor(put, { reportError });
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
await settle();
calls[0].resolve({
ok: false,
status: 409,
layout: { ...base(9), ungrouped: [s('c', { placement: 'manual', focus: true })] },
});
await settle();
expect(calls).toHaveLength(1);
expect(reportError).toHaveBeenCalledTimes(1);
expect(focused(applied.at(-1).layout)).toEqual(['session:a', 'webview:web', 'session:c']);
});
it('adopting an external layout keeps its flags under a pending setFocus', () => {
const { editor } = makeEditor(() => new Promise(() => undefined), { schedule: () => 1, cancel: () => undefined });
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
const external = { ...base(7), ungrouped: [s('c', { placement: 'manual' }), s('d')] };
expect(editor.adoptExternal(external)).toBe(true);
expect(focused(plain(editor.getLayout()))).toEqual(['session:a', 'webview:web', 'session:d']);
});
});
describe('focused refs for the rail', () => {
const h = loadHelper();
const options = { liveSessionIds: ['a', 'b', 'c', 'd'], openWebviewIds: ['web'] };
it('focusRefs lists renderable focused refs in stored order, with or without groups', () => {
expect(plain(h.focusRefs(base(), options))).toEqual([s('a'), w('web'), s('c')]);
const flat = { ...base(), groups: [], ungrouped: [s('d', { focus: true }), s('c'), w('web', { focus: true })] };
expect(plain(h.focusRefs(flat, options))).toEqual([s('d'), w('web')]);
// The flat layout still projects to null: the rail itself stays the flat list.
expect(h.project(flat, options)).toBeNull();
});
it('a gone session or a closed web tab drops out of Focus harmlessly', () => {
expect(plain(h.focusRefs(base(), { liveSessionIds: ['b', 'c'], openWebviewIds: [] }))).toEqual([s('c')]);
expect(plain(h.focusRefs(null, options))).toEqual([]);
expect(
plain(
h.focusRefs({ ...base(), ungrouped: [s('c', { focus: true }), s('c', { focus: true })], groups: [] }, options)
)
).toEqual([s('c')]);
});
it('project() exposes the same focusRefs on a grouped layout', () => {
const projection = h.project(base(), options);
expect(plain(projection.focusRefs)).toEqual([s('a'), w('web'), s('c')]);
// Shortcuts are not rows: the grouped rows are exactly what they were.
expect(plain(projection.visibleRefs)).toEqual([s('a'), s('b'), w('web'), s('c'), s('d')]);
});
});
describe('Focus collapse is per-device storage', () => {
const h = loadHelper();
const memory = () => {
const data = new Map<string, string>();
return {
data,
getItem: (key: string) => (data.has(key) ? data.get(key)! : null),
setItem: (key: string, value: string) => void data.set(key, value),
};
};
it('round-trips under its own key and never touches the group collapse list', () => {
const storage = memory();
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: false, ok: true });
expect(h.saveFocusCollapsed(storage, true)).toEqual({ collapsed: true, ok: true });
expect(storage.data.get('codeman:tab-focus-collapsed')).toBe('true');
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: true, ok: true });
expect(storage.data.has('codeman:tab-groups-collapsed')).toBe(false);
// Group collapse GC (ids of groups that no longer exist) leaves it alone.
h.loadCollapsedGroupIds(storage, []);
expect(h.loadFocusCollapsed(storage).collapsed).toBe(true);
});
it('a junk value reads as expanded, and a throwing store reports failure', () => {
const storage = memory();
storage.setItem('codeman:tab-focus-collapsed', '{nope');
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: false, ok: true });
const broken = {
getItem: () => {
throw new Error('denied');
},
setItem: () => {
throw new Error('denied');
},
};
expect(h.loadFocusCollapsed(broken)).toEqual({ collapsed: false, ok: false });
expect(h.saveFocusCollapsed(broken, true)).toEqual({ collapsed: false, ok: false });
});
});