import React, { PureComponent } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import InputBase from '@mui/material/InputBase'; import CircularProgress from '@mui/material/CircularProgress'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import SearchRoundedIcon from '@mui/icons-material/SearchRounded'; import ClearRoundedIcon from '@mui/icons-material/ClearRounded'; import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded'; import CloudUploadRoundedIcon from '@mui/icons-material/CloudUploadRounded'; import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; import EditRoundedIcon from '@mui/icons-material/EditRounded'; import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded'; import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'; import FolderRoundedIcon from '@mui/icons-material/FolderRounded'; import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded'; import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; import VfsFileIcon from './VfsFileIcon'; import { formatVfsSize, formatVfsDate } from './vfsUtils'; /** * Returns all ancestor directory paths for a file or folder path. * e.g. "docs/api/v1/spec.json" -> ["docs", "docs/api", "docs/api/v1"] */ function getParentFolderPaths(filePath) { if (!filePath) return []; const clean = String(filePath).replace(/^\/+/, '').replace(/\/+$/, ''); const parts = clean.split('/').filter(Boolean); const paths = []; for (let i = 0; i < parts.length - 1; i++) { paths.push(parts.slice(0, i + 1).join('/')); } return paths; } /** * Builds a hierarchical tree node structure from a flat list of VfsFiles. * In every node, folders and files are separated and can be rendered recursively. */ function buildFileTree(files, searchQuery = '') { const q = searchQuery ? searchQuery.trim().toLowerCase() : ''; const filtered = (files || []).filter((f) => { if (!q) return true; return f.name.toLowerCase().includes(q) || (f.mime && f.mime.toLowerCase().includes(q)); }); const root = { name: '', path: '', isDirectory: true, folders: new Map(), files: [], }; for (const file of filtered) { const cleanPath = String(file.name || '').replace(/^\/+/, '').replace(/\/+$/, ''); if (!cleanPath) continue; const parts = cleanPath.split('/').filter(Boolean); let current = root; for (let i = 0; i < parts.length - 1; i++) { const folderName = parts[i]; const folderPath = parts.slice(0, i + 1).join('/'); if (!current.folders.has(folderName)) { current.folders.set(folderName, { name: folderName, path: folderPath, isDirectory: true, folders: new Map(), files: [], }); } current = current.folders.get(folderName); } const fileName = parts[parts.length - 1]; current.files.push({ name: fileName, path: cleanPath, isDirectory: false, fileData: file, }); } // Calculate recursive file counts function countFiles(node) { let count = node.files.length; for (const folder of node.folders.values()) { count += countFiles(folder); } node.totalFileCount = count; return count; } countFiles(root); return { root, totalCount: filtered.length }; } export default class VfsFileExplorer extends PureComponent { constructor(props) { super(props); const initialExpanded = new Set(); if (props.selectedFileName) { for (const p of getParentFolderPaths(props.selectedFileName)) { initialExpanded.add(p); } } this.state = { menuAnchor: null, menuFile: null, deleteConfirmFile: null, isDraggingOver: false, expandedFolders: initialExpanded, }; this.fileInputRef = React.createRef(); } componentDidUpdate(prevProps) { if (this.props.selectedFileName && this.props.selectedFileName !== prevProps.selectedFileName) { const parents = getParentFolderPaths(this.props.selectedFileName); if (parents.length > 0) { this.setState((prev) => { const next = new Set(prev.expandedFolders); let changed = false; for (const p of parents) { if (!next.has(p)) { next.add(p); changed = true; } } return changed ? { expandedFolders: next } : null; }); } } } toggleFolder = (folderPath) => { this.setState((prev) => { const next = new Set(prev.expandedFolders); if (next.has(folderPath)) { next.delete(folderPath); } else { next.add(folderPath); } return { expandedFolders: next }; }); }; handleOpenMenu = (e, file) => { e.stopPropagation(); e.preventDefault(); this.setState({ menuAnchor: e.currentTarget, menuFile: file }); }; handleCloseMenu = () => { this.setState({ menuAnchor: null, menuFile: null }); }; handleTriggerUpload = () => { this.fileInputRef.current?.click(); }; handleFileInputChange = (e) => { const files = e.target?.files; if (files && files.length > 0) { this.props.onUpload(Array.from(files)); } if (e.target) e.target.value = ''; }; handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer?.types?.includes('Files')) { this.setState({ isDraggingOver: true }); } }; handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!this.state.isDraggingOver && e.dataTransfer?.types?.includes('Files')) { this.setState({ isDraggingOver: true }); } }; handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget.contains(e.relatedTarget)) return; this.setState({ isDraggingOver: false }); }; handleDrop = (e) => { e.preventDefault(); e.stopPropagation(); this.setState({ isDraggingOver: false }); const files = e.dataTransfer?.files; if (files && files.length > 0) { this.props.onUpload(Array.from(files)); } }; handleCopyPath = (file) => { if (!file) return; navigator.clipboard?.writeText?.(file.name); this.handleCloseMenu(); }; handleMentionInChat = (file) => { if (!file) return; if (this.props.onMentionInChat) { this.props.onMentionInChat(file); } this.handleCloseMenu(); }; renderFolderNode = (folder, depth) => { const { searchQuery } = this.props; const isSearching = Boolean(searchQuery && searchQuery.trim()); const isExpanded = isSearching || this.state.expandedFolders.has(folder.path); const subfolders = Array.from(folder.folders.values()).sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) ); const files = [...folder.files].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) ); return ( {/* Folder Header Row */} this.toggleFolder(folder.path)} sx={{ display: 'flex', alignItems: 'center', py: 0.5, px: 1, pl: `${depth * 14 + 6}px`, borderRadius: 1.5, cursor: 'pointer', userSelect: 'none', transition: 'background-color 0.1s', '&:hover': { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', }, }} > {/* Chevron */} {isExpanded ? ( ) : ( )} {/* Folder Icon */} {isExpanded ? ( ) : ( )} {/* Folder Name */} {folder.name} {/* Total Files Count in Folder */} {folder.totalFileCount} {/* Folder Children (Directories FIRST, then files) */} {isExpanded && ( {/* 1. Subdirectories FIRST */} {subfolders.map((sub) => this.renderFolderNode(sub, depth + 1))} {/* 2. Files NEXT */} {files.map((file) => this.renderFileNode(file, depth + 1))} )} ); }; renderFileNode = (file, depth) => { const { selectedFileName, onSelectFile } = this.props; const isSelected = file.path === selectedFileName; return ( onSelectFile(file.path)} sx={(theme) => ({ display: 'flex', alignItems: 'center', py: 0.5, px: 1, pl: `${depth * 14 + 26}px`, borderRadius: 1.5, cursor: 'pointer', userSelect: 'none', bgcolor: isSelected ? theme.palette.mode === 'dark' ? 'primary.dark' : 'action.selected' : 'transparent', border: '1px solid', borderColor: isSelected ? 'primary.main' : 'transparent', transition: 'background-color 0.1s, border-color 0.1s', '&:hover': { bgcolor: isSelected ? theme.palette.mode === 'dark' ? 'primary.dark' : 'action.selected' : 'action.hover', }, })} > {file.name} {formatVfsSize(file.fileData.size)} {file.fileData.updated_at && ( {formatVfsDate(file.fileData.updated_at)} )} {/* Quick action buttons on hover / select */} e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', opacity: isSelected ? 1 : 0.7, '&:hover': { opacity: 1 }, }} > this.handleOpenMenu(e, file.fileData)} aria-label="Weitere Optionen" sx={{ p: 0.5 }} > ); }; renderTree() { const { files, searchQuery } = this.props; const { root, totalCount } = buildFileTree(files, searchQuery); if (totalCount === 0) { return ( {searchQuery ? `Keine Dateien für "${searchQuery}" gefunden` : 'Keine Dateien im VFS vorhanden'} {!searchQuery && ( Klicke oben auf Upload oder erstelle eine neue Datei. )} ); } const rootFolders = Array.from(root.folders.values()).sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) ); const rootFiles = [...root.files].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) ); return ( {/* Top-level directories FIRST */} {rootFolders.map((folder) => this.renderFolderNode(folder, 0))} {/* Top-level files NEXT */} {rootFiles.map((file) => this.renderFileNode(file, 0))} ); } render() { const { loading, onOpenNewDialog, searchQuery, onSearchChange, } = this.props; const { menuAnchor, menuFile, isDraggingOver } = this.state; const isText = menuFile?.isText; return ( {/* Hidden Upload Input */} {/* Drag & Drop Overlay */} {isDraggingOver && ( Dateien im VFS ablegen Die Dateien werden sofort auf den Server hochgeladen )} {/* Explorer Header / Actions Row */} {/* Search Filter Bar */} ({ flexGrow: 1, display: 'flex', alignItems: 'center', px: 1, py: 0.25, borderRadius: 2, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', border: '1px solid transparent', '&:focus-within': { borderColor: 'primary.main', bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.09)' : '#fff', }, })} > onSearchChange(e.target.value)} sx={{ fontSize: '0.82rem' }} /> {loading && } {searchQuery && ( onSearchChange('')} sx={{ p: 0.2 }}> )} {/* Upload Icon Button (no word) */} {/* New File Icon Button (no word) */} {/* Scrollable File List */} {this.renderTree()} {/* Context Menu */} {isText && ( { if (menuFile) this.props.onSelectFile(menuFile.name); this.handleCloseMenu(); }} > )} {!isText && ( { if (menuFile) this.props.onSelectFile(menuFile.name); this.handleCloseMenu(); }} > )} { if (menuFile) this.props.onDownload(menuFile.name); this.handleCloseMenu(); }} > this.handleCopyPath(menuFile)}> {this.props.onMentionInChat && ( this.handleMentionInChat(menuFile)}> )} { if (menuFile && window.confirm(`Möchtest du "${menuFile.name}" wirklich aus dem VFS löschen?`)) { this.props.onDelete(menuFile.name); } this.handleCloseMenu(); }} sx={{ color: 'error.main' }} > ); } }