import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, FlatList, Image as RNImage, LayoutChangeEvent, ListRenderItemInfo, RefreshControl, ViewToken, NativeScrollEvent, NativeSyntheticEvent, Modal, Platform, Pressable, ScrollView, Text, View, useWindowDimensions, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import * as Clipboard from "expo-clipboard"; import { File as FSFile, Paths } from "expo-file-system"; import * as Sharing from "expo-sharing"; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, BottomSheetView, } from "@gorhom/bottom-sheet"; import { Check, ChevronDown, Download, File, FileText, Folder, Image as ImageIcon, MoreVertical, X, XCircle, } from "lucide-react-native"; import type { ExplorerEntry } from "@/stores/session-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { DaemonProfile } from "@/contexts/daemon-registry-context"; import { useSessionStore } from "@/stores/session-store"; import { useExplorerSidebarStore, type SortOption, } from "@/stores/explorer-sidebar-store"; const MAX_CONCURRENT_THUMBNAILS = 2; const THUMBNAIL_TIMEOUT_MS = 15000; const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: "name", label: "Name" }, { value: "modified", label: "Modified" }, { value: "size", label: "Size" }, ]; interface FileExplorerPaneProps { serverId: string; agentId: string; } export function FileExplorerPane({ serverId, agentId, }: FileExplorerPaneProps) { const { theme } = useUnistyles(); const { connectionStates } = useDaemonConnections(); const daemonProfile = connectionStates.get(serverId)?.daemon; const agent = useSessionStore((state) => agentId && state.sessions[serverId] ? state.sessions[serverId]?.agents.get(agentId) : undefined ); const explorerState = useSessionStore((state) => agentId && state.sessions[serverId] ? state.sessions[serverId]?.fileExplorer.get(agentId) : undefined ); const methods = useSessionStore((state) => state.sessions[serverId]?.methods); const requestDirectoryListing = methods?.requestDirectoryListing; const requestFilePreview = methods?.requestFilePreview; const requestFileDownloadToken = methods?.requestFileDownloadToken; const navigateExplorerBack = methods?.navigateExplorerBack; const { viewMode, sortOption, setSortOption } = useExplorerSidebarStore(); const [selectedEntryPath, setSelectedEntryPath] = useState(null); const listScrollRef = useRef | null>(null); const listScrollOffsetRef = useRef(0); const scrollOffsetsByPathRef = useRef>(new Map()); const pendingScrollRestoreRef = useRef(null); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const history = explorerState?.history ?? []; const lastKnownDirectory = history[history.length - 1]; const rememberedDirectory = explorerState?.lastVisitedPath; const initialTargetDirectory = rememberedDirectory ?? lastKnownDirectory ?? "."; const currentPath = explorerState?.currentPath ?? "."; const pendingRequest = explorerState?.pendingRequest ?? null; const isExplorerLoading = explorerState?.isLoading ?? false; const isListingLoading = Boolean( isExplorerLoading && pendingRequest?.mode === "list" ); const pendingDirectoryPath = isListingLoading && pendingRequest ? pendingRequest.path : null; const activePath = pendingDirectoryPath ?? currentPath; const directory = explorerState?.directories.get(activePath); const rawEntries = directory?.entries ?? []; const entries = useMemo(() => { const sorted = [...rawEntries]; sorted.sort((a, b) => { // Directories always come first 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; }, [rawEntries, sortOption]); const showInitialListLoading = isListingLoading && entries.length === 0; const showListLoadingBanner = isListingLoading && entries.length > 0; const isPreviewLoading = Boolean( isExplorerLoading && pendingRequest?.mode === "file" && pendingRequest?.path === selectedEntryPath ); const error = explorerState?.lastError ?? null; const preview = selectedEntryPath ? explorerState?.files.get(selectedEntryPath) : null; const shouldShowPreview = Boolean(selectedEntryPath); const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState>({}); const viewabilityConfigRef = useRef({ itemVisiblePercentThreshold: 10, minimumViewTime: 0, }); // Bottom sheet for file preview const previewSheetRef = useRef(null); const previewSnapPoints = useMemo(() => ["70%", "95%"], []); // Thumbnail queue state const thumbnailQueueRef = useRef([]); const inFlightPathsRef = useRef>(new Set()); // Responsive gallery columns based on container width const [containerWidth, setContainerWidth] = useState(0); const gridColumnCount = containerWidth > 0 && containerWidth >= 400 ? 4 : 3; const listColumns = viewMode === "grid" ? gridColumnCount : 1; const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list"; const [menuEntry, setMenuEntry] = useState(null); const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 }); const [menuHeight, setMenuHeight] = useState(0); const [isRefreshing, setIsRefreshing] = useState(false); const [downloadToast, setDownloadToast] = useState<{ status: "downloading" | "complete" | "error"; fileName: string; message?: string; } | null>(null); const downloadToastTimeoutRef = useRef | null>(null); const agentIdRef = useRef(agentId); const viewModeRef = useRef(viewMode); const requestFilePreviewRef = useRef(requestFilePreview); const explorerFilesRef = useRef(explorerState?.files); const refreshPathRef = useRef(null); const refreshStartedRef = useRef(false); const hasInitializedRef = useRef(false); useEffect(() => { agentIdRef.current = agentId; }, [agentId]); useEffect(() => { viewModeRef.current = viewMode; }, [viewMode]); useEffect(() => { requestFilePreviewRef.current = requestFilePreview; }, [requestFilePreview]); useEffect(() => { explorerFilesRef.current = explorerState?.files; }, [explorerState?.files]); // Process items from the thumbnail queue const processNextThumbnail = useCallback(() => { const currentAgentId = agentIdRef.current; const currentRequestFilePreview = requestFilePreviewRef.current; if (!currentAgentId || !currentRequestFilePreview) { return; } while ( inFlightPathsRef.current.size < MAX_CONCURRENT_THUMBNAILS && thumbnailQueueRef.current.length > 0 ) { const path = thumbnailQueueRef.current.shift()!; if (explorerFilesRef.current?.has(path) || inFlightPathsRef.current.has(path)) { continue; } inFlightPathsRef.current.add(path); setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true })); currentRequestFilePreview(currentAgentId, path); setTimeout(() => { if (inFlightPathsRef.current.has(path)) { inFlightPathsRef.current.delete(path); setThumbnailLoadingMap((prev) => { const next = { ...prev }; delete next[path]; return next; }); processNextThumbnail(); } }, THUMBNAIL_TIMEOUT_MS); } }, []); // Enqueue a file preview request const enqueueFilePreview = useCallback( (path: string, options?: { priority?: boolean }) => { const currentAgentId = agentIdRef.current; const currentRequestFilePreview = requestFilePreviewRef.current; if (!currentAgentId || !currentRequestFilePreview) { return; } if (explorerFilesRef.current?.has(path)) { return; } if (options?.priority) { thumbnailQueueRef.current = []; if (inFlightPathsRef.current.has(path)) { return; } if (inFlightPathsRef.current.size > 0) { const abandonedPaths = Array.from(inFlightPathsRef.current); setThumbnailLoadingMap((prev) => { const next = { ...prev }; abandonedPaths.forEach((p) => delete next[p]); return next; }); inFlightPathsRef.current.clear(); } inFlightPathsRef.current.add(path); setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true })); currentRequestFilePreview(currentAgentId, path); setTimeout(() => { if (inFlightPathsRef.current.has(path)) { inFlightPathsRef.current.delete(path); setThumbnailLoadingMap((prev) => { const next = { ...prev }; delete next[path]; return next; }); processNextThumbnail(); } }, THUMBNAIL_TIMEOUT_MS); return; } if ( !thumbnailQueueRef.current.includes(path) && !inFlightPathsRef.current.has(path) ) { thumbnailQueueRef.current.push(path); processNextThumbnail(); } }, [processNextThumbnail] ); const handleViewableItemsChangedRef = useRef( ({ viewableItems }: { viewableItems: Array }) => { const currentViewMode = viewModeRef.current; if (currentViewMode !== "grid") { return; } viewableItems.forEach((token) => { const item = token.item as ExplorerEntry | undefined; if (!item || getEntryDisplayKind(item) !== "image") { return; } enqueueFilePreviewRef.current?.(item.path); }); } ); const enqueueFilePreviewRef = useRef(enqueueFilePreview); useEffect(() => { enqueueFilePreviewRef.current = enqueueFilePreview; }, [enqueueFilePreview]); const restoreQueuedScrollOffset = useCallback(() => { if (pendingScrollRestoreRef.current === null) { return; } if (!listScrollRef.current) { return; } const targetOffset = pendingScrollRestoreRef.current; listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false }); listScrollOffsetRef.current = targetOffset; pendingScrollRestoreRef.current = null; }, []); const queueScrollRestore = useCallback((offset: number) => { pendingScrollRestoreRef.current = offset; requestAnimationFrame(restoreQueuedScrollOffset); }, [restoreQueuedScrollOffset]); useEffect(() => { setSelectedEntryPath(null); }, [activePath]); // Open/close preview sheet based on selection useEffect(() => { if (selectedEntryPath) { previewSheetRef.current?.present(); } else { previewSheetRef.current?.dismiss(); } }, [selectedEntryPath]); useEffect(() => { if (shouldShowPreview) { return; } const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? listScrollOffsetRef.current; queueScrollRestore(savedOffset); }, [activePath, queueScrollRestore, shouldShowPreview]); useEffect(() => { const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? 0; listScrollOffsetRef.current = savedOffset; queueScrollRestore(savedOffset); }, [activePath, queueScrollRestore]); // Initial directory listing request useEffect(() => { if (!agentId || !requestDirectoryListing) { return; } if (hasInitializedRef.current) { return; } hasInitializedRef.current = true; requestDirectoryListing(agentId, initialTargetDirectory); }, [agentId, initialTargetDirectory, requestDirectoryListing]); const handleEntryPress = useCallback( (entry: ExplorerEntry) => { if (!agentId || !requestDirectoryListing) { return; } if (entry.kind === "directory") { setSelectedEntryPath(null); requestDirectoryListing(agentId, entry.path); return; } setSelectedEntryPath(entry.path); enqueueFilePreview(entry.path, { priority: true }); }, [agentId, requestDirectoryListing, enqueueFilePreview] ); const handleCopyPath = useCallback(async (path: string) => { await Clipboard.setStringAsync(path); }, []); const handleOpenMenu = useCallback((entry: ExplorerEntry, event: any) => { event.stopPropagation(); const { pageX, pageY } = event.nativeEvent ?? {}; setMenuAnchor({ left: typeof pageX === "number" ? pageX : 0, top: typeof pageY === "number" ? pageY : 0, }); setMenuEntry(entry); }, []); const handleCloseMenu = useCallback(() => { setMenuEntry(null); setMenuHeight(0); }, []); const handleMenuLayout = useCallback((event: LayoutChangeEvent) => { const { height } = event.nativeEvent.layout; setMenuHeight((current) => (current === height ? current : height)); }, []); const showDownloadToast = useCallback( (toast: { status: "downloading" | "complete" | "error"; fileName: string; message?: string }) => { if (downloadToastTimeoutRef.current) { clearTimeout(downloadToastTimeoutRef.current); downloadToastTimeoutRef.current = null; } setDownloadToast(toast); if (toast.status !== "downloading") { downloadToastTimeoutRef.current = setTimeout(() => { setDownloadToast(null); }, 3000); } }, [] ); const handleDownloadEntry = useCallback( async (entry: ExplorerEntry) => { if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { return; } const displayName = entry.name; try { const tokenResponse = await requestFileDownloadToken(agentId, entry.path); if (tokenResponse.error || !tokenResponse.token) { throw new Error(tokenResponse.error ?? "Failed to request download token."); } const downloadTarget = resolveDaemonDownloadTarget(daemonProfile); if (!downloadTarget.baseUrl) { throw new Error("Download host is unavailable."); } const fileName = tokenResponse.fileName ?? entry.name; const downloadUrl = buildDownloadUrl( downloadTarget.baseUrl, tokenResponse.token, Platform.OS === "web" ? downloadTarget.authCredentials : null ); if (Platform.OS === "web") { triggerBrowserDownload(downloadUrl, fileName); return; } showDownloadToast({ status: "downloading", fileName: displayName }); const targetFile = resolveDownloadTargetFile(fileName); const downloadedFile = await FSFile.downloadFileAsync( downloadUrl, targetFile, downloadTarget.authHeader ? { headers: { Authorization: downloadTarget.authHeader } } : undefined ); showDownloadToast({ status: "complete", fileName: displayName }); if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(downloadedFile.uri, { mimeType: tokenResponse.mimeType ?? undefined, dialogTitle: fileName ? `Share ${fileName}` : "Share file", }); } } catch (error) { const message = error instanceof Error ? error.message : "Failed to download file."; if (Platform.OS === "web") { console.warn("[FileExplorer] Download failed:", message); return; } showDownloadToast({ status: "error", fileName: displayName, message }); } }, [agentId, daemonProfile, requestFileDownloadToken, showDownloadToast] ); const menuPosition = useMemo(() => { if (!menuEntry) { return null; } const menuWidth = 180; const horizontalPadding = theme.spacing[2]; const verticalPadding = theme.spacing[2]; const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding); const maxTop = Math.max(verticalPadding, windowHeight - menuHeight - verticalPadding); const left = Math.min( Math.max(menuAnchor.left - menuWidth + horizontalPadding, horizontalPadding), maxLeft ); const top = Math.min(Math.max(menuAnchor.top + verticalPadding, verticalPadding), maxTop); return { top, left, width: menuWidth }; }, [menuEntry, menuAnchor.left, menuAnchor.top, menuHeight, theme.spacing, windowHeight, windowWidth]); const handleListScroll = useCallback( (event: NativeSyntheticEvent) => { const offset = event.nativeEvent.contentOffset.y; listScrollOffsetRef.current = offset; scrollOffsetsByPathRef.current.set(activePath, offset); }, [activePath] ); const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { const { width } = event.nativeEvent.layout; setContainerWidth(width); }, []); const handleClosePreviewSheet = useCallback(() => { setSelectedEntryPath(null); }, []); const handlePreviewSheetChange = useCallback((index: number) => { if (index === -1) { setSelectedEntryPath(null); } }, []); const renderPreviewBackdrop = useCallback( (props: React.ComponentProps) => ( ), [] ); const handleRetryDirectory = useCallback(() => { if (!agentId || !requestDirectoryListing) { return; } requestDirectoryListing(agentId, activePath); }, [agentId, requestDirectoryListing, activePath]); const handleRefresh = useCallback(() => { if (!agentId || !requestDirectoryListing) { return; } refreshPathRef.current = activePath; refreshStartedRef.current = false; setIsRefreshing(true); requestDirectoryListing(agentId, activePath, { recordHistory: false }); }, [agentId, requestDirectoryListing, activePath]); useEffect(() => { if (!isRefreshing) { return; } const refreshPath = refreshPathRef.current; if (!refreshPath) { return; } const isMatchingList = pendingRequest?.mode === "list" && pendingRequest?.path === refreshPath; if (isMatchingList) { refreshStartedRef.current = true; return; } if (refreshStartedRef.current) { setIsRefreshing(false); refreshPathRef.current = null; refreshStartedRef.current = false; } }, [isRefreshing, pendingRequest?.mode, pendingRequest?.path]); const handleNavigateBack = useCallback(() => { if (!agentId || !navigateExplorerBack) { return; } if ((explorerState?.history?.length ?? 0) > 1) { navigateExplorerBack(agentId); } }, [agentId, explorerState?.history?.length, navigateExplorerBack]); const renderEntry = useCallback( ({ item }: ListRenderItemInfo) => { if (viewMode === "grid") { const preview = explorerState?.files.get(item.path); const isImage = getEntryDisplayKind(item) === "image"; const isLoadingThumb = Boolean(thumbnailLoadingMap[item.path]); return ( handleEntryPress(item)} > {isImage && preview?.content ? ( ) : isImage && isLoadingThumb ? ( ) : ( renderEntryIcon(getEntryDisplayKind(item), theme.colors) )} {item.name} {formatFileSize({ size: item.size })} ); } const displayKind = getEntryDisplayKind(item); return ( handleEntryPress(item)} > {renderEntryIcon(displayKind, theme.colors)} {item.name} {item.kind.toUpperCase()} · {formatFileSize({ size: item.size })} ·{" "} {formatModifiedTime({ value: item.modifiedAt })} handleOpenMenu(item, event)} hitSlop={8} style={styles.menuButton} > ); }, [ explorerState?.files, handleEntryPress, handleOpenMenu, theme.colors, thumbnailLoadingMap, viewMode, ] ); 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 currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; const listHeaderComponent = useMemo(() => { const canGoBack = (explorerState?.history?.length ?? 0) > 1; return ( {canGoBack && ( ← )} {formatDirectoryLabel(activePath)} {currentSortLabel} {showListLoadingBanner && ( Loading {formatDirectoryLabel(activePath)}... )} ); }, [activePath, currentSortLabel, explorerState?.history?.length, handleNavigateBack, handleSortCycle, showListLoadingBanner, theme.colors.mutedForeground]); // Watch for completed file previews and process queue useEffect(() => { if (!explorerState) { return; } const completedPaths: string[] = []; for (const path of inFlightPathsRef.current) { if (explorerState.files.has(path)) { completedPaths.push(path); } } if (completedPaths.length === 0) { return; } for (const path of completedPaths) { inFlightPathsRef.current.delete(path); } setThumbnailLoadingMap((prev) => { const next = { ...prev }; for (const path of completedPaths) { delete next[path]; } return next; }); queueMicrotask(() => { processNextThumbnail(); }); }, [explorerState?.files.size, processNextThumbnail]); // Clear queue and loading state on path/view change useEffect(() => { thumbnailQueueRef.current = []; inFlightPathsRef.current.clear(); setThumbnailLoadingMap({}); }, [activePath, viewMode]); if (!agent) { return ( Agent not found ); } return ( {error ? ( {error} Retry ) : showInitialListLoading ? ( Loading directory... ) : entries.length === 0 ? ( Directory is empty ) : ( item.path} contentContainerStyle={ viewMode === "grid" ? styles.gridContent : styles.entriesContent } columnWrapperStyle={ viewMode === "grid" && listColumns > 1 ? styles.gridColumnWrapper : undefined } numColumns={listColumns} key={listKey} onScroll={handleListScroll} scrollEventThrottle={16} onLayout={restoreQueuedScrollOffset} onContentSizeChange={restoreQueuedScrollOffset} ListHeaderComponent={listHeaderComponent} extraData={{ viewMode, thumbnailLoadingMap }} initialNumToRender={20} maxToRenderPerBatch={30} windowSize={15} refreshControl={ } onViewableItemsChanged={handleViewableItemsChangedRef.current} viewabilityConfig={viewabilityConfigRef.current} /> )} {menuEntry && menuPosition ? ( { handleCopyPath(menuEntry.path); handleCloseMenu(); }} > Copy Path {menuEntry.kind === "file" ? ( { handleCloseMenu(); await handleDownloadEntry(menuEntry); }} > Download ) : null} ) : null} {selectedEntryPath?.split("/").pop() ?? "Preview"} {isPreviewLoading && !preview ? ( Loading file... ) : !preview ? ( No preview available yet ) : preview.kind === "text" ? ( {preview.content} ) : preview.kind === "image" && preview.content ? ( ) : ( Binary preview unavailable {formatFileSize({ size: preview.size })} )} {downloadToast && ( {downloadToast.status === "downloading" ? ( ) : downloadToast.status === "complete" ? ( ) : ( )} {downloadToast.fileName} {downloadToast.status === "downloading" ? "Downloading..." : downloadToast.status === "complete" ? "Download complete" : downloadToast.message ?? "Download failed"} {downloadToast.status !== "downloading" && ( setDownloadToast(null)} hitSlop={8} style={styles.downloadToastDismiss} > )} )} ); } function formatDirectoryLabel(path: string): string { return path === "." ? "workspace root" : path; } 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`; } function formatModifiedTime({ value }: { value: string }): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } return date.toLocaleString(); } 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 } ) { const color = colors.foreground; switch (kind) { case "directory": return ; 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(); } type DownloadTarget = { baseUrl: string | null; authHeader: string | null; authCredentials: { username: string; password: string } | null; }; function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget { const rawUrl = daemon?.restUrl ?? daemon?.wsUrl; if (!rawUrl) { return { baseUrl: null, authHeader: null, authCredentials: null }; } let parsed: URL; try { parsed = new URL(rawUrl); } catch { return { baseUrl: null, authHeader: null, authCredentials: null }; } if (parsed.protocol === "ws:") { parsed.protocol = "http:"; } else if (parsed.protocol === "wss:") { parsed.protocol = "https:"; } let authCredentials: { username: string; password: string } | null = null; if (parsed.username || parsed.password) { authCredentials = { username: decodeURIComponent(parsed.username), password: decodeURIComponent(parsed.password), }; parsed.username = ""; parsed.password = ""; } parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/"); const baseUrl = parsed.origin; const authHeader = authCredentials ? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}` : null; return { baseUrl, authHeader, authCredentials }; } function buildDownloadUrl( baseUrl: string, token: string, authCredentials: { username: string; password: string } | null ): string { const url = new URL("/api/files/download", baseUrl); url.searchParams.set("token", token); if (authCredentials) { url.username = authCredentials.username; url.password = authCredentials.password; } return url.toString(); } function triggerBrowserDownload(url: string, fileName: string) { if (typeof document === "undefined") { if (typeof window !== "undefined") { window.open(url, "_blank", "noopener"); } return; } const link = document.createElement("a"); link.href = url; link.download = fileName; link.rel = "noopener"; document.body.appendChild(link); link.click(); link.remove(); } function resolveDownloadTargetFile(fileName: string): FSFile { const directory = Paths.cache ?? Paths.document; if (!directory) { throw new Error("No download directory available."); } const safeName = sanitizeDownloadFileName(fileName); const split = splitFileName(safeName); let targetFile = new FSFile(directory, safeName); let suffix = 1; while (targetFile.exists) { targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`); suffix += 1; } return targetFile; } function sanitizeDownloadFileName(fileName: string): string { const trimmed = fileName.trim(); if (!trimmed) { return "download"; } return trimmed.replace(/[\\/:*?"<>|]+/g, "_"); } function splitFileName(fileName: string): { base: string; ext: string } { const lastDot = fileName.lastIndexOf("."); if (lastDot <= 0) { return { base: fileName, ext: "" }; } return { base: fileName.slice(0, lastDot), ext: fileName.slice(lastDot), }; } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.background, }, content: { flex: 1, flexDirection: "column", paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3], gap: theme.spacing[3], }, listSection: { flex: 1, }, entriesContent: { paddingBottom: theme.spacing[4], }, headerContainer: { gap: theme.spacing[2], paddingBottom: theme.spacing[2], }, headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", }, pathContainer: { flex: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], marginRight: theme.spacing[2], }, pathText: { flex: 1, fontSize: theme.fontSize.sm, color: theme.colors.mutedForeground, fontFamily: "monospace", }, backButton: { padding: theme.spacing[1], }, backButtonText: { fontSize: theme.fontSize.lg, color: theme.colors.foreground, }, loadingBanner: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], }, loadingBannerText: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, }, sortButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, backgroundColor: theme.colors.card, }, sortButtonText: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, }, centerState: { flex: 1, alignItems: "center", justifyContent: "center", gap: theme.spacing[2], padding: theme.spacing[4], }, loadingText: { color: theme.colors.mutedForeground, 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.primary, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], }, retryButtonText: { color: theme.colors.primary, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, }, emptyText: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.base, textAlign: "center", }, entryRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, marginBottom: theme.spacing[1], }, entryRowBackground: { backgroundColor: theme.colors.card, }, entryInfo: { flex: 1, flexDirection: "row", alignItems: "center", columnGap: theme.spacing[2], marginRight: theme.spacing[3], }, entryIcon: { width: 28, alignItems: "center", }, entryTextContainer: { flex: 1, }, entryName: { color: theme.colors.foreground, fontSize: theme.fontSize.base, }, entryMeta: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, marginTop: theme.spacing[1], }, menuButton: { width: 36, height: 36, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, menuOverlay: { flex: 1, }, menuBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.2)", }, entryMenu: { borderRadius: theme.borderRadius.md, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, backgroundColor: theme.colors.card, padding: theme.spacing[1], }, entryMenuItem: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, }, entryMenuText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, }, codeText: { color: theme.colors.foreground, fontFamily: "monospace", fontSize: theme.fontSize.sm, flexShrink: 0, }, gridContent: { paddingBottom: theme.spacing[4], paddingHorizontal: theme.spacing[1], }, gridColumnWrapper: { justifyContent: "space-between", marginBottom: theme.spacing[2], }, gridCard: { flex: 1, borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, padding: theme.spacing[2], gap: theme.spacing[2], backgroundColor: theme.colors.card, marginHorizontal: theme.spacing[1], marginBottom: theme.spacing[1], minWidth: 0, }, gridThumbnail: { width: "100%", aspectRatio: 1, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", overflow: "hidden", backgroundColor: theme.colors.muted, }, gridImageBackground: { backgroundColor: theme.colors.background, }, gridImage: { width: "100%", height: "100%", }, gridName: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, gridMeta: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, }, sheetBackground: { backgroundColor: theme.colors.card, }, 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], }, sheetContent: { flex: 1, }, sheetScrollContent: { padding: theme.spacing[4], }, sheetCenterState: { flex: 1, alignItems: "center", justifyContent: "center", gap: theme.spacing[2], padding: theme.spacing[4], }, sheetImageScrollContent: { flex: 1, alignItems: "center", justifyContent: "center", padding: theme.spacing[4], }, sheetImage: { width: "100%", aspectRatio: 1, }, downloadToast: { position: "absolute", bottom: theme.spacing[4], left: theme.spacing[4], right: theme.spacing[4], zIndex: 1000, }, downloadToastContent: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], backgroundColor: theme.colors.card, borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[4], shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 8, elevation: 8, }, downloadToastTextContainer: { flex: 1, gap: theme.spacing[1], }, downloadToastFileName: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, }, downloadToastStatus: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, }, downloadToastDismiss: { padding: theme.spacing[1], }, }));