import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { ActivityIndicator, FlatList, Image as RNImage, ListRenderItemInfo, Pressable, ScrollView as RNScrollView, Text, View, Platform, } from "react-native"; import { ScrollView, Gesture, GestureDetector } 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 { Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, } from "@gorhom/bottom-sheet"; import { Copy, Download, File, FileText, Folder, FolderOpen, Image as ImageIcon, MoreVertical, RotateCw, X, } from "lucide-react-native"; import type { AgentFileExplorerState, ExplorerEntry, ExplorerFile, } from "@/stores/session-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; 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 { usePanelStore, DEFAULT_EXPLORER_FILES_SPLIT_RATIO, type SortOption, } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: "name", label: "Name" }, { value: "modified", label: "Modified" }, { value: "size", label: "Size" }, ]; const INDENT_PER_LEVEL = 12; interface FileExplorerPaneProps { serverId: string; agentId: string; } interface TreeRow { entry: ExplorerEntry; depth: number; } export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const { connectionStates } = useDaemonConnections(); const daemonProfile = connectionStates.get(serverId)?.daemon; const agentExists = useSessionStore((state) => agentId && state.sessions[serverId] ? state.sessions[serverId]?.agents.has(agentId) : false ); const explorerState = useSessionStore((state) => agentId && state.sessions[serverId] ? state.sessions[serverId]?.fileExplorer.get(agentId) : undefined ); const { requestDirectoryListing, requestFilePreview, requestFileDownloadToken, selectExplorerEntry, } = useFileExplorerActions(serverId); const sortOption = usePanelStore((state) => state.explorerSortOption); const setSortOption = usePanelStore((state) => state.setExplorerSortOption); const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio); const setSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio); const directories = explorerState?.directories ?? new Map(); const files = explorerState?.files ?? new Map(); const pendingRequest = explorerState?.pendingRequest ?? null; const isExplorerLoading = explorerState?.isLoading ?? false; const error = explorerState?.lastError ?? null; const selectedEntryPath = explorerState?.selectedEntryPath ?? null; const preview = selectedEntryPath ? files.get(selectedEntryPath) : null; const isPreviewLoading = Boolean( isExplorerLoading && pendingRequest?.mode === "file" && pendingRequest?.path === selectedEntryPath ); 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 [containerWidth, setContainerWidth] = useState(0); const wasInlinePreviewVisibleRef = useRef(false); // Bottom sheet for file preview (mobile) const previewSheetRef = useRef(null); const previewSnapPoints = useMemo(() => ["70%", "95%"], []); const hasInitializedRef = useRef(false); useEffect(() => { if (!agentId || !requestDirectoryListing) { return; } if (hasInitializedRef.current) { return; } hasInitializedRef.current = true; requestDirectoryListing(agentId, ".", { recordHistory: false, setCurrentPath: false }); }, [agentId, requestDirectoryListing]); // Expand ancestor directories when a file is selected (e.g., from an inline path click) useEffect(() => { if (!agentId || !selectedEntryPath || !requestDirectoryListing) { 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)) { requestDirectoryListing(agentId, path, { recordHistory: false, setCurrentPath: false }); } }); }, [agentId, directories, requestDirectoryListing, selectedEntryPath]); // Open/close preview sheet based on selection useEffect(() => { if (!isMobile) { return; } if (selectedEntryPath) { previewSheetRef.current?.present(); } else { previewSheetRef.current?.dismiss(); } }, [isMobile, selectedEntryPath]); const handleClosePreview = useCallback(() => { if (!agentId) { return; } selectExplorerEntry(agentId, null); }, [agentId, selectExplorerEntry]); const handleToggleDirectory = useCallback( (entry: ExplorerEntry) => { if (!agentId || !requestDirectoryListing) { 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)) { requestDirectoryListing(agentId, entry.path, { recordHistory: false, setCurrentPath: false }); } }, [agentId, directories, expandedPaths, requestDirectoryListing] ); const handleOpenFile = useCallback( (entry: ExplorerEntry) => { if (!agentId || !requestFilePreview) { return; } selectExplorerEntry(agentId, entry.path); requestFilePreview(agentId, entry.path); }, [agentId, requestFilePreview, 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(path); }, []); const startDownload = useDownloadStore((state) => state.startDownload); const handleDownloadEntry = useCallback( (entry: ExplorerEntry) => { if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { return; } startDownload({ serverId, agentId, fileName: entry.name, path: entry.path, daemonProfile, requestFileDownloadToken, }); }, [agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload] ); 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, agentId], queryFn: async () => { if (!agentId) { return null; } const directoryPaths = Array.from(expandedPaths); if (!directoryPaths.includes(".")) { directoryPaths.unshift("."); } await Promise.all([ ...directoryPaths.map((path) => requestDirectoryListing(agentId, path, { recordHistory: false, setCurrentPath: false, }) ), ...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []), ]); 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 shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath); const minTreeWidth = 220; const minPreviewWidth = 320; const safeSplitRatio = Number.isFinite(splitRatio) ? splitRatio : DEFAULT_EXPLORER_FILES_SPLIT_RATIO; const splitAvailableWidth = useSharedValue(0); const splitMaxTreeWidth = useSharedValue(minTreeWidth); const splitTreeWidth = useSharedValue(minTreeWidth); const splitStartTreeWidth = useSharedValue(minTreeWidth); useEffect(() => { if (!shouldShowInlinePreview) { wasInlinePreviewVisibleRef.current = false; return; } if (containerWidth <= 0) { return; } const available = Math.max(0, containerWidth); const maxTree = Math.max(minTreeWidth, available - minPreviewWidth); const isOpeningInlinePreview = !wasInlinePreviewVisibleRef.current; const desired = isOpeningInlinePreview ? Math.round(available * safeSplitRatio) : splitTreeWidth.value; const clamped = Math.max(minTreeWidth, Math.min(maxTree, desired)); splitAvailableWidth.value = available; splitMaxTreeWidth.value = maxTree; splitTreeWidth.value = clamped; wasInlinePreviewVisibleRef.current = true; }, [ containerWidth, minPreviewWidth, minTreeWidth, safeSplitRatio, shouldShowInlinePreview, splitAvailableWidth, splitMaxTreeWidth, splitTreeWidth, ]); const treePaneAnimatedStyle = useAnimatedStyle(() => ({ width: splitTreeWidth.value, flexBasis: splitTreeWidth.value, flexGrow: 0, flexShrink: 0, })); const splitResizeGesture = useMemo(() => { if (isMobile || !shouldShowInlinePreview) { return Gesture.Pan().enabled(false); } return Gesture.Pan() .hitSlop({ left: 12, right: 12, top: 0, bottom: 0 }) .onStart(() => { splitStartTreeWidth.value = splitTreeWidth.value; }) .onUpdate((event) => { const nextWidth = splitStartTreeWidth.value - event.translationX; const clamped = Math.max( minTreeWidth, Math.min(splitMaxTreeWidth.value, nextWidth) ); splitTreeWidth.value = clamped; }) .onEnd(() => { const available = splitAvailableWidth.value; const ratio = available > 0 ? splitTreeWidth.value / available : safeSplitRatio; runOnJS(setSplitRatio)(ratio); }); }, [ isMobile, minTreeWidth, safeSplitRatio, setSplitRatio, shouldShowInlinePreview, splitAvailableWidth, splitMaxTreeWidth, splitStartTreeWidth, splitTreeWidth, ]); 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 handlePreviewSheetChange = useCallback( (index: number) => { if (index === -1) { handleClosePreview(); } }, [handleClosePreview] ); const renderPreviewBackdrop = useCallback( (props: React.ComponentProps) => ( ), [] ); const handleBackFromError = useCallback(() => { if (!agentId || !requestDirectoryListing) { return; } selectExplorerEntry(agentId, null); requestDirectoryListing(agentId, errorRecoveryPath, { recordHistory: false, setCurrentPath: true, }); }, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]); if (!agentExists) { return ( Agent not found ); } return ( setContainerWidth(event.nativeEvent.layout.width)} > {error ? ( {error} {showBackFromError ? ( Back ) : null} { if (agentId) { requestDirectoryListing(agentId, ".", { recordHistory: false, setCurrentPath: false, }); } }} > Retry ) : showInitialLoading ? ( Loading files… ) : treeRows.length === 0 ? ( No files ) : ( {shouldShowInlinePreview ? ( {selectedEntryPath?.split("/").pop() ?? "Preview"} {isPreviewLoading ? ( ) : null} [ styles.iconButton, (hovered || pressed) && styles.iconButtonHovered, ]} accessibilityRole="button" accessibilityLabel="Close preview" > ) : null} {shouldShowInlinePreview ? ( [ styles.iconButton, (hovered || pressed) && styles.iconButtonHovered, pressed && styles.iconButtonPressed, ]} accessibilityRole="button" accessibilityLabel="Refresh files" > {currentSortLabel} row.entry.path} contentContainerStyle={styles.entriesContent} initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> ) : ( [ styles.iconButton, (hovered || pressed) && styles.iconButtonHovered, pressed && styles.iconButtonPressed, ]} accessibilityRole="button" accessibilityLabel="Refresh files" > {currentSortLabel} row.entry.path} contentContainerStyle={styles.entriesContent} initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> )} )} {isMobile ? ( {selectedEntryPath?.split("/").pop() ?? "Preview"} ) : null} ); } function FilePreviewBody({ preview, isLoading, variant, }: { preview: ExplorerFile | null; isLoading: boolean; variant: "inline" | "sheet"; }) { if (isLoading && !preview) { return ( Loading file… ); } if (!preview) { return ( No preview available ); } if (preview.kind === "text") { if (variant === "sheet") { return ( {preview.content} ); } return ( {preview.content} ); } if (preview.kind === "image" && preview.content) { if (variant === "sheet") { return ( ); } return ( ); } return ( Binary preview unavailable {formatFileSize({ size: preview.size })} ); } 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`; } 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.surface0, }, 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: 32 + theme.spacing[2] * 2, paddingHorizontal: theme.spacing[3], borderBottomWidth: 1, borderBottomColor: theme.colors.border, backgroundColor: theme.colors.surface0, }, 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: 32, 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: 32, height: 32, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, iconButtonPressed: { opacity: 0.8, transform: [{ scale: 0.96 }], }, refreshIcon: { width: 16, height: 16, alignItems: "center", justifyContent: "center", }, previewContent: { flex: 1, }, 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], }, }));