index.tsx450 lines · main
1import { IS_PLATFORM } from 'common'
2import { AnimatePresence, motion } from 'framer-motion'
3import { Plus } from 'lucide-react'
4import { useCallback, useEffect, useState } from 'react'
5import { toast } from 'sonner'
6import { Button, cn, flattenTree, INodeRendererProps, TreeView } from 'ui'
7
8import { FileAction, TreeChildData, type FileData } from './FileExplorerAndEditor.types'
9import {
10 extractZipFile,
11 getFileAction,
12 getLanguageFromFileName,
13 isBinaryFile,
14 isZipFile,
15} from './FileExplorerAndEditor.utils'
16import { FileExplorerAndEditorRow } from './FileExplorerAndEditorRow'
17import { AIEditor } from '@/components/ui/AIEditor'
18
19interface FileExplorerAndEditorProps {
20 files: FileData[]
21 onFilesChange: (files: FileData[]) => void
22 aiEndpoint?: string
23 aiMetadata?: {
24 projectRef?: string
25 connectionString?: string | null
26 orgSlug?: string
27 }
28
29 selectedFileId?: number
30 setSelectedFileId?: (id: number) => void
31}
32
33const denoJsonDefaultContent = JSON.stringify({ imports: {} }, null, '\t')
34
35export const FileExplorerAndEditor = ({
36 files,
37 onFilesChange,
38 aiEndpoint,
39 aiMetadata,
40 selectedFileId: extSelectedFileId,
41 setSelectedFileId: extSetSelectedFileId,
42}: FileExplorerAndEditorProps) => {
43 const [isDragOver, setIsDragOver] = useState(false)
44 const [_selectedFileId, _setSelectedFileId] = useState<number>(files[0]?.id)
45 const [extractionProgress, setExtractionProgress] = useState<{
46 current: number
47 total: number
48 } | null>(null)
49
50 const selectedFileId = extSelectedFileId ?? _selectedFileId
51 const setSelectedFileId = extSetSelectedFileId ?? _setSelectedFileId
52
53 const selectedFile = files.find((f) => f.id === selectedFileId)
54 const isExtractingZip = extractionProgress !== null
55
56 const [treeData, setTreeData] = useState<{ name: string; children: TreeChildData[] }>({
57 name: '',
58 children: files.map((file) => ({
59 id: file.id.toString(),
60 name: file.name,
61 metadata: {
62 isEditing: false,
63 originalId: file.id,
64 state: file.state,
65 },
66 })),
67 })
68
69 const handleChange = (value: string) => {
70 const updatedFiles = files.map((file) =>
71 file.id === selectedFileId ? { ...file, content: value } : file
72 )
73
74 onFilesChange(updatedFiles)
75 }
76
77 const addNewFile = () => {
78 const newId = Math.max(0, ...files.map((f) => f.id)) + 1
79 const updatedFiles = files.map((f) => ({ ...f, selected: false }))
80
81 setSelectedFileId(newId)
82 onFilesChange([
83 ...updatedFiles,
84 { id: newId, name: `file${newId}.ts`, content: '', state: 'new' },
85 ])
86 }
87
88 const addDroppedFiles = async (droppedFiles: FileList) => {
89 const newFiles: FileData[] = []
90 const updatedFiles = files.map((f) => ({ ...f, selected: false }))
91 const allFiles: { name: string; content: string; size: number }[] = []
92
93 let extractedCount = 0
94 let replacedCount = 0
95 let hasReplacedFiles = false // Track if any existing files were replaced
96
97 for (const file of droppedFiles) {
98 if (isZipFile(file.name)) {
99 try {
100 setExtractionProgress({ current: 0, total: 0 })
101 const extractedFiles = await extractZipFile(file, (current, total) => {
102 setExtractionProgress({ current, total })
103 })
104 allFiles.push(...extractedFiles)
105 } catch (error) {
106 toast.error(
107 <div className="flex flex-col gap-y-1">
108 <p className="text-foreground">Failed to extract {file.name}</p>
109 <p className="text-foreground-light">
110 {error instanceof Error ? error.message : 'Unknown error occurred'}
111 </p>
112 </div>,
113 { duration: 8000 }
114 )
115 } finally {
116 setExtractionProgress(null)
117 }
118 } else {
119 try {
120 let content: string
121 if (isBinaryFile(file.name)) {
122 // For binary files, read as ArrayBuffer and convert to base64 or keep as binary data
123 const arrayBuffer = await file.arrayBuffer()
124 const bytes = new Uint8Array(arrayBuffer)
125 content = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
126 } else {
127 content = await file.text()
128 }
129 allFiles.push({ name: file.name, size: file.size, content })
130 } catch (error) {
131 toast.error(`Failed to read file: ${file.name}: ${error}`)
132 }
133 }
134 }
135
136 for (const file of allFiles) {
137 const actionResult = getFileAction(file.name, updatedFiles, newFiles)
138
139 switch (actionResult.action) {
140 case FileAction.REPLACE_EXISTING:
141 updatedFiles[actionResult.index] = {
142 ...updatedFiles[actionResult.index],
143 content: file.content,
144 }
145 replacedCount++
146 hasReplacedFiles = true
147 break
148
149 case FileAction.REPLACE_NEW:
150 newFiles[actionResult.index] = {
151 ...newFiles[actionResult.index],
152 content: file.content,
153 }
154 replacedCount++
155 break
156
157 case FileAction.CREATE_NEW:
158 const newId =
159 Math.max(
160 0,
161 ...files.map((f) => f.id),
162 ...updatedFiles.map((f) => f.id),
163 ...newFiles.map((f) => f.id)
164 ) + 1
165 newFiles.push({
166 id: newId,
167 name: file.name,
168 content: file.content,
169 state: 'new',
170 })
171 extractedCount++
172 break
173 }
174 }
175
176 // Show success message
177 const messages: string[] = []
178 if (extractedCount > 0) {
179 messages.push(`Added ${extractedCount} new file${extractedCount > 1 ? 's' : ''}`)
180 }
181 if (replacedCount > 0) {
182 messages.push(`Replaced ${replacedCount} existing file${replacedCount > 1 ? 's' : ''}`)
183 }
184 const totalFilesProcessed = extractedCount + replacedCount
185
186 if (totalFilesProcessed > 0) {
187 toast.success(
188 <div className="flex flex-col gap-y-1">
189 <p className="text-foreground">
190 Successfully added dropped file{totalFilesProcessed > 1 ? 's' : ''}
191 </p>
192 <p className="text-foreground-light">{messages.join(', ')}.</p>
193 </div>,
194 { duration: 5000 }
195 )
196 }
197
198 // Select the last added/modified file
199 if (newFiles.length > 0) {
200 setSelectedFileId(newFiles[newFiles.length - 1].id)
201 onFilesChange([...updatedFiles, ...newFiles])
202 } else if (hasReplacedFiles) {
203 // If we only replaced files, select the first one
204 setSelectedFileId(updatedFiles[0].id)
205 onFilesChange(updatedFiles)
206 }
207 }
208
209 const handleStartRename = useCallback(
210 (id: number) => {
211 // Force re-render of the TreeView with the updated metadata
212 setTreeData({
213 name: '',
214 children: files.map((file) => ({
215 id: file.id.toString(),
216 name: file.name,
217 metadata: {
218 isEditing: file.id === id,
219 originalId: file.id,
220 state: file.state,
221 },
222 })),
223 })
224 },
225 [files]
226 )
227
228 const exitEditMode = useCallback(() => {
229 // Force re-render of the TreeView with the updated metadata
230 setTreeData({
231 name: '',
232 children: files.map((file) => ({
233 id: file.id.toString(),
234 name: file.name,
235 metadata: {
236 isEditing: false,
237 originalId: file.id,
238 state: file.state,
239 },
240 })),
241 })
242 }, [files])
243
244 const handleFileNameChange = useCallback(
245 (id: number, newName: string) => {
246 // Don't allow empty names
247 if (!newName.trim()) {
248 toast.error('File name cannot be empty')
249 return exitEditMode()
250 }
251
252 // Check if the new name already exists in other files
253 const isDuplicate = files.some((file) => file.id !== id && file.name === newName)
254 if (isDuplicate) {
255 toast.error(
256 `The name ${newName} already exists in the current directory. Please use a different name.`
257 )
258 return exitEditMode()
259 }
260
261 const updatedFiles = files.map((file) => {
262 return file.id === id
263 ? {
264 ...file,
265 name: newName,
266 content:
267 newName === 'deno.json' && file.content === ''
268 ? denoJsonDefaultContent
269 : file.content,
270 }
271 : file
272 })
273 onFilesChange(updatedFiles)
274 },
275 [files, onFilesChange, exitEditMode]
276 )
277
278 const handleFileDelete = useCallback(
279 (id: number) => {
280 if (files.length <= 1) {
281 // Don't allow deleting the last file
282 return
283 }
284
285 const fileToDelete = files.find((f) => f.id === id)
286 const isSelected = fileToDelete?.id === selectedFileId
287 const updatedFiles = files.filter((file) => file.id !== id)
288
289 // If the deleted file was selected, select another file
290 if (isSelected && updatedFiles.length > 0) {
291 setSelectedFileId(updatedFiles[0].id)
292 }
293
294 onFilesChange(updatedFiles)
295 },
296 [files, selectedFileId, setSelectedFileId, onFilesChange]
297 )
298
299 const handleDragOver = (e: React.DragEvent) => {
300 e.preventDefault()
301 setIsDragOver(true)
302 }
303
304 const handleDragLeave = (e: React.DragEvent) => {
305 e.preventDefault()
306 setIsDragOver(false)
307 }
308
309 const handleDrop = async (e: React.DragEvent) => {
310 e.preventDefault()
311 setIsDragOver(false)
312
313 const droppedFiles = e.dataTransfer.files
314 if (droppedFiles.length > 0) {
315 await addDroppedFiles(droppedFiles)
316 }
317 }
318
319 // Update treeData when files change
320 useEffect(() => {
321 setTreeData({
322 name: '',
323 children: files.map((file) => ({
324 id: file.id.toString(),
325 name: file.name,
326 metadata: {
327 isEditing: false,
328 originalId: file.id,
329 state: file.state,
330 },
331 })),
332 })
333
334 if (!selectedFileId && files.length > 0) setSelectedFileId(files[0].id)
335 // eslint-disable-next-line react-hooks/exhaustive-deps
336 }, [files])
337
338 const renderNode = useCallback(
339 (props: INodeRendererProps) => (
340 <FileExplorerAndEditorRow
341 {...props}
342 files={files}
343 selectedFileId={selectedFileId}
344 setSelectedFileId={setSelectedFileId}
345 handleFileNameChange={handleFileNameChange}
346 handleStartRename={handleStartRename}
347 handleFileDelete={handleFileDelete}
348 />
349 ),
350 [
351 files,
352 selectedFileId,
353 setSelectedFileId,
354 handleFileNameChange,
355 handleStartRename,
356 handleFileDelete,
357 ]
358 )
359
360 return (
361 <div
362 className={cn(
363 'flex-1 overflow-hidden flex h-full relative gap-x-3 bg-surface-100',
364 isDragOver && 'bg-blue-50'
365 )}
366 onDragOver={handleDragOver}
367 onDragLeave={handleDragLeave}
368 onDrop={handleDrop}
369 >
370 <AnimatePresence>
371 {(isDragOver || isExtractingZip) && (
372 <motion.div
373 initial={{ opacity: 0 }}
374 animate={{ opacity: 1 }}
375 exit={{ opacity: 0 }}
376 transition={{ duration: 0.1 }}
377 className="absolute inset-0 bg/30 z-10 flex items-center justify-center"
378 >
379 <div className="w-96 py-20 bg/60 border-2 border-dashed border-muted flex items-center justify-center">
380 {isExtractingZip && extractionProgress ? (
381 <div className="text-center space-y-2">
382 <div className="text-base">Extracting zip file...</div>
383 <div className="text-sm text-foreground-light">
384 Processing file {extractionProgress.current} of {extractionProgress.total}
385 </div>
386 </div>
387 ) : (
388 <div className="text-base">Drop files here to add them</div>
389 )}
390 </div>
391 </motion.div>
392 )}
393 </AnimatePresence>
394
395 <div className="min-w-64 w-64 border-r bg-surface-200 flex flex-col">
396 <div className="py-4 px-6 border-b flex items-center justify-between">
397 <h3 className="text-sm font-normal font-mono uppercase text-lighter tracking-wide">
398 Files
399 </h3>
400 {IS_PLATFORM && (
401 <Button size="tiny" type="default" icon={<Plus size={14} />} onClick={addNewFile}>
402 Add File
403 </Button>
404 )}
405 </div>
406 <div className="flex-1 overflow-y-auto">
407 <TreeView
408 data={flattenTree(treeData)}
409 aria-label="files tree"
410 nodeRenderer={renderNode}
411 />
412 </div>
413 </div>
414
415 <div className="grow min-w-0">
416 {selectedFile && isBinaryFile(selectedFile.name) ? (
417 <div className="flex items-center justify-center h-full">
418 <div className="text-center">
419 <div className="text-foreground-light text-lg mb-2">Cannot Edit Selected File</div>
420 <div className="text-foreground-lighter text-sm">
421 Binary files like .{selectedFile.name.split('.').pop()} cannot be edited in the text
422 editor
423 </div>
424 </div>
425 </div>
426 ) : (
427 <AIEditor
428 language={getLanguageFromFileName(selectedFile?.name || 'index.ts')}
429 value={selectedFile?.content}
430 onChange={handleChange}
431 aiEndpoint={aiEndpoint}
432 aiMetadata={aiMetadata}
433 options={{
434 tabSize: 2,
435 fontSize: 13,
436 minimap: { enabled: false },
437 wordWrap: 'on',
438 lineNumbers: 'on',
439 folding: false,
440 padding: { top: 20, bottom: 20 },
441 lineNumbersMinChars: 3,
442 fixedOverflowWidgets: true,
443 readOnly: !IS_PLATFORM,
444 }}
445 />
446 )}
447 </div>
448 </div>
449 )
450}