| 1 | import { LegendList } from "@legendapp/list/react-native"; |
| 2 | import type { ProjectEntry } from "@t3tools/contracts"; |
| 3 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 5 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 6 | import { Platform, Pressable, RefreshControl, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | |
| 9 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 11 | import { cn } from "../../lib/cn"; |
| 12 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; |
| 13 | import { |
| 14 | buildFileTree, |
| 15 | flattenFileTree, |
| 16 | type FileTreeNode, |
| 17 | type VisibleFileTreeNode, |
| 18 | } from "./fileTree"; |
| 19 | |
| 20 | const fileTreeCache = new WeakMap<ReadonlyArray<ProjectEntry>, ReadonlyArray<FileTreeNode>>(); |
| 21 | const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; |
| 22 | |
| 23 | function 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 | |
| 33 | function 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 | |
| 42 | const 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 | |
| 107 | export 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 | } |