import { useCallback, useRef, useState } from 'react' import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist' import { PageLoader } from '@/components/page-loader' import { Codicon } from '@/components/ui/codicon' import { useResizeObserver } from '@/hooks/use-resize-observer' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { getFileTreeDndManager } from './dnd-manager' import type { TreeNode } from './use-project-tree' const ROW_HEIGHT = 22 const INDENT = 10 /** Base inset for every row; react-arborist owns paddingLeft for depth indent. */ const TREE_ROW_INSET = 12 interface ProjectTreeProps { collapseNonce: number cwd: string data: TreeNode[] onActivateFile: (path: string) => void onActivateFolder: (path: string) => void onLoadChildren: (id: string) => void | Promise onNodeOpenChange: (id: string, open: boolean) => void onPreviewFile?: (path: string) => void openState: Record } export function ProjectTree({ collapseNonce, cwd, data, onActivateFile, onActivateFolder, onLoadChildren, onNodeOpenChange, onPreviewFile, openState }: ProjectTreeProps) { const containerRef = useRef(null) const treeRef = useRef | null>(null) const [size, setSize] = useState({ height: 0, width: 0 }) const syncTreeSize = useCallback(() => { const el = containerRef.current if (!el) { return } const { height, width } = el.getBoundingClientRect() setSize(prev => { if (prev.height === height && prev.width === width) { return prev } return { height, width } }) }, []) useResizeObserver(syncTreeSize, containerRef) const handleToggle = useCallback( (id: string) => { const node = treeRef.current?.get(id) if (!node) { return } onNodeOpenChange(id, node.isOpen) if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) { void onLoadChildren(id) } }, [onLoadChildren, onNodeOpenChange] ) const handleActivate = useCallback( (node: NodeApi) => { if (node.data && !node.data.isDirectory) { onPreviewFile?.(node.data.id) } }, [onPreviewFile] ) return (
{size.height > 0 && size.width > 0 ? ( childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)} data={data} disableDrag disableDrop disableEdit dndManager={getFileTreeDndManager()} height={size.height} indent={INDENT} initialOpenState={openState} key={`${cwd}:${collapseNonce}`} onActivate={handleActivate} onToggle={handleToggle} openByDefault={false} padding={0} ref={treeRef} rowHeight={ROW_HEIGHT} width={size.width} > {props => ( )} ) : ( )}
) } function TreeSizingState() { const { t } = useI18n() return } function ProjectTreeRow({ dragHandle, node, onAttachFile, onAttachFolder, onPreviewFile, style }: NodeRendererProps & { onAttachFile: (path: string) => void onAttachFolder: (path: string) => void onPreviewFile?: (path: string) => void }) { if (!node.data) { return
} const isFolder = node.data.isDirectory const isPlaceholder = Boolean(node.data.placeholder) const isErrorPlaceholder = node.data.placeholder === 'error' return (
{ event.stopPropagation() if (isPlaceholder) { return } if (event.shiftKey) { ;(isFolder ? onAttachFolder : onAttachFile)(node.data.id) return } if (isFolder) { node.toggle() } else { node.select() } }} onDoubleClick={event => { event.stopPropagation() if (!isFolder && !isPlaceholder) { onPreviewFile?.(node.data.id) } }} onDragStart={event => { if (isPlaceholder) { event.preventDefault() return } const payload = JSON.stringify([{ isDirectory: isFolder, path: node.data.id }]) event.dataTransfer.effectAllowed = 'copy' event.dataTransfer.setData('application/x-hermes-paths', payload) event.dataTransfer.setData('text/plain', node.data.id) }} ref={dragHandle} style={{ ...style, paddingLeft: (typeof style.paddingLeft === 'number' ? style.paddingLeft : Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET }} > {/* No chevron column — the folder icon (open/closed) already carries the expand state, so the extra glyph was pure noise. */} {isPlaceholder && !isErrorPlaceholder ? ( ) : isErrorPlaceholder ? ( ) : isFolder ? ( ) : ( )} {node.data.name}
) }