index.tsx 14 KB

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