/** * FileTree Component * * Adapted from bolt.new by StackBlitz * Copyright (c) 2024 StackBlitz, Inc. * Licensed under the MIT License * * Original source: https://github.com/stackblitz/bolt.new */ import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react"; import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { cn } from "~/lib/utils"; const NODE_PADDING_LEFT = 12; interface FileEntry { name: string; path: string; type: "file" | "directory"; size?: number; modifiedAt?: number; } interface Props { files?: FileEntry[]; selectedFile?: string; onFileSelect?: (filePath: string) => void; onFolderExpand?: (folderPath: string) => void; onFolderHover?: (folderPath: string) => void; expandedFolders?: Set; loadingFolders?: Set; className?: string; } export const FileTree = memo((props: Props) => { const { files = [], onFileSelect, selectedFile, onFolderExpand, onFolderHover, expandedFolders = new Set(), loadingFolders = new Set(), className, } = props; const fileList = useMemo(() => { return buildFileList(files); }, [files]); const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); 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 collapsed 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 = useCallback( (fullPath: string) => { setCollapsedFolders((prevSet) => { const newSet = new Set(prevSet); if (newSet.has(fullPath)) { newSet.delete(fullPath); onFolderExpand?.(fullPath); } else { newSet.add(fullPath); } return newSet; }); }, [onFolderExpand], ); // Add new folders to collapsed set when file list changes useEffect(() => { setCollapsedFolders((prevSet) => { const newSet = new Set(prevSet); for (const item of fileList) { if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) { newSet.add(item.fullPath); } } return newSet; }); }, [fileList, expandedFolders]); const handleFileSelect = useCallback( (filePath: string) => { onFileSelect?.(filePath); }, [onFileSelect], ); return (
{filteredFileList.map((fileOrFolder) => { switch (fileOrFolder.kind) { case "file": { return ( ); } case "folder": { return ( ); } default: { return undefined; } } })}
); }); interface FolderProps { folder: FolderNode; collapsed: boolean; loading?: boolean; onToggle: (fullPath: string) => void; onHover?: (fullPath: string) => void; } const Folder = memo(({ folder, collapsed, loading, onToggle, onHover }: FolderProps) => { const { depth, name, fullPath } = folder; const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; const handleClick = useCallback(() => { onToggle(fullPath); }, [onToggle, fullPath]); const handleMouseEnter = useCallback(() => { if (collapsed) { onHover?.(fullPath); } }, [onHover, fullPath, collapsed]); return ( ) : collapsed ? ( ) : ( ) } onClick={handleClick} onMouseEnter={handleMouseEnter} > {name} ); }); interface FileProps { file: FileNode; selected: boolean; onFileSelect: (filePath: string) => void; } const File = memo(({ file, onFileSelect, selected }: FileProps) => { const { depth, name, fullPath } = file; const handleClick = useCallback(() => { onFileSelect(fullPath); }, [onFileSelect, fullPath]); return ( } onClick={handleClick} > {name} ); }); interface ButtonProps { depth: number; icon: ReactNode; children: ReactNode; className?: string; onClick?: () => void; onMouseEnter?: () => void; } const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => { const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]); 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: FileEntry[]): Node[] { const fileMap = new Map(); for (const file of files) { const segments = file.path.split("/").filter((segment) => segment); const depth = segments.length - 1; const name = segments[segments.length - 1]; if (!fileMap.has(file.path)) { fileMap.set(file.path, { kind: file.type === "file" ? "file" : "folder", id: fileMap.size, name, fullPath: file.path, depth, }); } } // Convert map to array and sort return sortFileList(Array.from(fileMap.values())); } function sortFileList(nodeList: Node[]): Node[] { const nodeMap = new Map(); const childrenMap = new Map(); // Pre-sort nodes by name and type nodeList.sort((a, b) => compareNodes(a, b)); for (const node of nodeList) { nodeMap.set(node.fullPath, node); const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/"; if (parentPath !== "/") { if (!childrenMap.has(parentPath)) { childrenMap.set(parentPath, []); } childrenMap.get(parentPath)?.push(node); } } const sortedList: Node[] = []; const depthFirstTraversal = (path: string): void => { const node = nodeMap.get(path); if (node) { sortedList.push(node); } const children = childrenMap.get(path); if (children) { for (const child of children) { if (child.kind === "folder") { depthFirstTraversal(child.fullPath); } else { sortedList.push(child); } } } }; // Start with root level items const rootItems = nodeList.filter((node) => { const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/"; return parentPath === "/"; }); for (const item of rootItems) { depthFirstTraversal(item.fullPath); } return sortedList; } function compareNodes(a: Node, b: Node): number { if (a.kind !== b.kind) { return a.kind === "folder" ? -1 : 1; } return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); }