import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'; import type { FileMap } from '../../lib/stores/files'; import { classNames } from '../../utils/classNames'; import { renderLogger } from '../../utils/logger'; const NODE_PADDING_LEFT = 12; const DEFAULT_HIDDEN_FILES = [/\/node_modules\//]; interface Props { files?: FileMap; selectedFile?: string; onFileSelect?: (filePath: string) => void; rootFolder?: string; hiddenFiles?: Array; unsavedFiles?: Set; className?: string; } export const FileTree = memo( ({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className, unsavedFiles }: Props) => { renderLogger.trace('FileTree'); const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]); const fileList = useMemo(() => { return buildFileList(files, rootFolder, computedHiddenFiles); }, [files, rootFolder, computedHiddenFiles]); const [collapsedFolders, setCollapsedFolders] = useState(() => new Set()); useEffect(() => { setCollapsedFolders((prevCollapsed) => { const newCollapsed = new Set(); for (const folder of fileList) { if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) { newCollapsed.add(folder.fullPath); } } return newCollapsed; }); }, [fileList]); const filteredFileList = useMemo(() => { const list = []; let lastDepth = Number.MAX_SAFE_INTEGER; for (const fileOrFolder of fileList) { const depth = fileOrFolder.depth; // if the depth is equal we reached the end of the collaped group if (lastDepth === depth) { lastDepth = Number.MAX_SAFE_INTEGER; } // ignore collapsed folders if (collapsedFolders.has(fileOrFolder.fullPath)) { lastDepth = Math.min(lastDepth, depth); } // ignore files and folders below the last collapsed folder if (lastDepth < depth) { continue; } list.push(fileOrFolder); } return list; }, [fileList, collapsedFolders]); const toggleCollapseState = (fullPath: string) => { setCollapsedFolders((prevSet) => { const newSet = new Set(prevSet); if (newSet.has(fullPath)) { newSet.delete(fullPath); } else { newSet.add(fullPath); } return newSet; }); }; return (
{filteredFileList.map((fileOrFolder) => { switch (fileOrFolder.kind) { case 'file': { return ( { onFileSelect?.(fileOrFolder.fullPath); }} /> ); } case 'folder': { return ( { toggleCollapseState(fileOrFolder.fullPath); }} /> ); } default: { return undefined; } } })}
); }, ); export default FileTree; interface FolderProps { folder: FolderNode; collapsed: boolean; onClick: () => void; } function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) { return ( {name} ); } interface FileProps { file: FileNode; selected: boolean; unsavedChanges?: boolean; onClick: () => void; } function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) { return (
{name}
{unsavedChanges && }
); } interface ButtonProps { depth: number; iconClasses: string; children: ReactNode; className?: string; onClick?: () => void; } function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) { return ( ); } type Node = FileNode | FolderNode; interface BaseNode { id: number; depth: number; name: string; fullPath: string; } interface FileNode extends BaseNode { kind: 'file'; } interface FolderNode extends BaseNode { kind: 'folder'; } function buildFileList(files: FileMap, rootFolder = '/', hiddenFiles: Array): Node[] { const folderPaths = new Set(); const fileList: Node[] = []; let defaultDepth = 0; if (rootFolder === '/') { defaultDepth = 1; fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' }); } for (const [filePath, dirent] of Object.entries(files)) { const segments = filePath.split('/').filter((segment) => segment); const fileName = segments.at(-1); if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) { continue; } let currentPath = ''; let i = 0; let depth = 0; while (i < segments.length) { const name = segments[i]; const fullPath = (currentPath += `/${name}`); if (!fullPath.startsWith(rootFolder)) { i++; continue; } if (i === segments.length - 1 && dirent?.type === 'file') { fileList.push({ kind: 'file', id: fileList.length, name, fullPath, depth: depth + defaultDepth, }); } else if (!folderPaths.has(fullPath)) { folderPaths.add(fullPath); fileList.push({ kind: 'folder', id: fileList.length, name, fullPath, depth: depth + defaultDepth, }); } i++; depth++; } } return fileList; } function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array) { return hiddenFiles.some((pathOrRegex) => { if (typeof pathOrRegex === 'string') { return fileName === pathOrRegex; } return pathOrRegex.test(filePath); }); }