perf(ui): streamline session sidebar state
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Props = {
|
||||
selectedCount: number;
|
||||
scopeKey: string | null;
|
||||
scopeFolders: SessionFolder[];
|
||||
archivedBucket: boolean;
|
||||
onMoveToFolder: (folderId: string) => void;
|
||||
onCreateFolderAndMove: () => void;
|
||||
onRemoveFromFolder: () => void;
|
||||
canRemoveFromFolder: boolean;
|
||||
onRestore: () => void;
|
||||
onDelete: () => void;
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
export const BulkActionBar: React.FC<Props> = ({
|
||||
selectedCount,
|
||||
scopeKey,
|
||||
scopeFolders,
|
||||
archivedBucket,
|
||||
onMoveToFolder,
|
||||
onCreateFolderAndMove,
|
||||
onRemoveFromFolder,
|
||||
canRemoveFromFolder,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onDone,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const canMoveToFolder = Boolean(scopeKey) && !archivedBucket;
|
||||
const destructiveLabel = archivedBucket
|
||||
? t('sessions.sidebar.bulkActions.delete')
|
||||
: t('sessions.sidebar.bulkActions.archive');
|
||||
const iconButtonClass = 'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||
const destructiveIconButtonClass = 'inline-flex h-7 w-7 items-center justify-center rounded-md text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50';
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2.5 py-1.5">
|
||||
<span className="typography-ui-label text-muted-foreground whitespace-nowrap">
|
||||
{t('sessions.sidebar.bulkActions.selectedCount', { count: selectedCount })}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{canMoveToFolder ? (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClass}
|
||||
aria-label={t('sessions.sidebar.bulkActions.moveToFolder')}
|
||||
>
|
||||
<Icon name="folder" className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.moveToFolder')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
{scopeFolders.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
{t('sessions.sidebar.folders.none')}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
scopeFolders.map((folder) => (
|
||||
<DropdownMenuItem key={folder.id} onClick={() => onMoveToFolder(folder.id)}>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onCreateFolderAndMove}>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.folders.newFolderEllipsis')}
|
||||
</DropdownMenuItem>
|
||||
{canRemoveFromFolder ? (
|
||||
<DropdownMenuItem
|
||||
onClick={onRemoveFromFolder}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Icon name="close" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.folders.removeFromFolder')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{archivedBucket ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className={iconButtonClass}
|
||||
aria-label={t('sessions.sidebar.bulkActions.restore')}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.restore')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className={cn(destructiveIconButtonClass)}
|
||||
aria-label={destructiveLabel}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{destructiveLabel}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
className={iconButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.exitSelection')}
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.header.actions.exitSelection')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
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 { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type BulkActionCapture = {
|
||||
onCreateFolderAndMove: () => void;
|
||||
};
|
||||
|
||||
let bulkActionCapture: BulkActionCapture | null = null;
|
||||
|
||||
mock.module('./BulkActionBar', () => ({
|
||||
BulkActionBar: (props: BulkActionCapture) => {
|
||||
bulkActionCapture = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('./ConfirmDialogs', () => ({
|
||||
BulkSessionDeleteConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
const { SessionBulkActions } = await import('./SessionBulkActions');
|
||||
|
||||
describe('SessionBulkActions public behavior', () => {
|
||||
test('moves the selected sessions into a newly created folder while a row edit is active', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const originalFolders = useSessionFoldersStore.getState();
|
||||
const originalSelection = useSessionMultiSelectStore.getState();
|
||||
const cssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS');
|
||||
const renameRequests: Array<{ scopeKey: string; folder: { id: string; name: string } }> = [];
|
||||
const moved: Array<{ scopeKey: string; folderId: string; ids: string[] }> = [];
|
||||
useSessionFoldersStore.setState({
|
||||
foldersMap: {},
|
||||
addSessionsToFolder: (scopeKey, folderId, ids) => moved.push({ scopeKey, folderId, ids }),
|
||||
});
|
||||
useSessionMultiSelectStore.setState({
|
||||
enabled: true,
|
||||
selectedIds: new Set(['session-a']),
|
||||
scopeKey: 'project-a',
|
||||
anchorId: 'session-a',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'CSS', {
|
||||
configurable: true,
|
||||
value: { escape: (value: string) => value },
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<I18nProvider>
|
||||
<SessionBulkActions
|
||||
getFolderScopesForProject={() => [{ scopeKey: '/workspace', directory: '/workspace' }]}
|
||||
isInlineEditing
|
||||
startFolderRename={(scopeKey, folder) => renameRequests.push({ scopeKey, folder })}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
));
|
||||
expect(bulkActionCapture).not.toBeNull();
|
||||
|
||||
await act(async () => bulkActionCapture?.onCreateFolderAndMove());
|
||||
const createdFolder = useSessionFoldersStore.getState().foldersMap['/workspace']?.[0];
|
||||
expect(createdFolder?.name).toBe('New folder');
|
||||
expect(renameRequests).toEqual([{ scopeKey: '/workspace', folder: createdFolder }]);
|
||||
expect(moved).toEqual([{ scopeKey: '/workspace', folderId: createdFolder?.id ?? '', ids: ['session-a'] }]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
useSessionFoldersStore.setState(originalFolders, true);
|
||||
useSessionMultiSelectStore.setState(originalSelection, true);
|
||||
if (cssDescriptor) Object.defineProperty(globalThis, 'CSS', cssDescriptor);
|
||||
else Reflect.deleteProperty(globalThis, 'CSS');
|
||||
bulkActionCapture = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { BulkActionBar } from './BulkActionBar';
|
||||
import { BulkSessionDeleteConfirmDialog, type BulkDeleteSessionsConfirmState } from '../shell/ConfirmDialogs';
|
||||
import { useSidebarBulkActions } from './useSidebarBulkActions';
|
||||
|
||||
type Props = {
|
||||
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
|
||||
isInlineEditing: boolean;
|
||||
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
|
||||
};
|
||||
|
||||
/** Owns the sidebar selection projection and its destructive confirmation. */
|
||||
export function SessionBulkActions({ getFolderScopesForProject, isInlineEditing, startFolderRename }: Props): React.ReactNode {
|
||||
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||
const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder);
|
||||
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
|
||||
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
|
||||
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||
const bulk = useSidebarBulkActions({
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
getFolderScopesForProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename: (scopeKey) => {
|
||||
const folder = createFolder(scopeKey, 'New folder');
|
||||
startFolderRename(scopeKey, folder);
|
||||
return folder;
|
||||
},
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
});
|
||||
|
||||
return <>
|
||||
{bulk.selectionModeEnabled && bulk.hasSelection ? <BulkActionBar
|
||||
selectedCount={bulk.selectedIdsSize}
|
||||
scopeKey={bulk.derivedSelectionScope}
|
||||
scopeFolders={bulk.bulkScopeFolders}
|
||||
archivedBucket={bulk.bulkScopeIsArchived}
|
||||
onMoveToFolder={bulk.handleBulkMoveToFolder}
|
||||
onCreateFolderAndMove={bulk.handleBulkCreateFolderAndMove}
|
||||
onRemoveFromFolder={bulk.handleBulkRemoveFromFolder}
|
||||
canRemoveFromFolder={bulk.bulkCanRemoveFromFolder}
|
||||
onRestore={bulk.handleBulkRestore}
|
||||
onDelete={bulk.handleBulkDelete}
|
||||
onDone={bulk.handleExitSelectionMode}
|
||||
/> : null}
|
||||
<BulkSessionDeleteConfirmDialog
|
||||
value={bulkDeleteConfirm}
|
||||
setValue={setBulkDeleteConfirm}
|
||||
showDeletionDialog={showDeletionDialog}
|
||||
setShowDeletionDialog={setShowDeletionDialog}
|
||||
onConfirm={bulk.confirmBulkDelete}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type DragEnd = (event: {
|
||||
active: { data: { current: { type: string; sessionId: string } } };
|
||||
over: { data: { current: { type: string; folderId: string } } } | null;
|
||||
}) => void;
|
||||
|
||||
let handleDragEnd: DragEnd | null = null;
|
||||
|
||||
mock.module('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children, onDragEnd }: { children: React.ReactNode; onDragEnd: DragEnd }) => {
|
||||
handleDragEnd = onDragEnd;
|
||||
return <>{children}</>;
|
||||
},
|
||||
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PointerSensor: class {},
|
||||
closestCenter: () => null,
|
||||
useSensor: () => null,
|
||||
useSensors: () => [],
|
||||
useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: () => undefined, isDragging: false }),
|
||||
useDroppable: () => ({ setNodeRef: () => undefined, isOver: false }),
|
||||
}));
|
||||
|
||||
const { SessionFolderDndScope } = await import('./sessionFolderDnd');
|
||||
|
||||
describe('SessionFolderDndScope public behavior', () => {
|
||||
test('routes a session-folder drop without depending on row edit or menu state', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const drops: Array<{ sessionId: string; folderId: string }> = [];
|
||||
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<SessionFolderDndScope
|
||||
scopeKey="/workspace"
|
||||
hasFolders
|
||||
onSessionDroppedOnFolder={(sessionId, folderId) => drops.push({ sessionId, folderId })}
|
||||
>
|
||||
{null}
|
||||
</SessionFolderDndScope>,
|
||||
));
|
||||
expect(handleDragEnd).not.toBeNull();
|
||||
|
||||
await act(async () => handleDragEnd?.({
|
||||
active: { data: { current: { type: 'session', sessionId: 'session-a' } } },
|
||||
over: { data: { current: { type: 'folder', folderId: 'folder-a' } } },
|
||||
}));
|
||||
expect(drops).toEqual([{ sessionId: 'session-a', folderId: 'folder-a' }]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
handleDragEnd = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
export const DraggableSessionRow: React.FC<{
|
||||
sessionId: string;
|
||||
sessionDirectory: string | null;
|
||||
sessionTitle: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ sessionId, sessionDirectory, sessionTitle, children }) => {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `session-drag:${sessionId}`,
|
||||
data: { type: 'session', sessionId, sessionDirectory, sessionTitle },
|
||||
});
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (listeners?.onPointerDown) {
|
||||
(listeners.onPointerDown as (event: React.PointerEvent) => void)(e);
|
||||
}
|
||||
},
|
||||
[listeners],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
onPointerDown={handlePointerDown}
|
||||
className={isDragging ? 'opacity-30' : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DroppableFolderWrapper: React.FC<{
|
||||
folderId: string;
|
||||
children: (
|
||||
droppableRef: (node: HTMLElement | null) => void,
|
||||
isOver: boolean,
|
||||
) => React.ReactNode;
|
||||
}> = ({ folderId, children }) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `folder-drop:${folderId}`,
|
||||
data: { type: 'folder', folderId },
|
||||
});
|
||||
return <>{children(setNodeRef, isOver)}</>;
|
||||
};
|
||||
|
||||
export const SessionFolderDndScope: React.FC<{
|
||||
scopeKey: string | null;
|
||||
hasFolders: boolean;
|
||||
onSessionDroppedOnFolder: (sessionId: string, folderId: string) => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ scopeKey, hasFolders, onSessionDroppedOnFolder, children }) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||
const [activeDragTitle, setActiveDragTitle] = React.useState<string>('Session');
|
||||
const [activeDragWidth, setActiveDragWidth] = React.useState<number | null>(null);
|
||||
const [activeDragHeight, setActiveDragHeight] = React.useState<number | null>(null);
|
||||
|
||||
if (!scopeKey) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveDragId(null);
|
||||
setActiveDragWidth(null);
|
||||
setActiveDragHeight(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const activeData = active.data.current as { type?: string; sessionId?: string } | undefined;
|
||||
const overData = over.data.current as { type?: string; folderId?: string } | undefined;
|
||||
if (activeData?.type === 'session' && activeData.sessionId && overData?.type === 'folder' && overData.folderId) {
|
||||
onSessionDroppedOnFolder(activeData.sessionId, overData.folderId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(event) => {
|
||||
const data = event.active.data.current as { type?: string; sessionId?: string; sessionTitle?: string } | undefined;
|
||||
if (data?.type === 'session' && data.sessionId) {
|
||||
setActiveDragId(data.sessionId);
|
||||
setActiveDragTitle(data.sessionTitle ?? 'Session');
|
||||
const width = event.active.rect.current.initial?.width;
|
||||
const height = event.active.rect.current.initial?.height;
|
||||
setActiveDragWidth(typeof width === 'number' ? width : null);
|
||||
setActiveDragHeight(typeof height === 'number' ? height : null);
|
||||
}
|
||||
}}
|
||||
onDragCancel={() => {
|
||||
setActiveDragId(null);
|
||||
setActiveDragWidth(null);
|
||||
setActiveDragHeight(null);
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay>
|
||||
{activeDragId && hasFolders ? (
|
||||
<div
|
||||
style={{
|
||||
width: activeDragWidth ? `${activeDragWidth}px` : 'auto',
|
||||
height: activeDragHeight ? `${activeDragHeight}px` : 'auto',
|
||||
}}
|
||||
className="flex items-center rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1 shadow-none pointer-events-none"
|
||||
>
|
||||
<Icon name="sticky-note" className="h-4 w-4 text-muted-foreground mr-2 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
||||
{activeDragTitle}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
getArchivedScopeKey,
|
||||
resolveArchivedFolderName,
|
||||
} from '../utils';
|
||||
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||
|
||||
type ProjectForArchivedFolders = {
|
||||
id: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type FolderEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
sessionIds: string[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
normalizedProjects: ProjectForArchivedFolders[];
|
||||
ownership: SessionOwnershipIndex;
|
||||
isSessionsLoading: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
isWorktreeTopologyLoading: boolean;
|
||||
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||
foldersMap: Record<string, FolderEntry[]>;
|
||||
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
};
|
||||
|
||||
export const useArchivedAutoFolders = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
enabled = true,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedProjects.forEach((project) => {
|
||||
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
|
||||
const existingFolders = foldersMap[scopeKey] ?? [];
|
||||
const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder]));
|
||||
|
||||
projectArchivedSessions.forEach((session) => {
|
||||
const folderName = resolveArchivedFolderName(session, project.normalizedPath);
|
||||
const key = folderName.toLowerCase();
|
||||
let folder = folderByName.get(key);
|
||||
if (!folder) {
|
||||
folder = createFolder(scopeKey, folderName);
|
||||
folderByName.set(key, folder);
|
||||
}
|
||||
|
||||
if (!folder.sessionIds.includes(session.id)) {
|
||||
addSessionToFolder(scopeKey, folder.id, session.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
enabled,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveSelectionFolderScopes } from './useSidebarBulkActions';
|
||||
|
||||
describe('sidebar bulk project scopes', () => {
|
||||
test('uses every root and worktree scope owned by the selected project', () => {
|
||||
const scopes = resolveSelectionFolderScopes('project-a', (projectId) => projectId === 'project-a'
|
||||
? [
|
||||
{ scopeKey: '/workspace/project-a', directory: '/workspace/project-a' },
|
||||
{ scopeKey: '/workspace/project-a-worktree', directory: '/workspace/project-a-worktree' },
|
||||
]
|
||||
: []);
|
||||
|
||||
expect(scopes).toEqual(['/workspace/project-a', '/workspace/project-a-worktree']);
|
||||
});
|
||||
|
||||
test('keeps a directory scope when no project scope owns it', () => {
|
||||
expect(resolveSelectionFolderScopes('/workspace/vscode', () => [])).toEqual(['/workspace/vscode']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
|
||||
type Args = {
|
||||
isInlineEditing: boolean;
|
||||
showDeletionDialog: boolean;
|
||||
foldersMap: Record<string, SessionFolder[]>;
|
||||
/**
|
||||
* Selection scope is the project id (flat per-project session list); this
|
||||
* map resolves it to the project's folder scopes (root + worktrees). When
|
||||
* the scope is missing here it is treated as a plain directory scope.
|
||||
*/
|
||||
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
|
||||
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSessions: (ids: string[]) => Promise<{ restoredIds: string[]; failedIds: string[] }>;
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
|
||||
sessionCount: number;
|
||||
archivedBucket: boolean;
|
||||
} | null>>;
|
||||
};
|
||||
|
||||
export const resolveSelectionFolderScopes = (
|
||||
selectionScope: string | null,
|
||||
getFolderScopesForProject: Args['getFolderScopesForProject'],
|
||||
): string[] => {
|
||||
if (!selectionScope) return [];
|
||||
const projectScopes = getFolderScopesForProject(selectionScope);
|
||||
return projectScopes.length > 0
|
||||
? projectScopes.map((scope) => scope.scopeKey)
|
||||
: [selectionScope];
|
||||
};
|
||||
|
||||
/**
|
||||
* Bulk-action logic for the sidebar. The hot-path concern is that this
|
||||
* hook subscribes to `useSessionMultiSelectStore` — which can fire on
|
||||
* every selection toggle and on every setRange/toggleSelected call —
|
||||
* but the rest of the Sidebar tree only needs the boolean
|
||||
* `selectionModeEnabled` flag to decide whether to render the
|
||||
* selection chrome.
|
||||
*
|
||||
* To keep that subscription narrow, the heavy work (folders lookup,
|
||||
* DOM-attribute scanning for the active/archived scope, etc.) is
|
||||
* deferred behind a `selectedIds.size > 0` check inside the hook
|
||||
* itself, so toggling selection mode on/off does not force the
|
||||
* downstream useMemo chain to re-evaluate when no rows are selected.
|
||||
*/
|
||||
export const useSidebarBulkActions = (args: Args) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
getFolderScopesForProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
} = args;
|
||||
|
||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||
const selectedIdsSize = useSessionMultiSelectStore((state) => state.selectedIds.size);
|
||||
const hasSelection = selectedIdsSize > 0;
|
||||
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
|
||||
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
|
||||
|
||||
const handleToggleSelectionMode = React.useCallback(() => {
|
||||
useSessionMultiSelectStore.getState().toggleMode();
|
||||
}, []);
|
||||
const handleExitSelectionMode = React.useCallback(() => {
|
||||
useSessionMultiSelectStore.getState().disable();
|
||||
}, []);
|
||||
|
||||
// All of the below short-circuit on `hasSelection` so the DOM-scanning
|
||||
// and folder-lookup work only runs when there's something to act on.
|
||||
const bulkScopeIsArchived = React.useMemo(() => {
|
||||
if (!hasSelection) return false;
|
||||
if (typeof document === 'undefined') return false;
|
||||
let sawActive = false;
|
||||
let sawArchived = false;
|
||||
for (const id of selectedIds) {
|
||||
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
|
||||
for (const row of rows) {
|
||||
if (row.getAttribute('data-session-archived') === '1') sawArchived = true;
|
||||
else sawActive = true;
|
||||
}
|
||||
}
|
||||
return sawArchived && !sawActive;
|
||||
}, [hasSelection, selectedIds]);
|
||||
|
||||
const derivedSelectionScope = React.useMemo(() => {
|
||||
if (selectionScopeKey) return selectionScopeKey;
|
||||
if (!hasSelection) return null;
|
||||
if (typeof document === 'undefined') return null;
|
||||
for (const id of selectedIds) {
|
||||
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
|
||||
const scope = row?.getAttribute('data-session-scope');
|
||||
if (scope && scope.length > 0) return scope;
|
||||
}
|
||||
return null;
|
||||
}, [hasSelection, selectedIds, selectionScopeKey]);
|
||||
|
||||
// The selection scope is a project id; folders live per directory scope
|
||||
// (project root + each worktree). Resolve all of them, in project order.
|
||||
const selectionFolderScopes = React.useMemo<string[]>(() => {
|
||||
return resolveSelectionFolderScopes(derivedSelectionScope, getFolderScopesForProject);
|
||||
}, [derivedSelectionScope, getFolderScopesForProject]);
|
||||
|
||||
const bulkScopeFolders = React.useMemo(() => {
|
||||
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
|
||||
}, [foldersMap, selectionFolderScopes]);
|
||||
|
||||
const resolveFolderScope = React.useCallback((folderId: string): string | null => {
|
||||
for (const scope of selectionFolderScopes) {
|
||||
if ((foldersMap[scope] ?? []).some((folder) => folder.id === folderId)) return scope;
|
||||
}
|
||||
return null;
|
||||
}, [foldersMap, selectionFolderScopes]);
|
||||
|
||||
const bulkCanRemoveFromFolder = React.useMemo(() => {
|
||||
if (!hasSelection) return false;
|
||||
for (const scope of selectionFolderScopes) {
|
||||
for (const folder of foldersMap[scope] ?? []) {
|
||||
for (const id of folder.sessionIds) {
|
||||
if (selectedIds.has(id)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [foldersMap, selectionFolderScopes, hasSelection, selectedIds]);
|
||||
|
||||
const moveSelectionToFolder = React.useCallback((targetScope: string, folderId: string) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
// Clear memberships in every other scope first — the store only dedupes
|
||||
// within one scope, and a session must live in a single folder.
|
||||
for (const scope of selectionFolderScopes) {
|
||||
if (scope === targetScope) continue;
|
||||
removeSessionsFromFolders(scope, ids);
|
||||
}
|
||||
addSessionsToFolder(targetScope, folderId, ids);
|
||||
}, [addSessionsToFolder, removeSessionsFromFolders, selectedIds, selectionFolderScopes]);
|
||||
|
||||
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
|
||||
if (!hasSelection) return;
|
||||
const targetScope = resolveFolderScope(folderId);
|
||||
if (!targetScope) return;
|
||||
moveSelectionToFolder(targetScope, folderId);
|
||||
}, [hasSelection, moveSelectionToFolder, resolveFolderScope]);
|
||||
|
||||
const handleBulkCreateFolderAndMove = React.useCallback(() => {
|
||||
const targetScope = selectionFolderScopes[0];
|
||||
if (!targetScope || !hasSelection) return;
|
||||
const newFolder = createFolderAndStartRename(targetScope);
|
||||
if (!newFolder) return;
|
||||
moveSelectionToFolder(targetScope, newFolder.id);
|
||||
}, [createFolderAndStartRename, hasSelection, moveSelectionToFolder, selectionFolderScopes]);
|
||||
|
||||
const handleBulkRemoveFromFolder = React.useCallback(() => {
|
||||
if (!hasSelection) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
for (const scope of selectionFolderScopes) {
|
||||
removeSessionsFromFolders(scope, ids);
|
||||
}
|
||||
}, [removeSessionsFromFolders, selectedIds, selectionFolderScopes, hasSelection]);
|
||||
|
||||
const executeBulkDelete = React.useCallback(async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
if (bulkScopeIsArchived) {
|
||||
const { deletedIds, failedIds } = await deleteSessions(ids);
|
||||
if (deletedIds.length > 0) {
|
||||
toast.success(deletedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
|
||||
}
|
||||
} else {
|
||||
const { archivedIds, failedIds } = await archiveSessions(ids);
|
||||
if (archivedIds.length > 0) {
|
||||
toast.success(archivedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
|
||||
}
|
||||
}
|
||||
useSessionMultiSelectStore.getState().clear();
|
||||
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
|
||||
|
||||
const handleBulkDelete = React.useCallback(() => {
|
||||
if (!hasSelection) return;
|
||||
const count = selectedIds.size;
|
||||
if (!showDeletionDialog) {
|
||||
void executeBulkDelete();
|
||||
return;
|
||||
}
|
||||
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
|
||||
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
|
||||
|
||||
const handleBulkRestore = React.useCallback(async () => {
|
||||
if (!hasSelection || !bulkScopeIsArchived) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
const { restoredIds, failedIds } = await unarchiveSessions(ids);
|
||||
if (restoredIds.length > 0) {
|
||||
toast.success(restoredIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.restoredSingle', { count: restoredIds.length })
|
||||
: t('sessions.sidebar.bulkActions.restoredPlural', { count: restoredIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedRestoreSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedRestorePlural', { count: failedIds.length }));
|
||||
}
|
||||
useSessionMultiSelectStore.getState().clear();
|
||||
}, [bulkScopeIsArchived, hasSelection, selectedIds, t, unarchiveSessions]);
|
||||
|
||||
const confirmBulkDelete = React.useCallback(async () => {
|
||||
setBulkDeleteConfirm(null);
|
||||
await executeBulkDelete();
|
||||
// setBulkDeleteConfirm is a stable React state setter; intentionally
|
||||
// omitted from deps to avoid forcing the keyboard-listener effect
|
||||
// below to re-subscribe on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [executeBulkDelete]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectionModeEnabled) return;
|
||||
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (isInlineEditing) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
const modifier = isMac ? event.metaKey : event.ctrlKey;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
useSessionMultiSelectStore.getState().disable();
|
||||
return;
|
||||
}
|
||||
if (modifier && event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
handleBulkDelete();
|
||||
return;
|
||||
}
|
||||
if (modifier && (event.key === 'a' || event.key === 'A')) {
|
||||
const rows = typeof document !== 'undefined'
|
||||
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
|
||||
: [];
|
||||
if (rows.length === 0) return;
|
||||
event.preventDefault();
|
||||
const currentScope = useSessionMultiSelectStore.getState().scopeKey;
|
||||
const targetScope = currentScope
|
||||
?? rows[0]?.getAttribute('data-session-scope')
|
||||
?? null;
|
||||
const scopeFilter = (el: HTMLElement): boolean => {
|
||||
if (!targetScope) return true;
|
||||
return el.getAttribute('data-session-scope') === targetScope;
|
||||
};
|
||||
const ids = rows
|
||||
.filter(scopeFilter)
|
||||
.map((el) => el.getAttribute('data-session-row'))
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
if (ids.length === 0) return;
|
||||
useSessionMultiSelectStore.getState().replaceAll(ids, targetScope || null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', listener);
|
||||
return () => window.removeEventListener('keydown', listener);
|
||||
}, [handleBulkDelete, isInlineEditing, selectionModeEnabled]);
|
||||
|
||||
return {
|
||||
selectionModeEnabled,
|
||||
hasSelection,
|
||||
selectedIdsSize,
|
||||
bulkScopeIsArchived,
|
||||
derivedSelectionScope,
|
||||
bulkScopeFolders,
|
||||
bulkCanRemoveFromFolder,
|
||||
handleToggleSelectionMode,
|
||||
handleExitSelectionMode,
|
||||
handleBulkMoveToFolder,
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user