2026-03-18 14:09:06 +08:00
|
|
|
import React, { useState } from 'react'
|
|
|
|
|
import { clsx } from 'clsx'
|
|
|
|
|
import type { DocFile } from '@/lib/types'
|
|
|
|
|
import { getDisplayName } from '@/lib/parser'
|
|
|
|
|
|
|
|
|
|
interface DocTreeProps {
|
|
|
|
|
files: DocFile[]
|
|
|
|
|
selectedPath?: string
|
|
|
|
|
onSelect: (file: DocFile) => void
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 19:26:46 +08:00
|
|
|
interface TreeNodeProps {
|
2026-03-18 14:09:06 +08:00
|
|
|
file: DocFile
|
|
|
|
|
level: number
|
2026-03-18 19:26:46 +08:00
|
|
|
isLast: boolean
|
|
|
|
|
ancestorsLast: boolean[]
|
2026-03-18 14:09:06 +08:00
|
|
|
selectedPath?: string
|
|
|
|
|
onSelect: (file: DocFile) => void
|
2026-03-18 19:26:46 +08:00
|
|
|
expandedSet: Set<string>
|
|
|
|
|
onToggle: (path: string) => void
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 19:26:46 +08:00
|
|
|
const TreeNode = React.memo(({
|
|
|
|
|
file,
|
|
|
|
|
level,
|
|
|
|
|
isLast,
|
|
|
|
|
ancestorsLast,
|
|
|
|
|
selectedPath,
|
|
|
|
|
onSelect,
|
|
|
|
|
expandedSet,
|
|
|
|
|
onToggle,
|
|
|
|
|
}: TreeNodeProps) => {
|
2026-03-18 14:09:06 +08:00
|
|
|
const isDir = file.isDir
|
2026-03-18 19:26:46 +08:00
|
|
|
const isExpanded = expandedSet.has(file.relativePath)
|
|
|
|
|
const isSelected = selectedPath === file.relativePath
|
2026-03-18 14:09:06 +08:00
|
|
|
|
2026-03-18 19:26:46 +08:00
|
|
|
const handleClick = () => {
|
2026-03-18 14:09:06 +08:00
|
|
|
if (isDir) {
|
2026-03-18 19:26:46 +08:00
|
|
|
onToggle(file.relativePath)
|
2026-03-18 14:09:06 +08:00
|
|
|
} else {
|
|
|
|
|
onSelect(file)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 19:26:46 +08:00
|
|
|
const renderIndent = () => {
|
|
|
|
|
const parts: React.ReactNode[] = []
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < level; i++) {
|
|
|
|
|
const showLine = !ancestorsLast[i]
|
|
|
|
|
parts.push(
|
|
|
|
|
<span key={i} className="inline-block w-4 text-center text-zinc-600">
|
|
|
|
|
{showLine ? '│' : ' '}
|
|
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const connector = isLast ? '└──' : '├──'
|
|
|
|
|
parts.push(
|
|
|
|
|
<span key="connector" className="text-zinc-500">
|
|
|
|
|
{connector}
|
|
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return parts
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const showChildren = isDir && isExpanded && file.children
|
|
|
|
|
|
2026-03-18 14:09:06 +08:00
|
|
|
return (
|
2026-03-18 19:26:46 +08:00
|
|
|
<>
|
2026-03-18 14:09:06 +08:00
|
|
|
<div
|
2026-03-18 19:26:46 +08:00
|
|
|
onClick={handleClick}
|
2026-03-18 14:09:06 +08:00
|
|
|
className={clsx(
|
2026-03-18 19:26:46 +08:00
|
|
|
'flex items-center py-1 cursor-pointer text-sm select-none rounded',
|
|
|
|
|
isDir
|
|
|
|
|
? 'text-zinc-400 hover:text-zinc-200'
|
|
|
|
|
: isSelected
|
|
|
|
|
? 'text-blue-400'
|
|
|
|
|
: 'text-zinc-400 hover:text-zinc-200'
|
2026-03-18 14:09:06 +08:00
|
|
|
)}
|
|
|
|
|
>
|
2026-03-18 19:26:46 +08:00
|
|
|
<span className="font-mono text-xs leading-none">
|
|
|
|
|
{renderIndent()}
|
2026-03-18 14:09:06 +08:00
|
|
|
</span>
|
2026-03-18 19:26:46 +08:00
|
|
|
<span className={clsx(
|
|
|
|
|
'ml-1 truncate',
|
|
|
|
|
isDir && 'font-medium'
|
|
|
|
|
)}>
|
|
|
|
|
{getDisplayName(file.name)}
|
2026-03-18 14:09:06 +08:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-18 19:26:46 +08:00
|
|
|
{showChildren && file.children!.map((child, idx) => (
|
|
|
|
|
<TreeNode
|
|
|
|
|
key={child.relativePath}
|
|
|
|
|
file={child}
|
|
|
|
|
level={level + 1}
|
|
|
|
|
isLast={idx === file.children!.length - 1}
|
|
|
|
|
ancestorsLast={[...ancestorsLast, isLast]}
|
|
|
|
|
selectedPath={selectedPath}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
expandedSet={expandedSet}
|
|
|
|
|
onToggle={onToggle}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
2026-03-18 14:09:06 +08:00
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-18 19:26:46 +08:00
|
|
|
TreeNode.displayName = 'TreeNode'
|
2026-03-18 14:09:06 +08:00
|
|
|
|
|
|
|
|
export const DocTree = ({ files, selectedPath, onSelect }: DocTreeProps) => {
|
2026-03-18 19:26:46 +08:00
|
|
|
const [expandedSet, setExpandedSet] = useState<Set<string>>(() => {
|
|
|
|
|
const set = new Set<string>()
|
|
|
|
|
files.forEach(f => {
|
|
|
|
|
if (f.isDir) set.add(f.relativePath)
|
|
|
|
|
})
|
|
|
|
|
return set
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const handleToggle = (path: string) => {
|
|
|
|
|
setExpandedSet(prev => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
if (next.has(path)) {
|
|
|
|
|
next.delete(path)
|
|
|
|
|
} else {
|
|
|
|
|
next.add(path)
|
|
|
|
|
}
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:09:06 +08:00
|
|
|
return (
|
2026-03-18 19:26:46 +08:00
|
|
|
<div className="select-none w-full h-full overflow-auto py-2 px-1">
|
|
|
|
|
{files.map((file, idx) => (
|
|
|
|
|
<TreeNode
|
|
|
|
|
key={file.relativePath}
|
|
|
|
|
file={file}
|
|
|
|
|
level={0}
|
|
|
|
|
isLast={idx === files.length - 1}
|
|
|
|
|
ancestorsLast={[]}
|
|
|
|
|
selectedPath={selectedPath}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
expandedSet={expandedSet}
|
|
|
|
|
onToggle={handleToggle}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
2026-03-18 14:09:06 +08:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|