|
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\//, /\.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 (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; |
|
}); |
|
}; |
|
|
|
return ( |
|
<div className={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-white hover:bg-gray-50 text-md" |
|
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-white hover:bg-gray-50': !selected, |
|
'bg-gray-100': selected, |
|
})} |
|
depth={depth} |
|
iconClasses={classNames('i-ph:file-duotone scale-98', { |
|
'text-gray-600': !selected, |
|
})} |
|
onClick={onClick} |
|
> |
|
<div className="flex items-center"> |
|
<div className="flex-1 truncate pr-2">{name}</div> |
|
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-warning-400" />} |
|
</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={`flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded ${className ?? ''}`} |
|
style={{ paddingLeft: `${12 + 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 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); |
|
}); |
|
} |
|
|