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 */}
);
}
}