diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 648dfb35..f9908ef7 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -3,8 +3,8 @@ import { cn } from '@/lib/utils'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { useUIStore } from '@/stores/useUIStore'; -export const SIDEBAR_CONTENT_WIDTH = 250; -const SIDEBAR_MIN_WIDTH = 250; +export const SIDEBAR_CONTENT_WIDTH = 280; +const SIDEBAR_MIN_WIDTH = 280; const SIDEBAR_MAX_WIDTH = 500; interface SidebarProps { diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 6a28a67e..954fb01d 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -48,11 +48,15 @@ import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import type { WorktreeMetadata } from '@/types/worktree'; import type { SortableDragHandleProps } from './sidebar/sortableItems'; import { + BulkSessionDeleteConfirmDialog, FolderDeleteConfirmDialog, SessionDeleteConfirmDialog, + type BulkDeleteSessionsConfirmState, type DeleteFolderConfirmState, type DeleteSessionConfirmState, } from './sidebar/ConfirmDialogs'; +import { BulkActionBar } from './sidebar/BulkActionBar'; +import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'; import { type SessionGroup, type SessionNode } from './sidebar/types'; import { type ActiveNowEntry, @@ -145,6 +149,7 @@ export const SessionSidebar: React.FC = ({ const [renameFolderDraft, setRenameFolderDraft] = React.useState(''); const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState(null); const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState(null); + const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState(null); const [pinnedSessionIds, setPinnedSessionIds] = React.useState>(() => { try { const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY); @@ -251,7 +256,9 @@ export const SessionSidebar: React.FC = ({ const renameFolder = useSessionFoldersStore((state) => state.renameFolder); const deleteFolder = useSessionFoldersStore((state) => state.deleteFolder); const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder); + const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder); const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder); + const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders); const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse); const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions); const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId); @@ -389,7 +396,8 @@ export const SessionSidebar: React.FC = ({ } return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); - const showDesktopSidebarChrome = !mobileVariant && !isVSCode; + const isWebRuntime = !mobileVariant && !isVSCode && !isDesktopShellRuntime; + const showDesktopSidebarChrome = !mobileVariant && !isVSCode && !isWebRuntime; const desktopSidebarTopPaddingClass = isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen ? 'pl-[5.5rem]' : 'pl-3'; const desktopSidebarToggleButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50'; @@ -1380,6 +1388,154 @@ export const SessionSidebar: React.FC = ({ /> ) : null; const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId); + + const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); + const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds); + const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey); + const multiSelectStoreApi = useSessionMultiSelectStore; + + const handleToggleSelectionMode = React.useCallback(() => { + useSessionMultiSelectStore.getState().toggleMode(); + }, []); + const handleExitSelectionMode = React.useCallback(() => { + useSessionMultiSelectStore.getState().disable(); + }, []); + + const bulkScopeIsArchived = React.useMemo(() => { + if (selectedIds.size === 0) return false; + if (typeof document === 'undefined') return false; + let sawActive = false; + let sawArchived = false; + for (const id of selectedIds) { + const rows = document.querySelectorAll(`[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; + }, [selectedIds]); + + const derivedSelectionScope = React.useMemo(() => { + if (selectionScopeKey) return selectionScopeKey; + if (selectedIds.size === 0) return null; + if (typeof document === 'undefined') return null; + for (const id of selectedIds) { + const row = document.querySelector(`[data-session-row="${CSS.escape(id)}"]`); + const scope = row?.getAttribute('data-session-scope'); + if (scope && scope.length > 0) return scope; + } + return null; + }, [selectedIds, selectionScopeKey]); + + const bulkScopeFolders = React.useMemo(() => { + if (!derivedSelectionScope) return []; + return foldersMap[derivedSelectionScope] ?? []; + }, [foldersMap, derivedSelectionScope]); + + const bulkCanRemoveFromFolder = React.useMemo(() => { + if (!derivedSelectionScope || selectedIds.size === 0) return false; + const scopeFolders = foldersMap[derivedSelectionScope] ?? []; + for (const folder of scopeFolders) { + for (const id of folder.sessionIds) { + if (selectedIds.has(id)) return true; + } + } + return false; + }, [foldersMap, derivedSelectionScope, selectedIds]); + + const handleBulkMoveToFolder = React.useCallback((folderId: string) => { + if (!derivedSelectionScope || selectedIds.size === 0) return; + addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds)); + }, [addSessionsToFolder, selectedIds, derivedSelectionScope]); + + const handleBulkCreateFolderAndMove = React.useCallback(() => { + if (!derivedSelectionScope || selectedIds.size === 0) return; + const newFolder = createFolderAndStartRename(derivedSelectionScope); + if (!newFolder) return; + addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds)); + }, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope]); + + const handleBulkRemoveFromFolder = React.useCallback(() => { + if (!derivedSelectionScope || selectedIds.size === 0) return; + removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds)); + }, [removeSessionsFromFolders, selectedIds, derivedSelectionScope]); + + 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(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`); + if (failedIds.length > 0) toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); + } else { + const { archivedIds, failedIds } = await archiveSessions(ids); + if (archivedIds.length > 0) toast.success(`Archived ${archivedIds.length} session${archivedIds.length === 1 ? '' : 's'}`); + if (failedIds.length > 0) toast.error(`Failed to archive ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); + } + useSessionMultiSelectStore.getState().clear(); + }, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds]); + + const handleBulkDelete = React.useCallback(() => { + const count = selectedIds.size; + if (count === 0) return; + if (!showDeletionDialog) { + void executeBulkDelete(); + return; + } + setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived }); + }, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog]); + + const confirmBulkDelete = React.useCallback(async () => { + setBulkDeleteConfirm(null); + await executeBulkDelete(); + }, [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('[data-session-row]')) + : []; + if (rows.length === 0) return; + event.preventDefault(); + const currentScope = multiSelectStoreApi.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; + multiSelectStoreApi.getState().replaceAll(ids, targetScope || null); + } + }; + window.addEventListener('keydown', listener); + return () => window.removeEventListener('keydown', listener); + }, [handleBulkDelete, isInlineEditing, multiSelectStoreApi, selectionModeEnabled]); const handleSidebarNewSession = React.useCallback(() => { setActiveMainTab('chat'); if (mobileVariant) { @@ -1449,6 +1605,10 @@ export const SessionSidebar: React.FC = ({ collapseAllProjects={collapseAllProjects} expandAllProjects={expandAllProjects} openScheduledTasksDialog={() => setScheduledTasksDialogOpen(true)} + selectionModeEnabled={selectionModeEnabled} + onToggleSelectionMode={handleToggleSelectionMode} + showSidebarToggle={isWebRuntime} + onToggleSidebar={toggleSidebar} /> = ({ isInlineEditing={isInlineEditing} /> + {selectionModeEnabled && selectedIds.size > 0 ? ( + + ) : null} + = ({ setValue={setDeleteFolderConfirm} onConfirm={confirmDeleteFolder} /> + + ); }; diff --git a/packages/ui/src/components/session/sidebar/BulkActionBar.tsx b/packages/ui/src/components/session/sidebar/BulkActionBar.tsx new file mode 100644 index 00000000..b014ccd5 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/BulkActionBar.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { + RiAddLine, + RiCloseLine, + RiDeleteBinLine, + RiFolderLine, +} from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import type { SessionFolder } from '@/stores/useSessionFoldersStore'; + +type Props = { + selectedCount: number; + scopeKey: string | null; + scopeFolders: SessionFolder[]; + archivedBucket: boolean; + onMoveToFolder: (folderId: string) => void; + onCreateFolderAndMove: () => void; + onRemoveFromFolder: () => void; + canRemoveFromFolder: boolean; + onDelete: () => void; + onDone: () => void; +}; + +export const BulkActionBar: React.FC = ({ + selectedCount, + scopeKey, + scopeFolders, + archivedBucket, + onMoveToFolder, + onCreateFolderAndMove, + onRemoveFromFolder, + canRemoveFromFolder, + onDelete, + onDone, +}) => { + const canMoveToFolder = Boolean(scopeKey) && !archivedBucket; + const destructiveLabel = archivedBucket ? 'Delete' : '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 ( +
+ + {selectedCount} selected + + +
+ {canMoveToFolder ? ( + + + + + + + +

Move to folder

+
+ + {scopeFolders.length === 0 ? ( + + No folders yet + + ) : ( + scopeFolders.map((folder) => ( + onMoveToFolder(folder.id)}> + {folder.name} + + )) + )} + + + + New folder... + + {canRemoveFromFolder ? ( + + + Remove from folder + + ) : null} + +
+ ) : null} + + + + + +

{destructiveLabel}

+
+ + + + + +

Exit selection

+
+
+
+ ); +}; diff --git a/packages/ui/src/components/session/sidebar/ConfirmDialogs.tsx b/packages/ui/src/components/session/sidebar/ConfirmDialogs.tsx index a52e8091..6254df1b 100644 --- a/packages/ui/src/components/session/sidebar/ConfirmDialogs.tsx +++ b/packages/ui/src/components/session/sidebar/ConfirmDialogs.tsx @@ -65,6 +65,66 @@ export function SessionDeleteConfirmDialog(props: { ); } +export type BulkDeleteSessionsConfirmState = { + sessionCount: number; + archivedBucket: boolean; +} | null; + +export function BulkSessionDeleteConfirmDialog(props: { + value: BulkDeleteSessionsConfirmState; + setValue: (next: BulkDeleteSessionsConfirmState) => void; + showDeletionDialog: boolean; + setShowDeletionDialog: (next: boolean) => void; + onConfirm: () => Promise | void; +}): React.ReactNode { + const { value, setValue, showDeletionDialog, setShowDeletionDialog, onConfirm } = props; + const archived = value?.archivedBucket === true; + const n = value?.sessionCount ?? 0; + const plural = n === 1 ? '' : 's'; + const title = archived ? 'Delete sessions?' : 'Archive sessions?'; + const description = archived + ? `${n} session${plural} will be permanently deleted.` + : `${n} session${plural} will be archived.`; + + return ( + { if (!open) setValue(null); }}> + + + {title} + {description} + + + +
+ + +
+
+
+
+ ); +} + export type DeleteFolderConfirmState = { scopeKey: string; folderId: string; diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 2f038165..62913028 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -44,6 +44,7 @@ import type { SessionNode, SessionSummaryMeta } from './types'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; +import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'; type Folder = { id: string; name: string; sessionIds: string[] }; @@ -250,14 +251,37 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const hideOnHoverClass = isVSCode ? 'group-hover:opacity-0' : 'group-hover:opacity-0 group-focus-within:opacity-0'; - const revealPaddingClass = isVSCode - ? 'group-hover:pr-5' - : 'group-hover:pr-5 group-focus-within:pr-5'; + const revealPaddingClass = isMinimalMode + ? (isVSCode + ? 'group-hover:pr-1' + : 'group-hover:pr-1 group-focus-within:pr-1') + : (isVSCode + ? 'group-hover:pr-5' + : 'group-hover:pr-5 group-focus-within:pr-5'); const suppressNextSelectRef = React.useRef(false); const session = node.session; const liveSession = useSession(session.id); const resolvedSession = liveSession ?? session; + + const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); + const isRowSelected = useSessionMultiSelectStore( + React.useCallback((state) => state.selectedIds.has(session.id), [session.id]), + ); + const toggleRowSelected = useSessionMultiSelectStore((state) => state.toggleSelected); + const setRowRange = useSessionMultiSelectStore((state) => state.setRange); + + const collectNodeDescendantIds = React.useCallback((root: SessionNode): string[] => { + const out: string[] = []; + const walk = (n: SessionNode) => { + n.children.forEach((child) => { + out.push(child.session.id); + walk(child); + }); + }; + walk(root); + return out; + }, []); const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`; const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) @@ -460,16 +484,35 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { event.stopPropagation(); }; - const handleRowSelect = () => { + const handleRowSelect = (event?: React.MouseEvent) => { if (suppressNextSelectRef.current) { suppressNextSelectRef.current = false; return; } + if (selectionModeEnabled) { + event?.preventDefault(); + event?.stopPropagation(); + if (event?.shiftKey) { + const rows = typeof document !== 'undefined' + ? Array.from(document.querySelectorAll('[data-session-row]')) + : []; + const orderedIds = rows + .map((el) => el.getAttribute('data-session-row')) + .filter((id): id is string => typeof id === 'string' && id.length > 0); + const currentAnchor = useSessionMultiSelectStore.getState().anchorId; + const descendantsById = new Map(); + descendantsById.set(session.id, collectNodeDescendantIds(node)); + setRowRange(currentAnchor, session.id, orderedIds, sessionDirectory ?? null, descendantsById); + return; + } + toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node)); + return; + } handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId); }; const handleRowMouseDown = (event: React.MouseEvent) => { - if (event.button === 2 || (event.button === 0 && event.ctrlKey)) { + if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) { suppressNextSelectRef.current = true; } }; @@ -578,7 +621,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
0 && 'pl-[20px]')} + data-session-row={session.id} + data-session-scope={sessionDirectory ?? ''} + data-session-archived={archivedBucket ? '1' : '0'} + className={cn( + 'group relative my-0.5 flex items-center rounded-sm px-1.5 py-1', + isMissingDirectory ? 'opacity-75' : '', + depth > 0 && 'pl-[20px]', + isRowSelected && 'bg-primary/15', + )} > {minimalLeadingStatusMarker} {subsessionChevron} @@ -590,7 +641,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { type="button" disabled={isMissingDirectory} onMouseDown={handleRowMouseDown} - onClick={handleRowSelect} + onClick={(event) => handleRowSelect(event)} onDoubleClick={(e) => { e.stopPropagation(); handleSessionDoubleClick(); @@ -651,7 +702,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { type="button" disabled={isMissingDirectory} onMouseDown={handleRowMouseDown} - onClick={handleRowSelect} + onClick={(event) => handleRowSelect(event)} onDoubleClick={(e) => { e.stopPropagation(); handleSessionDoubleClick(); diff --git a/packages/ui/src/components/session/sidebar/SidebarHeader.tsx b/packages/ui/src/components/session/sidebar/SidebarHeader.tsx index 8258bb51..91cfcba0 100644 --- a/packages/ui/src/components/session/sidebar/SidebarHeader.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarHeader.tsx @@ -9,15 +9,18 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { RiCheckLine, + RiCheckboxMultipleLine, RiChatNewLine, RiEqualizer2Line, RiFolderAddLine, + RiLayoutLeftLine, RiSearchLine, RiCloseLine, RiContractUpDownLine, RiExpandUpDownLine, RiCalendarScheduleLine, } from '@remixicon/react'; +import { cn } from '@/lib/utils'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; @@ -40,6 +43,10 @@ type Props = { collapseAllProjects: () => void; expandAllProjects: () => void; openScheduledTasksDialog: () => void; + selectionModeEnabled: boolean; + onToggleSelectionMode: () => void; + showSidebarToggle?: boolean; + onToggleSidebar?: () => void; }; export function SidebarHeader(props: Props): React.ReactNode { @@ -62,6 +69,10 @@ export function SidebarHeader(props: Props): React.ReactNode { collapseAllProjects, expandAllProjects, openScheduledTasksDialog, + selectionModeEnabled, + onToggleSelectionMode, + showSidebarToggle = false, + onToggleSidebar, } = props; const displayMode = useSessionDisplayStore((state) => state.displayMode); @@ -72,11 +83,36 @@ export function SidebarHeader(props: Props): React.ReactNode { } return ( -
+
{reserveHeaderActionsSpace ? ( -
+
+ {showSidebarToggle && onToggleSidebar ? ( + + + + +

Close sessions

+
+ ) : null} + + +

{selectionModeEnabled ? 'Exit selection' : 'Select sessions'}

+
+
+ diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index 4216544d..50a9688a 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -29,7 +29,9 @@ interface SessionFoldersActions { renameFolder: (scopeKey: string, folderId: string, name: string) => void; deleteFolder: (scopeKey: string, folderId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; + addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void; removeSessionFromFolder: (scopeKey: string, sessionId: string) => void; + removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void; toggleFolderCollapse: (folderId: string) => void; cleanupSessions: (scopeKey: string, existingSessionIds: Set) => void; getSessionFolderId: (scopeKey: string, sessionId: string) => string | null; @@ -381,6 +383,64 @@ export const useSessionFoldersStore = create()( persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds); }, + addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]): void => { + if (!scopeKey || !folderId || sessionIds.length === 0) return; + const current = get().foldersMap; + const scopeFolders = current[scopeKey]; + if (!scopeFolders) return; + + const idSet = new Set(sessionIds.filter((id) => typeof id === 'string' && id.length > 0)); + if (idSet.size === 0) return; + + const nextFolders = scopeFolders.map((folder) => { + const withoutSessions = folder.sessionIds.filter((id) => !idSet.has(id)); + if (folder.id === folderId) { + return { ...folder, sessionIds: [...withoutSessions, ...idSet] }; + } + if (withoutSessions.length !== folder.sessionIds.length) { + return { ...folder, sessionIds: withoutSessions }; + } + return folder; + }); + + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; + const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds); + + set(nextCollapsed + ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } + : { foldersMap: nextMap }); + persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds); + }, + + removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]): void => { + if (!scopeKey || sessionIds.length === 0) return; + const current = get().foldersMap; + const scopeFolders = current[scopeKey]; + if (!scopeFolders) return; + + const idSet = new Set(sessionIds.filter((id) => typeof id === 'string' && id.length > 0)); + if (idSet.size === 0) return; + + let changed = false; + const nextFolders = scopeFolders.map((folder) => { + const filtered = folder.sessionIds.filter((id) => !idSet.has(id)); + if (filtered.length !== folder.sessionIds.length) { + changed = true; + return { ...folder, sessionIds: filtered }; + } + return folder; + }); + + if (!changed) return; + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; + const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds); + + set(nextCollapsed + ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } + : { foldersMap: nextMap }); + persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds); + }, + removeSessionFromFolder: (scopeKey: string, sessionId: string): void => { if (!scopeKey || !sessionId) return; const current = get().foldersMap; diff --git a/packages/ui/src/stores/useSessionMultiSelectStore.ts b/packages/ui/src/stores/useSessionMultiSelectStore.ts new file mode 100644 index 00000000..d160b8f3 --- /dev/null +++ b/packages/ui/src/stores/useSessionMultiSelectStore.ts @@ -0,0 +1,157 @@ +import { create } from 'zustand'; + +interface SessionMultiSelectState { + enabled: boolean; + selectedIds: Set; + scopeKey: string | null; + anchorId: string | null; +} + +interface SessionMultiSelectActions { + enable: () => void; + disable: () => void; + toggleMode: () => void; + toggleSelected: (id: string, scope: string | null, descendants?: string[]) => void; + setRange: (fromId: string | null, toId: string, orderedIds: string[], scope: string | null, descendantsById?: Map) => void; + replaceAll: (ids: string[], scope: string | null) => void; + clear: () => void; + removeMany: (ids: string[]) => void; +} + +type SessionMultiSelectStore = SessionMultiSelectState & SessionMultiSelectActions; + +const expandWithDescendants = (ids: Iterable, descendantsById?: Map): string[] => { + if (!descendantsById) { + return Array.from(ids); + } + const out: string[] = []; + const seen = new Set(); + for (const id of ids) { + if (!seen.has(id)) { + seen.add(id); + out.push(id); + } + const descendants = descendantsById.get(id); + if (!descendants) continue; + for (const d of descendants) { + if (!seen.has(d)) { + seen.add(d); + out.push(d); + } + } + } + return out; +}; + +export const useSessionMultiSelectStore = create()((set, get) => ({ + enabled: false, + selectedIds: new Set(), + scopeKey: null, + anchorId: null, + + enable: () => { + if (get().enabled) return; + set({ enabled: true }); + }, + + disable: () => { + set({ enabled: false, selectedIds: new Set(), scopeKey: null, anchorId: null }); + }, + + toggleMode: () => { + if (get().enabled) { + set({ enabled: false, selectedIds: new Set(), scopeKey: null, anchorId: null }); + } else { + set({ enabled: true }); + } + }, + + toggleSelected: (id, scope, descendants) => { + const state = get(); + const nextIds = new Set(state.selectedIds); + let nextScope = state.scopeKey; + let nextAnchor = state.anchorId; + + const scopeChanged = scope !== null && state.scopeKey !== null && scope !== state.scopeKey; + if (scopeChanged) { + nextIds.clear(); + nextScope = scope; + nextAnchor = null; + } else if (nextScope === null && scope !== null) { + nextScope = scope; + } + + const toToggle = descendants && descendants.length > 0 ? [id, ...descendants] : [id]; + const isCurrentlySelected = nextIds.has(id); + if (isCurrentlySelected) { + for (const targetId of toToggle) { + nextIds.delete(targetId); + } + if (nextAnchor === id) { + nextAnchor = null; + } + } else { + for (const targetId of toToggle) { + nextIds.add(targetId); + } + nextAnchor = id; + } + + if (nextIds.size === 0) { + set({ selectedIds: nextIds, scopeKey: null, anchorId: null }); + } else { + set({ selectedIds: nextIds, scopeKey: nextScope, anchorId: nextAnchor }); + } + }, + + setRange: (fromId, toId, orderedIds, scope, descendantsById) => { + const state = get(); + if (orderedIds.length === 0) return; + const effectiveFrom = fromId && orderedIds.includes(fromId) ? fromId : orderedIds[0]; + const start = orderedIds.indexOf(effectiveFrom); + const end = orderedIds.indexOf(toId); + if (start < 0 || end < 0) return; + const [lo, hi] = start <= end ? [start, end] : [end, start]; + const slice = orderedIds.slice(lo, hi + 1); + const expanded = expandWithDescendants(slice, descendantsById); + + const scopeChanged = scope !== null && state.scopeKey !== null && scope !== state.scopeKey; + const baseIds = scopeChanged ? new Set() : new Set(state.selectedIds); + for (const id of expanded) { + baseIds.add(id); + } + + set({ + selectedIds: baseIds, + scopeKey: scope ?? state.scopeKey, + anchorId: effectiveFrom, + }); + }, + + replaceAll: (ids, scope) => { + if (ids.length === 0) { + set({ selectedIds: new Set(), scopeKey: null, anchorId: null }); + return; + } + set({ selectedIds: new Set(ids), scopeKey: scope, anchorId: ids[0] ?? null }); + }, + + clear: () => { + set({ selectedIds: new Set(), scopeKey: null, anchorId: null }); + }, + + removeMany: (ids) => { + const state = get(); + if (state.selectedIds.size === 0 || ids.length === 0) return; + const next = new Set(state.selectedIds); + for (const id of ids) { + next.delete(id); + } + if (next.size === state.selectedIds.size) return; + if (next.size === 0) { + set({ selectedIds: next, scopeKey: null, anchorId: null }); + } else { + set({ selectedIds: next }); + } + }, +}));