1import { LegendList } from "@legendapp/list/react-native";
2import type { ProjectEntry } from "@t3tools/contracts";
3import { AppActivityIndicator } from "../../components/AppActivityIndicator";
4import { SymbolView } from "../../components/AppSymbol";
5import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
6import { Platform, Pressable, RefreshControl, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8
9import { AppText as Text } from "../../components/AppText";
10import { PierreEntryIcon } from "../../components/PierreEntryIcon";
11import { cn } from "../../lib/cn";
12import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
13import {
14 buildFileTree,
15 flattenFileTree,
16 type FileTreeNode,
17 type VisibleFileTreeNode,
18} from "./fileTree";
19
20const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>();
21const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000;
22
23function cachedFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
24 const cached = fileTreeCache.get(entries);
25 if (cached !== undefined) {
26 return cached;
27 }
28 const tree = buildFileTree(entries);
29 fileTreeCache.set(entries, tree);
30 return tree;
31}
32
33function ancestorPaths(path: string): ReadonlyArray<string> {
34 const parts = path.split("/").filter(Boolean);
35 const ancestors: string[] = [];
36 for (let index = 1; index < parts.length; index += 1) {
37 ancestors.push(parts.slice(0, index).join("/"));
38 }
39 return ancestors;
40}
41
42const FileTreeRow = memo(function FileTreeRow(props: {
43 readonly item: VisibleFileTreeNode;
44 readonly selected: boolean;
45 readonly expanded: boolean;
46 readonly loading: boolean;
47 readonly onPressDirectory: (path: string, expand: boolean) => void;
48 readonly onPreviewFile?: (path: string) => void;
49 readonly onPressFile: (path: string) => void;
50}) {
51 const { node, depth } = props.item;
52
53 return (
54 <Pressable
55 accessibilityRole="button"
56 accessibilityLabel={node.path}
57 accessibilityState={{ selected: props.selected }}
58 onPressIn={() => {
59 if (node.kind === "file") {
60 props.onPreviewFile?.(node.path);
61 }
62 }}
63 onPress={() => {
64 if (node.kind === "directory") {
65 props.onPressDirectory(node.path, !props.expanded);
66 return;
67 }
68 props.onPressFile(node.path);
69 }}
70 className={cn(
71 "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle",
72 props.selected && "bg-thread-selected",
73 )}
74 style={{ paddingLeft: 8 + depth * 18 }}
75 >
76 {node.kind === "directory" ? (
77 <SymbolView
78 name={props.expanded ? "chevron.down" : "chevron.right"}
79 size={12}
80 tintColorClassName="accent-icon-muted"
81 type="monochrome"
82 />
83 ) : (
84 <View className="w-3" />
85 )}
86 <PierreEntryIcon path={node.path} kind={node.kind} size={17} />
87 <Text
88 className={cn(
89 "min-w-0 flex-1 text-sm leading-normal",
90 props.selected
91 ? "font-t3-bold text-thread-selected-foreground"
92 : node.ignored
93 ? "font-t3-medium text-foreground-tertiary"
94 : "font-t3-medium text-foreground-secondary",
95 )}
96 numberOfLines={1}
97 >
98 {node.name}
99 </Text>
100 {node.kind === "directory" && props.expanded && props.loading ? (
101 <AppActivityIndicator size="small" accessibilityLabel={`Loading ${node.name}`} />
102 ) : null}
103 </Pressable>
104 );
105});
106
107export function FileTreeBrowser(props: {
108 readonly entries: ReadonlyArray<ProjectEntry>;
109 readonly error: string | null;
110 readonly isPending: boolean;
111 readonly isRefreshing: boolean;
112 readonly searchQuery: string;
113 readonly searchTruncated: boolean;
114 readonly selectedPath: string | null;
115 readonly loadingDirectories: ReadonlySet<string>;
116 readonly onLoadDirectory: (path: string) => void;
117 readonly onPreviewFile?: (path: string) => void;
118 readonly onRefresh: () => void;
119 readonly onSelectFile: (path: string) => void;
120}) {
121 const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set());
122 const [pendingSelection, setPendingSelection] = useState<{
123 readonly path: string;
124 readonly selectedPathAtPress: string | null;
125 } | null>(null);
126 const insets = useSafeAreaInsets();
127 const columnMetrics = useNativeColumnLayoutMetrics();
128 const headerInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0;
129 const {
130 onLoadDirectory,
131 onPreviewFile,
132 onSelectFile,
133 loadingDirectories,
134 selectedPath: controlledSelectedPath,
135 } = props;
136 const controlledSelectedPathRef = useRef(controlledSelectedPath);
137 const pendingSelectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
138 controlledSelectedPathRef.current = controlledSelectedPath;
139
140 const selectedPath =
141 pendingSelection?.selectedPathAtPress === controlledSelectedPath
142 ? pendingSelection.path
143 : controlledSelectedPath;
144 const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]);
145 const visibleNodes = useMemo(
146 () =>
147 flattenFileTree({
148 nodes: tree,
149 expanded: expandedPaths,
150 searchQuery: props.searchQuery,
151 }),
152 [expandedPaths, props.searchQuery, tree],
153 );
154
155 useEffect(() => {
156 if (!controlledSelectedPath) {
157 return;
158 }
159 setExpandedPaths((current) => {
160 const ancestors = ancestorPaths(controlledSelectedPath);
161 if (ancestors.every((ancestor) => current.has(ancestor))) {
162 return current;
163 }
164 const next = new Set(current);
165 for (const ancestor of ancestors) {
166 next.add(ancestor);
167 }
168 return next;
169 });
170 }, [controlledSelectedPath]);
171
172 useEffect(() => {
173 for (const path of expandedPaths) onLoadDirectory(path);
174 }, [expandedPaths, onLoadDirectory]);
175
176 useEffect(
177 () => () => {
178 if (pendingSelectionTimeoutRef.current !== null) {
179 clearTimeout(pendingSelectionTimeoutRef.current);
180 }
181 },
182 [],
183 );
184
185 const toggleDirectory = useCallback(
186 (path: string, expand: boolean) => {
187 if (expand) onLoadDirectory(path);
188 setExpandedPaths((current) => {
189 const next = new Set(current);
190 if (next.has(path)) {
191 next.delete(path);
192 } else {
193 next.add(path);
194 }
195 return next;
196 });
197 },
198 [onLoadDirectory],
199 );
200 const handleSelectFile = useCallback(
201 (path: string) => {
202 if (pendingSelectionTimeoutRef.current !== null) {
203 clearTimeout(pendingSelectionTimeoutRef.current);
204 }
205 setPendingSelection({
206 path,
207 selectedPathAtPress: controlledSelectedPathRef.current,
208 });
209 pendingSelectionTimeoutRef.current = setTimeout(() => {
210 pendingSelectionTimeoutRef.current = null;
211 setPendingSelection((current) => (current?.path === path ? null : current));
212 }, OPTIMISTIC_SELECTION_TIMEOUT_MS);
213 onSelectFile(path);
214 },
215 [onSelectFile],
216 );
217 const renderItem = useCallback(
218 ({ item }: { readonly item: VisibleFileTreeNode }) => (
219 <FileTreeRow
220 item={item}
221 selected={item.node.kind === "file" && item.node.path === selectedPath}
222 expanded={expandedPaths.has(item.node.path)}
223 loading={loadingDirectories.has(item.node.path)}
224 onPressDirectory={toggleDirectory}
225 onPreviewFile={onPreviewFile}
226 onPressFile={handleSelectFile}
227 />
228 ),
229 [
230 expandedPaths,
231 handleSelectFile,
232 onPreviewFile,
233 loadingDirectories,
234 selectedPath,
235 toggleDirectory,
236 ],
237 );
238
239 const extraData = useMemo(
240 () => ({ expandedPaths, loadingDirectories, selectedPath }),
241 [expandedPaths, loadingDirectories, selectedPath],
242 );
243
244 // UIKit owns the header inset on every supported iOS version. Keep the
245 // list as direct screen content so automatic inset adjustment can find it.
246 return (
247 <LegendList
248 contentInsetStartAdjustment={headerInset}
249 alwaysBounceVertical
250 className="flex-1"
251 data={visibleNodes}
252 keyExtractor={(item) => item.node.path}
253 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
254 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
255 keyboardDismissMode="on-drag"
256 keyboardShouldPersistTaps="handled"
257 estimatedItemSize={42}
258 recycleItems
259 maintainVisibleContentPosition
260 extraData={extraData}
261 contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}
262 refreshControl={
263 <RefreshControl refreshing={props.isRefreshing} onRefresh={props.onRefresh} />
264 }
265 renderItem={renderItem}
266 ListHeaderComponent={
267 <>
268 {props.error && props.entries.length > 0 ? (
269 <Text accessibilityRole="alert" className="mx-4 my-2 text-xs text-foreground-muted">
270 {props.error}
271 </Text>
272 ) : null}
273 {props.searchTruncated ? (
274 <Text className="mx-4 my-2 text-xs text-foreground-muted">
275 More search results available. Refine your search to see them.
276 </Text>
277 ) : null}
278 </>
279 }
280 ListEmptyComponent={
281 <View className="px-4 py-5">
282 {props.error && props.entries.length === 0 ? (
283 <>
284 <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
285 <Text
286 accessibilityRole="alert"
287 className="mt-1 text-xs leading-normal text-foreground-muted"
288 >
289 {props.error}
290 </Text>
291 <Pressable
292 accessibilityRole="button"
293 onPress={props.onRefresh}
294 disabled={props.isPending}
295 className="mt-3 min-h-11 self-start justify-center rounded-full bg-subtle px-4 active:opacity-70 disabled:opacity-50"
296 >
297 <Text className="text-sm font-t3-medium text-foreground">Try again</Text>
298 </Pressable>
299 </>
300 ) : props.isPending ? (
301 <AppActivityIndicator size="small" />
302 ) : (
303 <>
304 <Text className="text-sm font-t3-bold text-foreground">No files found</Text>
305 <Text className="mt-1 text-xs leading-normal text-foreground-muted">
306 {props.searchQuery.trim().length > 0
307 ? "Try a different search."
308 : "The workspace is empty."}
309 </Text>
310 </>
311 )}
312 </View>
313 }
314 />
315 );
316}