'use client' import type { DocumentChunkTree } from './document-detail-model' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { chunkTreeLabel, visibleDocumentChunkNodes } from './document-detail-model' const VIRTUALIZATION_THRESHOLD = 80 const TREE_ROW_HEIGHT = 32 export function DocumentChunkTreePanel({ chunkCount, error, fetchNextPage, hasNextPage, isFetchNextPageError, isFetchingNextPage, isPending, onRetry, onSelectChunk, selectedChunkId, tree, }: { chunkCount: number error: boolean fetchNextPage: () => Promise hasNextPage: boolean isFetchNextPageError: boolean isFetchingNextPage: boolean isPending: boolean onRetry: () => void onSelectChunk: (chunkId: string) => void selectedChunkId?: string tree: DocumentChunkTree }) { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') const [collapsedChunkIds, setCollapsedChunkIds] = useState>(() => new Set()) const [focusedChunkId, setFocusedChunkId] = useState() const [treeHasFocus, setTreeHasFocus] = useState(false) const treeScrollRef = useRef(null) const loadMoreRequestedRef = useRef(false) const chunkIdsBeforeLoadRef = useRef>(new Set()) const wasFetchingNextPageRef = useRef(false) const expandedChunkIds = useMemo( () => new Set([...tree.byId.keys()].filter((id) => !collapsedChunkIds.has(id))), [collapsedChunkIds, tree.byId], ) const visibleNodes = useMemo( () => visibleDocumentChunkNodes(tree.roots, expandedChunkIds), [expandedChunkIds, tree.roots], ) const shouldVirtualize = visibleNodes.length > VIRTUALIZATION_THRESHOLD const currentFocusedChunkId = focusedChunkId ?? visibleNodes[0]?.node.chunk.id const focusedIndex = visibleNodes.findIndex( (item) => item.node.chunk.id === currentFocusedChunkId, ) const rowVirtualizer = useVirtualizer({ count: shouldVirtualize ? visibleNodes.length : 0, estimateSize: () => TREE_ROW_HEIGHT, getItemKey: (index) => visibleNodes[index]?.node.chunk.id ?? index, getScrollElement: () => treeScrollRef.current, overscan: 8, rangeExtractor: (range) => { const indexes = defaultRangeExtractor(range) if (focusedIndex >= 0 && !indexes.includes(focusedIndex)) indexes.push(focusedIndex) return indexes.sort((left, right) => left - right) }, }) const rowVirtualizerRef = useRef(rowVirtualizer) rowVirtualizerRef.current = rowVirtualizer const virtualRows = rowVirtualizer.getVirtualItems() const toggleExpanded = (chunkId: string) => { setCollapsedChunkIds((current) => { const next = new Set(current) if (next.has(chunkId)) next.delete(chunkId) else next.add(chunkId) return next }) } const focusChunk = (chunkId: string) => { const index = visibleNodes.findIndex((item) => item.node.chunk.id === chunkId) if (index < 0) return setFocusedChunkId(chunkId) if (shouldVirtualize) rowVirtualizer.scrollToIndex(index, { align: 'auto' }) treeScrollRef.current?.focus() } const handleTreeKeyDown = (event: React.KeyboardEvent) => { const chunkId = currentFocusedChunkId if (!chunkId) return const index = visibleNodes.findIndex((item) => item.node.chunk.id === chunkId) const current = visibleNodes[index] if (!current) return const parentId = current.node.chunk.parentChunkId let nextId: string | undefined if (event.key === 'ArrowDown') nextId = visibleNodes[index + 1]?.node.chunk.id else if (event.key === 'ArrowUp') nextId = visibleNodes[index - 1]?.node.chunk.id else if (event.key === 'Home') nextId = visibleNodes[0]?.node.chunk.id else if (event.key === 'End') nextId = visibleNodes.at(-1)?.node.chunk.id else if (event.key === 'ArrowRight' && current.node.children.length) { if (collapsedChunkIds.has(chunkId)) toggleExpanded(chunkId) else nextId = current.node.children[0]?.chunk.id } else if (event.key === 'ArrowLeft') { if (current.node.children.length && !collapsedChunkIds.has(chunkId)) toggleExpanded(chunkId) else if (parentId && tree.byId.has(parentId)) nextId = parentId } else if (event.key === 'Enter' || event.key === ' ') onSelectChunk(chunkId) else return event.preventDefault() if (nextId) focusChunk(nextId) } const handleLoadMore = () => { if (isFetchingNextPage) return chunkIdsBeforeLoadRef.current = new Set(tree.byId.keys()) loadMoreRequestedRef.current = true void fetchNextPage() } useEffect(() => { if (isFetchingNextPage) wasFetchingNextPageRef.current = true if (isFetchingNextPage || !wasFetchingNextPageRef.current || !loadMoreRequestedRef.current) return wasFetchingNextPageRef.current = false loadMoreRequestedRef.current = false if (isFetchNextPageError) return const firstNewNode = visibleNodes.find( (item) => !chunkIdsBeforeLoadRef.current.has(item.node.chunk.id), ) requestAnimationFrame(() => { if (!firstNewNode) { treeScrollRef.current?.focus() return } const chunkId = firstNewNode.node.chunk.id const index = visibleNodes.findIndex((item) => item.node.chunk.id === chunkId) setFocusedChunkId(chunkId) if (shouldVirtualize) rowVirtualizerRef.current.scrollToIndex(index, { align: 'auto' }) treeScrollRef.current?.focus() }) }, [isFetchNextPageError, isFetchingNextPage, shouldVirtualize, visibleNodes]) const renderTreeItem = (item: (typeof visibleNodes)[number], style?: React.CSSProperties) => { const { depth, node, positionInSet, setSize } = item const { chunk } = node const hasChildren = node.children.length > 0 const expanded = !collapsedChunkIds.has(chunk.id) const label = chunkTreeLabel(chunk.text, chunk.ordinal) return ( ) } return ( ) }