1import type { ProjectEntry } from "@t3tools/contracts";
2
3export interface FileTreeNode {
4 readonly path: string;
5 readonly name: string;
6 readonly kind: ProjectEntry["kind"];
7 readonly ignored?: boolean;
8 readonly children: ReadonlyArray<FileTreeNode>;
9}
10
11export interface VisibleFileTreeNode {
12 readonly node: FileTreeNode;
13 readonly depth: number;
14}
15
16interface MutableFileTreeNode {
17 path: string;
18 name: string;
19 kind: ProjectEntry["kind"];
20 ignored?: boolean;
21 children: Map<string, MutableFileTreeNode>;
22}
23
24function createMutableNode(
25 path: string,
26 name: string,
27 kind: ProjectEntry["kind"],
28): MutableFileTreeNode {
29 return {
30 path,
31 name,
32 kind,
33 children: new Map(),
34 };
35}
36
37function freezeNode(node: MutableFileTreeNode): FileTreeNode {
38 return {
39 path: node.path,
40 name: node.name,
41 kind: node.kind,
42 ...(node.ignored ? { ignored: true } : {}),
43 children: [...node.children.values()].sort(compareNodes).map(freezeNode),
44 };
45}
46
47const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
48
49function compareNodes(
50 left: Pick<FileTreeNode, "kind" | "name">,
51 right: Pick<FileTreeNode, "kind" | "name">,
52): number {
53 if (left.kind !== right.kind) {
54 return left.kind === "directory" ? -1 : 1;
55 }
56 return fileNameCollator.compare(left.name, right.name);
57}
58
59export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
60 const root = createMutableNode("", "", "directory");
61
62 for (const entry of entries) {
63 const parts = entry.path.split("/").filter(Boolean);
64 if (parts.length === 0) {
65 continue;
66 }
67
68 let current = root;
69 for (let index = 0; index < parts.length; index += 1) {
70 const part = parts[index];
71 if (!part) {
72 continue;
73 }
74
75 const path = parts.slice(0, index + 1).join("/");
76 const isLeaf = index === parts.length - 1;
77 const kind = isLeaf ? entry.kind : "directory";
78 let child = current.children.get(part);
79 if (!child) {
80 child = createMutableNode(path, part, kind);
81 current.children.set(part, child);
82 } else if (isLeaf) {
83 child.kind = entry.kind;
84 }
85 if (isLeaf && entry.ignored) child.ignored = true;
86 current = child;
87 }
88 }
89
90 return [...root.children.values()].sort(compareNodes).map(freezeNode);
91}
92
93function flattenNode(
94 output: VisibleFileTreeNode[],
95 node: FileTreeNode,
96 depth: number,
97 expanded: ReadonlySet<string>,
98 searchResultPaths: ReadonlySet<string> | null,
99): boolean {
100 const isSearching = searchResultPaths !== null;
101 const matches = searchResultPaths?.has(node.path) ?? false;
102 let descendantMatches = false;
103 const start = output.length;
104 output.push({ node, depth });
105
106 if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) {
107 for (const child of node.children) {
108 if (flattenNode(output, child, depth + 1, expanded, searchResultPaths)) {
109 descendantMatches = true;
110 }
111 }
112 }
113
114 const visible = !isSearching || matches || descendantMatches;
115 if (!visible) {
116 output.length = start;
117 return false;
118 }
119
120 return matches || descendantMatches;
121}
122
123export function flattenFileTree(input: {
124 readonly nodes: ReadonlyArray<FileTreeNode>;
125 readonly expanded: ReadonlySet<string>;
126 // Null restores normal directory expansion; an empty set is a search with no results.
127 readonly searchResultPaths?: ReadonlySet<string> | null;
128}): ReadonlyArray<VisibleFileTreeNode> {
129 const output: VisibleFileTreeNode[] = [];
130 for (const node of input.nodes) {
131 flattenNode(output, node, 0, input.expanded, input.searchResultPaths ?? null);
132 }
133 return output;
134}