/** * FolderTree – Shared recursive folder/file tree component. * * Used on the Files page and in the Workspace chat. * Supports: * - Alphabetical sorting per level (folders first, then files) * - Multi-selection (CTRL+click, SHIFT+click) with visual highlight * - Batch drag-and-drop for selected items * - Inline CRUD icons for folders * - showFiles mode renders files inline under their parent folder * - Drag-out: sets application/tree-items on dataTransfer for external drop targets */ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa'; import { usePrompt, type PromptOptions } from '../../hooks/usePrompt'; import styles from './FolderTree.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; /* ── Public types ──────────────────────────────────────────────────────── */ export interface FolderNode { id: string; name: string; parentId: string | null; children?: FolderNode[]; } export interface FileNode { id: string; fileName: string; mimeType?: string; fileSize?: number; folderId?: string | null; scope?: string; neutralize?: boolean; } export interface TreeItem { id: string; type: 'file' | 'folder'; name: string; } export interface FolderTreeProps { folders: FolderNode[]; files?: FileNode[]; showFiles?: boolean; selectedFolderId: string | null; onSelect: (folderId: string | null) => void; onFileSelect?: (fileId: string) => void; selectedItemIds?: Set; onSelectionChange?: (selectedIds: Set) => void; expandedIds?: Set; onToggleExpand?: (id: string) => void; onRefresh?: () => void; onCreateFolder?: (name: string, parentId: string | null) => Promise; onRenameFolder?: (folderId: string, newName: string) => Promise; onDeleteFolder?: (folderId: string) => Promise; onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; onRenameFile?: (fileId: string, newName: string) => Promise; onDeleteFile?: (fileId: string) => Promise; onDeleteFiles?: (fileIds: string[]) => Promise; onDeleteFolders?: (folderIds: string[]) => Promise; onDownloadFolder?: (folderId: string, folderName: string) => Promise; onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; } /* ── Helpers ───────────────────────────────────────────────────────────── */ function _buildTree(folders: FolderNode[]): FolderNode[] { const map = new Map(); const roots: FolderNode[] = []; for (const f of folders) map.set(f.id, { ...f, children: [] }); for (const f of folders) { const node = map.get(f.id)!; if (f.parentId && map.has(f.parentId)) { map.get(f.parentId)!.children!.push(node); } else { roots.push(node); } } const _sortLevel = (nodes: FolderNode[]) => { nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); for (const n of nodes) { if (n.children && n.children.length > 0) _sortLevel(n.children); } }; _sortLevel(roots); return roots; } function _groupFilesByFolder(files: FileNode[]): Map { const map = new Map(); for (const f of files) { const key = f.folderId || ''; if (!map.has(key)) map.set(key, []); map.get(key)!.push(f); } for (const [, arr] of map) { arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' })); } return map; } function _computeFlatList( tree: FolderNode[], expandedIds: Set, showFiles: boolean, filesByFolder: Map, ): TreeItem[] { const result: TreeItem[] = []; const _walk = (nodes: FolderNode[]) => { for (const node of nodes) { result.push({ id: node.id, type: 'folder', name: node.name }); if (expandedIds.has(node.id)) { if (node.children) _walk(node.children); if (showFiles) { for (const f of (filesByFolder.get(node.id) || [])) { result.push({ id: f.id, type: 'file', name: f.fileName }); } } } } }; _walk(tree); if (showFiles) { for (const f of (filesByFolder.get('') || [])) { result.push({ id: f.id, type: 'file', name: f.fileName }); } } return result; } function _fileIcon(mime?: string): string { if (!mime) return '\uD83D\uDCC4'; if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; if (mime.includes('pdf')) return '\uD83D\uDCD5'; if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8'; if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA'; if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9'; if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6'; if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD'; if (mime.startsWith('audio/')) return '\uD83C\uDFB5'; if (mime.startsWith('video/')) return '\uD83C\uDFA5'; return '\uD83D\uDCC4'; } /* ── Selection context threaded through the tree ──────────────────────── */ const _SCOPE_ICONS: Record = { personal: '\uD83D\uDC64', featureInstance: '\uD83D\uDC65', mandate: '\uD83C\uDFE2', global: '\uD83C\uDF10', }; const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate']; const _SCOPE_LABELS: Record = { personal: 'Persönlich', featureInstance: 'Instanz', mandate: 'Mandant', global: 'Global', }; interface SelectionCtx { selectedItemIds: Set; selectedFileIds: string[]; selectedFolderIds: string[]; onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void; onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void; onRenameFile?: (fileId: string, newName: string) => Promise; onDeleteFile?: (fileId: string) => Promise; onDeleteFiles?: (fileIds: string[]) => Promise; onDeleteFolders?: (folderIds: string[]) => Promise; onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; } /* ── File node (leaf) ─────────────────────────────────────────────────── */ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { const { t } = useLanguage(); const [dragging, setDragging] = useState(false); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(''); const isSelected = sel.selectedItemIds.has(file.id); const multiSelected = sel.selectedItemIds.size > 1; const _handleRename = useCallback(async () => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== file.fileName && sel.onRenameFile) { await sel.onRenameFile(file.id, trimmed); } setRenaming(false); }, [renameValue, file.id, file.fileName, sel.onRenameFile]); const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) { await sel.onDeleteFiles(sel.selectedFileIds); } }, [sel]); const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) { await sel.onDeleteFolders(sel.selectedFolderIds); } }, [sel]); const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (sel.onDeleteFile) await sel.onDeleteFile(file.id); }, [file.id, sel]); return (
sel.onItemClick(file.id, 'file', e)} draggable onDragStart={(e) => { sel.onItemDragStart(e, file.id, 'file', file.fileName); setDragging(true); }} onDragEnd={() => setDragging(false)} > {_fileIcon(file.mimeType)} {renaming ? ( setRenameValue(e.target.value)} onBlur={_handleRename} onKeyDown={(e) => { if (e.key === 'Enter') _handleRename(); if (e.key === 'Escape') setRenaming(false); }} onClick={(e) => e.stopPropagation()} /> ) : ( {file.fileName} )} {!renaming && ( {sel.onRenameFile && !multiSelected && ( )} {multiSelected && isSelected ? ( <> {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( )} {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( )} ) : ( (sel.onDeleteFile || sel.onDeleteFiles) && ( ) )} {file.fileSize != null && ( {(file.fileSize / 1024).toFixed(0)}K )} {file.scope != null && ( )} )}
); } /* ── Tree node (folder) ───────────────────────────────────────────────── */ interface TreeNodeProps { node: FolderNode; depth: number; selectedFolderId: string | null; expandedIds: Set; showFiles: boolean; filesByFolder: Map; sel: SelectionCtx; promptFolderName: (message: string, options?: PromptOptions) => Promise; onToggle: (id: string) => void; onSelect: (id: string | null) => void; onCreateFolder?: (name: string, parentId: string | null) => Promise; onRenameFolder?: (folderId: string, newName: string) => Promise; onDeleteFolder?: (folderId: string) => Promise; onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; onDownloadFolder?: (folderId: string, folderName: string) => Promise; } function _TreeNode({ node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel, promptFolderName, onToggle, onSelect, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onDownloadFolder, }: TreeNodeProps) { const { t } = useLanguage(); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(node.name); const [dropOver, setDropOver] = useState(false); const [dragging, setDragging] = useState(false); const inputRef = useRef(null); const isExpanded = expandedIds.has(node.id); const isNavSelected = selectedFolderId === node.id; const isMultiSelected = sel.selectedItemIds.has(node.id); const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : []; const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0; useEffect(() => { if (renaming && inputRef.current) inputRef.current.focus(); }, [renaming]); const _handleRename = useCallback(async () => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== node.name && onRenameFolder) { await onRenameFolder(node.id, trimmed); } setRenaming(false); }, [renameValue, node.id, node.name, onRenameFolder]); const _handleAdd = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (!onCreateFolder) return; const name = await promptFolderName('Neuer Ordnername:', { title: t('Neuer Ordner'), placeholder: 'Ordnername' }); if (name?.trim()) { await onCreateFolder(name.trim(), node.id); if (!expandedIds.has(node.id)) onToggle(node.id); } }, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]); const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (onDeleteFolder) await onDeleteFolder(node.id); }, [onDeleteFolder, node.id]); const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) { await sel.onDeleteFolders(sel.selectedFolderIds); } }, [sel]); const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) { await sel.onDeleteFiles(sel.selectedFileIds); } }, [sel]); const _handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDropOver(true); }, []); const _handleDragLeave = useCallback(() => setDropOver(false), []); const _handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); setDropOver(false); const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson) { const items: TreeItem[] = JSON.parse(treeItemsJson); const fileIds = items.filter(i => i.type === 'file').map(i => i.id); const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id); if (folderIds.length > 0 && onMoveFolders) { await onMoveFolders(folderIds, node.id); } else if (onMoveFolder) { for (const fId of folderIds) await onMoveFolder(fId, node.id); } if (fileIds.length > 0 && onMoveFiles) { await onMoveFiles(fileIds, node.id); } else if (fileIds.length > 0 && onMoveFile) { for (const fId of fileIds) await onMoveFile(fId, node.id); } return; } const folderId = e.dataTransfer.getData('application/folder-id'); const fileIdsJson = e.dataTransfer.getData('application/file-ids'); const fileId = e.dataTransfer.getData('application/file-id'); if (folderId && folderId !== node.id && onMoveFolder) { await onMoveFolder(folderId, node.id); } else if (fileIdsJson && onMoveFiles) { await onMoveFiles(JSON.parse(fileIdsJson), node.id); } else if (fileId && onMoveFile) { await onMoveFile(fileId, node.id); } }, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]); const nodeClasses = [ styles.treeNode, isNavSelected && !isMultiSelected ? styles.selected : '', isMultiSelected ? styles.multiSelected : '', dropOver ? styles.dropTarget : '', dragging ? styles.dragging : '', ].filter(Boolean).join(' '); return (
sel.onItemClick(node.id, 'folder', e)} draggable onDragStart={(e) => { sel.onItemDragStart(e, node.id, 'folder', node.name); setDragging(true); }} onDragEnd={() => setDragging(false)} onDragOver={_handleDragOver} onDragLeave={_handleDragLeave} onDrop={_handleDrop} > { e.stopPropagation(); if (hasChildren) onToggle(node.id); }} > {isExpanded ? : } {renaming ? ( setRenameValue(e.target.value)} onBlur={_handleRename} onKeyDown={(e) => { if (e.key === 'Enter') _handleRename(); if (e.key === 'Escape') setRenaming(false); }} onClick={(e) => e.stopPropagation()} /> ) : ( {node.name} )} {onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( )} {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( )} {onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( )} {isMultiSelected && sel.selectedItemIds.size > 1 ? ( <> {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( )} {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( )} ) : onDeleteFolder && ( )}
{isExpanded && hasChildren && (
{node.children!.map((child) => ( <_TreeNode key={child.id} node={child} depth={depth + 1} selectedFolderId={selectedFolderId} expandedIds={expandedIds} showFiles={showFiles} filesByFolder={filesByFolder} sel={sel} promptFolderName={promptFolderName} onToggle={onToggle} onSelect={onSelect} onCreateFolder={onCreateFolder} onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} onMoveFolder={onMoveFolder} onMoveFolders={onMoveFolders} onMoveFile={onMoveFile} onMoveFiles={onMoveFiles} onDownloadFolder={onDownloadFolder} /> ))} {folderFiles.map((file) => ( <_FileItem key={file.id} file={file} sel={sel} /> ))}
)}
); } /* ── Root component ────────────────────────────────────────────────────── */ export default function FolderTree({ folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect, selectedItemIds: externalSelectedIds, onSelectionChange, expandedIds: externalExpandedIds, onToggleExpand, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, onScopeChange, onNeutralizeToggle, }: FolderTreeProps) { const { t } = useLanguage(); const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); const [rootDropOver, setRootDropOver] = useState(false); const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); const lastClickedIdRef = useRef(null); const { prompt: promptFolderName, PromptDialog } = usePrompt(); const expandedIds = externalExpandedIds ?? internalExpandedIds; const tree = useMemo(() => _buildTree(folders), [folders]); const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]); const rootFiles = showFiles ? (filesByFolder.get('') || []) : []; const selectedItemIds = externalSelectedIds ?? internalSelectedIds; const flatList = useMemo( () => _computeFlatList(tree, expandedIds, showFiles, filesByFolder), [tree, expandedIds, showFiles, filesByFolder], ); const _handleToggle = useCallback((id: string) => { if (onToggleExpand) { onToggleExpand(id); return; } setInternalExpandedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const _setSelection = useCallback((ids: Set) => { if (onSelectionChange) { onSelectionChange(ids); } else { setInternalSelectedIds(ids); } }, [onSelectionChange]); const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { const next = new Set(selectedItemIds); if (next.has(id)) next.delete(id); else next.add(id); _setSelection(next); lastClickedIdRef.current = id; return; } if (e.shiftKey && lastClickedIdRef.current) { const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current); const currIdx = flatList.findIndex(i => i.id === id); if (lastIdx >= 0 && currIdx >= 0) { const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx]; const next = new Set(selectedItemIds); for (let i = from; i <= to; i++) next.add(flatList[i].id); _setSelection(next); } return; } _setSelection(new Set([id])); lastClickedIdRef.current = id; if (type === 'folder') onSelect(id); if (type === 'file') onFileSelect?.(id); }, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]); const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => { const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1; if (isInSelection) { const items: TreeItem[] = []; for (const selId of selectedItemIds) { const item = flatList.find(i => i.id === selId); if (item) items.push(item); } e.dataTransfer.setData('application/tree-items', JSON.stringify(items)); const fileIds = items.filter(i => i.type === 'file').map(i => i.id); if (fileIds.length > 0) { e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds)); } } else { e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }])); if (type === 'file') { e.dataTransfer.setData('application/file-id', id); } else { e.dataTransfer.setData('application/folder-id', id); } } e.dataTransfer.effectAllowed = 'copyMove'; }, [selectedItemIds, flatList]); const allFileIds = useMemo(() => { const ids = new Set(); for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id); return ids; }, [filesByFolder]); const allFolderIds = useMemo(() => { const ids = new Set(); const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } }; _collect(tree); return ids; }, [tree]); const sel: SelectionCtx = useMemo(() => { const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); return { selectedItemIds, selectedFileIds: selFileIds, selectedFolderIds: selFolderIds, onItemClick: _handleItemClick, onItemDragStart: _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, }; }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]); const _handleRootDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); setRootDropOver(false); const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson) { const items: TreeItem[] = JSON.parse(treeItemsJson); const fileIds = items.filter(i => i.type === 'file').map(i => i.id); const folderIds = items.filter(i => i.type === 'folder').map(i => i.id); if (folderIds.length > 0 && onMoveFolders) { await onMoveFolders(folderIds, null); } else if (onMoveFolder) { for (const fId of folderIds) await onMoveFolder(fId, null); } if (fileIds.length > 0 && onMoveFiles) { await onMoveFiles(fileIds, null); } else if (fileIds.length > 0 && onMoveFile) { for (const fId of fileIds) await onMoveFile(fId, null); } return; } const folderId = e.dataTransfer.getData('application/folder-id'); const fileIdsJson = e.dataTransfer.getData('application/file-ids'); const fileId = e.dataTransfer.getData('application/file-id'); if (folderId && onMoveFolder) { await onMoveFolder(folderId, null); } else if (fileIdsJson && onMoveFiles) { await onMoveFiles(JSON.parse(fileIdsJson), null); } else if (fileId && onMoveFile) { await onMoveFile(fileId, null); } }, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]); const rootClasses = [ styles.treeNode, selectedFolderId === null ? styles.selected : '', rootDropOver ? styles.dropTarget : '', ].filter(Boolean).join(' '); return (
{ onSelect(null); _setSelection(new Set()); }} onDragOver={(e) => { e.preventDefault(); setRootDropOver(true); }} onDragLeave={() => setRootDropOver(false)} onDrop={_handleRootDrop} > (Global) {onRefresh && ( )} {onCreateFolder && ( )}
{tree.map((node) => ( <_TreeNode key={node.id} node={node} depth={1} selectedFolderId={selectedFolderId} expandedIds={expandedIds} showFiles={showFiles} filesByFolder={filesByFolder} sel={sel} promptFolderName={promptFolderName} onToggle={_handleToggle} onSelect={onSelect} onCreateFolder={onCreateFolder} onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} onMoveFolder={onMoveFolder} onMoveFolders={onMoveFolders} onMoveFile={onMoveFile} onMoveFiles={onMoveFiles} onDownloadFolder={onDownloadFolder} /> ))} {rootFiles.map((file) => ( <_FileItem key={file.id} file={file} sel={sel} /> ))}
); }