diff --git a/frontend/src/components/FileTreeComponent.tsx b/frontend/src/components/FileTreeComponent.tsx new file mode 100644 index 00000000..b106c106 --- /dev/null +++ b/frontend/src/components/FileTreeComponent.tsx @@ -0,0 +1,357 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { useSelector } from 'react-redux'; +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 Spinner from './Spinner'; +import { useTranslation } from 'react-i18next'; +import ContextMenu, { MenuOption } from './ContextMenu'; + +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; +} + +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; + }>({}); + + 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 (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): MenuOption[] => { + const options: MenuOption[] = []; + + if (isFile) { + options.push({ + icon: EyeView, + label: t('settings.documents.view'), + onClick: (event: React.SyntheticEvent) => { + event.stopPropagation(); + console.log('View file:', name); + // View file action will be implemented later + }, + 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 && ( + / + )} + + ))} + + )} +
+
+ ); + }; + const renderFileTree = (structure: DirectoryStructure): React.ReactNode[] => { + const entries = Object.entries(structure); + const directories = entries.filter(([_, node]) => !node.type); + const files = entries.filter(([_, node]) => node.type); + + return [ + ...directories.map(([name, node]) => { + const itemId = `dir-${name}`; + const menuRef = getMenuRef(itemId); + + return ( + + +
navigateToDirectory(name)} + > + Folder + {name} +
+ + - + - + +
+ + + setActiveMenuId(isOpen ? itemId : null) + } + options={getActionOptions(name, false)} + anchorRef={menuRef} + position="bottom-left" + offset={{ x: 0, y: 8 }} + /> +
+ + + ); + }), + ...files.map(([name, node]) => { + const itemId = `file-${name}`; + const menuRef = getMenuRef(itemId); + + return ( + + +
+ File + {name} +
+ + + {node.token_count?.toLocaleString() || '-'} + + + {node.size_bytes ? formatBytes(node.size_bytes) : '-'} + + +
+ + + setActiveMenuId(isOpen ? itemId : null) + } + options={getActionOptions(name, true)} + anchorRef={menuRef} + position="bottom-left" + offset={{ x: 0, y: 8 }} + /> +
+ + + ); + }), + ]; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return
{error}
; + } + + if (!directoryStructure) { + return ( +
+ No directory structure available +
+ ); + } + + const currentDirectory = getCurrentDirectory(); + + return ( +
+
{renderPathNavigation()}
+ +
+ + + + + + + + + + + {renderFileTree(currentDirectory)} + +
+ Name + + Tokens + + Size + + Actions +
+
+
+ ); +}; + +export default FileTreeComponent;