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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { normalizeFolderRoots, selectFolderIdsForProjection } from '../sessions/sessionNodeItemUtils';
|
||||
|
||||
const folder = (id: string, parentId: string | null = null, sessionIds: string[] = []): SessionFolder => ({
|
||||
id,
|
||||
name: id,
|
||||
parentId,
|
||||
sessionIds,
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
describe('normalizeFolderRoots', () => {
|
||||
test('returns cycle and orphan folders as deterministic fallback roots without duplication', () => {
|
||||
const folders = [
|
||||
folder('cycle-a', 'cycle-b', ['session-a']),
|
||||
folder('cycle-b', 'cycle-a'),
|
||||
folder('orphan', 'missing-parent'),
|
||||
folder('root'),
|
||||
];
|
||||
|
||||
expect(normalizeFolderRoots(folders).map((entry) => entry.id))
|
||||
.toEqual(['orphan', 'root', 'cycle-a']);
|
||||
});
|
||||
|
||||
test('keeps normal nested folder root order unchanged', () => {
|
||||
const folders = [folder('root-a'), folder('child-a', 'root-a'), folder('root-b')];
|
||||
|
||||
expect(normalizeFolderRoots(folders).map((entry) => entry.id)).toEqual(['root-a', 'root-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectFolderIdsForProjection', () => {
|
||||
const malformedFolders = [
|
||||
{ id: 'cycle-a', name: 'cycle-a', parentId: 'cycle-b', nodeCount: 0 },
|
||||
{ id: 'cycle-b', name: 'cycle-b', parentId: 'cycle-a', nodeCount: 1 },
|
||||
{ id: 'orphan', name: 'orphan', parentId: 'missing-parent', nodeCount: 0 },
|
||||
];
|
||||
|
||||
test('keeps malformed empty and nonempty folders in every projection mode', () => {
|
||||
for (const archivedBucket of [false, true]) {
|
||||
for (const searchQuery of ['', 'does-not-match']) {
|
||||
expect([...selectFolderIdsForProjection(malformedFolders, { archivedBucket, searchQuery })])
|
||||
.toEqual(['cycle-a', 'cycle-b', 'orphan']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps normal archived/search nesting semantics', () => {
|
||||
const folders = [
|
||||
{ id: 'root', name: 'root', parentId: null, nodeCount: 0 },
|
||||
{ id: 'child', name: 'matching-child', parentId: 'root', nodeCount: 1 },
|
||||
];
|
||||
|
||||
expect([...selectFolderIdsForProjection(folders, { archivedBucket: true, searchQuery: 'matching' })])
|
||||
.toEqual(['root', 'child']);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGroupRenderDescriptors } from './sessionProjectRender';
|
||||
import type { SessionGroup } from '../types';
|
||||
|
||||
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
|
||||
id,
|
||||
label: id,
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: id === 'main',
|
||||
worktree: null,
|
||||
directory: '/workspace',
|
||||
sessions: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('buildGroupRenderDescriptors', () => {
|
||||
test('renders the main group and archived bucket for the main workspace', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('main'), makeGroup('archived', { isArchivedBucket: true })],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: true })).toEqual([
|
||||
{
|
||||
group: section.groups[0],
|
||||
groupKey: 'project-a:main',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: true,
|
||||
},
|
||||
{
|
||||
group: section.groups[1],
|
||||
groupKey: 'project-a:archived',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('renders the primary group without a label and nested groups with labels', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('main'), makeGroup('feature')],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false })).toEqual([
|
||||
{
|
||||
group: section.groups[0],
|
||||
groupKey: 'project-a:main',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: true,
|
||||
},
|
||||
{
|
||||
group: section.groups[1],
|
||||
groupKey: 'project-a:feature',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps labels when a flat section has no main group', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('feature', { isMain: false }), makeGroup('other', { isMain: false })],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { buildGroupRenderDescriptors, type ProjectSection } from './sessionProjectRender';
|
||||
import { formatProjectLabel } from '../utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
type SessionProjectScrollerState = Pick<SessionGroupSectionProps,
|
||||
| 'editingId'
|
||||
| 'openSidebarMenuKey'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
> & {
|
||||
visibleSessionCountByGroup: Map<string, number>;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
|
||||
| 'hasSessionSearchQuery'
|
||||
| 'normalizedSessionSearchQuery'
|
||||
| 'groupSearchDataByGroup'
|
||||
| 'collapsedGroups'
|
||||
| 'hideDirectoryControls'
|
||||
| 'mobileVariant'
|
||||
| 'alwaysShowActions'
|
||||
| 'notifyOnSubtasks'
|
||||
| 'expandedParents'
|
||||
| 'editTitle'
|
||||
| 'copiedSessionId'
|
||||
| 'folderRename'
|
||||
| 'setFolderRenameDraft'
|
||||
| 'clearFolderRename'
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'setSessionSearchQuery'
|
||||
| 'setIsSessionSearchOpen'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
> & {
|
||||
activeProjectId: string | null;
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerGroupActions = Pick<SessionGroupSectionProps,
|
||||
| 'showMoreGroupSessions'
|
||||
| 'resetGroupSessionLimit'
|
||||
| 'setActiveProjectIdOnly'
|
||||
| 'setSessionSwitcherOpen'
|
||||
| 'openNewSessionDraft'
|
||||
| 'onToggleCollapsedGroup'
|
||||
>;
|
||||
|
||||
type SessionProjectScrollerModel = {
|
||||
topContent?: React.ReactNode;
|
||||
hasSharedSessions?: boolean;
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
stuckProjectHeaders: Set<string>;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
state: SessionProjectScrollerState;
|
||||
groupProps: SessionProjectScrollerGroupProps;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerView = {
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
showOnlyMainWorkspace: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
hideDirectoryControls: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stickyZoneHeaders: boolean;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
projectSortOrder: ProjectSortOrder;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerActions = {
|
||||
group: SessionProjectScrollerGroupActions;
|
||||
toggleProject: (id: string) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
model: SessionProjectScrollerModel;
|
||||
view: SessionProjectScrollerView;
|
||||
actions: SessionProjectScrollerActions;
|
||||
};
|
||||
|
||||
const TOP_FADE_MAX_SIZE = 48;
|
||||
const TOP_FADE_MIN_SIZE = 32;
|
||||
const TOP_FADE_CLEAR_MAX_SIZE = 24;
|
||||
|
||||
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
|
||||
formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
)
|
||||
);
|
||||
|
||||
function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_projects_list.render');
|
||||
const { t } = useI18n();
|
||||
const { model, view, actions } = props;
|
||||
const isInlineEditing = model.state.editingId !== null;
|
||||
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders;
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
const groupSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
||||
// walk) and skip the cost of a style recalc on every render.
|
||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
||||
// Keep per-scroll measurements out of React state so the interaction guard
|
||||
// can read the current fade boundary without rerendering the sidebar.
|
||||
const topFadeSizeRef = React.useRef(0);
|
||||
// Update the compositor-owned mask on every scroll, but cross the React
|
||||
// render boundary only when the sticky identity overlay appears or hides.
|
||||
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
|
||||
const hasTopScroll = scroller.scrollTop > 1;
|
||||
const topFadeSize = hasTopScroll
|
||||
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
|
||||
: 0;
|
||||
topFadeSizeRef.current = topFadeSize;
|
||||
scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
|
||||
scroller.style.setProperty(
|
||||
'--scroll-shadow-top-clear-size',
|
||||
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
|
||||
);
|
||||
}, []);
|
||||
const blockObscuredInteraction = React.useCallback((
|
||||
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
// SAFETY: React's mouse and pointer events are dispatched from Elements.
|
||||
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
|
||||
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
|
||||
if (eventY >= topFadeSizeRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const hasProjectScroller = model.projectSections.length > 0 && model.sectionsForRender.length > 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
||||
syncTopFade(scrollContainerRef.current);
|
||||
}
|
||||
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
|
||||
let stuckProject: ProjectSection['project'] | null = null;
|
||||
for (const section of model.projectSections) {
|
||||
if (model.stuckProjectHeaders.has(section.project.id)) {
|
||||
stuckProject = section.project;
|
||||
}
|
||||
}
|
||||
// The IntersectionObserver reports the stuck header asynchronously, a frame or
|
||||
// two after the (synchronous) mask has already hidden the real header — which
|
||||
// otherwise leaves a one-frame gap where the title blinks out with no crisp
|
||||
// replacement. Seed the overlay with the topmost rendered project so it is
|
||||
// ready in the same frame; the observer then corrects it. When shared sessions
|
||||
// lead the list, the Recent fallback below owns the top instead of a project.
|
||||
const leadingProject =
|
||||
stuckProject ?? (model.hasSharedSessions ? null : model.sectionsForRender[0]?.project ?? null);
|
||||
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
|
||||
|
||||
if (model.projectSections.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.topContent}{model.emptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
if (model.sectionsForRender.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.searchEmptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
return (
|
||||
// [overflow-anchor:none] — the browser's native scroll anchoring otherwise
|
||||
// latches onto content BELOW a growing session group (e.g. the "Show more"
|
||||
// button) and holds it in place, which makes newly revealed sessions look
|
||||
// like they insert upward. With anchoring off, scrollTop stays put and new
|
||||
// rows appear below naturally.
|
||||
<div
|
||||
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
|
||||
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
ref={scrollContainerRef}
|
||||
useScrollShadow
|
||||
hideTopScrollShadow={!enableStickyFade}
|
||||
scrollShadowSize={96}
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className={cn('oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', view.mobileVariant ? '' : '')}
|
||||
// SAFETY: the custom property is the only dynamic CSS declaration here.
|
||||
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||
>
|
||||
{model.topContent}
|
||||
{view.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{(() => {
|
||||
const activeSection = model.sectionsForRender.find((section) => section.project.id === model.activeProjectId) ?? model.sectionsForRender[0];
|
||||
if (!activeSection) {
|
||||
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
|
||||
}
|
||||
const descriptors = buildGroupRenderDescriptors(activeSection, { mainWorkspaceOnly: true });
|
||||
if (!descriptors.length) {
|
||||
return <div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>;
|
||||
}
|
||||
return descriptors.map(({ group, groupKey, projectId, hideGroupLabel }) => {
|
||||
return (
|
||||
<React.Fragment key={groupKey}>
|
||||
<SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectId} hideGroupLabel={hideGroupLabel} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} compactBodyPadding scrollContainerRef={scrollContainerRef} />
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={projectSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (isInlineEditing) return;
|
||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||
if (view.projectSortOrder !== 'manual') return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = model.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = model.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
actions.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={model.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{model.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = getProjectLabel(project, view.homeDirectory);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
|
||||
const isCollapsed = view.collapsedProjects.has(projectKey);
|
||||
const isRepo = model.projectRepoStatus.get(projectKey);
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={view.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
projectColor={project.color}
|
||||
projectIconImage={project.iconImage}
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={view.isDesktopShellRuntime}
|
||||
hideDirectoryControls={view.hideDirectoryControls}
|
||||
mobileVariant={view.mobileVariant}
|
||||
alwaysShowActions={view.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
openSidebarMenuKey={model.state.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||
onToggle={() => actions.toggleProject(projectKey)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
|
||||
actions.openNewSessionDraft({
|
||||
selectedProjectId: projectKey,
|
||||
directoryOverride: project.normalizedPath,
|
||||
});
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||
actions.openNewWorktreeDialog();
|
||||
}}
|
||||
onManageWorktrees={() => actions.openWorktreesPage(projectKey)}
|
||||
onRenameStart={() => actions.openProjectEditDialog(projectKey)}
|
||||
onClose={() => actions.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { model.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
showCreateButtons
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-0 pt-0.5 pb-0.5">
|
||||
{(() => {
|
||||
const orderedGroups = section.groups;
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
const nestedGroups = rootGroup
|
||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||
: orderedGroups;
|
||||
return (
|
||||
<DndContext
|
||||
sensors={groupSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (isInlineEditing) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
|
||||
const newIndex = nestedGroups.findIndex((item) => item.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
|
||||
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
|
||||
actions.setGroupOrderByProject((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(projectKey, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* Root/flat sessions render directly under the
|
||||
project zone header; worktree and archived
|
||||
groups keep their own slim sortable sub-header. */}
|
||||
{rootGroup ? <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={rootGroup} groupKey={`${projectKey}:${rootGroup.id}`} projectId={projectKey} hideGroupLabel visibleSessionCount={model.state.visibleSessionCountByGroup.get(`${projectKey}:${rootGroup.id}`)} scrollContainerRef={scrollContainerRef} /> : null}
|
||||
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||||
{nestedGroups.map((group) => {
|
||||
const groupKey = `${projectKey}:${group.id}`;
|
||||
return (
|
||||
<SortableGroupItem key={group.id} id={group.id} disabled={isInlineEditing}>
|
||||
{(dragHandleProps) => <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectKey} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} dragHandleProps={dragHandleProps} scrollContainerRef={scrollContainerRef} />}
|
||||
</SortableGroupItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableProjectItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
|
||||
<div
|
||||
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-1.5 py-1 pl-4 pr-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{leadingProject && leadingProjectLabel ? (
|
||||
<ProjectHeaderIdentity
|
||||
id={leadingProject.id}
|
||||
projectLabel={leadingProjectLabel}
|
||||
projectIcon={leadingProject.icon}
|
||||
projectColor={leadingProject.color}
|
||||
projectIconImage={leadingProject.iconImage}
|
||||
projectIconBackground={leadingProject.iconBackground}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
|
||||
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
|
||||
{t('sessions.sidebar.activity.recentTitle')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SessionProjectScroller = React.memo(SessionProjectScrollerComponent);
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SessionGroup } from '../types';
|
||||
|
||||
export type ProjectSection = {
|
||||
project: {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type GroupRenderDescriptor = {
|
||||
group: SessionGroup;
|
||||
groupKey: string;
|
||||
projectId: string;
|
||||
hideGroupLabel: boolean;
|
||||
};
|
||||
|
||||
export const buildGroupRenderDescriptors = (
|
||||
section: ProjectSection,
|
||||
options: { mainWorkspaceOnly: boolean },
|
||||
): GroupRenderDescriptor[] => {
|
||||
const primaryGroup = section.groups.find((group) => group.isMain && group.sessions.length > 0)
|
||||
?? section.groups.find((group) => group.sessions.length > 0)
|
||||
?? section.groups.find((group) => group.isMain)
|
||||
?? section.groups[0];
|
||||
if (!primaryGroup) return [];
|
||||
|
||||
const archivedGroup = section.groups.find((group) => group.isArchivedBucket && group.id !== primaryGroup.id);
|
||||
const groups = options.mainWorkspaceOnly
|
||||
? [primaryGroup, ...(archivedGroup ? [archivedGroup] : [])]
|
||||
: [
|
||||
...(section.groups.find((group) => group.isMain) ? [section.groups.find((group) => group.isMain)!] : []),
|
||||
...section.groups.filter((group) => !group.isMain),
|
||||
];
|
||||
|
||||
return groups.map((group) => ({
|
||||
group,
|
||||
groupKey: `${section.project.id}:${group.id}`,
|
||||
projectId: section.project.id,
|
||||
hideGroupLabel: options.mainWorkspaceOnly ? group.id === primaryGroup.id : group.isMain,
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,448 @@
|
||||
import React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type SortableDragHandleProps = {
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||
};
|
||||
|
||||
type ProjectIdentityProps = {
|
||||
id: string;
|
||||
projectLabel: string;
|
||||
projectIcon?: string;
|
||||
projectColor?: string;
|
||||
projectIconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
projectIconBackground?: string;
|
||||
};
|
||||
|
||||
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
||||
isCollapsed?: boolean;
|
||||
alwaysShowActions?: boolean;
|
||||
};
|
||||
|
||||
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
||||
id,
|
||||
projectLabel,
|
||||
projectIcon,
|
||||
projectColor,
|
||||
projectIconImage,
|
||||
projectIconBackground,
|
||||
isCollapsed,
|
||||
alwaysShowActions = false,
|
||||
}) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const projectIconName = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
|
||||
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
|
||||
const hasCollapseControl = isCollapsed !== undefined;
|
||||
const iconVisibilityClassName = hasCollapseControl
|
||||
? (alwaysShowActions ? 'hidden' : 'group-hover/project:hidden group-focus-within/project:hidden')
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{hasCollapseControl ? (
|
||||
<span className={cn(
|
||||
'h-3.5 w-3.5 items-center justify-center text-muted-foreground',
|
||||
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/project:inline-flex group-focus-within/project:inline-flex',
|
||||
)}>
|
||||
<Icon name={isCollapsed ? 'arrow-right-s' : 'arrow-down-s'} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
{projectIconImage ? (
|
||||
<span
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]',
|
||||
hasCollapseControl && alwaysShowActions ? 'hidden' : 'inline-flex',
|
||||
iconVisibilityClassName,
|
||||
)}
|
||||
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
|
||||
>
|
||||
<ProjectIconImage
|
||||
project={{ id, iconImage: projectIconImage }}
|
||||
options={{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
}}
|
||||
className="h-full w-full object-contain"
|
||||
fallback={projectIconName ? (
|
||||
<Icon name={projectIconName} className="h-3.5 w-3.5" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<Icon name="folder" className="h-3.5 w-3.5 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
) : projectIconName ? (
|
||||
<Icon name={projectIconName} className={cn('h-3.5 w-3.5', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<Icon name="folder" className={cn('h-3.5 w-3.5 text-muted-foreground/80', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[14px] font-semibold lowercase text-foreground">{projectLabel}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export interface SortableProjectItemProps extends ProjectIdentityProps {
|
||||
disabled?: boolean;
|
||||
projectDescription: string;
|
||||
isCollapsed: boolean;
|
||||
isRepo: boolean;
|
||||
isDesktopShell: boolean;
|
||||
hideDirectoryControls: boolean;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
onToggle: () => void;
|
||||
onNewSession: () => void;
|
||||
onNewWorktreeSession?: () => void;
|
||||
onManageWorktrees?: () => void;
|
||||
onRenameStart: () => void;
|
||||
onClose: () => void;
|
||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||
children?: React.ReactNode;
|
||||
showCreateButtons?: boolean;
|
||||
hideHeader?: boolean;
|
||||
/** Aggregated activity/attention indicator shown while the project is collapsed. */
|
||||
statusIndicator?: React.ReactNode;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
}
|
||||
|
||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
id,
|
||||
disabled = false,
|
||||
projectLabel,
|
||||
projectDescription,
|
||||
projectIcon,
|
||||
projectColor,
|
||||
projectIconImage,
|
||||
projectIconBackground,
|
||||
isCollapsed,
|
||||
isRepo,
|
||||
isDesktopShell,
|
||||
hideDirectoryControls,
|
||||
alwaysShowActions,
|
||||
onToggle,
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onManageWorktrees,
|
||||
onRenameStart,
|
||||
onClose,
|
||||
sentinelRef,
|
||||
children,
|
||||
showCreateButtons = true,
|
||||
hideHeader = false,
|
||||
statusIndicator = null,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled });
|
||||
|
||||
const suppressNextToggleRef = React.useRef(false);
|
||||
const menuInstanceKey = `project:${id}`;
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||||
|
||||
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
if (open) setIsContextMenuOpen(false);
|
||||
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
|
||||
}, [menuInstanceKey, setOpenSidebarMenuKey]);
|
||||
|
||||
const renderProjectMenuItems = (Item: React.ElementType) => (
|
||||
<>
|
||||
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
|
||||
<Item onClick={onNewSession}>
|
||||
<Icon name="add" className="mr-1.5 h-4 w-4" />
|
||||
{t('sessions.sidebar.project.actions.newSession')}
|
||||
</Item>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onManageWorktrees && (
|
||||
<Item onClick={onManageWorktrees}>
|
||||
<Icon name="node-tree" className="mr-1.5 h-4 w-4" />
|
||||
{t('sessions.sidebar.project.actions.manageWorktrees')}
|
||||
</Item>
|
||||
)}
|
||||
<Item onClick={onRenameStart}>
|
||||
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
|
||||
{t('sessions.sidebar.project.actions.edit')}
|
||||
</Item>
|
||||
<Item onClick={onClose} className="text-destructive focus:text-destructive">
|
||||
<Icon name="close" className="mr-1.5 h-4 w-4" />
|
||||
{t('sessions.sidebar.project.actions.closeProject')}
|
||||
</Item>
|
||||
</>
|
||||
);
|
||||
|
||||
const handleMenuTriggerClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleMenuTriggerPointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleMenuTriggerMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleToggleMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
|
||||
suppressNextToggleRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Drop mouse-click focus so hover-revealed chrome (chevron, actions)
|
||||
// hides again on mouse-leave instead of sticking via :focus-within.
|
||||
// Keyboard users keep their focus-visible ring (blur only fires here
|
||||
// for pointer interactions that produced a click).
|
||||
event.currentTarget.blur();
|
||||
if (suppressNextToggleRef.current) {
|
||||
suppressNextToggleRef.current = false;
|
||||
return;
|
||||
}
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn('relative', isDragging && 'opacity-30')}
|
||||
>
|
||||
{!hideHeader ? (
|
||||
<>
|
||||
{isDesktopShell && (
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
data-project-id={id}
|
||||
className="absolute top-0 h-px w-full pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
// Sticky zone header: this trigger div is a direct child of
|
||||
// the project wrapper (which spans header + sessions), so it
|
||||
// can stick for the whole zone.
|
||||
// Full-bleed band: pull past the list container's padding so
|
||||
// the section band spans the entire sidebar width (ref: edge-
|
||||
// to-edge section headers, not rounded pills).
|
||||
<div
|
||||
className={cn(
|
||||
'-ml-2.5 -mr-2 text-left group/project select-none',
|
||||
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
|
||||
)}
|
||||
data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}
|
||||
onContextMenu={(event) => {
|
||||
// VS Code hides project actions entirely (hideDirectoryControls).
|
||||
if (hideDirectoryControls) return;
|
||||
event.preventDefault();
|
||||
setIsContextMenuOpen(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
|
||||
{...attributes}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleToggleMouseDown}
|
||||
onClick={handleToggleClick}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
||||
isRepo && !hideDirectoryControls
|
||||
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
||||
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||
)}
|
||||
>
|
||||
<ProjectHeaderIdentity
|
||||
id={id}
|
||||
projectLabel={projectLabel}
|
||||
projectIcon={projectIcon}
|
||||
projectColor={projectColor}
|
||||
projectIconImage={projectIconImage}
|
||||
projectIconBackground={projectIconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
/>
|
||||
{statusIndicator ? (
|
||||
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className={cn(
|
||||
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
||||
showCreateButtons ? 'right-7' : 'right-0.5',
|
||||
)}>
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewWorktreeSession();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity',
|
||||
alwaysShowActions ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||
)}
|
||||
aria-label={t('sessions.sidebar.project.actions.newWorktree')}
|
||||
>
|
||||
<Icon name="node-tree" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>{t('sessions.sidebar.project.actions.newWorktreeEllipsis')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{!hideDirectoryControls ? (
|
||||
<DropdownMenu
|
||||
open={isMenuOpen}
|
||||
onOpenChange={handleMenuOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
||||
isMenuOpen
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: alwaysShowActions
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||
)}
|
||||
aria-label={t('sessions.sidebar.project.actions.projectMenu')}
|
||||
onPointerDown={handleMenuTriggerPointerDown}
|
||||
onMouseDown={handleMenuTriggerMouseDown}
|
||||
onClick={handleMenuTriggerClick}
|
||||
>
|
||||
<Icon name="more-2" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
{renderProjectMenuItems(DropdownMenuItem)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showCreateButtons && onNewSession ? (
|
||||
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewSession();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
alwaysShowActions ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||
)}
|
||||
aria-label={isRepo
|
||||
? t('sessions.sidebar.project.actions.newDraftSession')
|
||||
: t('sessions.sidebar.project.actions.newSession')}
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>{isRepo
|
||||
? t('sessions.sidebar.project.actions.newDraftSession')
|
||||
: t('sessions.sidebar.project.actions.newSession')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="min-w-[180px]">
|
||||
{renderProjectMenuItems(ContextMenuItem)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SortableGroupItemBase: React.FC<{
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
children: (dragHandleProps: SortableDragHandleProps) => React.ReactNode;
|
||||
}> = ({ id, disabled = false, children }) => {
|
||||
const {
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled });
|
||||
|
||||
const dragHandleProps = React.useMemo<SortableDragHandleProps>(() => ({
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
}), [listeners, setActivatorNodeRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
className={cn(
|
||||
'space-y-0.5 rounded-md',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{children(dragHandleProps)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SortableGroupItem = React.memo(SortableGroupItemBase);
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import type { SessionGroup } from '../types';
|
||||
|
||||
export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) => {
|
||||
const getOrderedGroups = React.useCallback(
|
||||
(projectId: string, groups: SessionGroup[]) => {
|
||||
const preferredOrder = groupOrderByProject.get(projectId);
|
||||
if (!preferredOrder || preferredOrder.length === 0) {
|
||||
return groups;
|
||||
}
|
||||
const groupById = new Map(groups.map((group) => [group.id, group]));
|
||||
const ordered: SessionGroup[] = [];
|
||||
preferredOrder.forEach((id) => {
|
||||
const group = groupById.get(id);
|
||||
if (group) {
|
||||
ordered.push(group);
|
||||
groupById.delete(id);
|
||||
}
|
||||
});
|
||||
// Groups unknown to the saved order are NEW worktrees — surface them at
|
||||
// the top of the worktree list (the root/main group is positioned by
|
||||
// the renderer regardless of this ordering). Archived buckets keep
|
||||
// appending at the end.
|
||||
const newGroups: SessionGroup[] = [];
|
||||
const trailingGroups: SessionGroup[] = [];
|
||||
groups.forEach((group) => {
|
||||
if (!groupById.has(group.id)) return;
|
||||
if (group.isArchivedBucket) trailingGroups.push(group);
|
||||
else newGroups.push(group);
|
||||
});
|
||||
return [...newGroups, ...ordered, ...trailingGroups];
|
||||
},
|
||||
[groupOrderByProject],
|
||||
);
|
||||
|
||||
return { getOrderedGroups };
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
|
||||
type Project = { id: string; path: string; normalizedPath: string };
|
||||
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
normalizedProjects: Project[];
|
||||
gitRepoStatus: Map<string, { isGitRepo: boolean | null; branch: string | null }>;
|
||||
setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>;
|
||||
setProjectRootBranches: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
};
|
||||
|
||||
export const useProjectRepoStatus = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
enabled = true,
|
||||
gitRepoStatus,
|
||||
setProjectRepoStatus,
|
||||
setProjectRootBranches,
|
||||
} = args;
|
||||
|
||||
const { git } = useRuntimeAPIs();
|
||||
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
||||
|
||||
// Derive repo status from centralized Git store
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !git || normalizedProjects.length === 0) {
|
||||
setProjectRepoStatus(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger ensureStatus for each project to populate store
|
||||
normalizedProjects.forEach((project) => {
|
||||
void runBackgroundNetworkTask(() => ensureStatus(project.normalizedPath, git));
|
||||
});
|
||||
}, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
|
||||
|
||||
// Read isGitRepo from the store-populated state
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const next = new Map<string, boolean | null>();
|
||||
normalizedProjects.forEach((project) => {
|
||||
next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null);
|
||||
});
|
||||
setProjectRepoStatus(next);
|
||||
}, [enabled, normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
|
||||
|
||||
const projectGitBranchesKey = React.useMemo(() => {
|
||||
return normalizedProjects
|
||||
.map((project) => {
|
||||
const branch = gitRepoStatus.get(project.normalizedPath)?.branch ?? '';
|
||||
return `${project.id}:${branch}`;
|
||||
})
|
||||
.join('|');
|
||||
}, [normalizedProjects, gitRepoStatus]);
|
||||
|
||||
// Tracks the project path + input branch we last resolved against, per project.
|
||||
// Used to resolve `getRootBranch` only for projects that are new or whose
|
||||
// input actually changed — rather than re-resolving every project whenever
|
||||
// any single project's branch settles (the old N² cascade).
|
||||
const resolvedInputKeyByProjectId = React.useRef<Map<string, string>>(new Map());
|
||||
|
||||
// TTL cache: when a project's `gitRepoStatus` refreshes (it can fire on
|
||||
// every background poll even if the branch is unchanged), skip the
|
||||
// `getRootBranch` re-resolution if we resolved the same input within
|
||||
// the TTL window. 5 minutes matches the polling interval that typically
|
||||
// drives these refreshes, so we always serve cached results on
|
||||
// background updates and only re-resolve on cold start or actual
|
||||
// branch changes (those still invalidate via the input-key check).
|
||||
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
// Debounce so the initial burst of per-project `ensureStatus` updates
|
||||
// settles into a single resolution pass instead of one pass per project.
|
||||
const timer = setTimeout(() => {
|
||||
const run = async () => {
|
||||
const validIds = new Set(normalizedProjects.map((project) => project.id));
|
||||
// Drop bookkeeping for projects that are no longer present.
|
||||
for (const id of resolvedInputKeyByProjectId.current.keys()) {
|
||||
if (!validIds.has(id)) {
|
||||
resolvedInputKeyByProjectId.current.delete(id);
|
||||
rootBranchCacheRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const pending = normalizedProjects.filter((project) => {
|
||||
const status = gitRepoStatus.get(project.normalizedPath);
|
||||
if (status?.isGitRepo === false) {
|
||||
resolvedInputKeyByProjectId.current.delete(project.id);
|
||||
rootBranchCacheRef.current.delete(project.id);
|
||||
return false;
|
||||
}
|
||||
if (status?.isGitRepo !== true || status.branch === null) {
|
||||
return false;
|
||||
}
|
||||
const currentBranch = status.branch.trim();
|
||||
const currentInputKey = `${project.normalizedPath}\0${currentBranch}`;
|
||||
const lastInputKey = resolvedInputKeyByProjectId.current.get(project.id);
|
||||
if (lastInputKey === currentInputKey) {
|
||||
// We've already resolved this exact (path, branch) pair.
|
||||
// The TTL cache is just an extra protection for the case
|
||||
// where the input key was reset by a transient blip —
|
||||
// keep the existing map entry fresh so future re-renders
|
||||
// hit the cache instead of refetching.
|
||||
const cached = rootBranchCacheRef.current.get(project.id);
|
||||
if (cached) cached.at = now;
|
||||
return false;
|
||||
}
|
||||
// Same input? Serve from TTL cache if it's still warm.
|
||||
if (lastInputKey !== undefined && now - (rootBranchCacheRef.current.get(project.id)?.at ?? 0) < ROOT_BRANCH_TTL_MS) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await mapWithConcurrency(pending, 2, async (project) => {
|
||||
const inputBranch = gitRepoStatus.get(project.normalizedPath)?.branch?.trim() ?? '';
|
||||
const inputKey = `${project.normalizedPath}\0${inputBranch}`;
|
||||
const branch = await runBackgroundNetworkTask(() =>
|
||||
getRootBranch(
|
||||
project.normalizedPath,
|
||||
inputBranch ? { knownBranch: inputBranch } : undefined,
|
||||
)
|
||||
).catch(() => null);
|
||||
return { id: project.id, inputKey, branch };
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = entries.filter((entry) => entry.branch);
|
||||
if (resolved.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowAfter = Date.now();
|
||||
setProjectRootBranches((prev) => {
|
||||
const next = new Map(prev);
|
||||
resolved.forEach(({ id, branch }) => {
|
||||
if (branch) {
|
||||
next.set(id, branch);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
resolved.forEach(({ id, inputKey, branch }) => {
|
||||
resolvedInputKeyByProjectId.current.set(id, inputKey);
|
||||
if (branch) {
|
||||
rootBranchCacheRef.current.set(id, { branch, at: nowAfter });
|
||||
}
|
||||
});
|
||||
};
|
||||
void run();
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||
|
||||
type Args = {
|
||||
ownership: SessionOwnershipIndex;
|
||||
};
|
||||
|
||||
export const useProjectSessionLists = (args: Args) => {
|
||||
const {
|
||||
ownership,
|
||||
} = args;
|
||||
|
||||
const getSessionsForProject = React.useCallback(
|
||||
(projectId: string) => {
|
||||
return ownership.sessionsByProject.get(projectId) ?? [];
|
||||
},
|
||||
[ownership],
|
||||
);
|
||||
|
||||
const getArchivedSessionsForProject = React.useCallback(
|
||||
(projectId: string) => {
|
||||
return ownership.archivedSessionsByProject.get(projectId) ?? [];
|
||||
},
|
||||
[ownership],
|
||||
);
|
||||
|
||||
return {
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
import { normalizePath } from '../utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type ProjectSection = {
|
||||
project: { id: string; normalizedPath: string };
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
activeSessionByProject: Map<string, string>;
|
||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
currentSessionId: string | null;
|
||||
currentSessionOwnerProjectId?: string | null;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
newSessionDraftOpen: boolean;
|
||||
mobileVariant: boolean;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export type MissingProjectSessionSelection =
|
||||
| { kind: 'preserve-current' }
|
||||
| { kind: 'open-draft' }
|
||||
| { kind: 'select-session'; sessionId: string }
|
||||
| { kind: 'none' };
|
||||
|
||||
/**
|
||||
* Resolves the active-project action after its rendered session map does not
|
||||
* contain the current session.
|
||||
*
|
||||
* Authoritative ownership wins. If ownership is still unknown, a session that
|
||||
* already appears under another project's rendered map is treated as foreign,
|
||||
* while a session missing from every rendered map is preserved so stale
|
||||
* worktree metadata can catch up.
|
||||
*/
|
||||
export function resolveMissingProjectSessionSelection<T>({
|
||||
activeProjectId,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
projectMap,
|
||||
metaByProject,
|
||||
rememberedSessionId,
|
||||
fallbackSessionId,
|
||||
}: {
|
||||
activeProjectId: string;
|
||||
currentSessionId: string | null;
|
||||
currentSessionOwnerProjectId?: string | null;
|
||||
projectMap: ReadonlyMap<string, T> | undefined;
|
||||
metaByProject: ReadonlyMap<string, ReadonlyMap<string, T>>;
|
||||
rememberedSessionId: string | undefined;
|
||||
fallbackSessionId: string | null;
|
||||
}): MissingProjectSessionSelection {
|
||||
if (currentSessionId && currentSessionOwnerProjectId === activeProjectId) {
|
||||
return { kind: 'preserve-current' };
|
||||
}
|
||||
|
||||
if (currentSessionOwnerProjectId == null) {
|
||||
const currentSessionBelongsToAnotherProject = Boolean(
|
||||
currentSessionId
|
||||
&& Array.from(metaByProject.entries()).some(
|
||||
([projectId, sessions]) => projectId !== activeProjectId && sessions.has(currentSessionId),
|
||||
),
|
||||
);
|
||||
if (currentSessionId && projectMap && !currentSessionBelongsToAnotherProject) {
|
||||
return { kind: 'preserve-current' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectMap || projectMap.size === 0) {
|
||||
return { kind: 'open-draft' };
|
||||
}
|
||||
|
||||
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
|
||||
? rememberedSessionId
|
||||
: null;
|
||||
const targetSessionId = remembered ?? fallbackSessionId;
|
||||
if (!targetSessionId || targetSessionId === currentSessionId) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
return { kind: 'select-session', sessionId: targetSessionId };
|
||||
}
|
||||
|
||||
export const useProjectSessionSelection = (args: Args): void => {
|
||||
const {
|
||||
projectSections,
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
handleSessionSelect,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
setSessionSwitcherOpen,
|
||||
} = args;
|
||||
|
||||
const projectSessionMeta = React.useMemo(() => {
|
||||
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
|
||||
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
|
||||
|
||||
const visitNodes = (
|
||||
projectId: string,
|
||||
projectRoot: string,
|
||||
fallbackDirectory: string | null,
|
||||
nodes: SessionNode[],
|
||||
) => {
|
||||
if (!metaByProject.has(projectId)) {
|
||||
metaByProject.set(projectId, new Map());
|
||||
}
|
||||
const projectMap = metaByProject.get(projectId)!;
|
||||
nodes.forEach((node) => {
|
||||
const sessionDirectory = normalizePath(
|
||||
node.worktree?.path
|
||||
?? (node.session as Session & { directory?: string | null }).directory
|
||||
?? fallbackDirectory
|
||||
?? projectRoot,
|
||||
);
|
||||
projectMap.set(node.session.id, { directory: sessionDirectory });
|
||||
if (!firstSessionByProject.has(projectId)) {
|
||||
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
projectSections.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
|
||||
});
|
||||
});
|
||||
|
||||
return { metaByProject, firstSessionByProject };
|
||||
}, [projectSections]);
|
||||
|
||||
const previousActiveProjectRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newSessionDraftOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useUIStore.getState().isNewWorktreeDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousActiveProjectRef.current === activeProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const section = projectSections.find((item) => item.project.id === activeProjectId);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
||||
|
||||
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
setActiveSessionByProject((prev) => {
|
||||
if (prev.get(activeProjectId) === currentSessionId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(activeProjectId, currentSessionId);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = resolveMissingProjectSessionSelection({
|
||||
activeProjectId,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
projectMap,
|
||||
metaByProject: projectSessionMeta.metaByProject,
|
||||
rememberedSessionId: activeSessionByProject.get(activeProjectId),
|
||||
fallbackSessionId: projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null,
|
||||
});
|
||||
|
||||
// Keep the project unprocessed while ownership/maps may still catch up,
|
||||
// so a later owner of another project can still select B.
|
||||
if (selection.kind === 'preserve-current') {
|
||||
if (currentSessionOwnerProjectId === activeProjectId) {
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
|
||||
if (selection.kind === 'open-draft') {
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
openNewSessionDraft({
|
||||
selectedProjectId: section.project.id,
|
||||
directoryOverride: section.project.normalizedPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.kind !== 'select-session') {
|
||||
return;
|
||||
}
|
||||
const targetDirectory = projectMap?.get(selection.sessionId)?.directory ?? null;
|
||||
handleSessionSelect(selection.sessionId, targetDirectory);
|
||||
}, [
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
handleSessionSelect,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
projectSections,
|
||||
projectSessionMeta,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveSessionByProject,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeProjectId || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
||||
if (!projectMap || !projectMap.has(currentSessionId)) {
|
||||
return;
|
||||
}
|
||||
setActiveSessionByProject((prev) => {
|
||||
if (prev.get(activeProjectId) === currentSessionId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(activeProjectId, currentSessionId);
|
||||
return next;
|
||||
});
|
||||
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
|
||||
};
|
||||
|
||||
type ProjectSessionSelectionEffectProps = Omit<
|
||||
Args,
|
||||
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen' | 'currentSessionOwnerProjectId'
|
||||
> & {
|
||||
initialActiveSessionByProject: Map<string, string>;
|
||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
|
||||
};
|
||||
|
||||
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
|
||||
initialActiveSessionByProject,
|
||||
persistActiveSessionByProject,
|
||||
sessionOwnerBySessionId,
|
||||
...args
|
||||
}) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const currentSessionOwnerProjectId = currentSessionId
|
||||
? sessionOwnerBySessionId?.get(currentSessionId)?.projectId ?? null
|
||||
: null;
|
||||
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
|
||||
() => new Map(initialActiveSessionByProject),
|
||||
);
|
||||
useProjectSessionSelection({
|
||||
...args,
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
newSessionDraftOpen,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
persistActiveSessionByProject(activeSessionByProject);
|
||||
}, [activeSessionByProject, persistActiveSessionByProject]);
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionActions } from '../sessions/useSessionActions';
|
||||
import { useSessionGrouping } from './useSessionGrouping';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
type FixtureSession = Session & { parentID?: string };
|
||||
const session = (id: string, parentID?: string): Session => {
|
||||
const value: FixtureSession = {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
title: id,
|
||||
version: '1',
|
||||
directory: '/workspace',
|
||||
time: { created: 1, updated: 1 },
|
||||
};
|
||||
if (parentID) value.parentID = parentID;
|
||||
return value;
|
||||
};
|
||||
|
||||
const collectIds = (nodes: SessionNode[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
const visit = (items: SessionNode[]): void => {
|
||||
for (const node of items) {
|
||||
ids.push(node.session.id);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return ids;
|
||||
};
|
||||
|
||||
describe('useSessionGrouping malformed hierarchy fallbacks', () => {
|
||||
test('renders a deterministic cycle/orphan fallback tree without duplicate sessions', async () => {
|
||||
type GroupingCapture = { buildGroupedSessions?: ReturnType<typeof useSessionGrouping>['buildGroupedSessions'] };
|
||||
const state: GroupingCapture = {};
|
||||
const Harness = () => {
|
||||
state.buildGroupedSessions = useSessionGrouping({
|
||||
homeDirectory: null,
|
||||
worktreeMetadata: new Map(),
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderRanks: new Map(),
|
||||
gitBranches: new Map(),
|
||||
isVSCode: false,
|
||||
}).buildGroupedSessions;
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||
const buildGroupedSessions = state.buildGroupedSessions;
|
||||
if (!buildGroupedSessions) throw new Error('grouping callback was not mounted');
|
||||
|
||||
const groups = buildGroupedSessions(
|
||||
[session('a', 'b'), session('b', 'a'), session('orphan', 'missing')],
|
||||
'/workspace',
|
||||
[],
|
||||
null,
|
||||
false,
|
||||
);
|
||||
const rootGroup = groups.find((group) => group.isMain);
|
||||
const ids = collectIds(rootGroup?.sessions ?? []);
|
||||
|
||||
expect(ids).toEqual(['orphan', 'a', 'b']);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
test('uses the row-local descendant snapshot for archive and hard-delete actions', async () => {
|
||||
type ActionsCapture = { handleDeleteSession?: ReturnType<typeof useSessionActions>['handleDeleteSession'] };
|
||||
const state: ActionsCapture = {};
|
||||
const Harness = () => {
|
||||
state.handleDeleteSession = useSessionActions({
|
||||
mobileVariant: false,
|
||||
allowReselect: false,
|
||||
isSessionSearchOpen: false,
|
||||
sessionSearchQuery: '',
|
||||
setSessionSearchQuery: () => undefined,
|
||||
setIsSessionSearchOpen: () => undefined,
|
||||
descendantIds: ['active-child', 'archived-child'],
|
||||
showDeletionDialog: false,
|
||||
setDeleteSessionConfirm: () => undefined,
|
||||
deleteSessionConfirm: null,
|
||||
setEditingId: () => undefined,
|
||||
setEditTitle: () => undefined,
|
||||
editingId: null,
|
||||
editTitle: '',
|
||||
copiedSessionId: null,
|
||||
setCopiedSessionId: () => undefined,
|
||||
}).handleDeleteSession;
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||
const handleDeleteSession = state.handleDeleteSession;
|
||||
if (!handleDeleteSession) throw new Error('session actions callback was not mounted');
|
||||
|
||||
handleDeleteSession(session('root'));
|
||||
handleDeleteSession(session('root'), { hardDelete: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
import {
|
||||
dedupeSessionsById,
|
||||
getArchivedScopeKey,
|
||||
normalizeForBranchComparison,
|
||||
normalizePath,
|
||||
} from '../utils';
|
||||
import { getSessionLifecycleOrderValue } from '@/sync/session-ordering';
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { getWorktreeFirstSeenAt } from './worktreeFirstSeen';
|
||||
|
||||
type Args = {
|
||||
homeDirectory: string | null;
|
||||
worktreeMetadata: Map<string, WorktreeMetadata>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||
gitBranches: Map<string, string | null>;
|
||||
isVSCode: boolean;
|
||||
};
|
||||
|
||||
const isArchivedSession = (session: Session): boolean => Boolean(session.time?.archived);
|
||||
|
||||
export const useSessionGrouping = (args: Args) => {
|
||||
const { t } = useI18n();
|
||||
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
|
||||
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
|
||||
}, []);
|
||||
|
||||
const buildSessionSearchText = React.useCallback((session: Session): string => {
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
||||
const sessionTitle = (session.title || t('sessions.sidebar.session.untitled')).trim();
|
||||
return `${sessionTitle} ${sessionDirectory}`.toLowerCase();
|
||||
}, [t]);
|
||||
|
||||
const filterSessionNodesForSearch = React.useCallback(
|
||||
(nodes: SessionNode[], query: string): SessionNode[] => {
|
||||
if (!query) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return nodes.flatMap((node) => {
|
||||
const nodeMatches = matchesRankQuery([buildSessionSearchText(node.session)], query);
|
||||
if (nodeMatches) {
|
||||
return [node];
|
||||
}
|
||||
|
||||
const filteredChildren = filterSessionNodesForSearch(node.children, query);
|
||||
if (filteredChildren.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...node, children: filteredChildren }];
|
||||
});
|
||||
},
|
||||
[buildSessionSearchText],
|
||||
);
|
||||
|
||||
const buildGroupedSessions = React.useCallback(
|
||||
(
|
||||
projectSessions: Session[],
|
||||
projectRoot: string | null,
|
||||
availableWorktrees: WorktreeMetadata[],
|
||||
projectRootBranch: string | null,
|
||||
projectIsRepo: boolean,
|
||||
) => {
|
||||
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
||||
// `orderSessionsByLifecycleScopes` owns lifecycle ordering before project
|
||||
// ownership buckets are built. Dedupe retains that root/sibling order.
|
||||
const sortedProjectSessions = dedupeSessionsById(projectSessions);
|
||||
|
||||
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
sortedProjectSessions.forEach((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return;
|
||||
const parentSession = sessionMap.get(parentID);
|
||||
if (!parentSession || isArchivedSession(parentSession) !== isArchivedSession(session)) {
|
||||
return;
|
||||
}
|
||||
const collection = childrenMap.get(parentID) ?? [];
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
});
|
||||
|
||||
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
||||
availableWorktrees.forEach((meta) => {
|
||||
if (meta.path) {
|
||||
const normalized = normalizePath(meta.path) ?? meta.path;
|
||||
worktreeByPath.set(normalized, meta);
|
||||
}
|
||||
});
|
||||
|
||||
const getSessionWorktree = (session: Session): WorktreeMetadata | null => {
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
const sessionWorktreeMeta = args.worktreeMetadata.get(session.id) ?? null;
|
||||
if (sessionWorktreeMeta) return sessionWorktreeMeta;
|
||||
if (sessionDirectory) {
|
||||
const worktree = worktreeByPath.get(sessionDirectory) ?? null;
|
||||
if (worktree && sessionDirectory !== normalizedProjectRoot) {
|
||||
return worktree;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const claimedSessionIds = new Set<string>();
|
||||
const buildProjectNode = (session: Session): SessionNode => {
|
||||
claimedSessionIds.add(session.id);
|
||||
const children = childrenMap.get(session.id) ?? [];
|
||||
const childNodes: SessionNode[] = [];
|
||||
for (const child of children) {
|
||||
if (claimedSessionIds.has(child.id)) continue;
|
||||
childNodes.push(buildProjectNode(child));
|
||||
}
|
||||
return { session, children: childNodes, worktree: getSessionWorktree(session) };
|
||||
};
|
||||
|
||||
const rootCandidates = sortedProjectSessions.filter((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return true;
|
||||
const parentSession = sessionMap.get(parentID);
|
||||
if (!parentSession) return true;
|
||||
return isArchivedSession(parentSession) !== isArchivedSession(session);
|
||||
});
|
||||
|
||||
// A malformed cycle has no structural root. Start with normal roots,
|
||||
// then expose each still-unclaimed component from its first input row.
|
||||
const roots: SessionNode[] = [];
|
||||
const addRoot = (session: Session): void => {
|
||||
if (claimedSessionIds.has(session.id)) return;
|
||||
roots.push(buildProjectNode(session));
|
||||
};
|
||||
rootCandidates.forEach(addRoot);
|
||||
sortedProjectSessions.forEach(addRoot);
|
||||
|
||||
const groupedNodes = new Map<string, SessionNode[]>();
|
||||
const archivedKey = '__archived__';
|
||||
|
||||
const getGroupKey = (session: Session) => {
|
||||
if (session.time?.archived) return archivedKey;
|
||||
// VS Code groups by open workspace, not by worktree: every non-archived
|
||||
// session in a project belongs to that project's single (root) group.
|
||||
// Worktrees aren't registered in VS Code, so the desktop directory-match
|
||||
// below would otherwise dump these sessions into the archived bucket.
|
||||
if (args.isVSCode) return normalizedProjectRoot ?? '__project_root__';
|
||||
const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null);
|
||||
const normalizedDir = metadataPath ?? resolveGlobalSessionDirectory(session);
|
||||
if (!normalizedDir) return archivedKey;
|
||||
if (normalizedDir !== normalizedProjectRoot && worktreeByPath.has(normalizedDir)) return normalizedDir;
|
||||
if (normalizedDir === normalizedProjectRoot) return normalizedProjectRoot ?? '__project_root__';
|
||||
return archivedKey;
|
||||
};
|
||||
|
||||
roots.forEach((node) => {
|
||||
const groupKey = getGroupKey(node.session);
|
||||
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
|
||||
groupedNodes.get(groupKey)?.push(node);
|
||||
});
|
||||
|
||||
const rootKey = normalizedProjectRoot ?? '__project_root__';
|
||||
const groups: SessionGroup[] = [{
|
||||
id: 'root',
|
||||
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD')
|
||||
? t('sessions.sidebar.grouping.projectRootWithBranch', { branch: projectRootBranch })
|
||||
: t('sessions.sidebar.grouping.projectRoot'),
|
||||
branch: projectRootBranch ?? null,
|
||||
description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, args.homeDirectory) : null,
|
||||
isMain: true,
|
||||
isArchivedBucket: false,
|
||||
worktree: null,
|
||||
directory: normalizedProjectRoot,
|
||||
folderScopeKey: normalizedProjectRoot,
|
||||
sessions: groupedNodes.get(rootKey) ?? [],
|
||||
}];
|
||||
|
||||
// Calculate display-order activity for each worktree.
|
||||
const worktreeActivityInfo = new Map<string, { hasActiveSession: boolean; lastUpdatedAt: number }>();
|
||||
availableWorktrees.forEach((meta) => {
|
||||
const directory = normalizePath(meta.path) ?? meta.path;
|
||||
const sessionsInWorktree = groupedNodes.get(directory) ?? [];
|
||||
const hasActiveSession = sessionsInWorktree.length > 0;
|
||||
// Lifecycle rank wins when present; timestamps seed bootstrap ordering.
|
||||
const lastUpdatedAt = sessionsInWorktree.reduce((max, node) => {
|
||||
const updatedAt = getSessionLifecycleOrderValue(node.session, args.sessionOrderRanks);
|
||||
if (!Number.isFinite(updatedAt)) {
|
||||
return max;
|
||||
}
|
||||
return Math.max(max, updatedAt);
|
||||
}, 0);
|
||||
|
||||
worktreeActivityInfo.set(directory, { hasActiveSession, lastUpdatedAt });
|
||||
});
|
||||
|
||||
// Sort populated worktrees by shared session activity, then empty ones by label.
|
||||
const sortedWorktrees = [...availableWorktrees].sort((a, b) => {
|
||||
const aDir = normalizePath(a.path) ?? a.path;
|
||||
const bDir = normalizePath(b.path) ?? b.path;
|
||||
const aInfo = worktreeActivityInfo.get(aDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
|
||||
const bInfo = worktreeActivityInfo.get(bDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
|
||||
|
||||
// First priority: active status (active first)
|
||||
if (aInfo.hasActiveSession !== bInfo.hasActiveSession) {
|
||||
return aInfo.hasActiveSession ? -1 : 1;
|
||||
}
|
||||
|
||||
// Second priority: for populated worktrees, sort by latest display activity.
|
||||
if (aInfo.hasActiveSession && bInfo.hasActiveSession) {
|
||||
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
|
||||
}
|
||||
|
||||
// Third priority: for inactive worktrees, most recently discovered
|
||||
// first (a worktree created mid-session surfaces at the top of the
|
||||
// list; startup discovery ties and falls through to labels).
|
||||
const aSeen = getWorktreeFirstSeenAt(a.path);
|
||||
const bSeen = getWorktreeFirstSeenAt(b.path);
|
||||
if (aSeen !== bSeen) {
|
||||
return bSeen - aSeen;
|
||||
}
|
||||
|
||||
// Fourth priority: sort by label (asc)
|
||||
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
|
||||
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
|
||||
// VS Code groups strictly by open workspace — no per-worktree subgroups.
|
||||
const worktreeGroups = args.isVSCode ? [] : sortedWorktrees;
|
||||
worktreeGroups.forEach((meta) => {
|
||||
const directory = normalizePath(meta.path) ?? meta.path;
|
||||
const currentBranch = args.gitBranches.get(directory)?.trim() || null;
|
||||
const metadataBranch = meta.branch?.trim() || null;
|
||||
const shouldSyncLabelWithBranch = Boolean(
|
||||
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
|
||||
);
|
||||
const label = shouldSyncLabelWithBranch
|
||||
? currentBranch!
|
||||
: (meta.label || meta.name || formatDirectoryName(directory, args.homeDirectory) || directory);
|
||||
|
||||
groups.push({
|
||||
id: `worktree:${directory}`,
|
||||
label,
|
||||
branch: currentBranch || metadataBranch,
|
||||
description: formatPathForDisplay(directory, args.homeDirectory),
|
||||
isMain: false,
|
||||
isArchivedBucket: false,
|
||||
worktree: meta,
|
||||
directory,
|
||||
folderScopeKey: directory,
|
||||
sessions: groupedNodes.get(directory) ?? [],
|
||||
});
|
||||
});
|
||||
|
||||
const archivedSessions = groupedNodes.get(archivedKey) ?? [];
|
||||
if (archivedSessions.length > 0) {
|
||||
groups.push({
|
||||
id: 'archived',
|
||||
label: t('sessions.sidebar.grouping.archived'),
|
||||
branch: null,
|
||||
description: t('sessions.sidebar.grouping.archivedDescription'),
|
||||
isMain: false,
|
||||
isArchivedBucket: true,
|
||||
worktree: null,
|
||||
directory: null,
|
||||
folderScopeKey: !args.isVSCode && normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null,
|
||||
sessions: archivedSessions,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
},
|
||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
);
|
||||
|
||||
return {
|
||||
buildGroupSearchText,
|
||||
buildSessionSearchText,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupedSessions,
|
||||
};
|
||||
};
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { useSessionProjectViewState } from './useSessionProjectViewState';
|
||||
|
||||
class ElementStub implements Partial<Element> {
|
||||
nodeType = 1;
|
||||
}
|
||||
type DocumentStub = {
|
||||
nodeType: number;
|
||||
defaultView: typeof globalThis;
|
||||
activeElement: null;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
documentElement?: Element;
|
||||
body?: Element;
|
||||
};
|
||||
type GlobalValue = typeof globalThis | typeof ElementStub | DocumentStub | boolean;
|
||||
type HookCapture = {
|
||||
state?: ReturnType<typeof useSessionProjectViewState>['state'];
|
||||
actions?: ReturnType<typeof useSessionProjectViewState>['actions'];
|
||||
renderCount: number;
|
||||
};
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: GlobalValue) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
const documentStub: DocumentStub = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
// SAFETY: React's test renderer only inspects this fixture's DOM identity fields and listeners.
|
||||
const container = Object.create(ElementStub.prototype) as Element;
|
||||
Object.assign(container, {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: documentStub,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
});
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
return {
|
||||
container,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('useSessionProjectViewState', () => {
|
||||
beforeEach(() => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
storage.removeItem('oc.sessions.projectCollapse');
|
||||
storage.removeItem('oc.sessions.groupCollapse');
|
||||
storage.removeItem('oc.sessions.groupOrder');
|
||||
});
|
||||
|
||||
test('keeps stable state/actions and ignores selection-store updates', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const projects = [{ id: 'project-a' }, { id: 'project-b' }];
|
||||
const Harness = () => {
|
||||
capture.renderCount += 1;
|
||||
const viewState = useSessionProjectViewState({ isVSCode: true, projects });
|
||||
capture.state = viewState.state;
|
||||
capture.actions = viewState.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
const initialState = capture.state;
|
||||
const initialActions = capture.actions;
|
||||
const initialRenderCount = capture.renderCount;
|
||||
if (!initialState || !initialActions) throw new Error('hook did not mount');
|
||||
|
||||
await act(async () => {
|
||||
useSessionUIStore.setState({ currentSessionId: 'selection-only' });
|
||||
});
|
||||
expect(capture.renderCount).toBe(initialRenderCount);
|
||||
expect(capture.state).toBe(initialState);
|
||||
expect(capture.actions).toBe(initialActions);
|
||||
|
||||
await act(async () => initialActions.toggleProject('project-a'));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
await act(async () => initialActions.collapseAllProjects());
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a', 'project-b']));
|
||||
await act(async () => initialActions.expandAllProjects());
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set());
|
||||
|
||||
await act(async () => initialActions.toggleGroup('project-a:group-a'));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
|
||||
await act(async () => {
|
||||
initialActions.setGroupOrderByProject((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set('project-a', ['group-b', 'group-a']);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
const group = (id: string): SessionGroup => ({
|
||||
id,
|
||||
label: id,
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: false,
|
||||
worktree: null,
|
||||
directory: null,
|
||||
sessions: [],
|
||||
});
|
||||
expect(capture.actions?.getOrderedGroups('project-a', [group('group-a'), group('group-b')])
|
||||
.map((item) => item.id)).toEqual(['group-b', 'group-a']);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const storage = getDeferredSafeStorage();
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.projectCollapse') ?? 'null')).toEqual([]);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({
|
||||
'project-a': ['group-b', 'group-a'],
|
||||
});
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves malformed group storage until explicit user mutation', async () => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
const malformedCollapse = '{malformed-collapse';
|
||||
const malformedOrder = JSON.stringify({ 'project-a': ['group-a', 2] });
|
||||
storage.setItem('oc.sessions.groupCollapse', malformedCollapse);
|
||||
storage.setItem('oc.sessions.groupOrder', malformedOrder);
|
||||
const dom = installMinimalDom();
|
||||
const root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const Harness = () => {
|
||||
capture.renderCount += 1;
|
||||
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
|
||||
capture.state = value.state;
|
||||
capture.actions = value.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set());
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map());
|
||||
expect(storage.getItem('oc.sessions.groupCollapse')).toBe(malformedCollapse);
|
||||
expect(storage.getItem('oc.sessions.groupOrder')).toBe(malformedOrder);
|
||||
|
||||
await act(async () => capture.actions!.toggleGroup('project-a:group-a'));
|
||||
await act(async () => capture.actions!.setGroupOrderByProject(new Map([['project-a', ['group-a']]])));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({ 'project-a': ['group-a'] });
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('retains persisted project/group state while hidden and across a full remount', async () => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
storage.setItem('oc.sessions.projectCollapse', JSON.stringify(['project-a']));
|
||||
storage.setItem('oc.sessions.groupCollapse', JSON.stringify(['project-a:group-a']));
|
||||
storage.setItem('oc.sessions.groupOrder', JSON.stringify({ 'project-a': ['group-b', 'group-a'] }));
|
||||
const dom = installMinimalDom();
|
||||
const root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const Harness = ({ hidden }: { hidden: boolean }) => {
|
||||
void hidden;
|
||||
capture.renderCount += 1;
|
||||
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
|
||||
capture.state = value.state;
|
||||
capture.actions = value.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: true })));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
|
||||
|
||||
await act(async () => root.render(null));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { z } from 'zod';
|
||||
import { useGroupOrdering } from './useGroupOrdering';
|
||||
|
||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||
|
||||
type Project = { id: string };
|
||||
|
||||
type SessionProjectViewStateArgs = {
|
||||
isVSCode: boolean;
|
||||
projects: readonly Project[];
|
||||
};
|
||||
|
||||
const parseStringSet = (raw: string | null): Set<string> => {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed = z.array(z.string()).safeParse(JSON.parse(raw));
|
||||
return new Set(parsed.success ? parsed.data : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const parseGroupOrder = (raw: string | null): Map<string, string[]> => {
|
||||
if (!raw) return new Map();
|
||||
try {
|
||||
const parsed = z.record(z.string(), z.array(z.string())).safeParse(JSON.parse(raw));
|
||||
if (!parsed.success) return new Map();
|
||||
const next = new Map<string, string[]>();
|
||||
for (const [projectId, order] of Object.entries(parsed.data)) {
|
||||
next.set(projectId, order);
|
||||
}
|
||||
return next;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
};
|
||||
|
||||
export const useSessionProjectViewState = ({
|
||||
isVSCode,
|
||||
projects,
|
||||
}: SessionProjectViewStateArgs) => {
|
||||
const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
|
||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(() => (
|
||||
parseStringSet(safeStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY))
|
||||
));
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => (
|
||||
parseStringSet(safeStorage.getItem(GROUP_COLLAPSE_STORAGE_KEY))
|
||||
));
|
||||
const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => (
|
||||
parseGroupOrder(safeStorage.getItem(GROUP_ORDER_STORAGE_KEY))
|
||||
));
|
||||
const ignoreIntersectionUntil = React.useRef<number>(0);
|
||||
const groupCollapseDirty = React.useRef(false);
|
||||
const groupOrderDirty = React.useRef(false);
|
||||
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
||||
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
|
||||
|
||||
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
||||
if (isVSCode) return;
|
||||
const collapsed = pendingCollapsedProjects.current;
|
||||
pendingCollapsedProjects.current = null;
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
if (!collapsed) return;
|
||||
|
||||
const { projects: storedProjects } = useProjectsStore.getState();
|
||||
const updatedProjects = storedProjects.map((project) => ({
|
||||
...project,
|
||||
sidebarCollapsed: collapsed.has(project.id),
|
||||
}));
|
||||
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
|
||||
}, [isVSCode]);
|
||||
|
||||
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
|
||||
if (!globalThis.window || isVSCode) return;
|
||||
pendingCollapsedProjects.current = collapsed;
|
||||
if (persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
|
||||
flushCollapsedProjectsPersist();
|
||||
}, 700);
|
||||
}, [flushCollapsedProjectsPersist, isVSCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (globalThis.window && persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
pendingCollapsedProjects.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!groupOrderDirty.current) return;
|
||||
try {
|
||||
safeStorage.setItem(GROUP_ORDER_STORAGE_KEY, JSON.stringify(Object.fromEntries(groupOrderByProject.entries())));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [groupOrderByProject, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!groupCollapseDirty.current) return;
|
||||
try {
|
||||
safeStorage.setItem(GROUP_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(collapsedGroups)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [collapsedGroups, safeStorage]);
|
||||
|
||||
const collapseAllProjects = React.useCallback(() => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedProjects(() => {
|
||||
const allIds = new Set(projects.map((project) => project.id));
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(allIds);
|
||||
return allIds;
|
||||
});
|
||||
}, [projects, safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const expandAllProjects = React.useCallback(() => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedProjects(() => {
|
||||
const empty = new Set<string>();
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([]));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(empty);
|
||||
return empty;
|
||||
});
|
||||
}, [safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const toggleProject = React.useCallback((projectId: string) => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
setCollapsedProjects((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(projectId)) next.delete(projectId);
|
||||
else next.add(projectId);
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(next);
|
||||
return next;
|
||||
});
|
||||
}, [safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const toggleGroup = React.useCallback((key: string) => {
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const updateGroupOrderByProject = React.useCallback<React.Dispatch<React.SetStateAction<Map<string, string[]>>>>((update) => {
|
||||
groupOrderDirty.current = true;
|
||||
setGroupOrderByProject(update);
|
||||
}, []);
|
||||
|
||||
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
|
||||
const state = React.useMemo(() => ({
|
||||
collapsedProjects,
|
||||
collapsedGroups,
|
||||
groupOrderByProject,
|
||||
}), [collapsedGroups, collapsedProjects, groupOrderByProject]);
|
||||
const actions = React.useMemo(() => ({
|
||||
setCollapsedProjects,
|
||||
toggleProject,
|
||||
collapseAllProjects,
|
||||
expandAllProjects,
|
||||
scheduleCollapsedProjectsPersist,
|
||||
setCollapsedGroups,
|
||||
toggleGroup,
|
||||
setGroupOrderByProject: updateGroupOrderByProject,
|
||||
getOrderedGroups,
|
||||
}), [collapseAllProjects, expandAllProjects, getOrderedGroups, scheduleCollapsedProjectsPersist, toggleGroup, toggleProject, updateGroupOrderByProject]);
|
||||
|
||||
return { state, actions };
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
||||
import { dedupeSessionsById, normalizePath } from '../utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
|
||||
type ProjectItem = {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
|
||||
type ProjectSection = {
|
||||
project: ProjectItem;
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type ProjectSectionCacheEntry = {
|
||||
project: ProjectItem;
|
||||
activeSessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
availableWorktrees: WorktreeMetadata[];
|
||||
rootBranch: string | null;
|
||||
isRepo: boolean;
|
||||
buildGroupedSessions: Args['buildGroupedSessions'];
|
||||
section: ProjectSection;
|
||||
};
|
||||
|
||||
const EMPTY_WORKTREES: WorktreeMetadata[] = [];
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectItem[];
|
||||
getSessionsForProject: (projectId: string) => Session[];
|
||||
getArchivedSessionsForProject: (projectId: string) => Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
projectRootBranches: Map<string, string | null>;
|
||||
lastRepoStatus: boolean;
|
||||
buildGroupedSessions: (
|
||||
sessions: Session[],
|
||||
projectRoot: string,
|
||||
availableWorktrees: WorktreeMetadata[],
|
||||
rootBranch: string | null,
|
||||
isRepo: boolean,
|
||||
) => SessionGroup[];
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[];
|
||||
buildGroupSearchText: (group: SessionGroup) => string;
|
||||
foldersMap: SessionFoldersMap;
|
||||
};
|
||||
|
||||
export const useSessionSidebarSections = (args: Args) => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject,
|
||||
projectRepoStatus,
|
||||
projectRootBranches,
|
||||
lastRepoStatus,
|
||||
buildGroupedSessions,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
} = args;
|
||||
const projectSectionCacheRef = React.useRef<Map<string, ProjectSectionCacheEntry>>(new Map());
|
||||
|
||||
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
||||
const previousCache = projectSectionCacheRef.current;
|
||||
const nextCache = new Map<string, ProjectSectionCacheEntry>();
|
||||
let reusedSections = 0;
|
||||
let rebuiltSections = 0;
|
||||
const sameSessions = (left: Session[], right: Session[]): boolean => (
|
||||
left.length === right.length && left.every((session, index) => session === right[index])
|
||||
);
|
||||
|
||||
const sections = normalizedProjects.map((project) => {
|
||||
const activeSessions = getSessionsForProject(project.id);
|
||||
const archivedSessions = getArchivedSessionsForProject(project.id);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? EMPTY_WORKTREES;
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
? Boolean(projectRepoStatus.get(project.id))
|
||||
: lastRepoStatus;
|
||||
const rootBranch = projectRootBranches.get(project.id) ?? null;
|
||||
const cached = previousCache.get(project.id);
|
||||
if (
|
||||
cached
|
||||
&& cached.project === project
|
||||
&& sameSessions(cached.activeSessions, activeSessions)
|
||||
&& sameSessions(cached.archivedSessions, archivedSessions)
|
||||
&& cached.availableWorktrees === worktreesForProject
|
||||
&& cached.rootBranch === rootBranch
|
||||
&& cached.isRepo === isRepo
|
||||
&& cached.buildGroupedSessions === buildGroupedSessions
|
||||
) {
|
||||
reusedSections += 1;
|
||||
nextCache.set(project.id, cached);
|
||||
return cached.section;
|
||||
}
|
||||
|
||||
rebuiltSections += 1;
|
||||
const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]);
|
||||
const groups = buildGroupedSessions(
|
||||
projectSessions,
|
||||
project.normalizedPath,
|
||||
worktreesForProject,
|
||||
rootBranch,
|
||||
isRepo,
|
||||
);
|
||||
const section = { project, groups };
|
||||
nextCache.set(project.id, {
|
||||
project,
|
||||
activeSessions,
|
||||
archivedSessions,
|
||||
availableWorktrees: worktreesForProject,
|
||||
rootBranch,
|
||||
isRepo,
|
||||
buildGroupedSessions,
|
||||
section,
|
||||
});
|
||||
return section;
|
||||
});
|
||||
projectSectionCacheRef.current = nextCache;
|
||||
if (reusedSections > 0) streamPerfCount('ui.sidebar.project_section.reused', reusedSections);
|
||||
if (rebuiltSections > 0) streamPerfCount('ui.sidebar.project_section.rebuilt', rebuiltSections);
|
||||
return sections;
|
||||
}, [
|
||||
normalizedProjects,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject,
|
||||
projectRepoStatus,
|
||||
lastRepoStatus,
|
||||
buildGroupedSessions,
|
||||
projectRootBranches,
|
||||
]);
|
||||
|
||||
const visibleProjectSections = React.useMemo(() => {
|
||||
return projectSections;
|
||||
}, [projectSections]);
|
||||
|
||||
const groupSearchDataByGroup = React.useMemo(() => {
|
||||
const result = new WeakMap<SessionGroup, GroupSearchData>();
|
||||
if (!hasSessionSearchQuery) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const countNodes = (nodes: SessionNode[]): number => nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
|
||||
|
||||
visibleProjectSections.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery);
|
||||
const matchedSessionCount = countNodes(filteredNodes);
|
||||
const groupMatches = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery);
|
||||
const scopeKey = normalizePath(group.directory ?? null);
|
||||
const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : [];
|
||||
const folderNameMatchCount = scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length;
|
||||
|
||||
result.set(group, {
|
||||
filteredNodes,
|
||||
matchedSessionCount,
|
||||
folderNameMatchCount,
|
||||
groupMatches,
|
||||
hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [
|
||||
hasSessionSearchQuery,
|
||||
visibleProjectSections,
|
||||
filterSessionNodesForSearch,
|
||||
normalizedSessionSearchQuery,
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
]);
|
||||
|
||||
const searchableProjectSections = React.useMemo(() => {
|
||||
if (!hasSessionSearchQuery) {
|
||||
return visibleProjectSections;
|
||||
}
|
||||
|
||||
return visibleProjectSections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
groups: section.groups.filter((group) => groupSearchDataByGroup.get(group)?.hasMatch === true),
|
||||
}))
|
||||
.filter((section) => section.groups.length > 0);
|
||||
}, [hasSessionSearchQuery, visibleProjectSections, groupSearchDataByGroup]);
|
||||
|
||||
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections;
|
||||
|
||||
// Flat display sections: one merged group per project containing every
|
||||
// non-archived session from the project root and all of its worktrees.
|
||||
// Worktree grouping stays available in `projectSections` for data consumers
|
||||
// (bootstrap demand planning, ownership); rendering is flat.
|
||||
// The per-section cache keeps merged group references stable so the
|
||||
// memoized SessionGroupSection subtree skips unrelated update waves.
|
||||
const flatSectionCacheRef = React.useRef<WeakMap<ProjectSection, { query: string; section: ProjectSection }>>(new WeakMap());
|
||||
const flatSectionsForRender = React.useMemo<ProjectSection[]>(() => {
|
||||
const cache = flatSectionCacheRef.current;
|
||||
return sectionsForRender.map((section) => {
|
||||
const cached = cache.get(section);
|
||||
if (cached && cached.query === normalizedSessionSearchQuery) {
|
||||
return cached.section;
|
||||
}
|
||||
|
||||
const nonArchivedGroups = section.groups.filter((group) => !group.isArchivedBucket);
|
||||
const archivedGroups = section.groups.filter((group) => group.isArchivedBucket);
|
||||
const sessions = nonArchivedGroups.flatMap((group) => hasSessionSearchQuery
|
||||
? (groupSearchDataByGroup.get(group)?.filteredNodes ?? [])
|
||||
: group.sessions);
|
||||
const folderScopes = nonArchivedGroups
|
||||
.map((group) => ({
|
||||
scopeKey: group.folderScopeKey ?? normalizePath(group.directory ?? null),
|
||||
directory: group.directory ?? null,
|
||||
}))
|
||||
.filter((scope): scope is { scopeKey: string; directory: string | null } => Boolean(scope.scopeKey));
|
||||
const rootGroup = nonArchivedGroups.find((group) => group.isMain) ?? null;
|
||||
|
||||
const flatGroup: SessionGroup = {
|
||||
id: 'flat',
|
||||
label: rootGroup?.label ?? '',
|
||||
branch: rootGroup?.branch ?? null,
|
||||
description: rootGroup?.description ?? null,
|
||||
isMain: true,
|
||||
isArchivedBucket: false,
|
||||
worktree: null,
|
||||
directory: rootGroup?.directory ?? section.project.normalizedPath,
|
||||
folderScopeKey: rootGroup?.folderScopeKey ?? section.project.normalizedPath,
|
||||
folderScopes,
|
||||
sessions,
|
||||
};
|
||||
|
||||
if (hasSessionSearchQuery) {
|
||||
const merged = nonArchivedGroups
|
||||
.map((group) => groupSearchDataByGroup.get(group))
|
||||
.filter((data): data is GroupSearchData => Boolean(data));
|
||||
groupSearchDataByGroup.set(flatGroup, {
|
||||
filteredNodes: sessions,
|
||||
matchedSessionCount: merged.reduce((total, data) => total + data.matchedSessionCount, 0),
|
||||
folderNameMatchCount: merged.reduce((total, data) => total + data.folderNameMatchCount, 0),
|
||||
groupMatches: merged.some((data) => data.groupMatches),
|
||||
hasMatch: merged.some((data) => data.hasMatch),
|
||||
});
|
||||
}
|
||||
|
||||
const flatSection: ProjectSection = {
|
||||
project: section.project,
|
||||
groups: [flatGroup, ...archivedGroups],
|
||||
};
|
||||
cache.set(section, { query: normalizedSessionSearchQuery, section: flatSection });
|
||||
return flatSection;
|
||||
});
|
||||
}, [groupSearchDataByGroup, hasSessionSearchQuery, normalizedSessionSearchQuery, sectionsForRender]);
|
||||
|
||||
const searchMatchCount = React.useMemo(() => {
|
||||
if (!hasSessionSearchQuery) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return sectionsForRender.reduce((total, section) => {
|
||||
return total + section.groups.reduce((groupTotal, group) => {
|
||||
const data = groupSearchDataByGroup.get(group);
|
||||
if (!data) {
|
||||
return groupTotal;
|
||||
}
|
||||
const metadataMatches = data.folderNameMatchCount + (data.groupMatches ? 1 : 0);
|
||||
return groupTotal + data.matchedSessionCount + metadataMatches;
|
||||
}, 0);
|
||||
}, 0);
|
||||
}, [hasSessionSearchQuery, sectionsForRender, groupSearchDataByGroup]);
|
||||
|
||||
return {
|
||||
projectSections,
|
||||
visibleProjectSections,
|
||||
groupSearchDataByGroup,
|
||||
searchableProjectSections,
|
||||
sectionsForRender,
|
||||
flatSectionsForRender,
|
||||
searchMatchCount,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
projectSections: unknown[];
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !isDesktopShellRuntime) {
|
||||
setStuckProjectHeaders((prev) => (prev.size === 0 ? prev : new Set()));
|
||||
return;
|
||||
}
|
||||
|
||||
const firstSentinel = Array.from(projectHeaderSentinelRefs.current.values()).find((el) => el !== null);
|
||||
const root = firstSentinel?.closest<HTMLElement>('.oc-sidebar-scroller') ?? null;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
setStuckProjectHeaders((prev) => {
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
for (const entry of entries) {
|
||||
const projectId = (entry.target as HTMLElement).dataset.projectId;
|
||||
if (!projectId) continue;
|
||||
|
||||
const rootTop = entry.rootBounds?.top ?? root.getBoundingClientRect().top;
|
||||
const isAboveScroller = !entry.isIntersecting && entry.boundingClientRect.top < rootTop;
|
||||
if (next.has(projectId) === isAboveScroller) continue;
|
||||
|
||||
changed = true;
|
||||
if (isAboveScroller) next.add(projectId);
|
||||
else next.delete(projectId);
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
{ root, threshold: 0 },
|
||||
);
|
||||
|
||||
projectHeaderSentinelRefs.current.forEach((el) => {
|
||||
if (el) {
|
||||
observer.observe(el);
|
||||
}
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [enabled, isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
|
||||
|
||||
return stuckProjectHeaders;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
// In-memory first-seen tracker for worktree directories. Worktree metadata
|
||||
// carries no creation time, so we record when a path first appears during
|
||||
// this app run: a worktree created mid-session sorts to the top of its
|
||||
// project's empty-worktree tail, while everything discovered at startup ties
|
||||
// (same tick) and falls back to alphabetical order.
|
||||
const firstSeenAtByPath = new Map<string, number>();
|
||||
|
||||
export const recordWorktreesSeen = (paths: Iterable<string | null | undefined>, seenAt: number): void => {
|
||||
for (const path of paths) {
|
||||
const normalized = normalizePath(path ?? null);
|
||||
if (normalized && !firstSeenAtByPath.has(normalized)) {
|
||||
firstSeenAtByPath.set(normalized, seenAt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorktreeFirstSeenAt = (path: string | null | undefined): number => {
|
||||
const normalized = normalizePath(path ?? null);
|
||||
return normalized ? (firstSeenAtByPath.get(normalized) ?? 0) : 0;
|
||||
};
|
||||
Reference in New Issue
Block a user