1import type { ProjectEntry } from "@t3tools/contracts";
2import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";
3
4export interface FileTreeNode {
5 readonly path: string;
6 readonly name: string;
7 readonly kind: ProjectEntry["kind"];
8 readonly ignored?: boolean;
9 readonly children: ReadonlyArray<FileTreeNode>;
10 readonly searchSegments: ReadonlyArray<string>;
11 readonly searchWords: ReadonlyArray<string>;
12}
13
14export interface VisibleFileTreeNode {
15 readonly node: FileTreeNode;
16 readonly depth: number;
17}
18
19interface MutableFileTreeNode {
20 path: string;
21 name: string;
22 kind: ProjectEntry["kind"];
23 ignored?: boolean;
24 children: Map<string, MutableFileTreeNode>;
25}
26
27function createMutableNode(
28 path: string,
29 name: string,
30 kind: ProjectEntry["kind"],
31): MutableFileTreeNode {
32 return {
33 path,
34 name,
35 kind,
36 children: new Map(),
37 };
38}
39
40function splitSearchWords(value: string): ReadonlyArray<string> {
41 return value
42 .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
43 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
44 .split(/[^A-Za-z0-9]+/)
45 .filter(Boolean)
46 .map((word) => word.toLowerCase());
47}
48
49function buildNodeSearchTerms(path: string): {
50 readonly segments: ReadonlyArray<string>;
51 readonly words: ReadonlyArray<string>;
52} {
53 const segments: string[] = [];
54 const words: string[] = [];
55
56 for (const segment of path.split("/")) {
57 if (!segment) {
58 continue;
59 }
60 segments.push(segment.toLowerCase());
61 words.push(...splitSearchWords(segment));
62 }
63
64 return { segments, words };
65}
66
67function freezeNode(node: MutableFileTreeNode): FileTreeNode {
68 const searchTerms = buildNodeSearchTerms(node.path);
69 return {
70 path: node.path,
71 name: node.name,
72 kind: node.kind,
73 ...(node.ignored ? { ignored: true } : {}),
74 children: [...node.children.values()].sort(compareNodes).map(freezeNode),
75 searchSegments: searchTerms.segments,
76 searchWords: searchTerms.words,
77 };
78}
79
80const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
81
82function compareNodes(
83 left: Pick<FileTreeNode, "kind" | "name">,
84 right: Pick<FileTreeNode, "kind" | "name">,
85): number {
86 if (left.kind !== right.kind) {
87 return left.kind === "directory" ? -1 : 1;
88 }
89 return fileNameCollator.compare(left.name, right.name);
90}
91
92export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
93 const root = createMutableNode("", "", "directory");
94
95 for (const entry of entries) {
96 const parts = entry.path.split("/").filter(Boolean);
97 if (parts.length === 0) {
98 continue;
99 }
100
101 let current = root;
102 for (let index = 0; index < parts.length; index += 1) {
103 const part = parts[index];
104 if (!part) {
105 continue;
106 }
107
108 const path = parts.slice(0, index + 1).join("/");
109 const isLeaf = index === parts.length - 1;
110 const kind = isLeaf ? entry.kind : "directory";
111 let child = current.children.get(part);
112 if (!child) {
113 child = createMutableNode(path, part, kind);
114 current.children.set(part, child);
115 } else if (isLeaf) {
116 child.kind = entry.kind;
117 }
118 if (isLeaf && entry.ignored) child.ignored = true;
119 current = child;
120 }
121 }
122
123 return [...root.children.values()].sort(compareNodes).map(freezeNode);
124}
125
126export function defaultExpandedTreePaths(nodes: ReadonlyArray<FileTreeNode>): ReadonlySet<string> {
127 const expanded = new Set<string>();
128 for (const node of nodes) {
129 if (node.kind === "directory") {
130 expanded.add(node.path);
131 }
132 }
133 return expanded;
134}
135
136function valueMatchesSearchToken(value: string, token: string, fuzzy: boolean): boolean {
137 return (
138 scoreQueryMatch({
139 value,
140 query: token,
141 exactBase: 0,
142 prefixBase: 2,
143 boundaryBase: 4,
144 includesBase: 6,
145 ...(fuzzy ? { fuzzyBase: 100 } : {}),
146 boundaryMarkers: ["/", "-", "_", "."],
147 }) !== null
148 );
149}
150
151function nodeMatchesSearch(node: FileTreeNode, tokens: ReadonlyArray<string>): boolean {
152 return tokens.every(
153 (token) =>
154 node.searchSegments.some((segment) => valueMatchesSearchToken(segment, token, false)) ||
155 node.searchWords.some((word) => valueMatchesSearchToken(word, token, true)),
156 );
157}
158
159function flattenNode(
160 output: VisibleFileTreeNode[],
161 node: FileTreeNode,
162 depth: number,
163 expanded: ReadonlySet<string>,
164 searchTokens: ReadonlyArray<string>,
165): boolean {
166 const isSearching = searchTokens.length > 0;
167 const matches = isSearching && nodeMatchesSearch(node, searchTokens);
168 let descendantMatches = false;
169 const childOutput: VisibleFileTreeNode[] = [];
170
171 if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) {
172 for (const child of node.children) {
173 if (flattenNode(childOutput, child, depth + 1, expanded, searchTokens)) {
174 descendantMatches = true;
175 }
176 }
177 }
178
179 const visible = !isSearching || matches || descendantMatches;
180 if (!visible) {
181 return false;
182 }
183
184 output.push({ node, depth });
185 output.push(...childOutput);
186 return matches || descendantMatches;
187}
188
189export function flattenFileTree(input: {
190 readonly nodes: ReadonlyArray<FileTreeNode>;
191 readonly expanded: ReadonlySet<string>;
192 readonly searchQuery?: string;
193}): ReadonlyArray<VisibleFileTreeNode> {
194 const output: VisibleFileTreeNode[] = [];
195 const normalizedSearch = normalizeSearchQuery(input.searchQuery ?? "");
196 const searchTokens = normalizedSearch.split(/[\s/\\._-]+/).filter(Boolean);
197 for (const node of input.nodes) {
198 flattenNode(output, node, 0, input.expanded, searchTokens);
199 }
200 return output;
201}