import {useState} from 'react'; import {useDropzone} from 'react-dropzone'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {ObjectsTable} from './ObjectsTable'; import {CreateDirectoryDialog} from './CreateDirectoryDialog'; import {DeleteObjectDialog} from './DeleteObjectDialog'; import {ConfirmDialog} from '@/components/ui/confirm-dialog'; import {UploadProgress} from './UploadProgress'; import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, ScanSearch, Search, Trash, Upload} from 'lucide-react'; import {getBreadcrumbs} from '@/lib/file-utils'; import type {S3Object, UploadTask} from '@/types'; interface ObjectBrowserViewProps { bucketName: string; objects: S3Object[]; currentPath: string; searchQuery: string; filterQuery: string; deepSearch: boolean; isLoading?: boolean; isTruncated?: boolean; nextContinuationToken?: string; itemsPerPage: number; onSearchChange: (query: string) => void; onDeepSearchChange: (enabled: boolean) => void; onNavigateToFolder: (path: string) => void; onBackToBuckets: () => void; onUploadFiles?: (files: File[]) => Promise; uploadTasks: UploadTask[]; onDeleteObject?: (key: string) => Promise; onDeleteMultipleObjects?: (keys: string[], prefixes?: string[]) => Promise; onCreateDirectory?: (name: string) => Promise; onRefresh: () => Promise; onPageChange: (token?: string) => void; onItemsPerPageChange: (count: number) => void; isRefreshing: boolean; isNavigating: boolean; initialPageToken?: string; initialItemsPerPage?: number; } export function ObjectBrowserView({ bucketName, objects, currentPath, searchQuery, filterQuery, deepSearch, isLoading = false, isTruncated = false, nextContinuationToken, itemsPerPage, onSearchChange, onDeepSearchChange, onNavigateToFolder, onBackToBuckets, onUploadFiles, uploadTasks, onDeleteObject, onDeleteMultipleObjects, onCreateDirectory, onRefresh, onPageChange, onItemsPerPageChange, isRefreshing, isNavigating, initialPageToken, initialItemsPerPage, }: ObjectBrowserViewProps) { const [showUploadZone, setShowUploadZone] = useState(false); const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false); const [selectedObject, setSelectedObject] = useState(null); const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false); const [selectedFileKeys, setSelectedFileKeys] = useState>(new Set()); const [selectedFolderKeys, setSelectedFolderKeys] = useState>(new Set()); // Holds the keys/prefixes awaiting confirmation in the bulk-delete dialog. const [pendingDelete, setPendingDelete] = useState<{ keys: string[]; prefixes: string[] } | null>(null); const [bulkDeleting, setBulkDeleting] = useState(false); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop: async (acceptedFiles, _fileRejections, event) => { if (!onUploadFiles) return; // Get files with their full paths from DataTransferItems API const filesWithPaths: File[] = []; // Type cast event to DragEvent to access dataTransfer const dragEvent = event as DragEvent; if (dragEvent.dataTransfer?.items) { // Use DataTransferItemList API to preserve folder structure const items = Array.from(dragEvent.dataTransfer.items); await Promise.all(items.map(async (item: DataTransferItem) => { if (item.kind === 'file') { const entry = item.webkitGetAsEntry?.(); if (entry) { await traverseFileTree(entry, '', filesWithPaths); } } })); } else { // Fallback to standard files filesWithPaths.push(...acceptedFiles); } await onUploadFiles(filesWithPaths.length > 0 ? filesWithPaths : acceptedFiles); setShowUploadZone(false); }, noClick: true, disabled: !onUploadFiles, }); // Helper function to traverse file/directory tree const traverseFileTree = async (item: FileSystemEntry, path: string, files: File[]): Promise => { return new Promise((resolve) => { if (item.isFile) { (item as FileSystemFileEntry).file((file: File) => { const fullPath = path + file.name; Object.defineProperty(file, 'webkitRelativePath', { value: fullPath, writable: false }); files.push(file); resolve(); }); } else if (item.isDirectory) { const dirReader = (item as FileSystemDirectoryEntry).createReader(); dirReader.readEntries(async (entries) => { for (const entry of entries) { await traverseFileTree(entry, path + item.name + '/', files); } resolve(); }); } else { resolve(); } }); }; const selectedCount = selectedFileKeys.size + selectedFolderKeys.size; const toggleInSet = (set: Set, key: string) => { const next = new Set(set); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }; const handleToggleFileSelection = (key: string) => { setSelectedFileKeys(prev => toggleInSet(prev, key)); }; const handleToggleFolderSelection = (key: string) => { setSelectedFolderKeys(prev => toggleInSet(prev, key)); }; // Select/deselect the currently visible (filtered) rows. The table passes the // keys it is actually showing so this stays aligned with the search filter // instead of operating on the full, unfiltered object list. const handleSelectAll = (fileKeys: string[], folderKeys: string[]) => { const allVisibleSelected = fileKeys.length + folderKeys.length > 0 && fileKeys.every(k => selectedFileKeys.has(k)) && folderKeys.every(k => selectedFolderKeys.has(k)); if (allVisibleSelected) { // Drop only the visible rows, leaving any off-screen selection intact. setSelectedFileKeys(prev => { const next = new Set(prev); fileKeys.forEach(k => next.delete(k)); return next; }); setSelectedFolderKeys(prev => { const next = new Set(prev); folderKeys.forEach(k => next.delete(k)); return next; }); } else { setSelectedFileKeys(prev => new Set([...prev, ...fileKeys])); setSelectedFolderKeys(prev => new Set([...prev, ...folderKeys])); } }; // Open the confirmation dialog for the current multi-selection. const handleRequestBulkDelete = () => { if (selectedCount === 0) return; setPendingDelete({ keys: Array.from(selectedFileKeys), prefixes: Array.from(selectedFolderKeys), }); }; // Open the confirmation dialog for a single folder (recursive delete). const handleDeleteFolder = (folderKey: string) => { setPendingDelete({ keys: [], prefixes: [folderKey] }); }; const handleConfirmBulkDelete = async () => { if (!pendingDelete || !onDeleteMultipleObjects) return; setBulkDeleting(true); const success = await onDeleteMultipleObjects(pendingDelete.keys, pendingDelete.prefixes); setBulkDeleting(false); if (success) { // Drop the deleted folders/files from the live selection. setSelectedFileKeys(prev => { const next = new Set(prev); pendingDelete.keys.forEach(k => next.delete(k)); return next; }); setSelectedFolderKeys(prev => { const next = new Set(prev); pendingDelete.prefixes.forEach(k => next.delete(k)); return next; }); setPendingDelete(null); } }; const handleDeleteObject = async (key: string): Promise => { if (!onDeleteObject) return false; const success = await onDeleteObject(key); if (success) { setDeleteObjectDialogOpen(false); setSelectedObject(null); } return success; }; const uploadFiles = async (files: File[]) => { if (!onUploadFiles) return; await onUploadFiles(files); setShowUploadZone(false); }; return (
{/* Back Button */} {/* Breadcrumb Navigation */}
{getBreadcrumbs(currentPath).map((crumb, index) => (
{index > 0 && }
))}
{/* Toolbar */}
onSearchChange(e.target.value)} className="pl-8" />
{onDeleteMultipleObjects && selectedCount > 0 && ( )} {onUploadFiles && ( )} {onCreateDirectory && ( )}
{/* Upload Zone */} {onUploadFiles && showUploadZone && uploadTasks.length === 0 && (

Drag and drop files/folders or{' '} {' / '}

{ if (e.target.files) { const files = Array.from(e.target.files); uploadFiles(files); e.target.value = ''; } }} style={{ display: 'none' }} /> )} onChange={(e) => { if (e.target.files) { const files = Array.from(e.target.files); uploadFiles(files); e.target.value = ''; } }} style={{ display: 'none' }} />
)} {/* Upload Progress */} {uploadTasks.length > 0 && } {/* Objects Table with Drag & Drop */}
{/* Drag & Drop Overlay */} {isDragActive && (

Drop files here to upload

Files will be uploaded to {currentPath || 'root'}

)} { setSelectedObject(obj); setDeleteObjectDialogOpen(true); } : undefined} onDeleteFolder={onDeleteMultipleObjects ? (obj) => handleDeleteFolder(obj.key) : undefined} onToggleFileSelection={handleToggleFileSelection} onToggleFolderSelection={handleToggleFolderSelection} onSelectAll={handleSelectAll} onPageChange={onPageChange} onItemsPerPageChange={onItemsPerPageChange} initialPageToken={initialPageToken} initialItemsPerPage={initialItemsPerPage} />
{/* Create Directory Dialog */} {onCreateDirectory && ( )} {/* Delete Object Dialog */} {/* Bulk / Folder Delete Confirmation */} { if (!open && !bulkDeleting) setPendingDelete(null); }} title={getBulkDeleteTitle(pendingDelete)} description={getBulkDeleteDescription(pendingDelete)} confirmLabel="Delete" loading={bulkDeleting} onConfirm={handleConfirmBulkDelete} />
); } // Builds a concise title summarising what the bulk-delete dialog will remove. function getBulkDeleteTitle(pending: { keys: string[]; prefixes: string[] } | null): string { if (!pending) return 'Delete items?'; const { keys, prefixes } = pending; const total = keys.length + prefixes.length; if (keys.length === 0 && prefixes.length === 1) { return 'Delete folder?'; } return `Delete ${total} item${total !== 1 ? 's' : ''}?`; } // Spells out the file/folder counts and warns that folders are removed recursively. function getBulkDeleteDescription( pending: { keys: string[]; prefixes: string[] } | null, ): string { if (!pending) return ''; const { keys, prefixes } = pending; const parts: string[] = []; if (keys.length > 0) { parts.push(`${keys.length} file${keys.length !== 1 ? 's' : ''}`); } if (prefixes.length > 0) { parts.push(`${prefixes.length} folder${prefixes.length !== 1 ? 's' : ''}`); } const summary = parts.join(' and '); if (prefixes.length > 0) { return `This will permanently delete ${summary}. Every object stored inside the selected folder${ prefixes.length !== 1 ? 's' : '' } will be removed recursively.`; } return `This will permanently delete ${summary}.`; }