import type { ProjectEntry } from "@t3tools/contracts"; import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; export interface FileTreeNode { readonly path: string; readonly name: string; readonly kind: ProjectEntry["kind"]; readonly children: ReadonlyArray; readonly searchSegments: ReadonlyArray; readonly searchWords: ReadonlyArray; } export interface VisibleFileTreeNode { readonly node: FileTreeNode; readonly depth: number; } interface MutableFileTreeNode { path: string; name: string; kind: ProjectEntry["kind"]; children: Map; } function createMutableNode( path: string, name: string, kind: ProjectEntry["kind"], ): MutableFileTreeNode { return { path, name, kind, children: new Map(), }; } function splitSearchWords(value: string): ReadonlyArray { return value .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .split(/[^A-Za-z0-9]+/) .filter(Boolean) .map((word) => word.toLowerCase()); } function buildNodeSearchTerms(path: string): { readonly segments: ReadonlyArray; readonly words: ReadonlyArray; } { const segments: string[] = []; const words: string[] = []; for (const segment of path.split("/")) { if (!segment) { continue; } segments.push(segment.toLowerCase()); words.push(...splitSearchWords(segment)); } return { segments, words }; } function freezeNode(node: MutableFileTreeNode): FileTreeNode { const searchTerms = buildNodeSearchTerms(node.path); return { path: node.path, name: node.name, kind: node.kind, children: [...node.children.values()].sort(compareNodes).map(freezeNode), searchSegments: searchTerms.segments, searchWords: searchTerms.words, }; } function compareNodes( left: Pick, right: Pick, ): number { if (left.kind !== right.kind) { return left.kind === "directory" ? -1 : 1; } return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }); } export function buildFileTree(entries: ReadonlyArray): ReadonlyArray { const root = createMutableNode("", "", "directory"); for (const entry of entries) { const parts = entry.path.split("/").filter(Boolean); if (parts.length === 0) { continue; } let current = root; for (let index = 0; index < parts.length; index += 1) { const part = parts[index]; if (!part) { continue; } const path = parts.slice(0, index + 1).join("/"); const isLeaf = index === parts.length - 1; const kind = isLeaf ? entry.kind : "directory"; let child = current.children.get(part); if (!child) { child = createMutableNode(path, part, kind); current.children.set(part, child); } else if (isLeaf) { child.kind = entry.kind; } current = child; } } return [...root.children.values()].sort(compareNodes).map(freezeNode); } export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { const expanded = new Set(); for (const node of nodes) { if (node.kind === "directory") { expanded.add(node.path); } } return expanded; } function valueMatchesSearchToken(value: string, token: string, fuzzy: boolean): boolean { return ( scoreQueryMatch({ value, query: token, exactBase: 0, prefixBase: 2, boundaryBase: 4, includesBase: 6, ...(fuzzy ? { fuzzyBase: 100 } : {}), boundaryMarkers: ["/", "-", "_", "."], }) !== null ); } function nodeMatchesSearch(node: FileTreeNode, tokens: ReadonlyArray): boolean { return tokens.every( (token) => node.searchSegments.some((segment) => valueMatchesSearchToken(segment, token, false)) || node.searchWords.some((word) => valueMatchesSearchToken(word, token, true)), ); } function flattenNode( output: VisibleFileTreeNode[], node: FileTreeNode, depth: number, expanded: ReadonlySet, searchTokens: ReadonlyArray, ): boolean { const isSearching = searchTokens.length > 0; const matches = isSearching && nodeMatchesSearch(node, searchTokens); let descendantMatches = false; const childOutput: VisibleFileTreeNode[] = []; if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) { for (const child of node.children) { if (flattenNode(childOutput, child, depth + 1, expanded, searchTokens)) { descendantMatches = true; } } } const visible = !isSearching || matches || descendantMatches; if (!visible) { return false; } output.push({ node, depth }); output.push(...childOutput); return matches || descendantMatches; } export function flattenFileTree(input: { readonly nodes: ReadonlyArray; readonly expanded: ReadonlySet; readonly searchQuery?: string; }): ReadonlyArray { const output: VisibleFileTreeNode[] = []; const normalizedSearch = normalizeSearchQuery(input.searchQuery ?? ""); const searchTokens = normalizedSearch.split(/[\s/\\._-]+/).filter(Boolean); for (const node of input.nodes) { flattenNode(output, node, 0, input.expanded, searchTokens); } return output; }