import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { ActivityIndicator, FlatList, ListRenderItemInfo, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, Pressable, Text, View, Platform, } from "react-native"; import { Gesture } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import Animated, { cancelAnimation, Easing, runOnJS, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from "react-native-reanimated"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import { Copy, Download, File, FileText, Folder, FolderOpen, Image as ImageIcon, MoreVertical, RotateCw, X, } from "lucide-react-native"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { useDownloadStore } from "@/stores/download-store"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, } from "@/components/web-desktop-scrollbar"; const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: "name", label: "Name" }, { value: "modified", label: "Modified" }, { value: "size", label: "Size" }, ]; const INDENT_PER_LEVEL = 12; function formatFileSize({ size }: { size: number }): string { if (size < 1024) { return `${size} B`; } if (size < 1024 * 1024) { return `${(size / 1024).toFixed(1)} KB`; } return `${(size / (1024 * 1024)).toFixed(1)} MB`; } interface FileExplorerPaneProps { serverId: string; workspaceId?: string | null; workspaceRoot: string; onOpenFile?: (filePath: string) => void; } interface TreeRow { entry: ExplorerEntry; depth: number; } export function FileExplorerPane({ serverId, workspaceId, workspaceRoot, onOpenFile, }: FileExplorerPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const daemons = useHosts(); const daemonProfile = useMemo( () => daemons.find((daemon) => daemon.serverId === serverId), [daemons, serverId], ); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const workspaceStateKey = useMemo( () => buildWorkspaceExplorerStateKey({ workspaceId, workspaceRoot: normalizedWorkspaceRoot, }), [normalizedWorkspaceRoot, workspaceId], ); const workspaceScopeId = useMemo( () => workspaceId?.trim() || normalizedWorkspaceRoot, [normalizedWorkspaceRoot, workspaceId], ); const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot); const explorerState = useSessionStore((state) => workspaceStateKey && state.sessions[serverId] ? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey) : undefined, ); const { workspaceStateKey: actionsWorkspaceStateKey, requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry, } = useFileExplorerActions({ serverId, workspaceId, workspaceRoot: normalizedWorkspaceRoot, }); const sortOption = usePanelStore((state) => state.explorerSortOption); const setSortOption = usePanelStore((state) => state.setExplorerSortOption); const directories = explorerState?.directories ?? new Map(); const pendingRequest = explorerState?.pendingRequest ?? null; const isExplorerLoading = explorerState?.isLoading ?? false; const error = explorerState?.lastError ?? null; const selectedEntryPath = explorerState?.selectedEntryPath ?? null; const isDirectoryLoading = useCallback( (path: string) => Boolean( isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path, ), [isExplorerLoading, pendingRequest?.mode, pendingRequest?.path], ); const [expandedPaths, setExpandedPaths] = useState>(() => new Set(["."])); const treeListRef = useRef>(null); const treeScrollbarMetrics = useWebDesktopScrollbarMetrics(); const hasInitializedRef = useRef(false); useEffect(() => { hasInitializedRef.current = false; setExpandedPaths(new Set(["."])); }, [actionsWorkspaceStateKey]); useEffect(() => { if (!hasWorkspaceScope) { return; } if (hasInitializedRef.current) { return; } hasInitializedRef.current = true; void requestDirectoryListing(".", { recordHistory: false, setCurrentPath: false, }); }, [hasWorkspaceScope, requestDirectoryListing]); // Expand ancestor directories when a file is selected (e.g., from an inline path click) useEffect(() => { if (!selectedEntryPath || !hasWorkspaceScope) { return; } const parentDir = getParentDirectory(selectedEntryPath); const ancestors = getAncestorDirectories(parentDir); setExpandedPaths((prev) => { const next = new Set(prev); ancestors.forEach((path) => next.add(path)); return next; }); ancestors.forEach((path) => { if (!directories.has(path)) { void requestDirectoryListing(path, { recordHistory: false, setCurrentPath: false, }); } }); }, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]); const handleToggleDirectory = useCallback( (entry: ExplorerEntry) => { if (!hasWorkspaceScope) { return; } const isExpanded = expandedPaths.has(entry.path); const nextExpanded = !isExpanded; setExpandedPaths((prev) => { const next = new Set(prev); if (isExpanded) { next.delete(entry.path); } else { next.add(entry.path); } return next; }); if (nextExpanded && !directories.has(entry.path)) { void requestDirectoryListing(entry.path, { recordHistory: false, setCurrentPath: false, }); } }, [directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing], ); const handleOpenFile = useCallback( (entry: ExplorerEntry) => { if (!hasWorkspaceScope) { return; } selectExplorerEntry(entry.path); onOpenFile?.(entry.path); }, [hasWorkspaceScope, onOpenFile, selectExplorerEntry], ); const handleEntryPress = useCallback( (entry: ExplorerEntry) => { if (entry.kind === "directory") { handleToggleDirectory(entry); return; } handleOpenFile(entry); }, [handleOpenFile, handleToggleDirectory], ); const handleCopyPath = useCallback( async (path: string) => { await Clipboard.setStringAsync( buildAbsoluteExplorerPath({ workspaceRoot: normalizedWorkspaceRoot, entryPath: path, }), ); }, [normalizedWorkspaceRoot], ); const startDownload = useDownloadStore((state) => state.startDownload); const handleDownloadEntry = useCallback( (entry: ExplorerEntry) => { if (!workspaceScopeId || entry.kind !== "file") { return; } startDownload({ serverId, scopeId: workspaceScopeId, fileName: entry.name, path: entry.path, daemonProfile, requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), }); }, [daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId], ); const handleSortCycle = useCallback(() => { const currentIndex = SORT_OPTIONS.findIndex((opt) => opt.value === sortOption); const nextIndex = (currentIndex + 1) % SORT_OPTIONS.length; setSortOption(SORT_OPTIONS[nextIndex].value); }, [sortOption, setSortOption]); const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({ queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey], queryFn: async () => { if (!hasWorkspaceScope) { return null; } const directoryPaths = Array.from(expandedPaths); if (!directoryPaths.includes(".")) { directoryPaths.unshift("."); } await Promise.all([ ...directoryPaths.map((path) => requestDirectoryListing(path, { recordHistory: false, setCurrentPath: false, }), ), ]); return null; }, enabled: false, }); const handleRefresh = useCallback(() => { void refetchExplorer(); }, [refetchExplorer]); const refreshIconRotation = useSharedValue(0); useEffect(() => { if (isRefreshFetching) { refreshIconRotation.value = 0; refreshIconRotation.value = withRepeat( withTiming(360, { duration: 700, easing: Easing.linear, }), -1, false, ); return; } cancelAnimation(refreshIconRotation); const remainder = refreshIconRotation.value % 360; if (Math.abs(remainder) < 0.001) { refreshIconRotation.value = 0; return; } const remaining = 360 - remainder; const duration = Math.max(80, Math.round((remaining / 360) * 700)); refreshIconRotation.value = withTiming( 360, { duration, easing: Easing.linear, }, (finished) => { if (finished) { refreshIconRotation.value = 0; } }, ); }, [isRefreshFetching, refreshIconRotation]); const refreshIconAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${refreshIconRotation.value}deg` }], })); const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; const treeRows = useMemo(() => { const rootDirectory = directories.get("."); if (!rootDirectory) { return []; } return buildTreeRows({ directories, expandedPaths, sortOption, path: ".", depth: 0, }); }, [directories, expandedPaths, sortOption]); const showInitialLoading = !directories.has(".") && Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === "."); const showBackFromError = Boolean(error && selectedEntryPath); const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]); const renderTreeRow = useCallback( ({ item }: ListRenderItemInfo) => { const entry = item.entry; const depth = item.depth; const displayKind = getEntryDisplayKind(entry); const isDirectory = entry.kind === "directory"; const isExpanded = isDirectory && expandedPaths.has(entry.path); const isSelected = selectedEntryPath === entry.path; const loading = isDirectory && isDirectoryLoading(entry.path); return ( handleEntryPress(entry)} style={({ hovered, pressed }) => [ styles.entryRow, { paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL }, (hovered || pressed || isSelected) && styles.entryRowActive, ]} > {loading ? ( ) : ( renderEntryIcon(isDirectory ? "directory" : displayKind, { foreground: theme.colors.foregroundMuted, primary: theme.colors.primary, directoryOpen: isExpanded, }) )} {entry.name} event.stopPropagation?.()} style={({ hovered, pressed, open }) => [ styles.menuButton, (hovered || pressed || open) && styles.menuButtonActive, ]} > Size {formatFileSize({ size: entry.size })} Modified {formatTimeAgo(new Date(entry.modifiedAt))} } onSelect={() => { void handleCopyPath(entry.path); }} > Copy path {entry.kind === "file" ? ( } onSelect={() => handleDownloadEntry(entry)} > Download ) : null} ); }, [ expandedPaths, handleEntryPress, handleCopyPath, handleDownloadEntry, isDirectoryLoading, selectedEntryPath, theme.colors, theme.spacing, ], ); const handleBackFromError = useCallback(() => { if (!hasWorkspaceScope) { return; } selectExplorerEntry(null); void requestDirectoryListing(errorRecoveryPath, { recordHistory: false, setCurrentPath: true, }); }, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]); const handleTreeListScroll = useCallback( (event: NativeSyntheticEvent) => { if (showDesktopWebScrollbar) { treeScrollbarMetrics.onScroll(event); } }, [showDesktopWebScrollbar, treeScrollbarMetrics], ); const handleTreeListLayout = useCallback( (event: LayoutChangeEvent) => { if (showDesktopWebScrollbar) { treeScrollbarMetrics.onLayout(event); } }, [showDesktopWebScrollbar, treeScrollbarMetrics], ); if (!hasWorkspaceScope) { return ( Workspace is unavailable ); } return ( {error ? ( {error} {showBackFromError ? ( Back ) : null} { void requestDirectoryListing(".", { recordHistory: false, setCurrentPath: false, }); }} > Retry ) : showInitialLoading ? ( Loading files… ) : treeRows.length === 0 ? ( No files ) : ( [ styles.iconButton, (hovered || pressed) && styles.iconButtonHovered, ]} accessibilityRole="button" accessibilityLabel="Refresh files" > {currentSortLabel} row.entry.path} testID="file-explorer-tree-scroll" contentContainerStyle={styles.entriesContent} onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined} onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined} onContentSizeChange={ showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined } scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined} showsVerticalScrollIndicator={!showDesktopWebScrollbar} initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> { treeListRef.current?.scrollToOffset({ offset: nextOffset, animated: false, }); }} /> )} ); } type EntryDisplayKind = "directory" | "image" | "text" | "other"; const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]); const TEXT_EXTENSIONS = new Set([ "txt", "md", "markdown", "ts", "tsx", "js", "jsx", "json", "yml", "yaml", "toml", "py", "rb", "go", "rs", "java", "kt", "c", "cpp", "cc", "h", "hpp", "cs", "swift", "php", "html", "css", "scss", "less", "xml", "sh", "bash", "zsh", "ini", "cfg", "conf", ]); function renderEntryIcon( kind: EntryDisplayKind, colors: { foreground: string; primary: string; directoryOpen?: boolean }, ) { const color = colors.foreground; switch (kind) { case "directory": return colors.directoryOpen ? ( ) : ( ); case "image": return ; case "text": return ; default: return ; } } function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind { if (entry.kind === "directory") { return "directory"; } const extension = getExtension(entry.name); if (extension === null) { return "other"; } if (IMAGE_EXTENSIONS.has(extension)) { return "image"; } if (TEXT_EXTENSIONS.has(extension)) { return "text"; } return "other"; } function getExtension(name: string): string | null { const index = name.lastIndexOf("."); if (index === -1 || index === name.length - 1) { return null; } return name.slice(index + 1).toLowerCase(); } function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { const sorted = [...entries]; sorted.sort((a, b) => { if (a.kind !== b.kind) { return a.kind === "directory" ? -1 : 1; } switch (sortOption) { case "name": return a.name.localeCompare(b.name); case "modified": return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); case "size": return b.size - a.size; default: return 0; } }); return sorted; } function buildTreeRows({ directories, expandedPaths, sortOption, path, depth, }: { directories: Map; expandedPaths: Set; sortOption: SortOption; path: string; depth: number; }): TreeRow[] { const directory = directories.get(path); if (!directory) { return []; } const rows: TreeRow[] = []; const entries = sortEntries(directory.entries, sortOption); for (const entry of entries) { rows.push({ entry, depth }); if (entry.kind === "directory" && expandedPaths.has(entry.path)) { rows.push( ...buildTreeRows({ directories, expandedPaths, sortOption, path: entry.path, depth: depth + 1, }), ); } } return rows; } function getParentDirectory(path: string): string { const normalized = path.replace(/\/+$/, ""); if (!normalized || normalized === ".") { return "."; } const lastSlash = normalized.lastIndexOf("/"); if (lastSlash === -1) { return "."; } const dir = normalized.slice(0, lastSlash); return dir.length > 0 ? dir : "."; } function getAncestorDirectories(directory: string): string[] { const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, ""); if (!trimmed || trimmed === ".") { return ["."]; } const parts = trimmed.split("/").filter(Boolean); const ancestors: string[] = ["."]; let acc = ""; for (const part of parts) { acc = acc ? `${acc}/${part}` : part; ancestors.push(acc); } return ancestors; } function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string { if (!state) { return "."; } const currentHistoryPath = state.history.length > 0 ? state.history[state.history.length - 1] : null; const candidate = currentHistoryPath ?? state.lastVisitedPath ?? state.currentPath; if (!candidate || candidate.length === 0) { return "."; } return candidate; } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.surfaceSidebar, }, desktopSplit: { flex: 1, flexDirection: "row", minHeight: 0, }, treePane: { minWidth: 0, position: "relative", }, treePaneFill: { flex: 1, }, treePaneWithPreview: { flex: 0, flexGrow: 0, flexShrink: 0, borderLeftWidth: 1, borderLeftColor: theme.colors.border, }, splitResizeHandle: { position: "absolute", left: -5, top: 0, bottom: 0, width: 10, zIndex: 20, }, previewPane: { flex: 1, minWidth: 0, }, paneHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", height: WORKSPACE_SECONDARY_HEADER_HEIGHT, paddingHorizontal: theme.spacing[3], borderBottomWidth: 1, borderBottomColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, paneHeaderLeft: { flex: 1, minWidth: 0, }, paneHeaderRight: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flexShrink: 0, }, previewHeaderRight: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flexShrink: 0, }, sortButton: { height: 28, flexDirection: "row", alignItems: "center", justifyContent: "center", paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, }, sortButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, treeList: { flex: 1, minHeight: 0, }, entriesContent: { paddingBottom: theme.spacing[4], }, centerState: { flex: 1, alignItems: "center", justifyContent: "center", gap: theme.spacing[2], padding: theme.spacing[4], }, loadingText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, errorText: { color: theme.colors.destructive, fontSize: theme.fontSize.base, textAlign: "center", }, retryButton: { borderRadius: theme.borderRadius.full, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], }, retryButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, }, errorActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, textAlign: "center", }, binaryMetaText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, entryRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 2, paddingRight: theme.spacing[2], }, entryRowActive: { backgroundColor: theme.colors.surface2, }, entryInfo: { flex: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], minWidth: 0, }, entryIcon: { flexShrink: 0, }, entryName: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, menuButton: { width: 30, height: 30, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, menuButtonActive: { backgroundColor: theme.colors.surface2, }, contextMetaBlock: { paddingVertical: theme.spacing[1], }, contextMetaRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", minHeight: 32, paddingHorizontal: theme.spacing[3], }, contextMetaLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 0, }, contextMetaValue: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, fontWeight: theme.fontWeight.medium, flex: 1, minWidth: 0, textAlign: "right", }, previewHeaderText: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, iconButton: { width: 22, height: 22, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, refreshIcon: { width: 16, height: 16, alignItems: "center", justifyContent: "center", }, previewContent: { flex: 1, }, previewScrollContainer: { flex: 1, minHeight: 0, position: "relative", }, previewCodeScrollContent: { paddingTop: theme.spacing[3], paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3] + theme.spacing[2], }, codeText: { color: theme.colors.foreground, fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, flexShrink: 0, }, previewImageScrollContent: { flex: 1, alignItems: "center", justifyContent: "center", padding: theme.spacing[3], }, previewImage: { width: "100%", aspectRatio: 1, }, sheetBackground: { backgroundColor: theme.colors.surface2, }, handleIndicator: { backgroundColor: theme.colors.palette.zinc[600], }, sheetHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, }, sheetTitle: { fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, flex: 1, }, sheetCloseButton: { padding: theme.spacing[2], }, sheetCenterState: { flex: 1, alignItems: "center", justifyContent: "center", gap: theme.spacing[2], padding: theme.spacing[4], }, }));