From 74e0997b3e6f93b5bdf0bb5b04632ac6566bfbf4 Mon Sep 17 00:00:00 2001 From: Chris Scott Date: Sun, 14 Dec 2025 14:13:15 -0500 Subject: [PATCH 1/2] Add multi-file and directory upload support with progress tracking --- backend/src/routes/files.ts | 3 +- backend/src/services/files.ts | 20 +- .../components/file-browser/FileBrowser.tsx | 226 ++++++++++++++++-- .../file-browser/FileOperations.tsx | 62 +++-- frontend/src/components/ui/progress.tsx | 36 +++ 5 files changed, 310 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/ui/progress.tsx diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index a61acef..8da02a2 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -66,7 +66,8 @@ export function createFileRoutes(_database: Database) { return c.json({ error: 'No file provided' }, 400) } - const result = await fileService.uploadFile(path, file) + const relativePath = body.relativePath as string | undefined + const result = await fileService.uploadFile(path, file, relativePath) return c.json(result) } catch (error: any) { logger.error('Failed to upload file:', error) diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts index b0f265a..e755dcb 100644 --- a/backend/src/services/files.ts +++ b/backend/src/services/files.ts @@ -138,7 +138,7 @@ export async function getFile(userPath: string): Promise { } } -export async function uploadFile(userPath: string, file: File): Promise { +export async function uploadFile(userPath: string, file: File, relativePath?: string): Promise { if (file.size > FILE_LIMITS.MAX_UPLOAD_SIZE_BYTES) { throw new Error('File too large') } @@ -148,17 +148,25 @@ export async function uploadFile(userPath: string, file: File): Promise void } +async function readFileEntry(entry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + entry.file(resolve, reject) + }) +} + +async function readDirectoryEntries(dirReader: FileSystemDirectoryReader): Promise { + return new Promise((resolve, reject) => { + dirReader.readEntries(resolve, reject) + }) +} + +async function traverseFileSystemEntry( + entry: FileSystemEntry, + basePath: string = '' +): Promise { + const items: UploadItem[] = [] + const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name + + if (entry.isFile) { + const fileEntry = entry as FileSystemFileEntry + const file = await readFileEntry(fileEntry) + items.push({ file, relativePath }) + } else if (entry.isDirectory) { + const dirEntry = entry as FileSystemDirectoryEntry + const dirReader = dirEntry.createReader() + let entries: FileSystemEntry[] = [] + let batch: FileSystemEntry[] + + do { + batch = await readDirectoryEntries(dirReader) + entries = entries.concat(batch) + } while (batch.length > 0) + + for (const childEntry of entries) { + const childItems = await traverseFileSystemEntry(childEntry, relativePath) + items.push(...childItems) + } + } + + return items +} + +async function getUploadItemsFromDataTransfer(dataTransfer: DataTransfer): Promise { + const items: UploadItem[] = [] + const entries: FileSystemEntry[] = [] + + for (let i = 0; i < dataTransfer.items.length; i++) { + const item = dataTransfer.items[i] + const entry = item.webkitGetAsEntry?.() + if (entry) { + entries.push(entry) + } + } + + if (entries.length > 0) { + for (const entry of entries) { + const entryItems = await traverseFileSystemEntry(entry) + items.push(...entryItems) + } + } else { + for (let i = 0; i < dataTransfer.files.length; i++) { + const file = dataTransfer.files[i] + items.push({ file, relativePath: file.name }) + } + } + + return items +} + +function getUploadItemsFromFileList(fileList: FileList): UploadItem[] { + const items: UploadItem[] = [] + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i] + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name + items.push({ file, relativePath }) + } + return items +} + export function FileBrowser({ basePath = '', onFileSelect, embedded = false, initialSelectedFile, onDirectoryLoad }: FileBrowserProps) { const [currentPath, setCurrentPath] = useState(basePath) const [files, setFiles] = useState(null) @@ -32,8 +124,10 @@ export function FileBrowser({ basePath = '', onFileSelect, embedded = false, ini const [error, setError] = useState(null) const [isDragging, setIsDragging] = useState(false) const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false) + const [uploadProgress, setUploadProgress] = useState(null) const dropZoneRef = useRef(null) + const uploadCancelledRef = useRef(false) const isMobile = useMobile() const { data: initialFileData, error: initialFileError } = useFile(initialSelectedFile) @@ -117,9 +211,10 @@ useEffect(() => { loadFiles(currentPath) } - const handleUpload = useCallback(async (files: FileList) => { + const uploadSingleFile = useCallback(async (item: UploadItem): Promise => { const formData = new FormData() - formData.append('file', files[0]) + formData.append('file', item.file) + formData.append('relativePath', item.relativePath) try { const response = await fetch(`${API_BASE_URL}/api/files/${currentPath}`, { @@ -128,15 +223,75 @@ useEffect(() => { }) if (!response.ok) { - throw new Error(`Upload failed: ${response.statusText}`) + const errorData = await response.json().catch(() => ({})) + return errorData.error || `Upload failed: ${response.statusText}` } - await loadFiles(currentPath) + return null } catch (err) { - setError(err instanceof Error ? err.message : 'Upload failed') + return err instanceof Error ? err.message : 'Upload failed' } }, [currentPath]) + const handleUploadItems = useCallback(async (items: UploadItem[]) => { + if (items.length === 0) return + + uploadCancelledRef.current = false + const errors: string[] = [] + + setUploadProgress({ + current: 0, + total: items.length, + currentFile: items[0].relativePath, + errors: [], + cancelled: false, + }) + + for (let i = 0; i < items.length; i++) { + if (uploadCancelledRef.current) { + setUploadProgress(prev => prev ? { ...prev, cancelled: true } : null) + break + } + + const item = items[i] + setUploadProgress(prev => prev ? { + ...prev, + current: i, + currentFile: item.relativePath, + } : null) + + const error = await uploadSingleFile(item) + if (error) { + errors.push(`${item.relativePath}: ${error}`) + } + } + + setUploadProgress(prev => prev ? { + ...prev, + current: items.length, + errors, + cancelled: uploadCancelledRef.current, + } : null) + + await loadFiles(currentPath) + + setTimeout(() => { + setUploadProgress(null) + if (errors.length > 0) { + setError(`${errors.length} file(s) failed to upload`) + } + }, 2000) + }, [currentPath, uploadSingleFile]) + + const handleUpload = useCallback(async (fileList: FileList) => { + const items = getUploadItemsFromFileList(fileList) + await handleUploadItems(items) + }, [handleUploadItems]) + + const cancelUpload = useCallback(() => { + uploadCancelledRef.current = true + }, []) + const handleCreateFile = useCallback(async (name: string, type: 'file' | 'folder') => { try { const response = await fetch(`${API_BASE_URL}/api/files/${currentPath}/${name}`, { @@ -214,9 +369,9 @@ useEffect(() => { e.stopPropagation() setIsDragging(false) - const droppedFiles = e.dataTransfer.files - if (droppedFiles.length > 0) { - await handleUpload(droppedFiles) + const items = await getUploadItemsFromDataTransfer(e.dataTransfer) + if (items.length > 0) { + await handleUploadItems(items) } } @@ -253,6 +408,45 @@ useEffect(() => { file.name.toLowerCase().includes(searchQuery.toLowerCase()) ) + const uploadDialog = ( + {}}> + + + + + {uploadProgress?.cancelled ? 'Upload Cancelled' : + uploadProgress && uploadProgress.current >= uploadProgress.total ? 'Upload Complete' : 'Uploading...'} + + {uploadProgress && uploadProgress.current < uploadProgress.total && !uploadProgress.cancelled && ( + + )} + + + {uploadProgress && ( +
+ +

+ {uploadProgress.current} / {uploadProgress.total} files +

+

+ {uploadProgress.currentFile} +

+ {uploadProgress.errors.length > 0 && ( +

+ {uploadProgress.errors.length} file(s) failed +

+ )} +
+ )} +
+
+ ) + if (embedded) { return (
{
-

Drop files here to upload

+

Drop files or folders here to upload

)} + {uploadDialog} + {/* Mobile: Full width file listing, Desktop: Split view */}
@@ -354,10 +550,10 @@ useEffect(() => { > {isDragging && ( -
+
- -

Drop files here to upload

+ +

Drop files or folders here to upload

)} @@ -438,6 +634,8 @@ useEffect(() => { onClose={handleCloseModal} file={selectedFile} /> + + {uploadDialog}
) } diff --git a/frontend/src/components/file-browser/FileOperations.tsx b/frontend/src/components/file-browser/FileOperations.tsx index 109af2f..577aaca 100644 --- a/frontend/src/components/file-browser/FileOperations.tsx +++ b/frontend/src/components/file-browser/FileOperations.tsx @@ -1,9 +1,11 @@ -import { useState, memo } from 'react' +import { useState, memo, useRef } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Upload, Plus, FolderPlus, FilePlus } from 'lucide-react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Upload, Plus, FolderPlus, FilePlus, File, Folder } from 'lucide-react' +import { useMobile } from '@/hooks/useMobile' interface FileOperationsProps { onUpload: (files: FileList) => void @@ -14,6 +16,10 @@ export const FileOperations = memo(function FileOperations({ onUpload, onCreate const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createType, setCreateType] = useState<'file' | 'folder'>('file') const [createName, setCreateName] = useState('') + + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + const isMobile = useMobile() const handleFileSelect = (event: React.ChangeEvent) => { const files = event.target.files @@ -33,21 +39,45 @@ export const FileOperations = memo(function FileOperations({ onUpload, onCreate return (
-
- - + + + fileInputRef.current?.click()}> + + Files + + folderInputRef.current?.click()}> + + Folder + + + + ) : ( + -
+ )} diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..8000f7e --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +interface ProgressProps extends React.HTMLAttributes { + value?: number + max?: number +} + +const Progress = React.forwardRef( + ({ className, value = 0, max = 100, ...props }, ref) => { + const percentage = Math.min(100, Math.max(0, (value / max) * 100)) + + return ( +
+
+
+ ) + } +) +Progress.displayName = "Progress" + +export { Progress } From 782bfe97dd57805783721bd2861fee2900dabe7f Mon Sep 17 00:00:00 2001 From: Chris Scott Date: Sun, 14 Dec 2025 14:16:32 -0500 Subject: [PATCH 2/2] Show upload error details and fix delete dialog overflow --- .../components/file-browser/FileBrowser.tsx | 49 +++++++++++++------ frontend/src/components/ui/delete-dialog.tsx | 8 +-- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index 5a68480..e2d0f79 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -274,13 +274,6 @@ useEffect(() => { } : null) await loadFiles(currentPath) - - setTimeout(() => { - setUploadProgress(null) - if (errors.length > 0) { - setError(`${errors.length} file(s) failed to upload`) - } - }, 2000) }, [currentPath, uploadSingleFile]) const handleUpload = useCallback(async (fileList: FileList) => { @@ -408,14 +401,17 @@ useEffect(() => { file.name.toLowerCase().includes(searchQuery.toLowerCase()) ) + const isUploadComplete = uploadProgress && uploadProgress.current >= uploadProgress.total + const canDismissDialog = isUploadComplete || uploadProgress?.cancelled + const uploadDialog = ( - {}}> - + { if (!open && canDismissDialog) setUploadProgress(null) }}> + {uploadProgress?.cancelled ? 'Upload Cancelled' : - uploadProgress && uploadProgress.current >= uploadProgress.total ? 'Upload Complete' : 'Uploading...'} + isUploadComplete ? 'Upload Complete' : 'Uploading...'} {uploadProgress && uploadProgress.current < uploadProgress.total && !uploadProgress.cancelled && ( + )}
)} diff --git a/frontend/src/components/ui/delete-dialog.tsx b/frontend/src/components/ui/delete-dialog.tsx index 00ba80d..033086a 100644 --- a/frontend/src/components/ui/delete-dialog.tsx +++ b/frontend/src/components/ui/delete-dialog.tsx @@ -35,10 +35,10 @@ export function DeleteDialog({ {itemName && ( - - - - This will permanently delete "{itemName}". This action cannot be undone. + + + + This will permanently delete "{itemName}". This action cannot be undone. )}