mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Merge pull request #517 from aakhter/pr/grouped-vertical-rail
feat(tabs): grouped vertical rail from owner tab layouts
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @fileoverview Browser projection of the owner tab layout (tab-layout-browser.js).
|
||||
*
|
||||
* Pins the pure half of the grouped vertical rail: which live rows land in which
|
||||
* group, what a collapsed group hides (and the one row it must keep showing),
|
||||
* the per-device collapse storage, the grouped markup, and newest-wins loading.
|
||||
*
|
||||
* Port: none (vm-loaded module + static wiring assertions).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const SOURCE = readFileSync(new URL('../src/web/public/tab-layout-browser.js', import.meta.url), 'utf8');
|
||||
const APP_SOURCE = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf8');
|
||||
const INDEX_SOURCE = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
|
||||
const BUILD_SOURCE = readFileSync(new URL('../scripts/build.mjs', import.meta.url), 'utf8');
|
||||
|
||||
type Ref = { kind: 'session' | 'webview'; id: string };
|
||||
|
||||
function loadHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(SOURCE, context, { filename: 'tab-layout-browser.js' });
|
||||
return (context.window as any).CodemanTabLayout;
|
||||
}
|
||||
|
||||
const escape = (value: string) =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
const layout = (version = 4) => ({
|
||||
version,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
groups: [
|
||||
{
|
||||
id: 'g1',
|
||||
name: '<Core & Ops>',
|
||||
refs: [
|
||||
{ kind: 'session', id: 's2' },
|
||||
{ kind: 'webview', id: 'w1' },
|
||||
{ kind: 'session', id: 's1' },
|
||||
{ kind: 'webview', id: 'saved-closed' },
|
||||
{ kind: 'session', id: 'gone' },
|
||||
],
|
||||
},
|
||||
{ id: 'g2', name: 'Later', refs: [{ kind: 'session', id: 's3' }] },
|
||||
],
|
||||
ungrouped: [
|
||||
{ kind: 'webview', id: 'w2' },
|
||||
{ kind: 'session', id: 's4' },
|
||||
{ kind: 'session', id: 's2' },
|
||||
],
|
||||
});
|
||||
|
||||
const ids = (refs: Ref[]) => refs.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
|
||||
describe('projection', () => {
|
||||
it('returns null for a layout without groups, so the caller keeps the flat rail', () => {
|
||||
const h = loadHelper();
|
||||
expect(h.project({ version: 1, groups: [], ungrouped: [{ kind: 'session', id: 's1' }] }, {})).toBeNull();
|
||||
expect(h.project(null, {})).toBeNull();
|
||||
expect(h.hasGroups(h.normalizeLayout({ version: 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('places stored refs in their groups, skips gone/unopened refs, and deduplicates', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
});
|
||||
expect(result.sections.map((s: any) => [s.id, ids(s.refs), s.count])).toEqual([
|
||||
['g1', ['session:s2', 'webview:w1', 'session:s1'], 3],
|
||||
['g2', ['session:s3'], 1],
|
||||
[null, ['webview:w2', 'session:s4'], 2],
|
||||
]);
|
||||
expect(new Set(ids(result.visibleRefs)).size).toBe(result.visibleRefs.length);
|
||||
});
|
||||
|
||||
it('appends live sessions and open web tabs the layout has not placed, in caller order', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), {
|
||||
liveSessionIds: ['new-b', 's1', 'new-a'],
|
||||
openWebviewIds: ['w-local'],
|
||||
});
|
||||
expect(ids(result.sections.at(-1).refs)).toEqual(['session:new-b', 'session:new-a', 'webview:w-local']);
|
||||
});
|
||||
|
||||
it('omits an empty ungrouped section', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), { liveSessionIds: ['s1', 's3'], openWebviewIds: [] });
|
||||
expect(result.sections.map((s: any) => s.id)).toEqual(['g1', 'g2']);
|
||||
});
|
||||
|
||||
it('hides a collapsed group but keeps its highlighted row, and records where hidden rows went', () => {
|
||||
const h = loadHelper();
|
||||
const common = {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
collapsedGroupIds: ['g1'],
|
||||
};
|
||||
const collapsed = h.project(layout(), { ...common, activeSessionId: 's1' });
|
||||
expect(ids(collapsed.sections[0].refs)).toEqual(['session:s1']);
|
||||
expect(collapsed.sections[0].count).toBe(3);
|
||||
expect(collapsed.sections[0].collapsed).toBe(true);
|
||||
expect(collapsed.hiddenTabGroupByRef).toEqual({ 'session:s2': 'g1', 'webview:w1': 'g1' });
|
||||
|
||||
// An active web tab owns the highlight even while a session stays selected.
|
||||
const web = h.project(layout(), { ...common, activeSessionId: 's1', activeWebviewId: 'w1' });
|
||||
expect(ids(web.sections[0].refs)).toEqual(['webview:w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('structure key', () => {
|
||||
it('changes with every structural input and ignores everything else', () => {
|
||||
const h = loadHelper();
|
||||
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
|
||||
const base = h.structureKey(layout(), h.project(layout(), opts), []);
|
||||
expect(h.structureKey(layout(), h.project(layout(), opts), [])).toBe(base);
|
||||
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).not.toBe(base);
|
||||
expect(h.structureKey(layout(), h.project(layout(), { ...opts, collapsedGroupIds: ['g1'] }), ['g1'])).not.toBe(
|
||||
base
|
||||
);
|
||||
expect(h.structureKey(layout(), h.project(layout(), { ...opts, liveSessionIds: ['s1', 's2'] }), [])).not.toBe(base);
|
||||
expect(h.structureKey(layout(), null, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-device collapse storage', () => {
|
||||
function storage(initial: string | null = null) {
|
||||
const values = new Map<string, string>();
|
||||
if (initial !== null) values.set('codeman:tab-groups-collapsed', initial);
|
||||
return {
|
||||
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, next: string) => {
|
||||
values.set(key, next);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it('round-trips collapsed group ids', () => {
|
||||
const h = loadHelper();
|
||||
const local = storage();
|
||||
expect(h.saveCollapsedGroupIds(local, ['g2', 'g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
|
||||
});
|
||||
|
||||
it('garbage-collects ids of groups that no longer exist', () => {
|
||||
const h = loadHelper();
|
||||
const local = storage('["gone","g1"]');
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g1'], ok: true });
|
||||
expect(local.setItem).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '["g1"]');
|
||||
});
|
||||
|
||||
it('reports failure (all-expanded) on unreadable, malformed or unwritable storage', () => {
|
||||
const h = loadHelper();
|
||||
const throwing = {
|
||||
getItem: () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('quota');
|
||||
},
|
||||
};
|
||||
expect(h.loadCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
|
||||
expect(h.loadCollapsedGroupIds(storage('{"g1":true}'), ['g1'])).toEqual({ ids: [], ok: false });
|
||||
expect(h.saveCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped markup', () => {
|
||||
it('renders escaped group sections with real toggle buttons around caller-rendered rows', () => {
|
||||
const h = loadHelper();
|
||||
const projection = h.project(layout(), {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
collapsedGroupIds: ['g2'],
|
||||
});
|
||||
const html = h.renderProjection(
|
||||
projection,
|
||||
(ref: Ref) => `<div class="row" data-ref="${ref.kind}:${ref.id}"></div>`,
|
||||
escape
|
||||
);
|
||||
const doc = new JSDOM(`<main>${html}</main>`).window.document;
|
||||
expect([...doc.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
|
||||
'<Core & Ops>',
|
||||
'Later',
|
||||
'Ungrouped',
|
||||
]);
|
||||
expect(doc.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
|
||||
expect(doc.querySelector('.tab-layout-group-name')?.hasAttribute('data-i18n-skip')).toBe(true);
|
||||
|
||||
const g1 = doc.querySelector<HTMLElement>('[data-tab-group-header="g1"]')!;
|
||||
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
|
||||
expect(g1.tagName).toBe('BUTTON');
|
||||
expect(g1.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(g2.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(doc.getElementById(g1.getAttribute('aria-controls')!)?.querySelectorAll('.row')).toHaveLength(3);
|
||||
// The ungrouped header is a label, not a control: there is nothing to collapse.
|
||||
expect(doc.querySelector('.tab-layout-ungrouped-header')?.tagName).toBe('DIV');
|
||||
expect([...doc.querySelectorAll<HTMLElement>('.row')].map((el) => el.dataset.ref)).toEqual(
|
||||
ids(projection.visibleRefs)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load coordination', () => {
|
||||
it('applies only the newest response when loads overlap', async () => {
|
||||
const h = loadHelper();
|
||||
const pending: Array<(value: unknown) => void> = [];
|
||||
const applied: number[] = [];
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: () => new Promise((resolve) => pending.push(resolve)),
|
||||
applyLayout: (value: any) => applied.push(value.version),
|
||||
applyFallback: vi.fn(),
|
||||
scheduleRetry: vi.fn(),
|
||||
});
|
||||
const first = coordinator.load();
|
||||
const second = coordinator.load();
|
||||
pending[1](layout(2));
|
||||
await second;
|
||||
pending[0](layout(1));
|
||||
await first;
|
||||
expect(applied).toEqual([2]);
|
||||
});
|
||||
|
||||
it('falls back on failure, schedules one retry, and lets a successful retry replace the fallback', async () => {
|
||||
const h = loadHelper();
|
||||
const applyLayout = vi.fn();
|
||||
const applyFallback = vi.fn();
|
||||
let retry: (() => Promise<boolean>) | undefined;
|
||||
let attempt = 0;
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
attempt++;
|
||||
if (attempt === 1) throw new Error('offline');
|
||||
return layout(7);
|
||||
},
|
||||
applyLayout,
|
||||
applyFallback,
|
||||
scheduleRetry: (fn: () => Promise<boolean>) => {
|
||||
retry = fn;
|
||||
return 42;
|
||||
},
|
||||
cancelRetry: vi.fn(),
|
||||
});
|
||||
await coordinator.load();
|
||||
expect(applyFallback).toHaveBeenCalledTimes(1);
|
||||
expect(applyLayout).not.toHaveBeenCalled();
|
||||
await retry!();
|
||||
expect(applyLayout).toHaveBeenCalledWith(layout(7));
|
||||
});
|
||||
|
||||
it('cancels a pending retry and ignores in-flight results after dispose', async () => {
|
||||
const h = loadHelper();
|
||||
let resolve!: (value: unknown) => void;
|
||||
const applyLayout = vi.fn();
|
||||
const cancelRetry = vi.fn();
|
||||
let calls = 0;
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.reject(new Error('offline'));
|
||||
return new Promise((r) => (resolve = r));
|
||||
},
|
||||
applyLayout,
|
||||
applyFallback: vi.fn(),
|
||||
scheduleRetry: () => 7,
|
||||
cancelRetry,
|
||||
});
|
||||
await coordinator.load();
|
||||
const second = coordinator.load();
|
||||
expect(cancelRetry).toHaveBeenCalledWith(7);
|
||||
coordinator.dispose();
|
||||
resolve(layout(3));
|
||||
await second;
|
||||
expect(applyLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser wiring', () => {
|
||||
it('loads the helper before app.js, ships it through the build, and wires GET + SSE', () => {
|
||||
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeGreaterThan(-1);
|
||||
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeLessThan(
|
||||
INDEX_SOURCE.indexOf('<script defer src="app.js"')
|
||||
);
|
||||
expect(BUILD_SOURCE).toContain("'tab-layout-browser.js'");
|
||||
expect(APP_SOURCE).toContain("this._apiJson('/api/tab-layout')");
|
||||
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
|
||||
});
|
||||
|
||||
it('never writes the layout from the browser in this slice', () => {
|
||||
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
|
||||
expect(SOURCE).not.toContain('fetch(');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* @fileoverview The grouped vertical rail, driven through the shipping CodemanApp.
|
||||
*
|
||||
* Covers the app.js half of the owner-tab-layout frontend: the rail renders the
|
||||
* owner's groups only when it is vertical AND there is at least one group (every
|
||||
* other case must be byte-for-byte the flat rail), collapse is per-device and
|
||||
* keeps the active row, a structural change escapes the incremental patch path,
|
||||
* drag-reorder is withheld, and lineage arcs to a collapse-hidden session anchor
|
||||
* to its group header.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
* `document`, `localStorage` and `window` below are that window's, not Node's.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
let localStorage: Storage;
|
||||
let localStorageDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
// Evaluate after DOMContentLoaded so app.js's own bootstrap (new CodemanApp()
|
||||
// on that event) never runs: each test builds the instance it needs.
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
localStorage = window.localStorage;
|
||||
localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n' +
|
||||
read('session-lineage.js') +
|
||||
'\n;window.__GroupedRailCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__GroupedRailCodemanApp;
|
||||
});
|
||||
|
||||
const layout = {
|
||||
version: 8,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
groups: [
|
||||
{
|
||||
id: 'group-x',
|
||||
name: '<Core & Ops>',
|
||||
refs: [
|
||||
{ kind: 'session', id: 's2' },
|
||||
{ kind: 'webview', id: 'w1' },
|
||||
{ kind: 'session', id: 's1' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 's3' }],
|
||||
};
|
||||
|
||||
function makeApp(options: { tabLayout?: unknown } = {}) {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
document.documentElement.dataset.tabRailSort = 'manual';
|
||||
document.body.innerHTML = '<div id="sessionTabs"></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'One', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'Two', status: 'busy' }],
|
||||
['s3', { id: 's3', name: 'Three', status: 'idle' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3'];
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = 'tabLayout' in options ? options.tabLayout : layout;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._tabCollapseStorageFailed = false;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const rowIds = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
|
||||
(tab) => tab.dataset.webviewId || tab.dataset.id
|
||||
);
|
||||
const badges = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
|
||||
(tab) => tab.querySelector('.tab-number')?.textContent ?? null
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
delete window.__codemanUser;
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
document.body.innerHTML = '';
|
||||
localStorage.removeItem('codeman:tab-groups-collapsed');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (localStorageDescriptor) Object.defineProperty(window, 'localStorage', localStorageDescriptor);
|
||||
else delete window.localStorage;
|
||||
});
|
||||
|
||||
describe('flat rail is unchanged without groups', () => {
|
||||
it('renders identical markup for no layout, a failed read and a layout with zero groups', () => {
|
||||
const none = makeApp({ tabLayout: null });
|
||||
none._fullRenderSessionTabs();
|
||||
const flat = document.getElementById('sessionTabs')!.innerHTML;
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
|
||||
const empty = makeApp({ tabLayout: { version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] } });
|
||||
empty._fullRenderSessionTabs();
|
||||
expect(document.getElementById('sessionTabs')!.innerHTML).toBe(flat);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(document.getElementById('sessionTabs')!.classList.contains('session-tabs--grouped')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the horizontal strip flat even when the owner has groups', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app._fullRenderSessionTabs();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped vertical rail', () => {
|
||||
it('draws escaped sections in layout order while badges keep their Alt+N slot', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
expect([...document.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
|
||||
'<Core & Ops>',
|
||||
'Ungrouped',
|
||||
]);
|
||||
expect(document.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
// sessionOrder is s1,s2,s3 and the web tab follows every session: the badge
|
||||
// names the Alt+N key, never the row position.
|
||||
expect(badges()).toEqual(['2', '4', '1', '3']);
|
||||
expect(document.querySelectorAll('.session-tab.active')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('withholds drag-reorder in the grouped rail only', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const draggable = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.getAttribute('draggable'));
|
||||
expect(draggable()).toEqual(['false', 'false', 'false']);
|
||||
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
expect(draggable()).toEqual(['true', 'true', 'true']);
|
||||
});
|
||||
|
||||
it('collapses from the header button, persists per device, and keeps the active row', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const button = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
// The inline onclick calls the GLOBAL app with the button's own dataset.
|
||||
expect(button.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
|
||||
app.toggleTabGroupCollapsed(button.dataset.tabGroupHeader);
|
||||
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
const header = document.querySelector('[data-tab-group-header="group-x"]')!;
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(header.querySelector('.tab-layout-group-count')?.textContent).toBe('3');
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
expect(app._hiddenTabGroupByRef.get('session:s1')).toBe('group-x');
|
||||
|
||||
app.toggleTabGroupCollapsed('group-x');
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
|
||||
});
|
||||
|
||||
it('reveals a hidden session when it is selected, without expanding its group', () => {
|
||||
const app = makeApp();
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
|
||||
app.activeSessionId = 's1';
|
||||
app._updateActiveTabImmediate('s1');
|
||||
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(rowIds()).toEqual(['s1', 's3']);
|
||||
expect(badges()).toEqual(['1', '3']);
|
||||
expect(document.querySelector('.session-tab.active')?.getAttribute('data-id')).toBe('s1');
|
||||
});
|
||||
|
||||
it('patches rows in place while the structure holds, and rebuilds when it changes', () => {
|
||||
const app = makeApp();
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
|
||||
app.sessions.get('s3').status = 'busy';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[data-id="s3"] .tab-status')?.className).toBe('tab-status busy');
|
||||
|
||||
// Same rows on screen, different grouping: s3 moves into group-x. The id
|
||||
// sets still match, so only the structure key can catch this.
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._fullRenderSessionTabs();
|
||||
full.mockClear();
|
||||
app.tabLayout = {
|
||||
...layout,
|
||||
version: 9,
|
||||
groups: [{ ...layout.groups[0], refs: [...layout.groups[0].refs, { kind: 'session', id: 's3' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).toHaveBeenCalledTimes(1);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(1);
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
});
|
||||
|
||||
it('falls back to all-expanded when collapse cannot be stored', () => {
|
||||
const app = makeApp();
|
||||
const setItem = vi.spyOn(window.Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota');
|
||||
});
|
||||
app.toggleTabGroupCollapsed('group-x', true);
|
||||
setItem.mockRestore();
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(app._tabCollapseStorageFailed).toBe(true);
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('survives localStorage itself being unavailable', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new window.DOMException('denied', 'SecurityError');
|
||||
},
|
||||
});
|
||||
expect(() => app._applyTabLayout(layout)).not.toThrow();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout adoption', () => {
|
||||
it('garbage-collects stale collapse ids and renders on adoption; a failed read renders flat', () => {
|
||||
localStorage.setItem('codeman:tab-groups-collapsed', '["gone","group-x"]');
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._applyTabLayout(layout);
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
|
||||
app._applyTabLayout(null);
|
||||
expect(app.tabLayout).toBeNull();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
});
|
||||
|
||||
it('ignores an older layout than the one it already shows', () => {
|
||||
const app = makeApp();
|
||||
app.tabLayout = { ...layout, version: 9 };
|
||||
app._applyTabLayout({ ...layout, version: 8, groups: [] });
|
||||
expect(app.tabLayout.version).toBe(9);
|
||||
});
|
||||
|
||||
it('re-reads on tab:layoutChanged only for its own owner and a newer version', () => {
|
||||
const app = makeApp();
|
||||
app._loadTabLayout = vi.fn();
|
||||
app.tabLayout = { version: 8, groups: [], ungrouped: [] };
|
||||
|
||||
app._onTabLayoutChanged({ owner: '@single', version: 8 });
|
||||
expect(app._loadTabLayout).not.toHaveBeenCalled();
|
||||
app._onTabLayoutChanged({ owner: '@single', version: 9 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.__codemanUser = { username: 'alice', multiUser: true };
|
||||
app._onTabLayoutChanged({ owner: 'bob', version: 20 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
|
||||
app._onTabLayoutChanged({ owner: 'alice', version: 20 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('loads through GET /api/tab-layout and adopts the envelope payload', async () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._tabLayoutCoordinator = null;
|
||||
app._apiJson = vi.fn(async () => ({ layout }));
|
||||
await app._loadTabLayout();
|
||||
expect(app._apiJson).toHaveBeenCalledWith('/api/tab-layout');
|
||||
expect(app.tabLayout.version).toBe(8);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
app._tabLayoutCoordinator.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lineage in the grouped rail', () => {
|
||||
function rect(top: number) {
|
||||
return () => ({ left: 32, top, right: 292, bottom: top + 36, width: 260, height: 36, x: 32, y: top }) as DOMRect;
|
||||
}
|
||||
|
||||
it('anchors a collapse-hidden endpoint to its group header', () => {
|
||||
const app = makeApp();
|
||||
app.activeSessionId = 's3';
|
||||
app.sessions.get('s3').status = 'working';
|
||||
app.sessions.get('s3').parentSessionId = 's1';
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._lineageLinesEnabled = () => true;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).toEqual(['s3']);
|
||||
|
||||
document.getElementById('sessionTabs')!.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, right: 320, bottom: 320, width: 320, height: 320 }) as DOMRect;
|
||||
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.getBoundingClientRect = rect(20);
|
||||
document.querySelector<HTMLElement>('[data-id="s3"]')!.getBoundingClientRect = rect(180);
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
app._appendLineageConnectionLines(svg, new Map());
|
||||
|
||||
const line = svg.querySelector('.lineage-line');
|
||||
expect(line?.classList.contains('lineage-line--proxied')).toBe(true);
|
||||
expect(line?.getAttribute('data-parent-tab')).toBe('s1');
|
||||
expect(svg.querySelector('.lineage-line-dot')?.classList.contains('lineage-line-dot--proxied')).toBe(true);
|
||||
expect(app._lineageEdgeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('draws nothing when both endpoints hide behind the same header', () => {
|
||||
const app = makeApp();
|
||||
app.activeSessionId = 's3';
|
||||
app.sessions.get('s1').parentSessionId = 's2';
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._lineageLinesEnabled = () => true;
|
||||
app._fullRenderSessionTabs();
|
||||
document.getElementById('sessionTabs')!.getBoundingClientRect = rect(0);
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
app._appendLineageConnectionLines(svg, new Map());
|
||||
|
||||
expect(svg.querySelectorAll('.lineage-line')).toHaveLength(0);
|
||||
expect(app._lineageEdgeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user