Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add multi-file and directory upload support with progress tracking
  • Loading branch information
Chris Scott committed Dec 14, 2025
commit 74e0997b3e6f93b5bdf0bb5b04632ac6566bfbf4
3 changes: 2 additions & 1 deletion backend/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 14 additions & 6 deletions backend/src/services/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export async function getFile(userPath: string): Promise<FileInfo> {
}
}

export async function uploadFile(userPath: string, file: File): Promise<FileUploadResult> {
export async function uploadFile(userPath: string, file: File, relativePath?: string): Promise<FileUploadResult> {
if (file.size > FILE_LIMITS.MAX_UPLOAD_SIZE_BYTES) {
throw new Error('File too large')
}
Expand All @@ -148,17 +148,25 @@ export async function uploadFile(userPath: string, file: File): Promise<FileUplo
throw new Error('File type not allowed')
}

const validatedPath = validatePath(userPath)
const fileName = file.name || path.basename(userPath)
const fullPath = path.join(validatedPath, fileName)
const validatedBasePath = validatePath(userPath)
const targetRelativePath = relativePath || file.name || path.basename(userPath)
const fullPath = path.join(validatedBasePath, targetRelativePath)

const finalValidatedPath = validatePath(path.join(userPath, targetRelativePath))
if (finalValidatedPath !== fullPath) {
throw { message: 'Invalid relative path', statusCode: 400 }
}

const parentDir = path.dirname(fullPath)
await fs.mkdir(parentDir, { recursive: true })

const buffer = await file.arrayBuffer()

await writeFileContent(fullPath, Buffer.from(buffer))

return {
name: fileName,
path: path.join(userPath, fileName),
name: path.basename(targetRelativePath),
path: path.join(userPath, targetRelativePath),
size: file.size,
mimeType,
}
Expand Down
226 changes: 212 additions & 14 deletions frontend/src/components/file-browser/FileBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,26 @@ import { MobileFilePreviewModal } from './MobileFilePreviewModal'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { FolderOpen, Upload, RefreshCw } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { FolderOpen, Upload, RefreshCw, X } from 'lucide-react'
import type { FileInfo } from '@/types/files'
import { API_BASE_URL } from '@/config'
import { useMobile } from '@/hooks/useMobile'
import { useFile } from '@/api/files'

interface UploadItem {
file: File
relativePath: string
}


interface UploadProgress {
current: number
total: number
currentFile: string
errors: string[]
cancelled: boolean
}

interface FileBrowserProps {
basePath?: string
Expand All @@ -23,6 +35,86 @@ interface FileBrowserProps {
onDirectoryLoad?: (info: { workspaceRoot?: string; currentPath: string }) => void
}

async function readFileEntry(entry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
entry.file(resolve, reject)
})
}

async function readDirectoryEntries(dirReader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject)
})
}

async function traverseFileSystemEntry(
entry: FileSystemEntry,
basePath: string = ''
): Promise<UploadItem[]> {
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<UploadItem[]> {
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<FileInfo | null>(null)
Expand All @@ -32,8 +124,10 @@ export function FileBrowser({ basePath = '', onFileSelect, embedded = false, ini
const [error, setError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false)
const [uploadProgress, setUploadProgress] = useState<UploadProgress | null>(null)

const dropZoneRef = useRef<HTMLDivElement>(null)
const uploadCancelledRef = useRef(false)
const isMobile = useMobile()

const { data: initialFileData, error: initialFileError } = useFile(initialSelectedFile)
Expand Down Expand Up @@ -117,9 +211,10 @@ useEffect(() => {
loadFiles(currentPath)
}

const handleUpload = useCallback(async (files: FileList) => {
const uploadSingleFile = useCallback(async (item: UploadItem): Promise<string | null> => {
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}`, {
Expand All @@ -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}`, {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -253,6 +408,45 @@ useEffect(() => {
file.name.toLowerCase().includes(searchQuery.toLowerCase())
)

const uploadDialog = (
<Dialog open={!!uploadProgress} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md" hideCloseButton>
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>
{uploadProgress?.cancelled ? 'Upload Cancelled' :
uploadProgress && uploadProgress.current >= uploadProgress.total ? 'Upload Complete' : 'Uploading...'}
</span>
{uploadProgress && uploadProgress.current < uploadProgress.total && !uploadProgress.cancelled && (
<Button variant="ghost" size="sm" onClick={cancelUpload}>
<X className="w-4 h-4" />
</Button>
)}
</DialogTitle>
</DialogHeader>
{uploadProgress && (
<div className="space-y-3">
<Progress
value={uploadProgress.current}
max={uploadProgress.total}
/>
<p className="text-sm text-muted-foreground">
{uploadProgress.current} / {uploadProgress.total} files
</p>
<p className="text-xs text-muted-foreground truncate">
{uploadProgress.currentFile}
</p>
{uploadProgress.errors.length > 0 && (
<p className="text-xs text-destructive">
{uploadProgress.errors.length} file(s) failed
</p>
)}
</div>
)}
</DialogContent>
</Dialog>
)

if (embedded) {
return (
<div
Expand All @@ -267,11 +461,13 @@ useEffect(() => {
<div className="absolute inset-0 z-50 bg-primary/10 border-2 border-dashed border-primary rounded-lg flex items-center justify-center">
<div className="text-center">
<Upload className="w-12 h-12 mx-auto mb-2 text-primary" />
<p className="text-lg font-semibold text-primary">Drop files here to upload</p>
<p className="text-lg font-semibold text-primary">Drop files or folders here to upload</p>
</div>
</div>
)}

{uploadDialog}

{/* Mobile: Full width file listing, Desktop: Split view */}
<div className="flex-1 flex overflow-hidden min-h-0 h-full">
<div className={`${isMobile ? 'w-full' : 'w-[30%]'} border-r border-border px-1 md:px-4 flex flex-col min-h-0 h-full`}>
Expand Down Expand Up @@ -354,10 +550,10 @@ useEffect(() => {
>
<Card className="flex-1 relative">
{isDragging && (
<div className="absolute inset-0 z-50 bg-blue-50/90 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center">
<div className="absolute inset-0 z-50 bg-primary/10 border-2 border-dashed border-primary rounded-lg flex items-center justify-center">
<div className="text-center">
<Upload className="w-12 h-12 mx-auto mb-2 text-blue-500" />
<p className="text-lg font-semibold text-blue-600">Drop files here to upload</p>
<Upload className="w-12 h-12 mx-auto mb-2 text-primary" />
<p className="text-lg font-semibold text-primary">Drop files or folders here to upload</p>
</div>
</div>
)}
Expand Down Expand Up @@ -438,6 +634,8 @@ useEffect(() => {
onClose={handleCloseModal}
file={selectedFile}
/>

{uploadDialog}
</div>
)
}
Loading