Files
dify/web/app/components/workflow/skill/utils/tree-utils.ts
yyh f1100b82f9 feat(skill-editor): render flat search result list in file tree
Replace the tree-filtered search with a flat list that shows icon + name
on the left and parent path on the right, matching the Figma design.
Clicking a file opens its tab; clicking a folder clears the search and
reveals the folder in the tree.
2026-02-06 15:39:00 +08:00

247 lines
6.1 KiB
TypeScript

import type { ContextMenuType, NodeMenuType } from '../constants'
import type { AppAssetTreeView } from '@/types/app-asset'
import { CONTEXT_MENU_TYPE, NODE_MENU_TYPE, ROOT_ID } from '../constants'
// Root utilities
export function isRootId(id: string | null | undefined): boolean {
return !id || id === ROOT_ID
}
export function toApiParentId(folderId: string | null | undefined): string | null {
return isRootId(folderId) ? null : folderId!
}
export function getNodeMenuType(
contextType: ContextMenuType,
isFolder?: boolean,
): NodeMenuType {
if (contextType === CONTEXT_MENU_TYPE.BLANK)
return NODE_MENU_TYPE.ROOT
return isFolder ? NODE_MENU_TYPE.FOLDER : NODE_MENU_TYPE.FILE
}
export function getMenuNodeId(
contextType: ContextMenuType,
nodeId?: string,
): string {
return contextType === CONTEXT_MENU_TYPE.BLANK
? ROOT_ID
: (nodeId ?? ROOT_ID)
}
// Tree utilities
export function buildNodeMap(nodes: AppAssetTreeView[]): Map<string, AppAssetTreeView> {
const map = new Map<string, AppAssetTreeView>()
function traverse(nodeList: AppAssetTreeView[]): void {
for (const node of nodeList) {
map.set(node.id, node)
if (node.children && node.children.length > 0)
traverse(node.children)
}
}
traverse(nodes)
return map
}
export function getAncestorIds(nodeId: string, nodes: AppAssetTreeView[]): string[] {
const ancestors: string[] = []
function findPath(nodeList: AppAssetTreeView[], targetId: string, currentPath: string[]): boolean {
for (const node of nodeList) {
if (node.id === targetId) {
ancestors.push(...currentPath)
return true
}
if (node.children && node.children.length > 0) {
const newPath = node.node_type === 'folder' ? [...currentPath, node.id] : currentPath
if (findPath(node.children, targetId, newPath))
return true
}
}
return false
}
findPath(nodes, nodeId, [])
return ancestors
}
export function toOpensObject(expandedIds: Set<string>): Record<string, boolean> {
return Object.fromEntries([...expandedIds].map(id => [id, true]))
}
export function findNodeById(
nodes: AppAssetTreeView[],
nodeId: string,
): AppAssetTreeView | null {
for (const node of nodes) {
if (node.id === nodeId)
return node
if (node.children && node.children.length > 0) {
const found = findNodeById(node.children, nodeId)
if (found)
return found
}
}
return null
}
export function getAllDescendantFileIds(
nodeId: string,
nodes: AppAssetTreeView[],
): string[] {
const targetNode = findNodeById(nodes, nodeId)
if (!targetNode)
return []
if (targetNode.node_type === 'file')
return [targetNode.id]
const fileIds: string[] = []
function collectFileIds(nodeList: AppAssetTreeView[]): void {
for (const node of nodeList) {
if (node.node_type === 'file')
fileIds.push(node.id)
if (node.children && node.children.length > 0)
collectFileIds(node.children)
}
}
if (targetNode.children)
collectFileIds(targetNode.children)
return fileIds
}
export function isDescendantOf(
potentialDescendantId: string | null | undefined,
ancestorId: string | null | undefined,
nodes: AppAssetTreeView[],
): boolean {
if (!potentialDescendantId || !ancestorId)
return false
if (potentialDescendantId === ancestorId)
return true
const ancestors = getAncestorIds(potentialDescendantId, nodes)
return ancestors.includes(ancestorId)
}
export function getTargetFolderIdFromSelection(
selectedId: string | null,
nodes: AppAssetTreeView[],
): string {
if (!selectedId)
return ROOT_ID
const selectedNode = findNodeById(nodes, selectedId)
if (!selectedNode)
return ROOT_ID
if (selectedNode.node_type === 'folder')
return selectedNode.id
const ancestors = getAncestorIds(selectedId, nodes)
return ancestors.length > 0 ? ancestors[ancestors.length - 1] : ROOT_ID
}
export type DraftTreeNodeOptions = {
id: string
nodeType: AppAssetTreeView['node_type']
}
export function createDraftTreeNode(options: DraftTreeNodeOptions): AppAssetTreeView {
return {
id: options.id,
node_type: options.nodeType,
name: '',
path: '',
extension: '',
size: 0,
children: [],
}
}
type InsertDraftNodeResult = {
nodes: AppAssetTreeView[]
inserted: boolean
}
function insertDraftNodeAtParent(
nodes: AppAssetTreeView[],
parentId: string,
draftNode: AppAssetTreeView,
): InsertDraftNodeResult {
let inserted = false
const nextNodes = nodes.map((node) => {
if (node.id === parentId) {
inserted = true
return {
...node,
children: [draftNode, ...node.children],
}
}
if (node.children.length > 0) {
const result = insertDraftNodeAtParent(node.children, parentId, draftNode)
if (result.inserted) {
inserted = true
return {
...node,
children: result.nodes,
}
}
}
return node
})
return { nodes: inserted ? nextNodes : nodes, inserted }
}
export type FlatSearchResult = {
node: AppAssetTreeView
parentPath: string
}
export function flattenMatchingNodes(
nodes: AppAssetTreeView[],
searchTerm: string,
): FlatSearchResult[] {
if (!searchTerm)
return []
const results: FlatSearchResult[] = []
const lowerTerm = searchTerm.toLowerCase()
function traverse(nodeList: AppAssetTreeView[]): void {
for (const node of nodeList) {
if (node.name.toLowerCase().includes(lowerTerm)) {
const lastSlash = node.path.lastIndexOf('/')
const parentPath = lastSlash > 0 ? node.path.slice(1, lastSlash) : ''
results.push({ node, parentPath })
}
if (node.children && node.children.length > 0)
traverse(node.children)
}
}
traverse(nodes)
return results
}
export function insertDraftTreeNode(
nodes: AppAssetTreeView[],
parentId: string | null,
draftNode: AppAssetTreeView,
): AppAssetTreeView[] {
if (!parentId)
return [draftNode, ...nodes]
const result = insertDraftNodeAtParent(nodes, parentId, draftNode)
if (!result.inserted)
return [draftNode, ...nodes]
return result.nodes
}