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