fix: directory picker cannot navigate into subdirectories (#19)

Root cause: ArcoDesign Tree loadMore callback receives NodeInstance where
the key is at node.props.dataRef.key, not node.props.key. The old code
passed node.props directly which resulted in undefined key, causing
child directory loading to silently fail.

Fix:
- Access node key via node.props.dataRef?.key ?? node.props._key
- Add showLine + blockNode + folder icons for better visual hierarchy
- Add path display with copy button in selection modal
- Add unmountOnExit to reset state on close

Closes #19
This commit is contained in:
Awuqing
2026-03-31 00:32:02 +08:00
parent 9033ccabb7
commit 2ace5a5352

View File

@@ -1,4 +1,5 @@
import { Button, Input, Modal, Space, Spin, Tree, Typography } from '@arco-design/web-react'
import { Button, Input, Message, Modal, Space, Spin, Tree, Typography } from '@arco-design/web-react'
import { IconFolder, IconFile } from '@arco-design/web-react/icon'
import { useCallback, useState } from 'react'
import { listNodeDirectory } from '../../services/nodes'
import type { DirEntry } from '../../types/nodes'
@@ -14,8 +15,10 @@ interface DirectoryPickerProps {
interface TreeNodeData {
key: string
title: string
icon?: React.ReactNode
isLeaf: boolean
children?: TreeNodeData[]
loaded?: boolean
}
function entriesToTreeNodes(entries: DirEntry[], mode: 'directory' | 'file'): TreeNodeData[] {
@@ -24,8 +27,8 @@ function entriesToTreeNodes(entries: DirEntry[], mode: 'directory' | 'file'): Tr
.map((entry) => ({
key: entry.path,
title: entry.name,
icon: entry.isDir ? <IconFolder /> : <IconFile />,
isLeaf: !entry.isDir,
children: entry.isDir ? [] : undefined,
}))
}
@@ -34,16 +37,15 @@ export function DirectoryPicker({ value, onChange, placeholder, mode = 'director
const [treeData, setTreeData] = useState<TreeNodeData[]>([])
const [loading, setLoading] = useState(false)
const [selectedPath, setSelectedPath] = useState('')
const [error, setError] = useState('')
const loadDirectory = useCallback(
async (path: string) => {
async (path: string): Promise<TreeNodeData[]> => {
if (nodeId === undefined) return []
try {
const entries = await listNodeDirectory(nodeId, path)
return entriesToTreeNodes(entries, mode)
} catch {
setError('加载目录失败')
Message.error(`加载目录失败: ${path}`)
return []
}
},
@@ -53,7 +55,6 @@ export function DirectoryPicker({ value, onChange, placeholder, mode = 'director
async function handleOpen() {
setModalVisible(true)
setSelectedPath(value || '')
setError('')
setLoading(true)
try {
const rootNodes = await loadDirectory('/')
@@ -63,21 +64,26 @@ export function DirectoryPicker({ value, onChange, placeholder, mode = 'director
}
}
async function handleLoadMore(node: TreeNodeData) {
const children = await loadDirectory(node.key)
// ArcoDesign Tree loadMore: node.props.dataRef 指向 treeData 中的原始对象
async function handleLoadMore(treeNode: any): Promise<void> {
const nodeKey = treeNode.props.dataRef?.key ?? treeNode.props._key
if (!nodeKey) return
const children = await loadDirectory(nodeKey)
setTreeData((prev) => {
function updateChildren(nodes: TreeNodeData[]): TreeNodeData[] {
function insertChildren(nodes: TreeNodeData[]): TreeNodeData[] {
return nodes.map((n) => {
if (n.key === node.key) {
return { ...n, children }
if (n.key === nodeKey) {
return { ...n, children, loaded: true }
}
if (n.children) {
return { ...n, children: updateChildren(n.children) }
if (n.children && n.children.length > 0) {
return { ...n, children: insertChildren(n.children) }
}
return n
})
}
return updateChildren(prev)
return insertChildren(prev)
})
}
@@ -88,6 +94,7 @@ export function DirectoryPicker({ value, onChange, placeholder, mode = 'director
setModalVisible(false)
}
// 没有 nodeId 时退化为普通输入框
if (nodeId === undefined) {
return <Input value={value} placeholder={placeholder} onChange={onChange} />
}
@@ -108,30 +115,37 @@ export function DirectoryPicker({ value, onChange, placeholder, mode = 'director
onOk={handleConfirm}
okText="选择"
cancelText="取消"
style={{ width: 520 }}
style={{ width: 560 }}
okButtonProps={{ disabled: !selectedPath }}
unmountOnExit
>
{error && <Typography.Text type="error">{error}</Typography.Text>}
{selectedPath && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
: {selectedPath}
</Typography.Text>
<div style={{ padding: '8px 12px', marginBottom: 12, background: 'var(--color-fill-2)', borderRadius: 4 }}>
<Typography.Text copyable style={{ fontSize: 13 }}>
{selectedPath}
</Typography.Text>
</div>
)}
{loading ? (
<Spin style={{ display: 'block', textAlign: 'center', padding: 24 }} />
<Spin style={{ display: 'block', textAlign: 'center', padding: 40 }} />
) : treeData.length === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 40 }}>
</Typography.Text>
) : (
<div style={{ maxHeight: 400, overflow: 'auto' }}>
<div style={{ maxHeight: 420, overflow: 'auto', border: '1px solid var(--color-border)', borderRadius: 4, padding: '4px 0' }}>
<Tree
blockNode
showLine
treeData={treeData as any}
selectedKeys={selectedPath ? [selectedPath] : []}
onSelect={(keys) => {
if (keys.length > 0) {
setSelectedPath(keys[0] as string)
}
}}
selectedKeys={selectedPath ? [selectedPath] : []}
loadMore={(node: any) => handleLoadMore(node.props)}
loadMore={handleLoadMore}
icons={{ switcherIcon: <IconFolder style={{ fontSize: 14 }} /> }}
/>
</div>
)}