import { useCallback, useMemo, useState } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { createRootNode, DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils'; import { getEntryId, isDocumentEntry, isFolderEntry, createDocumentEntryKey, createFolderEntryKey, } from '../../app/entryKey'; type Identifier = string | number; type FolderId = Identifier | 'root'; interface DocumentLike { id?: Identifier | null; [key: string]: unknown; } interface FolderSummary { id?: FolderId; name?: string; parent_id?: FolderId | null; parentId?: FolderId | null; children?: FolderId[]; subfolders?: FolderSummary[]; has_children?: boolean; hasChildren?: boolean; [key: string]: unknown; } interface FolderContentsEntry { folder?: FolderSummary | null; documents?: DocumentLike[]; subfolders?: FolderSummary[]; __includesDocuments?: boolean; __sortField?: string | null; __sortDirection?: string | null; [key: string]: unknown; } interface FolderTreeNode extends FolderSummary { id: FolderId; children: FolderId[]; expanded?: boolean; loaded?: boolean; hasChildren?: boolean; } interface ApiClient { get(path: string, config?: { params?: Record }): Promise<{ data: T }>; } interface SelectionHelpers { focusedDocumentId: Identifier | null; setFocusedDocumentId: Dispatch>; setSelectedEntries: Dispatch>; setSelectionOrder: Dispatch>; selectionOrderRef: MutableRefObject; selectionAnchorRef: MutableRefObject; } interface UseFolderTreeOptions { initialSelectedFolder?: FolderId; apiClient: ApiClient; tenantIdRef: MutableRefObject; documentsSortFieldRef: MutableRefObject; documentsSortDirectionRef: MutableRefObject; selectionHelpers: SelectionHelpers; setDocuments: Dispatch>; setFolderContents: Dispatch>>; folderContentsRef: MutableRefObject>; } interface FolderOption { id: FolderId; label: string; } const useFolderTree = ({ initialSelectedFolder = 'root', apiClient, tenantIdRef, documentsSortFieldRef, documentsSortDirectionRef, selectionHelpers, setDocuments, setFolderContents, folderContentsRef, }: UseFolderTreeOptions) => { const [folderNodes, setFolderNodes] = useState>(() => { const rootNode = createRootNode() as FolderTreeNode; return new Map([[rootNode.id, rootNode]]); }); const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root'); const [currentFolder, setCurrentFolder] = useState(null); const [currentSubfolders, setCurrentSubfolders] = useState([]); const { focusedDocumentId, setFocusedDocumentId, setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, } = selectionHelpers; const applySelectedFolder = useCallback( (folderId: FolderId, contents?: FolderContentsEntry | null) => { const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : []; const docs = Array.isArray(contents?.documents) ? contents.documents : []; const folderInfo = contents?.folder ?? null; setCurrentSubfolders(subfolders); setDocuments(docs); setCurrentFolder(folderInfo); const availableDocKeys = docs .map((doc) => createDocumentEntryKey(doc?.id as Identifier)) .filter(Boolean); const availableDocKeySet = new Set(availableDocKeys); const availableFolderKeys = new Set( subfolders .map((folder) => createFolderEntryKey(folder?.id as Identifier)) .filter(Boolean), ); let nextDocKeys = []; let mergedSelection = []; setSelectedEntries((previous) => { const previousFolderKeys = previous .filter(isFolderEntry) .filter((key) => availableFolderKeys.has(key)); const previousDocKeys = previous.filter(isDocumentEntry); nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); mergedSelection = [...previousFolderKeys, ...nextDocKeys]; return mergedSelection; }); const nextFocus = (() => { const currentFocusedKey = createDocumentEntryKey(focusedDocumentId); if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) { return focusedDocumentId; } if (nextDocKeys.length) { const lastDocKey = nextDocKeys[nextDocKeys.length - 1]; return getEntryId(lastDocKey) || null; } return null; })(); setFocusedDocumentId(nextFocus); const nextAnchor = mergedSelection.length ? mergedSelection[mergedSelection.length - 1] : null; selectionAnchorRef.current = nextAnchor; selectionOrderRef.current = mergedSelection; setSelectionOrder(mergedSelection); }, [ focusedDocumentId, selectionAnchorRef, selectionOrderRef, setDocuments, setFocusedDocumentId, setSelectedEntries, setSelectionOrder, ], ); const expandFolderAncestors = useCallback((targetId: FolderId | null) => { if (!targetId || targetId === 'root') { setFolderNodes((prev: Map) => { const root = prev.get('root'); if (root?.expanded) return prev; const next = new Map(prev); next.set('root', { ...root, expanded: true }); return next; }); return; } setFolderNodes((prev: Map) => { const next = new Map(prev); let currentId = targetId; let guard = 0; while (currentId && guard < 32) { guard += 1; const node = next.get(currentId); if (!node) break; if (!node.expanded) { next.set(currentId, { ...node, expanded: true }); } currentId = node.parentId ?? 'root'; } return next; }); }, []); const ensureFolderData = useCallback( async ( folderId: FolderId, { includeDocuments = true, prefetchDepth = 0, force = false, sortField = documentsSortFieldRef.current, sortDirection = documentsSortDirectionRef.current, }: { includeDocuments?: boolean; prefetchDepth?: number; force?: boolean; sortField?: string; sortDirection?: string; } = {}, ): Promise => { const requestTenantId = tenantIdRef.current; const cached = folderContentsRef.current.get(folderId); const cachedSortField = cached?.__sortField || documentsSortFieldRef.current; const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current; const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection; if (!force && cached) { const includesDocuments = Boolean(cached.__includesDocuments); if (!includeDocuments || (includesDocuments && cachedSortMatches)) { if (prefetchDepth > 0) { const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : []; await Promise.allSettled( subfolders.map((entry) => ensureFolderData(entry.id, { includeDocuments: false, prefetchDepth: prefetchDepth - 1, force: false, }), ), ); } return cached; } } const path = folderId === 'root' ? 'root' : folderId; const params: Record = {}; if (!includeDocuments) { params.include_documents = false; } else { params.sort = sortField; params.dir = sortDirection; } const requestConfig = Object.keys(params).length ? { params } : {}; const { data } = await apiClient.get(`/folders/${path}/contents`, requestConfig); const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; const childIds = childFolders .map((child) => (child?.id ?? null) as FolderId | null) .filter((id): id is FolderId => Boolean(id)); const enriched = { ...data, __includesDocuments: includeDocuments, __sortField: includeDocuments ? sortField : cachedSortField, __sortDirection: includeDocuments ? sortDirection : cachedSortDirection, }; if (tenantIdRef.current !== requestTenantId) { return enriched; } setFolderNodes((prev: Map) => { const next = new Map(prev); const existingNode = next.get(folderId) || { id: folderId, name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder', parentId: data.folder?.parent_id || 'root', children: [], expanded: folderId === 'root', loaded: false, hasChildren: false, }; next.set(folderId, { ...existingNode, name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name, parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root', children: childIds, expanded: folderId === 'root' ? true : existingNode.expanded, loaded: true, hasChildren: childIds.length > 0, }); childFolders.forEach((child) => { const childId = (child?.id ?? null) as FolderId | null; if (!childId) { return; } const childNode = next.get(childId); const previousChildren = Array.isArray(childNode?.children) ? childNode.children : []; const childHasChildren = (() => { if (childNode?.loaded) { return previousChildren.length > 0; } if (Array.isArray(child?.subfolders)) { return child.subfolders.length > 0; } const flag = [child?.has_children, child?.hasChildren, childNode?.hasChildren] .find((value) => value != null); return Boolean(flag); })(); next.set(childId, { id: childId, name: child.name, parentId: (child.parent_id ?? 'root') as FolderId, children: previousChildren, expanded: childNode?.expanded ?? false, loaded: childNode?.loaded ?? false, hasChildren: childHasChildren, }); }); return next; }); if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) { await Promise.allSettled( childIds.map((childId) => ensureFolderData(childId, { includeDocuments: false, force: false, prefetchDepth: prefetchDepth - 1, }), ), ); } setFolderContents((prev: Map) => { if (tenantIdRef.current !== requestTenantId) { return prev; } const next = new Map(prev); if (includeDocuments) { next.set(folderId, enriched); } else { const existingEntry = next.get(folderId); if (existingEntry) { next.set(folderId, { ...existingEntry, ...data, documents: existingEntry.__includesDocuments ? existingEntry.documents : data.documents, __includesDocuments: existingEntry.__includesDocuments || false, __sortField: existingEntry.__sortField ?? enriched.__sortField, __sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection, }); } else { next.set(folderId, enriched); } } return next; }); return enriched; }, [ apiClient, documentsSortDirectionRef, documentsSortFieldRef, tenantIdRef, setFolderContents, folderContentsRef, ], ); const ensureFolderAncestorsLoaded = useCallback( async (targetId: FolderId | null) => { if (!targetId || targetId === 'root') { return; } let current = targetId; let guard = 0; while (current && current !== 'root' && guard < 32) { guard += 1; const node = folderNodes.get(current); if (node?.loaded) { current = node.parentId ?? 'root'; continue; } await ensureFolderData(current, { includeDocuments: false, prefetchDepth: 0 }); current = folderNodes.get(current)?.parentId ?? 'root'; } }, [folderNodes, ensureFolderData], ); const isInvalidFolderDrop = useCallback( (sourceId: FolderId | null, targetId: FolderId | null) => { if (!sourceId) return false; if (!targetId || targetId === 'root') { return false; } if (sourceId === targetId) { return true; } let current = targetId; const visited = new Set(); while (current && current !== 'root' && !visited.has(current)) { visited.add(current); if (current === sourceId) { return true; } const node = folderNodes.get(current); if (!node) break; current = node.parentId ?? 'root'; } return false; }, [folderNodes], ); const resetFolderTreeState = useCallback(() => { const rootNode = createRootNode() as FolderTreeNode; setFolderNodes(new Map([[rootNode.id, rootNode]])); setFolderContents(new Map()); setSelectedFolder('root'); setCurrentFolder(null); setCurrentSubfolders([]); }, [setFolderContents]); const currentFolderName = useMemo(() => { if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME; return currentFolder.name; }, [selectedFolder, currentFolder]); const folderOptions: FolderOption[] = useMemo(() => { const cache = new Map(); const computePath = (id: FolderId | null): string => { if (cache.has(id as FolderId)) { return cache.get(id as FolderId) as string; } if (!id || id === 'root') { cache.set('root', DEFAULT_FOLDER_NAME); return DEFAULT_FOLDER_NAME; } const node = folderNodes.get(id); if (!node) { return 'Folder'; } const parentId = (node.parentId || 'root') as FolderId; const parentPath = computePath(parentId); const name = node.name || 'Folder'; const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`; cache.set(id, fullPath); return fullPath; }; const entries: FolderOption[] = []; folderNodes.forEach((node, id) => { if (!node) return; entries.push({ id, label: computePath(id) }); }); entries.sort((a, b) => { if (a.id === 'root') return -1; if (b.id === 'root') return 1; return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); }); return entries; }, [folderNodes]); const folderLabelMap = useMemo(() => { const map = new Map(); folderOptions.forEach((option) => { map.set(option.id, option.label); }); return map; }, [folderOptions]); return { folderNodes, setFolderNodes, selectedFolder, setSelectedFolder, currentFolder, setCurrentFolder, currentSubfolders, setCurrentSubfolders, currentFolderName, folderOptions, folderLabelMap, applySelectedFolder, ensureFolderData, ensureFolderAncestorsLoaded, expandFolderAncestors, isInvalidFolderDrop, resetFolderTreeState, }; }; export default useFolderTree;