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<ProjectEntry>, ReadonlyArray<FileTreeNode>>();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<ProjectEntry>): ReadonlyArray<FileTreeNode> { 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<string> { 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 ( <Pressable accessibilityRole="button" accessibilityLabel={node.path} onPressIn={() => { 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" ? ( <SymbolView name={props.expanded ? "chevron.down" : "chevron.right"} size={12} tintColorClassName="accent-icon-muted" type="monochrome" /> ) : ( <View className="w-3" /> )} <PierreEntryIcon path={node.path} kind={node.kind} size={17} /> <Text className={cn( "min-w-0 flex-1 text-sm leading-normal", props.selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary", )} numberOfLines={1} > {node.name} </Text> {node.kind === "directory" ? ( <Text className="text-2xs font-t3-medium text-foreground-tertiary"> {node.children.length} </Text> ) : null} </Pressable> );}); export function FileTreeBrowser(props: { readonly entries: ReadonlyArray<ProjectEntry>; 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<ReadonlySet<string>>(() => 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<ReturnType<typeof setTimeout> | 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 }) => ( <FileTreeRow item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} onPressFile={handleSelectFile} /> ), [expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory], ); if (props.error && props.entries.length === 0) { return ( <View className="flex-1 bg-sheet px-4 py-5"> <Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text> <Text className="mt-1 text-xs leading-normal text-foreground-muted">{props.error}</Text> </View> ); } // 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 ( <FlatList className="flex-1" data={visibleNodes} keyExtractor={(item) => 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={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />} renderItem={renderItem} ListEmptyComponent={ <View className="px-4 py-5"> {props.isPending ? ( <ActivityIndicator size="small" /> ) : ( <> <Text className="text-sm font-t3-bold text-foreground">No files found</Text> <Text className="mt-1 text-xs leading-normal text-foreground-muted"> {props.searchQuery.trim().length > 0 ? "Try a different search." : "The workspace file index is empty."} </Text> </> )} </View> } /> );}
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<ProjectEntry>, ReadonlyArray<FileTreeNode>>();
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<ProjectEntry>): ReadonlyArray<FileTreeNode> {
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<string> {
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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={node.path}
onPressIn={() => {
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" ? (
<SymbolView
name={props.expanded ? "chevron.down" : "chevron.right"}
size={12}
tintColorClassName="accent-icon-muted"
type="monochrome"
/>
) : (
<View className="w-3" />
)}
<PierreEntryIcon path={node.path} kind={node.kind} size={17} />
<Text
className={cn(
"min-w-0 flex-1 text-sm leading-normal",
props.selected
? "font-t3-bold text-foreground"
: "font-t3-medium text-foreground-secondary",
)}
numberOfLines={1}
>
{node.name}
</Text>
{node.kind === "directory" ? (
<Text className="text-2xs font-t3-medium text-foreground-tertiary">
{node.children.length}
</Text>
) : null}
</Pressable>
);
});
export function FileTreeBrowser(props: {
readonly entries: ReadonlyArray<ProjectEntry>;
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<ReadonlySet<string>>(() => 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<ReturnType<typeof setTimeout> | 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 }) => (
<FileTreeRow
item={item}
selected={item.node.kind === "file" && item.node.path === selectedPath}
expanded={expandedPaths.has(item.node.path)}
onPressDirectory={toggleDirectory}
onPreviewFile={onPreviewFile}
onPressFile={handleSelectFile}
/>
),
[expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory],
);
if (props.error && props.entries.length === 0) {
return (
<View className="flex-1 bg-sheet px-4 py-5">
<Text className="text-sm font-t3-bold text-foreground">Files unavailable</Text>
<Text className="mt-1 text-xs leading-normal text-foreground-muted">{props.error}</Text>
</View>
);
}
// 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 (
<FlatList
className="flex-1"
data={visibleNodes}
keyExtractor={(item) => 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={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />}
renderItem={renderItem}
ListEmptyComponent={
<View className="px-4 py-5">
{props.isPending ? (
<ActivityIndicator size="small" />
) : (
<>
<Text className="text-sm font-t3-bold text-foreground">No files found</Text>
<Text className="mt-1 text-xs leading-normal text-foreground-muted">
{props.searchQuery.trim().length > 0
? "Try a different search."
: "The workspace file index is empty."}
</Text>
</>
)}
</View>
}
/>
);
}