import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { FileMap } from '~/lib/stores/files'; import { classNames } from '~/utils/classNames'; import { createScopedLogger, renderLogger } from '~/utils/logger'; import * as ContextMenu from '@radix-ui/react-context-menu'; import type { FileHistory } from '~/types/actions'; import { diffLines, type Change } from 'diff'; import { workbenchStore } from '~/lib/stores/workbench'; import { toast } from 'react-toastify'; import { path } from '~/utils/path'; const logger = createScopedLogger('FileTree'); const NODE_PADDING_LEFT = 8; const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/]; interface Props { files?: FileMap; selectedFile?: string; onFileSelect?: (filePath: string) => void; rootFolder?: string; hideRoot?: boolean; collapsed?: boolean; allowFolderSelection?: boolean; hiddenFiles?: Array<string | RegExp>; unsavedFiles?: Set<string>; fileHistory?: Record<string, FileHistory>; className?: string; } interface InlineInputProps { depth: number; placeholder: string; initialValue?: string; onSubmit: (value: string) => void; onCancel: () => void; } export const FileTree = memo( ({ files = {}, onFileSelect, selectedFile, rootFolder, hideRoot = false, collapsed = false, allowFolderSelection = false, hiddenFiles, className, unsavedFiles, fileHistory = {}, }: Props) => { renderLogger.trace('FileTree'); const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]); const fileList = useMemo(() => { return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles); }, [files, rootFolder, hideRoot, computedHiddenFiles]); const [collapsedFolders, setCollapsedFolders] = useState(() => { return collapsed ? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)) : new Set<string>(); }); useEffect(() => { if (collapsed) { setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))); return; } setCollapsedFolders((prevCollapsed) => { const newCollapsed = new Set<string>(); for (const folder of fileList) { if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) { newCollapsed.add(folder.fullPath); } } return newCollapsed; }); }, [fileList, collapsed]); 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; }); }; const onCopyPath = (fileOrFolder: FileNode | FolderNode) => { try { navigator.clipboard.writeText(fileOrFolder.fullPath); } catch (error) { logger.error(error); } }; const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => { try { navigator.clipboard.writeText(fileOrFolder.fullPath.substring((rootFolder || '').length)); } catch (error) { logger.error(error); } }; return ( <div className={classNames('text-sm', className, 'overflow-y-auto modern-scrollbar')}> {filteredFileList.map((fileOrFolder) => { switch (fileOrFolder.kind) { case 'file': { return ( <File key={fileOrFolder.id} selected={selectedFile === fileOrFolder.fullPath} file={fileOrFolder} unsavedChanges={unsavedFiles instanceof Set && unsavedFiles.has(fileOrFolder.fullPath)} fileHistory={fileHistory} onCopyPath={() => { onCopyPath(fileOrFolder); }} onCopyRelativePath={() => { onCopyRelativePath(fileOrFolder); }} onClick={() => { onFileSelect?.(fileOrFolder.fullPath); }} /> ); } case 'folder': { return ( <Folder key={fileOrFolder.id} folder={fileOrFolder} selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath} collapsed={collapsedFolders.has(fileOrFolder.fullPath)} onCopyPath={() => { onCopyPath(fileOrFolder); }} onCopyRelativePath={() => { onCopyRelativePath(fileOrFolder); }} onClick={() => { toggleCollapseState(fileOrFolder.fullPath); }} /> ); } default: { return undefined; } } })} </div> ); }, ); export default FileTree; interface FolderProps { folder: FolderNode; collapsed: boolean; selected?: boolean; onCopyPath: () => void; onCopyRelativePath: () => void; onClick: () => void; } interface FolderContextMenuProps { onCopyPath?: () => void; onCopyRelativePath?: () => void; children: ReactNode; } function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) { return ( <ContextMenu.Item onSelect={onSelect} className="flex items-center gap-2 px-2 py-1.5 outline-0 text-sm text-bolt-elements-textPrimary cursor-pointer ws-nowrap text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive rounded-md" > <span className="size-4 shrink-0"></span> <span>{children}</span> </ContextMenu.Item> ); } function InlineInput({ depth, placeholder, initialValue = '', onSubmit, onCancel }: InlineInputProps) { const inputRef = useRef<HTMLInputElement>(null); useEffect(() => { const timer = setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); if (initialValue) { inputRef.current.value = initialValue; inputRef.current.select(); } } }, 50); return () => clearTimeout(timer); }, [initialValue]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { const value = inputRef.current?.value.trim(); if (value) { onSubmit(value); } } else if (e.key === 'Escape') { onCancel(); } }; return ( <div className="flex items-center w-full px-2 bg-bolt-elements-background-depth-4 border border-bolt-elements-item-contentAccent py-0.5 text-bolt-elements-textPrimary" style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }} > <div className="scale-120 shrink-0 i-ph:file-plus text-bolt-elements-textTertiary" /> <input ref={inputRef} type="text" className="ml-2 flex-1 bg-transparent border-none outline-none py-0.5 text-sm text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary min-w-0" placeholder={placeholder} onKeyDown={handleKeyDown} onBlur={() => { setTimeout(() => { if (document.activeElement !== inputRef.current) { onCancel(); } }, 100); }} /> </div> ); } function FileContextMenu({ onCopyPath, onCopyRelativePath, fullPath, children, }: FolderContextMenuProps & { fullPath: string }) { const [isCreatingFile, setIsCreatingFile] = useState(false); const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [isDragging, setIsDragging] = useState(false); const depth = useMemo(() => fullPath.split('/').length, [fullPath]); const fileName = useMemo(() => path.basename(fullPath), [fullPath]); const isFolder = useMemo(() => { const files = workbenchStore.files.get(); const fileEntry = files[fullPath]; return !fileEntry || fileEntry.type === 'folder'; }, [fullPath]); const targetPath = useMemo(() => { return isFolder ? fullPath : path.dirname(fullPath); }, [fullPath, isFolder]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }, []); const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const items = Array.from(e.dataTransfer.items); const files = items.filter((item) => item.kind === 'file'); for (const item of files) { const file = item.getAsFile(); if (file) { try { const filePath = path.join(fullPath, file.name); // Convert file to binary data (Uint8Array) const arrayBuffer = await file.arrayBuffer(); const binaryContent = new Uint8Array(arrayBuffer); const success = await workbenchStore.createFile(filePath, binaryContent); if (success) { toast.success(`File ${file.name} uploaded successfully`); } else { toast.error(`Failed to upload file ${file.name}`); } } catch (error) { toast.error(`Error uploading ${file.name}`); logger.error(error); } } } setIsDragging(false); }, [fullPath], ); const handleCreateFile = async (fileName: string) => { const newFilePath = path.join(targetPath, fileName); const success = await workbenchStore.createFile(newFilePath, ''); if (success) { toast.success('File created successfully'); } else { toast.error('Failed to create file'); } setIsCreatingFile(false); }; const handleCreateFolder = async (folderName: string) => { const newFolderPath = path.join(targetPath, folderName); const success = await workbenchStore.createFolder(newFolderPath); if (success) { toast.success('Folder created successfully'); } else { toast.error('Failed to create folder'); } setIsCreatingFolder(false); }; const handleDelete = async () => { try { if (!confirm(`Are you sure you want to delete ${isFolder ? 'folder' : 'file'}: ${fileName}?`)) { return; } let success; if (isFolder) { success = await workbenchStore.deleteFolder(fullPath); } else { success = await workbenchStore.deleteFile(fullPath); } if (success) { toast.success(`${isFolder ? 'Folder' : 'File'} deleted successfully`); } else { toast.error(`Failed to delete ${isFolder ? 'folder' : 'file'}`); } } catch (error) { toast.error(`Error deleting ${isFolder ? 'folder' : 'file'}`); logger.error(error); } }; // Handler for locking a file with full lock const handleLockFile = () => { try { if (isFolder) { return; } const success = workbenchStore.lockFile(fullPath); if (success) { toast.success(`File locked successfully`); } else { toast.error(`Failed to lock file`); } } catch (error) { toast.error(`Error locking file`); logger.error(error); } }; // Handler for unlocking a file const handleUnlockFile = () => { try { if (isFolder) { return; } const success = workbenchStore.unlockFile(fullPath); if (success) { toast.success(`File unlocked successfully`); } else { toast.error(`Failed to unlock file`); } } catch (error) { toast.error(`Error unlocking file`); logger.error(error); } }; // Handler for locking a folder with full lock const handleLockFolder = () => { try { if (!isFolder) { return; } const success = workbenchStore.lockFolder(fullPath); if (success) { toast.success(`Folder locked successfully`); } else { toast.error(`Failed to lock folder`); } } catch (error) { toast.error(`Error locking folder`); logger.error(error); } }; // Handler for unlocking a folder const handleUnlockFolder = () => { try { if (!isFolder) { return; } const success = workbenchStore.unlockFolder(fullPath); if (success) { toast.success(`Folder unlocked successfully`); } else { toast.error(`Failed to unlock folder`); } } catch (error) { toast.error(`Error unlocking folder`); logger.error(error); } }; return ( <> <ContextMenu.Root> <ContextMenu.Trigger> <div onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} className={classNames('relative', { 'bg-bolt-elements-background-depth-2 border border-dashed border-bolt-elements-item-contentAccent rounded-md': isDragging, })} > {children} </div> </ContextMenu.Trigger> <ContextMenu.Portal> <ContextMenu.Content style={{ zIndex: 998 }} className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56" > <ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor"> <ContextMenuItem onSelect={() => setIsCreatingFile(true)}> <div className="flex items-center gap-2"> <div className="i-ph:file-plus" /> New File </div> </ContextMenuItem> <ContextMenuItem onSelect={() => setIsCreatingFolder(true)}> <div className="flex items-center gap-2"> <div className="i-ph:folder-plus" /> New Folder </div> </ContextMenuItem> </ContextMenu.Group> <ContextMenu.Group className="p-1"> <ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem> <ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem> </ContextMenu.Group> {/* Add lock/unlock options for files and folders */} <ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor"> {!isFolder ? ( <> <ContextMenuItem onSelect={handleLockFile}> <div className="flex items-center gap-2"> <div className="i-ph:lock-simple" /> Lock File </div> </ContextMenuItem> <ContextMenuItem onSelect={handleUnlockFile}> <div className="flex items-center gap-2"> <div className="i-ph:lock-key-open" /> Unlock File </div> </ContextMenuItem> </> ) : ( <> <ContextMenuItem onSelect={handleLockFolder}> <div className="flex items-center gap-2"> <div className="i-ph:lock-simple" /> Lock Folder </div> </ContextMenuItem> <ContextMenuItem onSelect={handleUnlockFolder}> <div className="flex items-center gap-2"> <div className="i-ph:lock-key-open" /> Unlock Folder </div> </ContextMenuItem> </> )} </ContextMenu.Group> {/* Add delete option in a new group */} <ContextMenu.Group className="p-1 border-t-px border-solid border-bolt-elements-borderColor"> <ContextMenuItem onSelect={handleDelete}> <div className="flex items-center gap-2 text-red-500"> <div className="i-ph:trash" /> Delete {isFolder ? 'Folder' : 'File'} </div> </ContextMenuItem> </ContextMenu.Group> </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> {isCreatingFile && ( <InlineInput depth={depth} placeholder="Enter file name..." onSubmit={handleCreateFile} onCancel={() => setIsCreatingFile(false)} /> )} {isCreatingFolder && ( <InlineInput depth={depth} placeholder="Enter folder name..." onSubmit={handleCreateFolder} onCancel={() => setIsCreatingFolder(false)} /> )} </> ); } function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) { // Check if the folder is locked const { isLocked } = workbenchStore.isFolderLocked(folder.fullPath); return ( <FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={folder.fullPath}> <NodeButton className={classNames('group', { 'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive': !selected, 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected, })} depth={folder.depth} iconClasses={classNames({ 'i-ph:caret-right scale-98': collapsed, 'i-ph:caret-down scale-98': !collapsed, })} onClick={onClick} > <div className="flex items-center w-full"> <div className="flex-1 truncate pr-2">{folder.name}</div> {isLocked && ( <span className={classNames('shrink-0', 'i-ph:lock-simple scale-80 text-red-500')} title={'Folder is locked'} /> )} </div> </NodeButton> </FileContextMenu> ); } interface FileProps { file: FileNode; selected: boolean; unsavedChanges?: boolean; fileHistory?: Record<string, FileHistory>; onCopyPath: () => void; onCopyRelativePath: () => void; onClick: () => void; } function File({ file, onClick, onCopyPath, onCopyRelativePath, selected, unsavedChanges = false, fileHistory = {}, }: FileProps) { const { depth, name, fullPath } = file; // Check if the file is locked const { locked } = workbenchStore.isFileLocked(fullPath); const fileModifications = fileHistory[fullPath]; const { additions, deletions } = useMemo(() => { if (!fileModifications?.originalContent) { return { additions: 0, deletions: 0 }; } const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n'); const normalizedCurrent = fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || ''; if (normalizedOriginal === normalizedCurrent) { return { additions: 0, deletions: 0 }; } const changes = diffLines(normalizedOriginal, normalizedCurrent, { newlineIsToken: false, ignoreWhitespace: true, ignoreCase: false, }); return changes.reduce( (acc: { additions: number; deletions: number }, change: Change) => { if (change.added) { acc.additions += change.value.split('\n').length; } if (change.removed) { acc.deletions += change.value.split('\n').length; } return acc; }, { additions: 0, deletions: 0 }, ); }, [fileModifications]); const showStats = additions > 0 || deletions > 0; return ( <FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} fullPath={fullPath}> <NodeButton className={classNames('group', { 'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault': !selected, 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected, })} depth={depth} iconClasses={classNames('i-ph:file-duotone scale-98', { 'group-hover:text-bolt-elements-item-contentActive': !selected, })} onClick={onClick} > <div className={classNames('flex items-center', { 'group-hover:text-bolt-elements-item-contentActive': !selected, })} > <div className="flex-1 truncate pr-2">{name}</div> <div className="flex items-center gap-1"> {showStats && ( <div className="flex items-center gap-1 text-xs"> {additions > 0 && <span className="text-green-500">+{additions}</span>} {deletions > 0 && <span className="text-red-500">-{deletions}</span>} </div> )} {locked && ( <span className={classNames('shrink-0', 'i-ph:lock-simple scale-80 text-red-500')} title={'File is locked'} /> )} {unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />} </div> </div> </NodeButton> </FileContextMenu> ); } interface ButtonProps { depth: number; iconClasses: string; children: ReactNode; className?: string; onClick?: () => void; } function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) { return ( <button className={classNames( 'flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded py-0.5', className, )} style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }} onClick={() => onClick?.()} > <div className={classNames('scale-120 shrink-0', iconClasses)}></div> <div className="truncate w-full text-left">{children}</div> </button> ); } 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 = '/', hideRoot: boolean, hiddenFiles: Array<string | RegExp>, ): Node[] { const folderPaths = new Set<string>(); const fileList: Node[] = []; let defaultDepth = 0; if (rootFolder === '/' && !hideRoot) { 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) || (hideRoot && fullPath === 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 sortFileList(rootFolder, fileList, hideRoot); } function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) { return hiddenFiles.some((pathOrRegex) => { if (typeof pathOrRegex === 'string') { return fileName === pathOrRegex; } return pathOrRegex.test(filePath); }); } /** * Sorts the given list of nodes into a tree structure (still a flat list). * * This function organizes the nodes into a hierarchical structure based on their paths, * with folders appearing before files and all items sorted alphabetically within their level. * * @note This function mutates the given `nodeList` array for performance reasons. * * @param rootFolder - The path of the root folder to start the sorting from. * @param nodeList - The list of nodes to be sorted. * * @returns A new array of nodes sorted in depth-first order. */ function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] { logger.trace('sortFileList'); const nodeMap = new Map<string, Node>(); const childrenMap = new Map<string, Node[]>(); // 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 !== rootFolder.slice(0, rootFolder.lastIndexOf('/'))) { 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); } } } }; if (hideRoot) { // if root is hidden, start traversal from its immediate children const rootChildren = childrenMap.get(rootFolder) || []; for (const child of rootChildren) { depthFirstTraversal(child.fullPath); } } else { depthFirstTraversal(rootFolder); } 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' }); }