bolt.new/packages/bolt/app/components/workbench/FileTree.tsx

370 lines
9.8 KiB
TypeScript
Raw Normal View History

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';
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;
hiddenFiles?: Array<string | RegExp>;
unsavedFiles?: Set<string>;
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<string>());
useEffect(() => {
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]);
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 (
<div className={classNames('text-sm', className)}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case 'file': {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
onClick={() => {
onFileSelect?.(fileOrFolder.fullPath);
}}
/>
);
}
case 'folder': {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
onClick={() => {
toggleCollapseState(fileOrFolder.fullPath);
}}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
},
);
export default FileTree;
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
onClick: () => void;
}
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
return (
<NodeButton
className="group bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive"
depth={depth}
iconClasses={classNames({
'i-ph:caret-right scale-98': collapsed,
'i-ph:caret-down scale-98': !collapsed,
})}
onClick={onClick}
>
{name}
</NodeButton>
);
}
interface FileProps {
file: FileNode;
selected: boolean;
unsavedChanges?: boolean;
onClick: () => void;
}
function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
return (
<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>
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
</div>
</NodeButton>
);
}
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 = '/', hiddenFiles: Array<string | RegExp>): Node[] {
const folderPaths = new Set<string>();
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 sortFileList(rootFolder, fileList);
}
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[]): 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) {
logger.warn(`Node not found for path: ${path}`);
return;
}
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);
}
}
}
};
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' });
}