import React, { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import DocumentChunks from './DocumentChunks'; import ContextMenu, { MenuOption } from './ContextMenu'; import userService from '../api/services/userService'; import FileIcon from '../assets/file.svg'; import FolderIcon from '../assets/folder.svg'; import ArrowLeft from '../assets/arrow-left.svg'; import ThreeDots from '../assets/three-dots.svg'; import EyeView from '../assets/eye-view.svg'; import OutlineSource from '../assets/outline-source.svg'; import Trash from '../assets/red-trash.svg'; import SearchIcon from '../assets/search.svg'; import { useOutsideAlerter } from '../hooks'; interface FileNode { type?: string; token_count?: number; size_bytes?: number; [key: string]: any; } interface DirectoryStructure { [key: string]: FileNode; } interface FileTreeComponentProps { docId: string; sourceName: string; onBackToDocuments?: () => void; } interface SearchResult { name: string; path: string; isFile: boolean; } const FileTreeComponent: React.FC = ({ docId, sourceName, onBackToDocuments, }) => { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [directoryStructure, setDirectoryStructure] = useState(null); const [currentPath, setCurrentPath] = useState([]); const token = useSelector((state: any) => state.auth?.token); const [activeMenuId, setActiveMenuId] = useState(null); const menuRefs = useRef<{ [key: string]: React.RefObject; }>({}); const [selectedFile, setSelectedFile] = useState<{ id: string; name: string; } | null>(null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const searchDropdownRef = useRef(null); useOutsideAlerter( searchDropdownRef, () => { setSearchQuery(''); setSearchResults([]); }, [], false ); const handleFileClick = (fileName: string) => { const fullPath = [...currentPath, fileName].join('/'); setSelectedFile({ id: fullPath, name: fileName, }); }; useEffect(() => { const fetchDirectoryStructure = async () => { try { setLoading(true); const response = await userService.getDirectoryStructure(docId, token); const data = await response.json(); if (data && data.directory_structure) { setDirectoryStructure(data.directory_structure); } else { setError('Invalid response format'); } } catch (err) { setError('Failed to load directory structure'); console.error(err); } finally { setLoading(false); } }; if (docId) { fetchDirectoryStructure(); } }, [docId, token]); const formatBytes = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; const navigateToDirectory = (dirName: string) => { setCurrentPath((prev) => [...prev, dirName]); }; const navigateUp = () => { setCurrentPath((prev) => prev.slice(0, -1)); }; const getCurrentDirectory = (): DirectoryStructure => { if (!directoryStructure) return {}; let current: any = directoryStructure; for (const dir of currentPath) { if (current[dir] && !current[dir].type) { current = current[dir]; } else { return {}; } } return current; }; const handleBackNavigation = () => { if (selectedFile) { setSelectedFile(null); } else if (currentPath.length === 0) { if (onBackToDocuments) { onBackToDocuments(); } } else { navigateUp(); } }; const getMenuRef = (itemId: string) => { if (!menuRefs.current[itemId]) { menuRefs.current[itemId] = React.createRef(); } return menuRefs.current[itemId]; }; const handleMenuClick = (e: React.MouseEvent, itemId: string) => { e.preventDefault(); e.stopPropagation(); if (activeMenuId === itemId) { setActiveMenuId(null); return; } setActiveMenuId(itemId); }; const getActionOptions = ( name: string, isFile: boolean, itemId: string, ): MenuOption[] => { const options: MenuOption[] = []; if (isFile) { options.push({ icon: EyeView, label: t('settings.documents.view'), onClick: (event: React.SyntheticEvent) => { event.stopPropagation(); handleFileClick(name); }, iconWidth: 18, iconHeight: 18, variant: 'primary', }); } options.push({ icon: Trash, label: t('convTile.delete'), onClick: (event: React.SyntheticEvent) => { event.stopPropagation(); console.log('Delete item:', name); // Delete action will be implemented later }, iconWidth: 18, iconHeight: 18, variant: 'danger', }); return options; }; const renderPathNavigation = () => { return (
source {sourceName} {currentPath.length > 0 && ( <> / {currentPath.map((dir, index) => ( {dir} {index < currentPath.length - 1 && ( / )} ))} )} {selectedFile && ( <> / {selectedFile.name} )}
); }; const calculateDirectoryStats = ( structure: DirectoryStructure, ): { totalSize: number; totalTokens: number } => { let totalSize = 0; let totalTokens = 0; Object.entries(structure).forEach(([_, node]) => { if (node.type) { // It's a file totalSize += node.size_bytes || 0; totalTokens += node.token_count || 0; } else { // It's a directory, recurse const stats = calculateDirectoryStats(node); totalSize += stats.totalSize; totalTokens += stats.totalTokens; } }); return { totalSize, totalTokens }; }; const renderFileTree = (structure: DirectoryStructure): React.ReactNode[] => { // Separate directories and files const entries = Object.entries(structure); const directories = entries.filter(([_, node]) => !node.type); const files = entries.filter(([_, node]) => node.type); // Create parent directory row const parentRow = currentPath.length > 0 ? [
{t('settings.documents.parentFolderAlt')} ..
- - , ] : []; // Render directories first, then files return [ ...parentRow, ...directories.map(([name, node]) => { const itemId = `dir-${name}`; const menuRef = getMenuRef(itemId); const dirStats = calculateDirectoryStats(node as DirectoryStructure); return ( navigateToDirectory(name)} >
{t('settings.documents.folderAlt')} {name}
{dirStats.totalTokens > 0 ? dirStats.totalTokens.toLocaleString() : '-'} {dirStats.totalSize > 0 ? formatBytes(dirStats.totalSize) : '-'}
setActiveMenuId(isOpen ? itemId : null) } options={getActionOptions(name, false, itemId)} anchorRef={menuRef} position="bottom-left" offset={{ x: 0, y: 8 }} />
); }), ...files.map(([name, node]) => { const itemId = `file-${name}`; const menuRef = getMenuRef(itemId); return ( handleFileClick(name)} >
{t('settings.documents.fileAlt')} {name}
{node.token_count?.toLocaleString() || '-'} {node.size_bytes ? formatBytes(node.size_bytes) : '-'}
setActiveMenuId(isOpen ? itemId : null) } options={getActionOptions(name, true, itemId)} anchorRef={menuRef} position="bottom-left" offset={{ x: 0, y: 8 }} />
); }), ]; }; const currentDirectory = getCurrentDirectory(); const searchFiles = (query: string, structure: DirectoryStructure, currentPath: string[] = []): SearchResult[] => { let results: SearchResult[] = []; Object.entries(structure).forEach(([name, node]) => { const fullPath = [...currentPath, name].join('/'); if (name.toLowerCase().includes(query.toLowerCase())) { results.push({ name, path: fullPath, isFile: !!node.type }); } if (!node.type) { // If it's a directory, search recursively results = [...results, ...searchFiles(query, node as DirectoryStructure, [...currentPath, name])]; } }); return results; }; const handleSearchSelect = (result: SearchResult) => { if (result.isFile) { const pathParts = result.path.split('/'); const fileName = pathParts.pop() || ''; setCurrentPath(pathParts); setSelectedFile({ id: result.path, name: fileName }); } else { setCurrentPath(result.path.split('/')); setSelectedFile(null); } setSearchQuery(''); setSearchResults([]); }; const renderFileSearch = () => { return (
{ setSearchQuery(e.target.value); if (directoryStructure) { setSearchResults(searchFiles(e.target.value, directoryStructure)); } }} placeholder={t('settings.documents.searchFiles')} className={`w-full px-4 py-2 pl-10 border border-[#D1D9E0] dark:border-[#6A6A6A] ${ searchQuery ? 'rounded-t-md rounded-b-none border-b-0' : 'rounded-md' } bg-transparent dark:text-[#E0E0E0] focus:outline-none`} /> Search {searchQuery && (
{searchResults.length === 0 ? (
{t('settings.documents.noResults')}
) : ( searchResults.map((result, index) => (
handleSearchSelect(result)} title={result.path} className={`flex items-center px-3 py-2 cursor-pointer hover:bg-[#ECEEEF] dark:hover:bg-[#27282D] ${ index !== searchResults.length - 1 ? "border-b border-[#D1D9E0] dark:border-[#6A6A6A]" : "" }`} > {result.isFile {result.path.split('/').pop() || result.path}
)) )}
)}
); }; return ( <> {selectedFile ? (
setSelectedFile(null)} path={selectedFile.id} renderFileSearch={renderFileSearch} />
) : (
{renderPathNavigation()}
{/* Left side: Search dropdown */} {renderFileSearch()} {/* Right side: File table */}
{renderFileTree(currentDirectory)}
{t('settings.documents.fileName')} {t('settings.documents.tokens')} {t('settings.documents.size')} {t('settings.documents.actions')}
)} ); }; export default FileTreeComponent;