"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { IconChevronDown, IconChevronRight, IconFolder, IconFolderOpen } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import type { FileItem } from "@/http/endpoints/files/types"; import type { FolderItem } from "@/http/endpoints/folders/types"; import { cn } from "@/lib/utils"; import { getFileIcon } from "@/utils/file-icons"; export interface TreeFile { id: string; name: string; type: "file"; size?: number; parentId: string | null; } export interface TreeFolder { id: string; name: string; type: "folder"; parentId: string | null; totalSize?: string; } export type TreeItem = TreeFile | TreeFolder; export interface FileTreeProps { files: FileItem[]; folders: FolderItem[]; selectedItems: string[]; onSelectionChange: (selectedIds: string[]) => void; showFiles?: boolean; showFolders?: boolean; className?: string; maxHeight?: string; singleSelection?: boolean; useRadioButtons?: boolean; useCheckboxAsRadio?: boolean; searchQuery?: string; autoExpandToItem?: string | null; } interface TreeNode { item: TreeItem; children: TreeNode[]; level: number; } interface TreeNodeProps { node: TreeNode; isExpanded: boolean; isSelected: boolean; isIndeterminate: boolean; onToggleExpand: (nodeId: string) => void; onToggleSelect: (nodeId: string) => void; expandedFolders: Set; selectedSet: Set; showFiles: boolean; showFolders: boolean; singleSelection?: boolean; useRadioButtons?: boolean; useCheckboxAsRadio?: boolean; } function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; } function TreeNodeComponent({ node, isExpanded, isSelected, isIndeterminate, onToggleExpand, onToggleSelect, expandedFolders, selectedSet, showFiles, showFolders, singleSelection = false, useRadioButtons = false, useCheckboxAsRadio = false, }: TreeNodeProps) { const { item, children, level } = node; const isFolder = item.type === "folder"; const hasChildren = children.length > 0; const shouldShow = (isFolder && showFolders) || (!isFolder && showFiles); if (!shouldShow) return null; const paddingLeft = level * 20 + 8; return (
{isFolder && hasChildren && ( )}
{useRadioButtons ? ( onToggleSelect(item.id)} className="flex-shrink-0" /> ) : ( { if (ref && ref.querySelector) { const checkbox = ref.querySelector('input[type="checkbox"]') as HTMLInputElement; if (checkbox && !useCheckboxAsRadio) { checkbox.indeterminate = isIndeterminate; } } }} onCheckedChange={() => onToggleSelect(item.id)} className="flex-shrink-0" /> )}
{isFolder ? ( isExpanded ? ( ) : ( ) ) : ( (() => { const { icon: FileIcon, color } = getFileIcon(item.name); return ; })() )} {item.name} {!isFolder && item.size && ( ({formatFileSize(item.size)}) )} {isFolder && (item as TreeFolder).totalSize && ( ({formatFileSize(Number((item as TreeFolder).totalSize!))}) )}
{isFolder && isExpanded && hasChildren && (
{children.map((childNode) => ( ))}
)}
); } export function FileTree({ files, folders, selectedItems, onSelectionChange, showFiles = true, showFolders = true, className, maxHeight = "400px", singleSelection = false, useRadioButtons = false, useCheckboxAsRadio = false, searchQuery = "", autoExpandToItem = null, }: FileTreeProps) { const [expandedFolders, setExpandedFolders] = useState>(new Set()); const selectedSet = useMemo(() => new Set(selectedItems), [selectedItems]); const convertToTreeItems = useCallback((): TreeItem[] => { let treeFolders: TreeItem[] = folders.map((folder) => ({ id: folder.id, name: folder.name, type: "folder" as const, parentId: folder.parentId, totalSize: folder.totalSize, })); let treeFiles: TreeItem[] = files.map((file) => ({ id: file.id, name: file.name, type: "file" as const, size: parseInt(file.size), parentId: file.folderId, })); if (searchQuery.trim()) { const searchLower = searchQuery.toLowerCase(); const getMatchingItems = (allItems: TreeItem[]): TreeItem[] => { const matching = new Set(); allItems.forEach((item) => { if (item.name.toLowerCase().includes(searchLower)) { matching.add(item.id); } }); const addParents = (itemId: string) => { const item = allItems.find((i) => i.id === itemId); if (item && item.parentId) { matching.add(item.parentId); addParents(item.parentId); } }; matching.forEach((itemId) => addParents(itemId)); return allItems.filter((item) => matching.has(item.id)); }; const allItems = [...treeFolders, ...treeFiles]; const filteredItems = getMatchingItems(allItems); treeFolders = filteredItems.filter((item) => item.type === "folder") as TreeFolder[]; treeFiles = filteredItems.filter((item) => item.type === "file") as TreeFile[]; } return [...treeFolders, ...treeFiles]; }, [files, folders, searchQuery]); useEffect(() => { if (autoExpandToItem) { const allItems = convertToTreeItems(); const item = allItems.find((i) => i.id === autoExpandToItem); if (item && item.parentId) { const pathToRoot: string[] = []; let currentItem: TreeItem | undefined = item; while (currentItem && currentItem.parentId) { pathToRoot.push(currentItem.parentId); currentItem = allItems.find((i) => i.id === currentItem!.parentId); } if (pathToRoot.length > 0) { setExpandedFolders(new Set(pathToRoot)); } } } }, [autoExpandToItem, convertToTreeItems]); const tree = useMemo(() => { const allItems = convertToTreeItems(); const itemMap = new Map(); const childrenMap = new Map(); allItems.forEach((item) => { itemMap.set(item.id, item); childrenMap.set(item.id, []); }); allItems.forEach((item) => { if (item.parentId) { const parentChildren = childrenMap.get(item.parentId); if (parentChildren) { parentChildren.push(item); } } }); function buildTreeNode(item: TreeItem, level: number): TreeNode { const children = childrenMap.get(item.id) || []; return { item, children: children .sort((a, b) => { if (a.type !== b.type) { return a.type === "folder" ? -1 : 1; } return a.name.localeCompare(b.name); }) .map((child) => buildTreeNode(child, level + 1)), level, }; } const rootItems = allItems.filter((item) => !item.parentId); return rootItems .sort((a, b) => { if (a.type !== b.type) { return a.type === "folder" ? -1 : 1; } return a.name.localeCompare(b.name); }) .map((item) => buildTreeNode(item, 0)); }, [convertToTreeItems]); const getDescendants = useCallback( (folderId: string): string[] => { const descendants: string[] = []; const allItems = convertToTreeItems(); function collectDescendants(parentId: string) { allItems.forEach((item) => { if (item.parentId === parentId) { descendants.push(item.id); if (item.type === "folder") { collectDescendants(item.id); } } }); } collectDescendants(folderId); return descendants; }, [convertToTreeItems] ); const getAllAncestors = useCallback( (itemId: string): string[] => { const ancestors: string[] = []; const allItems = convertToTreeItems(); let currentItem = allItems.find((i) => i.id === itemId); while (currentItem && currentItem.parentId) { ancestors.push(currentItem.parentId); currentItem = allItems.find((i) => i.id === currentItem!.parentId); } return ancestors; }, [convertToTreeItems] ); const handleToggleExpand = useCallback((folderId: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(folderId)) { next.delete(folderId); } else { next.add(folderId); } return next; }); }, []); const handleToggleSelect = useCallback( (itemId: string) => { const allItems = convertToTreeItems(); const item = allItems.find((i) => i.id === itemId); if (!item) return; if (singleSelection || useCheckboxAsRadio) { onSelectionChange([itemId]); return; } const newSelection = new Set(selectedItems); if (selectedSet.has(itemId)) { newSelection.delete(itemId); if (item.type === "folder") { const descendants = getDescendants(itemId); descendants.forEach((id) => newSelection.delete(id)); } else { const ancestors = getAllAncestors(itemId); const allItems = convertToTreeItems(); ancestors.forEach((ancestorId) => { const ancestorDescendants = getDescendants(ancestorId); const selectedDescendants = ancestorDescendants.filter((id) => id !== itemId && newSelection.has(id)); if (selectedDescendants.length === 0) { const ancestorSiblings = allItems.filter((i) => { const ancestor = allItems.find((a) => a.id === ancestorId); return ancestor && i.parentId === ancestor.parentId && i.id !== ancestorId; }); const selectedSiblings = ancestorSiblings.filter((sibling) => newSelection.has(sibling.id)); if (selectedSiblings.length === 0) { newSelection.delete(ancestorId); } } }); } } else { newSelection.add(itemId); const ancestors = getAllAncestors(itemId); ancestors.forEach((ancestorId) => { newSelection.add(ancestorId); }); if (item.type === "folder") { const descendants = getDescendants(itemId); const allItems = convertToTreeItems(); descendants.forEach((id) => { const descendantItem = allItems.find((i) => i.id === id); if (descendantItem) { if ((descendantItem.type === "folder" && showFolders) || (descendantItem.type === "file" && showFiles)) { newSelection.add(id); } } }); } } onSelectionChange(Array.from(newSelection)); }, [ selectedItems, selectedSet, getDescendants, getAllAncestors, onSelectionChange, showFiles, showFolders, convertToTreeItems, singleSelection, useCheckboxAsRadio, ] ); const isIndeterminate = useCallback( (folderId: string): boolean => { const descendants = getDescendants(folderId); const allItems = convertToTreeItems(); const visibleDescendants = descendants.filter((id) => { const item = allItems.find((i) => i.id === id); if (!item) return false; return (item.type === "folder" && showFolders) || (item.type === "file" && showFiles); }); if (visibleDescendants.length === 0) return false; const selectedDescendants = visibleDescendants.filter((id) => selectedSet.has(id)); return selectedDescendants.length > 0 && selectedDescendants.length < visibleDescendants.length; }, [getDescendants, selectedSet, showFiles, showFolders, convertToTreeItems] ); if (tree.length === 0) { return (

No items to display

); } return (
{tree.map((node) => ( ))}
); }