perf(ui): streamline session sidebar state
This commit is contained in:
+227
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { SessionProjectScroller } from './SessionProjectScroller';
|
||||
import { RecentSessionSection } from '../recent/RecentSessionSection';
|
||||
import { SidebarActivitySections } from '../recent/SidebarActivitySections';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type FolderCallbacks = {
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
type RowPropsCapture = Pick<SessionGroupSectionProps,
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'copiedSessionId'
|
||||
| 'setCopiedSessionId'
|
||||
>;
|
||||
|
||||
type ExpectNever<T extends never> = T;
|
||||
type RowDomainCallback =
|
||||
| 'handleSaveEdit'
|
||||
| 'handleCancelEdit'
|
||||
| 'handleSessionSelect'
|
||||
| 'handleSessionDoubleClick'
|
||||
| 'handleShareSession'
|
||||
| 'handleCopyShareUrl'
|
||||
| 'handleCopySessionId'
|
||||
| 'handleUnshareSession'
|
||||
| 'handleDeleteSession'
|
||||
| 'handleRestoreSession';
|
||||
|
||||
// Structural group contracts must not expose the row domain action surface.
|
||||
type _SessionGroupSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof SessionGroupSectionProps, RowDomainCallback>>;
|
||||
type _SessionProjectScrollerHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SessionProjectScroller>, RowDomainCallback>>;
|
||||
type _RecentSessionSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof RecentSessionSection>, RowDomainCallback>>;
|
||||
type _SidebarActivitySectionsHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SidebarActivitySections>, RowDomainCallback>>;
|
||||
|
||||
let folderCallbacks: FolderCallbacks | null = null;
|
||||
let rowPropsCapture: RowPropsCapture | null = null;
|
||||
|
||||
mock.module('../../SessionFolderItem', () => ({
|
||||
SessionFolderItem: (props: FolderCallbacks) => {
|
||||
folderCallbacks = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../folders/sessionFolderDnd', () => ({
|
||||
DroppableFolderWrapper: ({ children }: { children: (ref: () => void, isOver: boolean) => React.ReactNode }) => <>{children(() => undefined, false)}</>,
|
||||
SessionFolderDndScope: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
mock.module('@/sync/sync-context', () => ({
|
||||
setActiveSession: () => undefined,
|
||||
useChildStoreManager: () => ({
|
||||
subscribeBootstrap: () => () => undefined,
|
||||
getBootstrapState: () => null,
|
||||
getBootstrapFailure: () => undefined,
|
||||
requestBootstrap: () => undefined,
|
||||
}),
|
||||
useDirectoryStore: () => null,
|
||||
useGlobalSessionStatus: () => null,
|
||||
useSessionPermissions: () => null,
|
||||
useSessionQuestionCount: () => 0,
|
||||
useSyncSDK: () => null,
|
||||
useSyncDirectory: () => null,
|
||||
buildSessionMessageRecordsSnapshot: () => [],
|
||||
}));
|
||||
|
||||
mock.module('../sessions/collapsedActivityIndicator', () => ({
|
||||
CollapsedSessionActivityIndicator: () => null,
|
||||
useCollapsedSessionActivityState: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../sessions/SessionTreeItem', () => ({
|
||||
SessionTreeItem: (props: RowPropsCapture) => {
|
||||
rowPropsCapture = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const { SessionGroupSection } = await import('./SessionGroupSection');
|
||||
|
||||
const folder: SessionFolder = {
|
||||
id: 'folder-a',
|
||||
name: 'Initial folder',
|
||||
parentId: null,
|
||||
sessionIds: [],
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const group: SessionGroupSectionProps['group'] = {
|
||||
id: 'main',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace',
|
||||
folderScopeKey: '/workspace',
|
||||
sessions: [],
|
||||
};
|
||||
|
||||
const groupWithSession: SessionGroupSectionProps['group'] = {
|
||||
...group,
|
||||
// SAFETY: SessionGroupSection only reads the fixture session's id in this test.
|
||||
sessions: [{ session: { id: 'session-a' } as Session, children: [], worktree: null }],
|
||||
};
|
||||
|
||||
const createProps = (): SessionGroupSectionProps => ({
|
||||
group,
|
||||
groupKey: 'project:main',
|
||||
projectId: 'project',
|
||||
hideGroupLabel: true,
|
||||
hasSessionSearchQuery: false,
|
||||
normalizedSessionSearchQuery: '',
|
||||
groupSearchDataByGroup: new WeakMap(),
|
||||
collapsedGroups: new Set(),
|
||||
hideDirectoryControls: false,
|
||||
showMoreGroupSessions: () => undefined,
|
||||
resetGroupSessionLimit: () => undefined,
|
||||
mobileVariant: false,
|
||||
alwaysShowActions: false,
|
||||
activeProjectId: 'project',
|
||||
setActiveProjectIdOnly: () => undefined,
|
||||
setActiveMainTab: () => undefined,
|
||||
setSessionSwitcherOpen: () => undefined,
|
||||
openNewSessionDraft: () => undefined,
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderIndex: new Map(),
|
||||
notifyOnSubtasks: false,
|
||||
expandedParents: new Set(),
|
||||
editingId: null,
|
||||
editTitle: '',
|
||||
copiedSessionId: null,
|
||||
openSidebarMenuKey: null,
|
||||
setEditingId: () => undefined,
|
||||
setEditTitle: () => undefined,
|
||||
toggleParent: () => undefined,
|
||||
setOpenSidebarMenuKey: () => undefined,
|
||||
startFolderRename: () => undefined,
|
||||
allowReselect: false,
|
||||
isSessionSearchOpen: false,
|
||||
sessionSearchQuery: '',
|
||||
setSessionSearchQuery: () => undefined,
|
||||
setIsSessionSearchOpen: () => undefined,
|
||||
deleteSessionConfirm: null,
|
||||
setDeleteSessionConfirm: () => undefined,
|
||||
setCopiedSessionId: () => undefined,
|
||||
onToggleCollapsedGroup: () => undefined,
|
||||
folderRename: null,
|
||||
setFolderRenameDraft: () => undefined,
|
||||
clearFolderRename: () => undefined,
|
||||
});
|
||||
|
||||
describe('SessionGroupSection public behavior', () => {
|
||||
test('routes rendered folder rename and delete actions to the owning folder store', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const originalFolders = useSessionFoldersStore.getState();
|
||||
const originalUi = useUIStore.getState();
|
||||
useSessionFoldersStore.setState({ foldersMap: { '/workspace': [folder] } });
|
||||
useUIStore.setState({ showDeletionDialog: false });
|
||||
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...createProps()} /></I18nProvider>));
|
||||
expect(folderCallbacks).not.toBeNull();
|
||||
|
||||
await act(async () => folderCallbacks?.onRename('Renamed folder'));
|
||||
expect(useSessionFoldersStore.getState().foldersMap['/workspace']?.[0]?.name).toBe('Renamed folder');
|
||||
|
||||
await act(async () => folderCallbacks?.onDelete());
|
||||
expect(useSessionFoldersStore.getState().foldersMap['/workspace']).toEqual([]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
useSessionFoldersStore.setState(originalFolders, true);
|
||||
useUIStore.setState(originalUi, true);
|
||||
folderCallbacks = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('propagates confirmation, search/navigation, and copy ownership changes to rendered rows', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const firstSelected = () => undefined;
|
||||
const nextSelected = () => undefined;
|
||||
const firstCopied = () => undefined;
|
||||
const nextCopied = () => undefined;
|
||||
const initialProps = createProps();
|
||||
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} onSessionSelected={firstSelected} setCopiedSessionId={firstCopied} /></I18nProvider>));
|
||||
expect(rowPropsCapture?.onSessionSelected).toBe(firstSelected);
|
||||
expect(rowPropsCapture?.sessionSearchQuery).toBe('');
|
||||
expect(rowPropsCapture?.deleteSessionConfirm).toBeNull();
|
||||
expect(rowPropsCapture?.copiedSessionId).toBeNull();
|
||||
expect(rowPropsCapture?.setCopiedSessionId).toBe(firstCopied);
|
||||
|
||||
// SAFETY: the confirmation is only forwarded by identity to the row mock.
|
||||
const confirmation = { session: { id: 'session-a' } as Session, descendantCount: 0, descendantIds: [], archivedBucket: false };
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} allowReselect onSessionSelected={nextSelected} isSessionSearchOpen sessionSearchQuery="search" deleteSessionConfirm={confirmation} copiedSessionId="session-a" setCopiedSessionId={nextCopied} /></I18nProvider>));
|
||||
expect(rowPropsCapture?.allowReselect).toBe(true);
|
||||
expect(rowPropsCapture?.onSessionSelected).toBe(nextSelected);
|
||||
expect(rowPropsCapture?.isSessionSearchOpen).toBe(true);
|
||||
expect(rowPropsCapture?.sessionSearchQuery).toBe('search');
|
||||
expect(rowPropsCapture?.deleteSessionConfirm).toBe(confirmation);
|
||||
expect(rowPropsCapture?.copiedSessionId).toBe('session-a');
|
||||
expect(rowPropsCapture?.setCopiedSessionId).toBe(nextCopied);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
rowPropsCapture = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user