import React, { useEffect, useMemo, useRef, useState } from 'react'; import { themedColor } from '../../theme'; import { ActivityIndicator, Image, Modal, Pressable, ScrollView, Text, TextInput, View, } from 'react-native'; import { AlertCircle, ChevronDown, ChevronRight, File, FileCode, FileImage, FileText, Folder, Save, Search, Upload, X, type LucideIcon, } from 'lucide-react-native'; import { DEFAULT_SANDBOX_FILE_PATHS, buildFileTree, flattenFileTree, getFileCategory, getFileName, getFolderPath, getImageMimeType, isEditableFile, } from './fileTreeUtils'; import { editorShellStyles } from './editorShellStyles'; import { useAgentBi } from '../../analytics/mixpanelContext'; import { useSuperagentFiles } from '../../runtime/runtimeContext'; import { styles } from '../../styles'; import type { SuperagentAgent, SuperagentSandboxFileContent, SuperagentSandboxFileNode, } from '../../types'; export function FilesPanel({ agent }: { agent: SuperagentAgent }) { const { fileLoadError, fileLoadFailed, filePaths = [], isLoadingFiles: isLoading, onOpenSandboxFile, onSaveSandboxFile, onUploadSandboxFiles, } = useSuperagentFiles(); const bi = useAgentBi(); const [query, setQuery] = useState(''); const [openedFile, setOpenedFile] = useState(null); const [draftContent, setDraftContent] = useState(''); const [fileError, setFileError] = useState(null); const [loadingPath, setLoadingPath] = useState(null); const [isSaving, setIsSaving] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadStatus, setUploadStatus] = useState(null); const { fileCount, folderCount, tree } = useMemo(() => { const primaryTree = buildFileTree(filePaths); if (primaryTree.fileCount > 0 || primaryTree.folderCount > 0 || primaryTree.tree.length > 0) { return primaryTree; } return buildFileTree(DEFAULT_SANDBOX_FILE_PATHS); }, [filePaths]); const allFiles = useMemo(() => flattenFileTree(tree), [tree]); const filteredFiles = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { return []; } return allFiles.filter((path) => path.toLowerCase().includes(normalizedQuery)); }, [allFiles, query]); // Fire once per drawer open, after the load completes — never with the fallback // scaffold's counts (skip while loading or when the API list failed), and using // the real filePaths counts rather than the displayed scaffold. const viewedRef = useRef(false); useEffect(() => { if (viewedRef.current || isLoading || fileLoadFailed) return; viewedRef.current = true; const loaded = buildFileTree(filePaths); void bi.trackEditor('Files Viewed', { file_count: loaded.fileCount, folder_count: loaded.folderCount }); }, [isLoading, fileLoadFailed, filePaths, bi]); // Report search on the settled query, not per keystroke, so search-volume and // zero-result counts aren't inflated. Native search is flat, so folder_depth is 0. // Keyed on `query` alone (latest count read via a ref) so a file-list reload that // changes results_count for the same query text doesn't re-fire the event. const filteredCountRef = useRef(0); filteredCountRef.current = filteredFiles.length; useEffect(() => { const normalizedQuery = query.trim(); if (!normalizedQuery) return; const timer = setTimeout(() => { void bi.trackEditor('Files Search', { query_length: normalizedQuery.length, results_count: filteredCountRef.current, folder_depth: 0, }); }, 400); return () => clearTimeout(timer); }, [query, bi]); const openFile = async (path: string) => { if (!onOpenSandboxFile) { setFileError('File opening is not connected in this app build.'); return; } void bi.trackEditor('File Open', { file_name: path }); // Binary files (non-SVG images, PDFs) can't be read through the text-only // sandbox content endpoint — it returns 415 for non-UTF-8 bytes — so don't // fetch them. Open a placeholder instead of surfacing a confusing error; // a real preview can return once a binary-safe read/download endpoint exists. const category = getFileCategory(path); const isBinaryUnreadable = (category === 'image' && getImageMimeType(path) !== 'image/svg+xml') || category === 'pdf'; if (isBinaryUnreadable) { setFileError(null); setOpenedFile({ content: '', path }); setDraftContent(''); return; } setLoadingPath(path); setFileError(null); try { const file = await onOpenSandboxFile({ agentId: agent.id, path }); setOpenedFile(file); setDraftContent(file.content); } catch (error) { setFileError(error instanceof Error ? error.message : 'Failed to open file.'); } finally { setLoadingPath(null); } }; const uploadFiles = async () => { if (!onUploadSandboxFiles) { setFileError('Native sandbox upload is not connected in this app build.'); return; } void bi.trackEditor('File Upload'); setIsUploading(true); setFileError(null); setUploadStatus(null); try { const result = await onUploadSandboxFiles({ agentId: agent.id }); const uploadedCount = Array.isArray(result) ? result.length : 0; setUploadStatus(uploadedCount > 0 ? `Uploaded ${uploadedCount} file${uploadedCount === 1 ? '' : 's'}.` : null); } catch (error) { setFileError(error instanceof Error ? error.message : 'Failed to upload files.'); } finally { setIsUploading(false); } }; const closeOpenedFile = () => { setOpenedFile(null); setDraftContent(''); }; const saveOpenedFile = async () => { if (!openedFile || !onSaveSandboxFile || !isEditableFile(openedFile.path)) { return; } setIsSaving(true); setFileError(null); try { await onSaveSandboxFile({ agentId: agent.id, content: draftContent, path: openedFile.path, }); setOpenedFile({ content: draftContent, path: openedFile.path }); void bi.trackEditor('Library File Save', { redeploy: false }); // native has no redeploy toggle } catch (error) { setFileError(error instanceof Error ? error.message : 'Failed to save file.'); } finally { setIsSaving(false); } }; return ( Sandbox files {fileCount} files · {folderCount} folders [ editorShellStyles.fileIconButton, pressed && styles.pressed, isUploading && editorShellStyles.disabledAction, ]} > {isUploading ? ( ) : ( )} {fileError ? {fileError} : null} {uploadStatus ? {uploadStatus} : null} {fileLoadFailed ? ( Files API did not return a list. Showing the default sandbox folders. ) : null} {fileLoadFailed && fileLoadError ? ( {fileLoadError} ) : null} {isLoading ? ( Loading files... ) : fileCount === 0 && folderCount === 0 && tree.length === 0 ? ( No files yet Generated and uploaded sandbox files will appear here. ) : ( {query.trim() ? ( filteredFiles.length > 0 ? ( filteredFiles.map((path) => ( openFile(path)} path={path} /> )) ) : ( No matching files Try another folder or file name. ) ) : ( tree.map((node) => ( )) )} )} ); } function FileTreeNode({ depth = 0, isLoadingPath, node, onFilePress, parentPath = '', }: { depth?: number; isLoadingPath: string | null; node: SuperagentSandboxFileNode; onFilePress: (path: string) => void; parentPath?: string; }) { const bi = useAgentBi(); const [expanded, setExpanded] = useState(depth < 1); const isFolder = node.type === 'folder'; const path = parentPath ? `${parentPath}/${node.name}` : node.name; const toggleExpanded = () => { if (!expanded) void bi.trackEditor('Files Folder Open', { file_count: (node.children ?? []).filter((child) => child.type === 'file').length }); setExpanded((current) => !current); }; if (!isFolder) { return ( onFilePress(path)} path={path} /> ); } return ( [ editorShellStyles.fileTreeRow, { paddingLeft: 10 + depth * 14 }, pressed && styles.pressed, ]} > {expanded ? ( ) : ( )} {node.name} {flattenFileTree(node.children ?? []).length} {expanded ? ( node.children?.map((child) => ( )) ) : null} ); } function FileSearchRow({ isLoading, onPress, path, }: { isLoading: boolean; onPress: () => void; path: string; }) { return ( [ editorShellStyles.fileSearchRow, pressed && styles.pressed, ]} > {getFileName(path)} {getFolderPath(path) || 'root'} {isLoading ? : null} ); } function FileTreeRow({ depth, isLoading, onPress, path, }: { depth: number; isLoading: boolean; onPress: () => void; path: string; }) { return ( [ editorShellStyles.fileTreeRow, { paddingLeft: 32 + depth * 14 }, pressed && styles.pressed, ]} > {getFileName(path)} {isLoading ? : null} ); } function FileKindIcon({ path }: { path: string }) { const category = getFileCategory(path); const Icon: LucideIcon = category === 'image' ? FileImage : category === 'code' || category === 'html' ? FileCode : category === 'markdown' || category === 'text' ? FileText : File; return ; } function FileEditorModal({ draftContent, file, isSaving, onChangeDraft, onClose, onSave, }: { draftContent: string; file: SuperagentSandboxFileContent | null; isSaving: boolean; onChangeDraft: (content: string) => void; onClose: () => void; onSave: () => void; }) { if (!file) { return null; } const editable = isEditableFile(file.path); const isDirty = draftContent !== file.content; const category = getFileCategory(file.path); return ( [editorShellStyles.fileIconButton, pressed && styles.pressed]} > {getFileName(file.path)} {file.path} {editable ? ( [ editorShellStyles.fileSaveButton, (!isDirty || isSaving) && editorShellStyles.disabledAction, pressed && styles.pressed, ]} > {isSaving ? ( ) : ( )} Save ) : ( Read only )} ); } function FileContentView({ category, content, editable, onChangeText, path, }: { category: ReturnType; content: string; editable: boolean; onChangeText: (content: string) => void; path: string; }) { if (category === 'image' && getImageMimeType(path) !== 'image/svg+xml') { // Binary images open without content (the text content endpoint can't return // them); show a placeholder rather than a broken image. If a binary-safe // endpoint later supplies base64/data-URI content, render it. if (!content) { return ( Image preview This file is available in the sandbox. Open it in the web app to view it — native preview of binary files isn't enabled in this build yet. ); } const source = content.startsWith('data:') ? content : `data:${getImageMimeType(path)};base64,${content}`; return ( ); } if (category === 'pdf') { return ( PDF preview This file is available in the sandbox. Native PDF viewing is not enabled in this build yet. ); } return ( ); }