import type { ProjectEntry } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, defaultExpandedTreePaths, flattenFileTree, type FileTreeNode, type VisibleFileTreeNode, } from "./fileTree"; const fileTreeCache = new WeakMap, ReadonlyArray>(); const FILE_TREE_INITIAL_RENDER_COUNT = 20; const FILE_TREE_RENDER_BATCH_SIZE = 12; const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; function cachedFileTree(entries: ReadonlyArray): ReadonlyArray { const cached = fileTreeCache.get(entries); if (cached !== undefined) { return cached; } const tree = buildFileTree(entries); fileTreeCache.set(entries, tree); return tree; } function ancestorPaths(path: string): ReadonlyArray { const parts = path.split("/").filter(Boolean); const ancestors: string[] = []; for (let index = 1; index < parts.length; index += 1) { ancestors.push(parts.slice(0, index).join("/")); } return ancestors; } const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; readonly onPressDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; }) { const { node, depth } = props.item; return ( { if (node.kind === "file") { props.onPreviewFile?.(node.path); } }} onPress={() => { if (node.kind === "directory") { props.onPressDirectory(node.path); return; } props.onPressFile(node.path); }} className={cn( "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle", props.selected && "bg-subtle-strong", )} style={{ paddingLeft: 8 + depth * 18 }} > {node.kind === "directory" ? ( ) : ( )} {node.name} {node.kind === "directory" ? ( {node.children.length} ) : null} ); }); export function FileTreeBrowser(props: { readonly entries: ReadonlyArray; readonly error: string | null; readonly isPending: boolean; readonly searchQuery: string; readonly selectedPath: string | null; readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; readonly onSelectFile: (path: string) => void; }) { const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); const [pendingSelection, setPendingSelection] = useState<{ readonly path: string; readonly selectedPathAtPress: string | null; } | null>(null); const insets = useSafeAreaInsets(); // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); const pendingSelectionTimeoutRef = useRef | null>(null); controlledSelectedPathRef.current = controlledSelectedPath; const selectedPath = pendingSelection?.selectedPathAtPress === controlledSelectedPath ? pendingSelection.path : controlledSelectedPath; const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]); const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); const visibleNodes = useMemo( () => flattenFileTree({ nodes: tree, expanded: expandedPaths, searchQuery: props.searchQuery, }), [expandedPaths, props.searchQuery, tree], ); useEffect(() => { setExpandedPaths((current) => { if (current.size > 0 || defaultExpanded.size === 0) { return current; } return new Set(defaultExpanded); }); }, [defaultExpanded]); useEffect(() => { if (!controlledSelectedPath) { return; } setExpandedPaths((current) => { const ancestors = ancestorPaths(controlledSelectedPath); if (ancestors.every((ancestor) => current.has(ancestor))) { return current; } const next = new Set(current); for (const ancestor of ancestors) { next.add(ancestor); } return next; }); }, [controlledSelectedPath]); useEffect( () => () => { if (pendingSelectionTimeoutRef.current !== null) { clearTimeout(pendingSelectionTimeoutRef.current); } }, [], ); const toggleDirectory = useCallback((path: string) => { setExpandedPaths((current) => { const next = new Set(current); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }, []); const handleSelectFile = useCallback( (path: string) => { if (pendingSelectionTimeoutRef.current !== null) { clearTimeout(pendingSelectionTimeoutRef.current); } setPendingSelection({ path, selectedPathAtPress: controlledSelectedPathRef.current, }); pendingSelectionTimeoutRef.current = setTimeout(() => { pendingSelectionTimeoutRef.current = null; setPendingSelection((current) => (current?.path === path ? null : current)); }, OPTIMISTIC_SELECTION_TIMEOUT_MS); onSelectFile(path); }, [onSelectFile], ); const renderItem = useCallback( ({ item }: { readonly item: VisibleFileTreeNode }) => ( ), [expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory], ); if (props.error && props.entries.length === 0) { return ( Files unavailable {props.error} ); } // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge // blur to a scroll view in the screen's primary position; a scroll view buried in // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. return ( item.node.path} contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} scrollIndicatorInsets={ NATIVE_LIQUID_GLASS_SUPPORTED ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined } keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} updateCellsBatchingPeriod={16} windowSize={5} contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} refreshControl={} renderItem={renderItem} ListEmptyComponent={ {props.isPending ? ( ) : ( <> No files found {props.searchQuery.trim().length > 0 ? "Try a different search." : "The workspace file index is empty."} )} } /> ); }