Spaces:
Running
Running
"use client"; | |
import React, { useState, useEffect, useCallback } from 'react'; | |
import { FileDocument, FolderDocument, FileType } from '@/types'; | |
import { FileTree } from './file-tree'; | |
// import { FileSearch } from './file-search'; | |
// import { FileActions } from './file-actions'; | |
import { CreateFileDialog } from './create-file-dialog'; | |
import { CreateFolderDialog } from './create-folder-dialog'; | |
import { Button } from '@/components/ui/button'; | |
import { Input } from '@/components/ui/input'; | |
import { ScrollArea } from '@/components/ui/scroll-area'; | |
// import { Separator } from '@/components/ui/separator'; | |
import { Badge } from '@/components/ui/badge'; | |
import { | |
FolderPlus, | |
FilePlus, | |
Search, | |
Filter, | |
RefreshCw, | |
// Settings, | |
ChevronDown, | |
ChevronRight | |
} from 'lucide-react'; | |
import { api } from '@/lib/api'; | |
import { toast } from 'sonner'; | |
export interface FileExplorerProps { | |
projectId: string; | |
onFileSelect: (file: FileDocument) => void; | |
onFileCreate: (file: FileDocument) => void; | |
onFileUpdate: (file: FileDocument) => void; | |
onFileDelete: (fileId: string) => void; | |
onFolderCreate: (folder: FolderDocument) => void; | |
selectedFileId?: string; | |
className?: string; | |
} | |
export function FileExplorer({ | |
projectId, | |
onFileSelect, | |
onFileCreate, | |
// onFileUpdate, | |
onFileDelete, | |
onFolderCreate, | |
selectedFileId, | |
className = "" | |
}: FileExplorerProps) { | |
const [files, setFiles] = useState<FileDocument[]>([]); | |
const [folders, setFolders] = useState<FolderDocument[]>([]); | |
const [loading, setLoading] = useState(true); | |
const [searchQuery, setSearchQuery] = useState(''); | |
const [selectedFolder, setSelectedFolder] = useState<string | null>(null); | |
const [filterType, setFilterType] = useState<FileType | 'all'>('all'); | |
const [isCreateFileOpen, setIsCreateFileOpen] = useState(false); | |
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); | |
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); | |
const [isCollapsed, setIsCollapsed] = useState(false); | |
// Load project files and folders | |
const loadProjectData = useCallback(async () => { | |
try { | |
setLoading(true); | |
// Load folders | |
const foldersResponse = await api.get(`/projects/${projectId}/folders?tree=true`); | |
if (foldersResponse.data.ok) { | |
setFolders(foldersResponse.data.tree || []); | |
} | |
// Load files | |
const filesResponse = await api.get(`/projects/${projectId}/files`); | |
if (filesResponse.data.ok) { | |
setFiles(filesResponse.data.files || []); | |
} | |
} catch (error) { | |
console.error('Error loading project data:', error); | |
toast.error('Failed to load project files'); | |
} finally { | |
setLoading(false); | |
} | |
}, [projectId]); | |
useEffect(() => { | |
loadProjectData(); | |
}, [loadProjectData]); | |
// Filter files based on search and type | |
const filteredFiles = files.filter(file => { | |
const matchesSearch = !searchQuery || | |
file.name.toLowerCase().includes(searchQuery.toLowerCase()) || | |
file.content.toLowerCase().includes(searchQuery.toLowerCase()); | |
const matchesType = filterType === 'all' || file.type === filterType; | |
const matchesFolder = !selectedFolder || file.parentFolderId === selectedFolder; | |
return matchesSearch && matchesType && matchesFolder; | |
}); | |
// Handle file creation | |
const handleFileCreate = async (fileData: { | |
name: string; | |
content: string; | |
type: FileType; | |
parentFolderId?: string; | |
}) => { | |
try { | |
const response = await api.post(`/projects/${projectId}/files`, { | |
...fileData, | |
parentFolderId: selectedFolder || fileData.parentFolderId | |
}); | |
if (response.data.ok) { | |
const newFile = response.data.file; | |
setFiles(prev => [...prev, newFile]); | |
onFileCreate(newFile); | |
toast.success(`File "${newFile.name}" created successfully`); | |
setIsCreateFileOpen(false); | |
} | |
} catch (error: unknown) { | |
console.error('Error creating file:', error); | |
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to create file'; | |
toast.error(message); | |
} | |
}; | |
// Handle folder creation | |
const handleFolderCreate = async (folderData: { | |
name: string; | |
parentFolderId?: string; | |
}) => { | |
try { | |
const response = await api.post(`/projects/${projectId}/folders`, { | |
...folderData, | |
parentFolderId: selectedFolder || folderData.parentFolderId | |
}); | |
if (response.data.ok) { | |
const newFolder = response.data.folder; | |
setFolders(prev => [...prev, newFolder]); | |
onFolderCreate(newFolder); | |
toast.success(`Folder "${newFolder.name}" created successfully`); | |
setIsCreateFolderOpen(false); | |
// Expand parent folder | |
if (newFolder.parentFolderId) { | |
setExpandedFolders(prev => new Set([...prev, newFolder.parentFolderId!])); | |
} | |
} | |
} catch (error: unknown) { | |
console.error('Error creating folder:', error); | |
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to create folder'; | |
toast.error(message); | |
} | |
}; | |
// Handle file deletion | |
const handleFileDelete = async (fileId: string) => { | |
try { | |
const response = await api.delete(`/projects/${projectId}/files/${fileId}`); | |
if (response.data.ok) { | |
setFiles(prev => prev.filter(f => f._id !== fileId)); | |
onFileDelete(fileId); | |
toast.success('File deleted successfully'); | |
} | |
} catch (error: unknown) { | |
console.error('Error deleting file:', error); | |
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to delete file'; | |
toast.error(message); | |
} | |
}; | |
// Handle folder selection | |
const handleFolderSelect = (folderId: string | null) => { | |
setSelectedFolder(folderId); | |
}; | |
// Toggle folder expansion | |
const toggleFolderExpansion = (folderId: string) => { | |
setExpandedFolders(prev => { | |
const newSet = new Set(prev); | |
if (newSet.has(folderId)) { | |
newSet.delete(folderId); | |
} else { | |
newSet.add(folderId); | |
} | |
return newSet; | |
}); | |
}; | |
// Get file type options for filter | |
const fileTypes = Array.from(new Set(files.map(f => f.type))); | |
if (isCollapsed) { | |
return ( | |
<div className={`w-12 bg-neutral-900 border-r border-neutral-800 flex flex-col items-center py-4 ${className}`}> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={() => setIsCollapsed(false)} | |
className="p-2" | |
> | |
<ChevronRight className="h-4 w-4" /> | |
</Button> | |
</div> | |
); | |
} | |
return ( | |
<div className={`w-80 bg-neutral-900 border-r border-neutral-800 flex flex-col ${className}`}> | |
{/* Header */} | |
<div className="p-4 border-b border-neutral-800"> | |
<div className="flex items-center justify-between mb-3"> | |
<h2 className="text-lg font-semibold text-white">Files</h2> | |
<div className="flex items-center gap-1"> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={loadProjectData} | |
disabled={loading} | |
className="p-2" | |
> | |
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> | |
</Button> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={() => setIsCollapsed(true)} | |
className="p-2" | |
> | |
<ChevronDown className="h-4 w-4" /> | |
</Button> | |
</div> | |
</div> | |
{/* Search */} | |
<div className="relative mb-3"> | |
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-neutral-400" /> | |
<Input | |
placeholder="Search files..." | |
value={searchQuery} | |
onChange={(e) => setSearchQuery(e.target.value)} | |
className="pl-10 bg-neutral-800 border-neutral-700 text-white" | |
/> | |
</div> | |
{/* Actions */} | |
<div className="flex gap-2"> | |
<Button | |
variant="outline" | |
size="sm" | |
onClick={() => setIsCreateFileOpen(true)} | |
className="flex-1 bg-neutral-800 border-neutral-700 hover:bg-neutral-700" | |
> | |
<FilePlus className="h-4 w-4 mr-2" /> | |
File | |
</Button> | |
<Button | |
variant="outline" | |
size="sm" | |
onClick={() => setIsCreateFolderOpen(true)} | |
className="flex-1 bg-neutral-800 border-neutral-700 hover:bg-neutral-700" | |
> | |
<FolderPlus className="h-4 w-4 mr-2" /> | |
Folder | |
</Button> | |
</div> | |
</div> | |
{/* Filter */} | |
<div className="p-4 border-b border-neutral-800"> | |
<div className="flex items-center gap-2 mb-2"> | |
<Filter className="h-4 w-4 text-neutral-400" /> | |
<span className="text-sm text-neutral-400">Filter by type</span> | |
</div> | |
<div className="flex flex-wrap gap-1"> | |
<Badge | |
variant={filterType === 'all' ? 'default' : 'secondary'} | |
className="cursor-pointer text-xs" | |
onClick={() => setFilterType('all')} | |
> | |
All ({files.length}) | |
</Badge> | |
{fileTypes.map(type => { | |
const count = files.filter(f => f.type === type).length; | |
return ( | |
<Badge | |
key={type} | |
variant={filterType === type ? 'default' : 'secondary'} | |
className="cursor-pointer text-xs" | |
onClick={() => setFilterType(type)} | |
> | |
{type} ({count}) | |
</Badge> | |
); | |
})} | |
</div> | |
</div> | |
{/* File Tree */} | |
<ScrollArea className="flex-1"> | |
<div className="p-4"> | |
{loading ? ( | |
<div className="text-center text-neutral-400 py-8"> | |
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" /> | |
Loading files... | |
</div> | |
) : ( | |
<FileTree | |
files={filteredFiles} | |
folders={folders} | |
selectedFileId={selectedFileId} | |
selectedFolderId={selectedFolder} | |
expandedFolders={expandedFolders} | |
onFileSelect={onFileSelect} | |
onFolderSelect={handleFolderSelect} | |
onFolderToggle={toggleFolderExpansion} | |
onFileDelete={handleFileDelete} | |
onFileRename={(fileId, newName) => { | |
// Handle file rename | |
console.log('Rename file:', fileId, newName); | |
}} | |
onFileDuplicate={(fileId) => { | |
// Handle file duplicate | |
console.log('Duplicate file:', fileId); | |
}} | |
/> | |
)} | |
</div> | |
</ScrollArea> | |
{/* Dialogs */} | |
<CreateFileDialog | |
open={isCreateFileOpen} | |
onOpenChange={setIsCreateFileOpen} | |
onFileCreate={handleFileCreate} | |
parentFolderId={selectedFolder} | |
/> | |
<CreateFolderDialog | |
open={isCreateFolderOpen} | |
onOpenChange={setIsCreateFolderOpen} | |
onFolderCreate={handleFolderCreate} | |
parentFolderId={selectedFolder} | |
/> | |
</div> | |
); | |
} | |