| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450 |
- 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<number>(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(
- <div className="flex flex-col gap-y-1">
- <p className="text-foreground">Failed to extract {file.name}</p>
- <p className="text-foreground-light">
- {error instanceof Error ? error.message : 'Unknown error occurred'}
- </p>
- </div>,
- { 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(
- <div className="flex flex-col gap-y-1">
- <p className="text-foreground">
- Successfully added dropped file{totalFilesProcessed > 1 ? 's' : ''}
- </p>
- <p className="text-foreground-light">{messages.join(', ')}.</p>
- </div>,
- { 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) => (
- <FileExplorerAndEditorRow
- {...props}
- files={files}
- selectedFileId={selectedFileId}
- setSelectedFileId={setSelectedFileId}
- handleFileNameChange={handleFileNameChange}
- handleStartRename={handleStartRename}
- handleFileDelete={handleFileDelete}
- />
- ),
- [
- files,
- selectedFileId,
- setSelectedFileId,
- handleFileNameChange,
- handleStartRename,
- handleFileDelete,
- ]
- )
- return (
- <div
- className={cn(
- 'flex-1 overflow-hidden flex h-full relative gap-x-3 bg-surface-100',
- isDragOver && 'bg-blue-50'
- )}
- onDragOver={handleDragOver}
- onDragLeave={handleDragLeave}
- onDrop={handleDrop}
- >
- <AnimatePresence>
- {(isDragOver || isExtractingZip) && (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- transition={{ duration: 0.1 }}
- className="absolute inset-0 bg/30 z-10 flex items-center justify-center"
- >
- <div className="w-96 py-20 bg/60 border-2 border-dashed border-muted flex items-center justify-center">
- {isExtractingZip && extractionProgress ? (
- <div className="text-center space-y-2">
- <div className="text-base">Extracting zip file...</div>
- <div className="text-sm text-foreground-light">
- Processing file {extractionProgress.current} of {extractionProgress.total}
- </div>
- </div>
- ) : (
- <div className="text-base">Drop files here to add them</div>
- )}
- </div>
- </motion.div>
- )}
- </AnimatePresence>
- <div className="min-w-64 w-64 border-r bg-surface-200 flex flex-col">
- <div className="py-4 px-6 border-b flex items-center justify-between">
- <h3 className="text-sm font-normal font-mono uppercase text-lighter tracking-wide">
- Files
- </h3>
- {IS_PLATFORM && (
- <Button size="tiny" type="default" icon={<Plus size={14} />} onClick={addNewFile}>
- Add File
- </Button>
- )}
- </div>
- <div className="flex-1 overflow-y-auto">
- <TreeView
- data={flattenTree(treeData)}
- aria-label="files tree"
- nodeRenderer={renderNode}
- />
- </div>
- </div>
- <div className="grow min-w-0">
- {selectedFile && isBinaryFile(selectedFile.name) ? (
- <div className="flex items-center justify-center h-full">
- <div className="text-center">
- <div className="text-foreground-light text-lg mb-2">Cannot Edit Selected File</div>
- <div className="text-foreground-lighter text-sm">
- Binary files like .{selectedFile.name.split('.').pop()} cannot be edited in the text
- editor
- </div>
- </div>
- </div>
- ) : (
- <AIEditor
- language={getLanguageFromFileName(selectedFile?.name || 'index.ts')}
- value={selectedFile?.content}
- onChange={handleChange}
- aiEndpoint={aiEndpoint}
- aiMetadata={aiMetadata}
- options={{
- tabSize: 2,
- fontSize: 13,
- minimap: { enabled: false },
- wordWrap: 'on',
- lineNumbers: 'on',
- folding: false,
- padding: { top: 20, bottom: 20 },
- lineNumbersMinChars: 3,
- fixedOverflowWidgets: true,
- readOnly: !IS_PLATFORM,
- }}
- />
- )}
- </div>
- </div>
- )
- }
|