2026-06-16 23:33:29 +03:00
|
|
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
2026-07-27 23:07:42 +03:00
|
|
|
import { CONTEXT_SURFACES, sortContextSurfaces } from '../lib/surfaces/registry';
|
2026-06-16 23:33:29 +03:00
|
|
|
import { useUIStore } from './useUIStore';
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2026-07-27 23:07:42 +03:00
|
|
|
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
|
2026-06-16 23:33:29 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('useUIStore context panel tabs', () => {
|
|
|
|
|
test('updates readOnly when an existing chat tab is reopened', () => {
|
|
|
|
|
const directory = '/repo';
|
|
|
|
|
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, {
|
|
|
|
|
mode: 'chat',
|
|
|
|
|
dedupeKey: 'session:ses_1',
|
|
|
|
|
label: 'Session',
|
|
|
|
|
readOnly: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, {
|
|
|
|
|
mode: 'chat',
|
|
|
|
|
dedupeKey: 'session:ses_1',
|
|
|
|
|
label: 'Session',
|
|
|
|
|
readOnly: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
|
|
|
|
expect(tabs).toHaveLength(1);
|
|
|
|
|
expect(tabs[0]?.readOnly).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-27 23:07:42 +03:00
|
|
|
|
|
|
|
|
describe('useUIStore openContextSurface', () => {
|
|
|
|
|
const directory = '/repo';
|
|
|
|
|
|
|
|
|
|
test('opens a fresh singleton tab when none of that mode exists', () => {
|
|
|
|
|
useUIStore.getState().openContextSurface(directory, 'diff');
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
expect(state?.isOpen).toBe(true);
|
|
|
|
|
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['diff']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('activates the existing tab of the requested mode instead of duplicating it', () => {
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, { mode: 'file', targetPath: '/repo/a.ts' });
|
|
|
|
|
|
|
|
|
|
useUIStore.getState().openContextSurface(directory, 'diff');
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
expect(state?.tabs.filter((tab) => tab.mode === 'diff')).toHaveLength(1);
|
|
|
|
|
expect(state?.activeTabId).toBe('diff');
|
|
|
|
|
expect(state?.isOpen).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('toggles the panel closed when the requested mode is already active and open', () => {
|
|
|
|
|
useUIStore.getState().openContextSurface(directory, 'diff');
|
|
|
|
|
useUIStore.getState().openContextSurface(directory, 'diff');
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
expect(state?.isOpen).toBe(false);
|
|
|
|
|
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['diff']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('does nothing for content-driven modes without existing content', () => {
|
|
|
|
|
useUIStore.getState().openContextSurface(directory, 'chat');
|
|
|
|
|
|
|
|
|
|
expect(useUIStore.getState().contextPanelByDirectory[directory]).toBe(undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('opens an empty editor tab that a real file later replaces', () => {
|
|
|
|
|
useUIStore.getState().openContextSurface(directory, 'file');
|
|
|
|
|
|
|
|
|
|
let state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
expect(state?.isOpen).toBe(true);
|
|
|
|
|
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['file']);
|
|
|
|
|
expect(state?.tabs[0]?.targetPath).toBe(null);
|
|
|
|
|
|
|
|
|
|
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
|
|
|
|
|
|
|
|
|
|
state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
expect(state?.tabs.filter((tab) => tab.mode === 'file')).toHaveLength(1);
|
|
|
|
|
expect(state?.tabs.find((tab) => tab.mode === 'file')?.targetPath).toBe('/repo/a.ts');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('activates the most recently touched tab of a content-driven mode', () => {
|
|
|
|
|
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
|
|
|
|
|
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
|
|
|
|
|
|
|
|
|
|
useUIStore.getState().openContextSurface(directory, 'file');
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
const activeTab = state?.tabs.find((tab) => tab.id === state.activeTabId);
|
|
|
|
|
expect(activeTab?.mode).toBe('file');
|
|
|
|
|
expect(activeTab?.targetPath).toBe('/repo/b.ts');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('useUIStore closeContextPanelTab surface stability', () => {
|
|
|
|
|
const directory = '/repo';
|
|
|
|
|
|
|
|
|
|
test('closing an active file tab activates another file tab, not another surface', () => {
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
|
|
|
|
|
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
|
|
|
|
|
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
|
|
|
|
|
|
|
|
|
|
const stateBefore = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
const activeTabId = stateBefore?.activeTabId as string;
|
|
|
|
|
useUIStore.getState().closeContextPanelTab(directory, activeTabId);
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
const activeTab = state?.tabs.find((tab) => tab.id === state.activeTabId);
|
|
|
|
|
expect(activeTab?.mode).toBe('file');
|
|
|
|
|
expect(activeTab?.targetPath).toBe('/repo/a.ts');
|
|
|
|
|
expect(state?.isOpen).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('closing the last tab of the active surface closes the panel', () => {
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
|
|
|
|
|
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
|
|
|
|
|
|
|
|
|
|
const stateBefore = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
useUIStore.getState().closeContextPanelTab(directory, stateBefore?.activeTabId as string);
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
expect(state?.isOpen).toBe(false);
|
|
|
|
|
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['terminal']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('closing an inactive tab keeps the active tab untouched', () => {
|
|
|
|
|
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
|
|
|
|
|
|
|
|
|
|
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
const fileTab = state0?.tabs.find((tab) => tab.mode === 'file');
|
|
|
|
|
useUIStore.getState().closeContextPanelTab(directory, fileTab?.id as string);
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
expect(state?.activeTabId).toBe('terminal');
|
|
|
|
|
expect(state?.isOpen).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('useUIStore per-surface panel widths', () => {
|
|
|
|
|
const directory = '/repo';
|
|
|
|
|
|
|
|
|
|
test('setContextPanelWidth stores a clamped manual width for one mode only', () => {
|
|
|
|
|
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
|
|
|
|
|
useUIStore.getState().setContextPanelWidth(directory, 'diff', 700);
|
|
|
|
|
useUIStore.getState().setContextPanelWidth(directory, 'git', 100);
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
expect(state?.widthByMode.diff).toBe(700);
|
|
|
|
|
expect(state?.widthByMode.git).toBe(380);
|
|
|
|
|
expect(state?.widthByMode.browser).toBe(undefined);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('useUIStore contextRailOrder', () => {
|
|
|
|
|
test('setContextRailOrder drops empty and duplicate ids', () => {
|
|
|
|
|
useUIStore.getState().setContextRailOrder(['diff', 'diff', '', 'editor']);
|
|
|
|
|
expect(useUIStore.getState().contextRailOrder).toEqual(['diff', 'editor']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('sortContextSurfaces applies persisted order and appends missing surfaces', () => {
|
|
|
|
|
const ordered = sortContextSurfaces(['browser', 'unknown-id', 'diff']);
|
|
|
|
|
const ids = ordered.map((surface) => surface.id);
|
|
|
|
|
|
|
|
|
|
expect(ids.slice(0, 2)).toEqual(['browser', 'diff']);
|
|
|
|
|
// Assert against the registry itself so this test cannot go stale when a
|
|
|
|
|
// surface is added or removed.
|
|
|
|
|
expect(new Set(ids)).toEqual(new Set(CONTEXT_SURFACES.map((surface) => surface.id)));
|
|
|
|
|
expect(ids).toHaveLength(CONTEXT_SURFACES.length);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-13 22:44:13 +03:00
|
|
|
|
|
|
|
|
describe('context panel tab limits', () => {
|
|
|
|
|
test('a surface filling up never evicts another surface tab', () => {
|
|
|
|
|
const directory = '/repo';
|
|
|
|
|
useUIStore.getState().openContextDiff(directory, 'src/app.ts');
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < 20; index += 1) {
|
|
|
|
|
useUIStore.getState().openContextPreview(directory, `http://localhost:${3000 + index}/`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
|
|
|
|
// The diff tab is not on screen while browsing, so losing it would be a
|
|
|
|
|
// disappearance the user never saw happen.
|
|
|
|
|
expect(tabs.some((tab) => tab.mode === 'diff')).toBe(true);
|
|
|
|
|
expect(tabs.filter((tab) => tab.mode === 'browser').length).toBeLessThan(20);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeps the tab that was just opened', () => {
|
|
|
|
|
const directory = '/repo';
|
|
|
|
|
for (let index = 0; index < 20; index += 1) {
|
|
|
|
|
useUIStore.getState().openContextPreview(directory, `http://localhost:${3000 + index}/`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
|
|
|
|
const tabs = state?.tabs ?? [];
|
|
|
|
|
expect(tabs.some((tab) => tab.id === state?.activeTabId)).toBe(true);
|
|
|
|
|
expect(tabs.some((tab) => tab.targetPath === 'http://localhost:3019/')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|