import React, { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { formatBytes } from '../utils/stringUtils'; import { selectToken } from '../preferences/preferenceSlice'; import Chunks from './Chunks'; 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 SyncIcon from '../assets/sync.svg'; import { useOutsideAlerter } from '../hooks'; interface FileNode { type?: string; token_count?: number; size_bytes?: number; [key: string]: any; } interface DirectoryStructure { [key: string]: FileNode; } interface ConnectorTreeComponentProps { docId: string; sourceName: string; onBackToDocuments: () => void; } interface SearchResult { name: string; path: string; isFile: boolean; } const ConnectorTreeComponent: 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(selectToken); 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); const [isSyncing, setIsSyncing] = useState(false); const [syncProgress, setSyncProgress] = useState(0); const [sourceProvider, setSourceProvider] = useState(''); const [syncDone, setSyncDone] = useState(false); useOutsideAlerter( searchDropdownRef, () => { setSearchQuery(''); setSearchResults([]); }, [], false, ); const handleFileClick = (fileName: string) => { const fullPath = [...currentPath, fileName].join('/'); setSelectedFile({ id: fullPath, name: fileName, }); }; const handleSync = async () => { if (isSyncing) return; const provider = sourceProvider; setIsSyncing(true); setSyncProgress(0); try { const response = await userService.syncConnector(docId, provider, token); const data = await response.json(); if (data.success) { console.log('Sync started successfully:', data.task_id); setSyncProgress(10); // Poll task status using userService const maxAttempts = 30; const pollInterval = 2000; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const statusResponse = await userService.getTaskStatus( data.task_id, token, ); const statusData = await statusResponse.json(); console.log( `Task status (attempt ${attempt + 1}):`, statusData.status, ); if (statusData.status === 'SUCCESS') { setSyncProgress(100); console.log('Sync completed successfully'); // Refresh directory structure try { const refreshResponse = await userService.getDirectoryStructure( docId, token, ); const refreshData = await refreshResponse.json(); if (refreshData && refreshData.directory_structure) { setDirectoryStructure(refreshData.directory_structure); setCurrentPath([]); } if (refreshData && refreshData.provider) { setSourceProvider(refreshData.provider); } setSyncDone(true); setTimeout(() => setSyncDone(false), 5000); } catch (err) { console.error('Error refreshing directory structure:', err); } break; } else if (statusData.status === 'FAILURE') { console.error('Sync task failed:', statusData.result); break; } else if (statusData.status === 'PROGRESS') { const progress = Number( statusData.result && statusData.result.current != null ? statusData.result.current : statusData.meta && statusData.meta.current != null ? statusData.meta.current : 0, ); setSyncProgress(Math.max(10, progress)); } await new Promise((resolve) => setTimeout(resolve, pollInterval)); } catch (error) { console.error('Error polling task status:', error); break; } } } else { console.error('Sync failed:', data.error); } } catch (err) { console.error('Error syncing connector:', err); } finally { setIsSyncing(false); setSyncProgress(0); } }; useEffect(() => { const fetchDirectoryStructure = async () => { try { setLoading(true); const directoryResponse = await userService.getDirectoryStructure( docId, token, ); const directoryData = await directoryResponse.json(); if (directoryData && directoryData.directory_structure) { setDirectoryStructure(directoryData.directory_structure); } else { setError('Invalid response format'); } if (directoryData && directoryData.provider) { setSourceProvider(directoryData.provider); } } catch (err) { setError('Failed to load source information'); console.error(err); } finally { setLoading(false); } }; if (docId) { fetchDirectoryStructure(); } }, [docId, token]); const navigateToDirectory = (dirName: string) => { setCurrentPath([...currentPath, dirName]); }; const navigateUp = () => { setCurrentPath(currentPath.slice(0, -1)); }; const getCurrentDirectory = (): DirectoryStructure => { if (!directoryStructure) return {}; let current = directoryStructure; for (const dir of currentPath) { if (current[dir] && !current[dir].type) { current = current[dir] as DirectoryStructure; } else { return {}; } } return current; }; const getMenuRef = (id: string) => { if (!menuRefs.current[id]) { menuRefs.current[id] = React.createRef(); } return menuRefs.current[id]; }; const handleMenuClick = ( e: React.MouseEvent, id: string, ) => { e.stopPropagation(); setActiveMenuId(activeMenuId === id ? null : id); }; const getActionOptions = ( name: string, isFile: boolean, _itemId: string, ): MenuOption[] => { const options: MenuOption[] = []; options.push({ icon: EyeView, label: t('settings.sources.view'), onClick: (event: React.SyntheticEvent) => { event.stopPropagation(); if (isFile) { handleFileClick(name); } else { navigateToDirectory(name); } }, iconWidth: 18, iconHeight: 18, variant: 'primary', }); return options; }; 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 handleBackNavigation = () => { if (selectedFile) { setSelectedFile(null); } else if (currentPath.length === 0) { if (onBackToDocuments) { onBackToDocuments(); } } else { navigateUp(); } }; const renderPathNavigation = () => { return (
{/* Left side with path navigation */}
{sourceName} {currentPath.length > 0 && ( <> / {currentPath.map((dir, index) => ( {dir} {index < currentPath.length - 1 && ( / )} ))} )}
{renderFileSearch()} {/* Sync button */}
); }; const renderFileTree = (directory: DirectoryStructure) => { if (!directory) return []; // Create parent directory row const parentRow = currentPath.length > 0 ? [
{t('settings.sources.parentFolderAlt')} ..
- - , ] : []; // Sort entries: directories first, then files, both alphabetically const sortedEntries = Object.entries(directory).sort( ([nameA, nodeA], [nameB, nodeB]) => { const isFileA = !!nodeA.type; const isFileB = !!nodeB.type; if (isFileA !== isFileB) { return isFileA ? 1 : -1; // Directories first } return nameA.localeCompare(nameB); // Alphabetical within each group }, ); // Process directories const directoryRows = sortedEntries .filter(([_, node]) => !node.type) .map(([name, node]) => { const itemId = `dir-${name}`; const menuRef = getMenuRef(itemId); // Calculate directory stats const dirStats = calculateDirectoryStats(node as DirectoryStructure); return ( navigateToDirectory(name)} >
{t('settings.sources.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: -4, y: 4 }} />
); }); // Process files const fileRows = sortedEntries .filter(([_, node]) => !!node.type) .map(([name, node]) => { const itemId = `file-${name}`; const menuRef = getMenuRef(itemId); return ( handleFileClick(name)} >
{t('settings.sources.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: -4, y: 4 }} />
); }); return [...parentRow, ...directoryRows, ...fileRows]; }; 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.sources.searchFiles')} className={`h-[38px] w-full border border-[#D1D9E0] px-4 py-2 dark:border-[#6A6A6A] ${searchQuery ? 'rounded-t-[24px]' : 'rounded-[24px]'} bg-transparent focus:outline-none dark:text-[#E0E0E0]`} /> {searchQuery && (
{searchResults.length === 0 ? (
{t('settings.sources.noResults')}
) : ( searchResults.map((result, index) => (
handleSearchSelect(result)} title={result.path} className={`flex min-w-0 cursor-pointer items-center px-3 py-2 hover:bg-[#ECEEEF] dark:hover:bg-[#27282D] ${ index !== searchResults.length - 1 ? 'border-b border-[#D1D9E0] dark:border-[#6A6A6A]' : '' }`} > { {result.path.split('/').pop() || result.path}
)) )}
)}
); }; const handleFileSearch = (searchQuery: string) => { if (directoryStructure) { return searchFiles(searchQuery, directoryStructure); } return []; }; const handleFileSelect = (path: string) => { const pathParts = path.split('/'); const fileName = pathParts.pop() || ''; setCurrentPath(pathParts); setSelectedFile({ id: path, name: fileName, }); }; const currentDirectory = getCurrentDirectory(); const navigateToPath = (index: number) => { setCurrentPath(currentPath.slice(0, index + 1)); }; return (
{selectedFile ? (
setSelectedFile(null)} path={selectedFile.id} onFileSearch={handleFileSearch} onFileSelect={handleFileSelect} />
) : (
{renderPathNavigation()}
{renderFileTree(getCurrentDirectory())}
{t('settings.sources.fileName')} {t('settings.sources.tokens')} {t('settings.sources.size')}
)}
); }; export default ConnectorTreeComponent;