| | import { memo, useEffect, useMemo, 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'; |
| |
|
| | 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; |
| | } |
| |
|
| | 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 (lastDepth === depth) { |
| | lastDepth = Number.MAX_SAFE_INTEGER; |
| | } |
| |
|
| | |
| | if (collapsedFolders.has(fileOrFolder.fullPath)) { |
| | lastDepth = Math.min(lastDepth, depth); |
| | } |
| |
|
| | |
| | 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')}> |
| | {filteredFileList.map((fileOrFolder) => { |
| | switch (fileOrFolder.kind) { |
| | case 'file': { |
| | return ( |
| | <File |
| | key={fileOrFolder.id} |
| | selected={selectedFile === fileOrFolder.fullPath} |
| | file={fileOrFolder} |
| | unsavedChanges={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 FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) { |
| | return ( |
| | <ContextMenu.Root> |
| | <ContextMenu.Trigger>{children}</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={onCopyPath}>Copy path</ContextMenuItem> |
| | <ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem> |
| | </ContextMenu.Group> |
| | </ContextMenu.Content> |
| | </ContextMenu.Portal> |
| | </ContextMenu.Root> |
| | ); |
| | } |
| |
|
| | function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) { |
| | return ( |
| | <FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}> |
| | <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} |
| | > |
| | {folder.name} |
| | </NodeButton> |
| | </FileContextMenu> |
| | ); |
| | } |
| |
|
| | interface FileProps { |
| | file: FileNode; |
| | selected: boolean; |
| | unsavedChanges?: boolean; |
| | fileHistory?: Record<string, FileHistory>; |
| | onCopyPath: () => void; |
| | onCopyRelativePath: () => void; |
| | onClick: () => void; |
| | } |
| |
|
| | function File({ |
| | file: { depth, name, fullPath }, |
| | onClick, |
| | onCopyPath, |
| | onCopyRelativePath, |
| | selected, |
| | unsavedChanges = false, |
| | fileHistory = {}, |
| | }: FileProps) { |
| | 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}> |
| | <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> |
| | )} |
| | {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); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] { |
| | logger.trace('sortFileList'); |
| |
|
| | const nodeMap = new Map<string, Node>(); |
| | const childrenMap = new Map<string, Node[]>(); |
| |
|
| | |
| | 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) { |
| | |
| | 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' }); |
| | } |
| |
|