import { IS_PLATFORM } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { Plus } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { Button, cn, flattenTree, INodeRendererProps, TreeView } from 'ui' import { FileAction, TreeChildData, type FileData } from './FileExplorerAndEditor.types' import { extractZipFile, getFileAction, getLanguageFromFileName, isBinaryFile, isZipFile, } from './FileExplorerAndEditor.utils' import { FileExplorerAndEditorRow } from './FileExplorerAndEditorRow' import { AIEditor } from '@/components/ui/AIEditor' interface FileExplorerAndEditorProps { files: FileData[] onFilesChange: (files: FileData[]) => void aiEndpoint?: string aiMetadata?: { projectRef?: string connectionString?: string | null orgSlug?: string } selectedFileId?: number setSelectedFileId?: (id: number) => void } const denoJsonDefaultContent = JSON.stringify({ imports: {} }, null, '\t') export const FileExplorerAndEditor = ({ files, onFilesChange, aiEndpoint, aiMetadata, selectedFileId: extSelectedFileId, setSelectedFileId: extSetSelectedFileId, }: FileExplorerAndEditorProps) => { const [isDragOver, setIsDragOver] = useState(false) const [_selectedFileId, _setSelectedFileId] = useState(files[0]?.id) const [extractionProgress, setExtractionProgress] = useState<{ current: number total: number } | null>(null) const selectedFileId = extSelectedFileId ?? _selectedFileId const setSelectedFileId = extSetSelectedFileId ?? _setSelectedFileId const selectedFile = files.find((f) => f.id === selectedFileId) const isExtractingZip = extractionProgress !== null const [treeData, setTreeData] = useState<{ name: string; children: TreeChildData[] }>({ name: '', children: files.map((file) => ({ id: file.id.toString(), name: file.name, metadata: { isEditing: false, originalId: file.id, state: file.state, }, })), }) const handleChange = (value: string) => { const updatedFiles = files.map((file) => file.id === selectedFileId ? { ...file, content: value } : file ) onFilesChange(updatedFiles) } const addNewFile = () => { const newId = Math.max(0, ...files.map((f) => f.id)) + 1 const updatedFiles = files.map((f) => ({ ...f, selected: false })) setSelectedFileId(newId) onFilesChange([ ...updatedFiles, { id: newId, name: `file${newId}.ts`, content: '', state: 'new' }, ]) } const addDroppedFiles = async (droppedFiles: FileList) => { const newFiles: FileData[] = [] const updatedFiles = files.map((f) => ({ ...f, selected: false })) const allFiles: { name: string; content: string; size: number }[] = [] let extractedCount = 0 let replacedCount = 0 let hasReplacedFiles = false // Track if any existing files were replaced for (const file of droppedFiles) { if (isZipFile(file.name)) { try { setExtractionProgress({ current: 0, total: 0 }) const extractedFiles = await extractZipFile(file, (current, total) => { setExtractionProgress({ current, total }) }) allFiles.push(...extractedFiles) } catch (error) { toast.error(

Failed to extract {file.name}

{error instanceof Error ? error.message : 'Unknown error occurred'}

, { duration: 8000 } ) } finally { setExtractionProgress(null) } } else { try { let content: string if (isBinaryFile(file.name)) { // For binary files, read as ArrayBuffer and convert to base64 or keep as binary data const arrayBuffer = await file.arrayBuffer() const bytes = new Uint8Array(arrayBuffer) content = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') } else { content = await file.text() } allFiles.push({ name: file.name, size: file.size, content }) } catch (error) { toast.error(`Failed to read file: ${file.name}: ${error}`) } } } for (const file of allFiles) { const actionResult = getFileAction(file.name, updatedFiles, newFiles) switch (actionResult.action) { case FileAction.REPLACE_EXISTING: updatedFiles[actionResult.index] = { ...updatedFiles[actionResult.index], content: file.content, } replacedCount++ hasReplacedFiles = true break case FileAction.REPLACE_NEW: newFiles[actionResult.index] = { ...newFiles[actionResult.index], content: file.content, } replacedCount++ break case FileAction.CREATE_NEW: const newId = Math.max( 0, ...files.map((f) => f.id), ...updatedFiles.map((f) => f.id), ...newFiles.map((f) => f.id) ) + 1 newFiles.push({ id: newId, name: file.name, content: file.content, state: 'new', }) extractedCount++ break } } // Show success message const messages: string[] = [] if (extractedCount > 0) { messages.push(`Added ${extractedCount} new file${extractedCount > 1 ? 's' : ''}`) } if (replacedCount > 0) { messages.push(`Replaced ${replacedCount} existing file${replacedCount > 1 ? 's' : ''}`) } const totalFilesProcessed = extractedCount + replacedCount if (totalFilesProcessed > 0) { toast.success(

Successfully added dropped file{totalFilesProcessed > 1 ? 's' : ''}

{messages.join(', ')}.

, { duration: 5000 } ) } // Select the last added/modified file if (newFiles.length > 0) { setSelectedFileId(newFiles[newFiles.length - 1].id) onFilesChange([...updatedFiles, ...newFiles]) } else if (hasReplacedFiles) { // If we only replaced files, select the first one setSelectedFileId(updatedFiles[0].id) onFilesChange(updatedFiles) } } const handleStartRename = useCallback( (id: number) => { // Force re-render of the TreeView with the updated metadata setTreeData({ name: '', children: files.map((file) => ({ id: file.id.toString(), name: file.name, metadata: { isEditing: file.id === id, originalId: file.id, state: file.state, }, })), }) }, [files] ) const exitEditMode = useCallback(() => { // Force re-render of the TreeView with the updated metadata setTreeData({ name: '', children: files.map((file) => ({ id: file.id.toString(), name: file.name, metadata: { isEditing: false, originalId: file.id, state: file.state, }, })), }) }, [files]) const handleFileNameChange = useCallback( (id: number, newName: string) => { // Don't allow empty names if (!newName.trim()) { toast.error('File name cannot be empty') return exitEditMode() } // Check if the new name already exists in other files const isDuplicate = files.some((file) => file.id !== id && file.name === newName) if (isDuplicate) { toast.error( `The name ${newName} already exists in the current directory. Please use a different name.` ) return exitEditMode() } const updatedFiles = files.map((file) => { return file.id === id ? { ...file, name: newName, content: newName === 'deno.json' && file.content === '' ? denoJsonDefaultContent : file.content, } : file }) onFilesChange(updatedFiles) }, [files, onFilesChange, exitEditMode] ) const handleFileDelete = useCallback( (id: number) => { if (files.length <= 1) { // Don't allow deleting the last file return } const fileToDelete = files.find((f) => f.id === id) const isSelected = fileToDelete?.id === selectedFileId const updatedFiles = files.filter((file) => file.id !== id) // If the deleted file was selected, select another file if (isSelected && updatedFiles.length > 0) { setSelectedFileId(updatedFiles[0].id) } onFilesChange(updatedFiles) }, [files, selectedFileId, setSelectedFileId, onFilesChange] ) const handleDragOver = (e: React.DragEvent) => { e.preventDefault() setIsDragOver(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) } const handleDrop = async (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) const droppedFiles = e.dataTransfer.files if (droppedFiles.length > 0) { await addDroppedFiles(droppedFiles) } } // Update treeData when files change useEffect(() => { setTreeData({ name: '', children: files.map((file) => ({ id: file.id.toString(), name: file.name, metadata: { isEditing: false, originalId: file.id, state: file.state, }, })), }) if (!selectedFileId && files.length > 0) setSelectedFileId(files[0].id) // eslint-disable-next-line react-hooks/exhaustive-deps }, [files]) const renderNode = useCallback( (props: INodeRendererProps) => ( ), [ files, selectedFileId, setSelectedFileId, handleFileNameChange, handleStartRename, handleFileDelete, ] ) return (
{(isDragOver || isExtractingZip) && (
{isExtractingZip && extractionProgress ? (
Extracting zip file...
Processing file {extractionProgress.current} of {extractionProgress.total}
) : (
Drop files here to add them
)}
)}

Files

{IS_PLATFORM && ( )}
{selectedFile && isBinaryFile(selectedFile.name) ? (
Cannot Edit Selected File
Binary files like .{selectedFile.name.split('.').pop()} cannot be edited in the text editor
) : ( )}
) }