import { useCallback, useEffect, useRef, useState } from 'react'; import type { DragEvent } from 'react'; import { IS_PLATFORM } from '../../../constants/config'; import type { Project } from '../../../types/app'; import { MAX_FILE_UPLOAD_COUNT, MAX_FILE_UPLOAD_SIZE_BYTES, MAX_FILE_UPLOAD_SIZE_LABEL, } from '../constants/constants'; type UseFileTreeUploadOptions = { selectedProject: Project | null; onRefresh: () => void; showToast: (message: string, type: 'success' | 'error') => void; }; export type FileTreeUploadProgressState = { status: 'uploading' | 'complete' | 'error'; progress: number; fileCount: number; uploadedCount?: number; fileName?: string; targetPath?: string; error?: string; }; type UploadResponse = { error?: string; message?: string; files?: unknown[]; uploadedCount?: number; requestedFileCount?: number; }; const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400; const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200; const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files'); const getRelativePath = (file: File) => { const fileWithRelativePath = file as File & { webkitRelativePath?: string }; return fileWithRelativePath.webkitRelativePath || file.name; }; const getFileDisplayName = (file: File) => { const relativePath = getRelativePath(file); return relativePath.split(/[\\/]/).pop() || file.name; }; const validateFilesForUpload = (files: File[]): string | null => { if (files.length > MAX_FILE_UPLOAD_COUNT) { return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`; } const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES); if (oversizedFile) { return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`; } return null; }; const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => { if (!xhr.responseText) { return {}; } try { return JSON.parse(xhr.responseText) as UploadResponse; } catch { return {}; } }; const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => { if (uploadedCount !== requestedFileCount) { return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`; } return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`; }; const buildUploadFormData = (files: File[], targetPath: string) => { const formData = new FormData(); const relativePaths: string[] = []; formData.append('targetPath', targetPath); formData.append('requestedFileCount', String(files.length)); files.forEach((file) => { const relativePath = getRelativePath(file); const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, { type: file.type, lastModified: file.lastModified, }); formData.append('files', cleanFile); relativePaths.push(relativePath); }); formData.append('relativePaths', JSON.stringify(relativePaths)); return formData; }; const uploadFormDataWithProgress = ( projectId: string, formData: FormData, onProgress: (progress: number) => void, ) => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`); const token = localStorage.getItem('auth-token'); if (!IS_PLATFORM && token) { xhr.setRequestHeader('Authorization', `Bearer ${token}`); } xhr.upload.onprogress = (event) => { if (!event.lengthComputable) { return; } // Keep 100% for the server response so the UI can distinguish transfer // completion from the final write/refresh step. onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100))); }; xhr.onload = () => { const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token'); if (refreshedToken) { localStorage.setItem('auth-token', refreshedToken); } const payload = parseUploadResponse(xhr); if (xhr.status >= 200 && xhr.status < 300) { resolve(payload); return; } reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`)); }; xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.')); xhr.onabort = () => reject(new Error('Upload canceled.')); xhr.send(formData); }); // Helper function to read all files from a directory entry recursively const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise => { const files: File[] = []; const reader = directoryEntry.createReader(); let entries: FileSystemEntry[] = []; // Read all entries from the directory (may need multiple reads) let batch: FileSystemEntry[]; do { batch = await new Promise((resolve, reject) => { reader.readEntries(resolve, reject); }); entries = entries.concat(batch); } while (batch.length > 0); // Files to ignore (system files) const ignoredFiles = ['.DS_Store', 'Thumbs.db', 'desktop.ini']; for (const entry of entries) { const entryPath = basePath ? `${basePath}/${entry.name}` : entry.name; if (entry.isFile) { const fileEntry = entry as FileSystemFileEntry; const file = await new Promise((resolve, reject) => { fileEntry.file(resolve, reject); }); // Skip ignored files if (ignoredFiles.includes(file.name)) { continue; } // Create a new file with the relative path as the name const fileWithPath = new File([file], entryPath, { type: file.type, lastModified: file.lastModified, }); files.push(fileWithPath); } else if (entry.isDirectory) { const dirEntry = entry as FileSystemDirectoryEntry; const subFiles = await readAllDirectoryEntries(dirEntry, entryPath); files.push(...subFiles); } } return files; }; const collectDroppedFiles = async (dataTransfer: DataTransfer) => { const files: File[] = []; // Use DataTransferItemList for folder support const { items } = dataTransfer; if (items) { for (const item of Array.from(items)) { if (item.kind !== 'file') { continue; } const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; if (!entry) { const file = item.getAsFile(); if (file) { files.push(file); } continue; } if (entry.isFile) { const file = await new Promise((resolve, reject) => { (entry as FileSystemFileEntry).file(resolve, reject); }); files.push(file); } else if (entry.isDirectory) { // Pass the directory name as basePath so files include the folder path const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name); files.push(...dirFiles); } } return files; } // Fallback for browsers that don't support webkitGetAsEntry for (const file of Array.from(dataTransfer.files)) { files.push(file); } return files; }; export const useFileTreeUpload = ({ selectedProject, onRefresh, showToast, }: UseFileTreeUploadOptions) => { const [isDragOver, setIsDragOver] = useState(false); const [dropTarget, setDropTarget] = useState(null); const [operationLoading, setOperationLoading] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); const treeRef = useRef(null); const clearProgressTimerRef = useRef | null>(null); const clearProgressTimer = useCallback(() => { if (clearProgressTimerRef.current) { clearTimeout(clearProgressTimerRef.current); clearProgressTimerRef.current = null; } }, []); const scheduleProgressClear = useCallback( (delay: number) => { clearProgressTimer(); clearProgressTimerRef.current = setTimeout(() => { setUploadProgress(null); clearProgressTimerRef.current = null; }, delay); }, [clearProgressTimer], ); useEffect(() => clearProgressTimer, [clearProgressTimer]); const setUploadError = useCallback( (message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => { setUploadProgress({ status: 'error', progress, fileCount, fileName, targetPath, error: message, }); scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS); }, [scheduleProgressClear], ); const uploadFiles = useCallback( async (files: File[], targetPath = '') => { if (files.length === 0) { setDropTarget(null); return; } const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined; if (!selectedProject) { const message = 'Select a project before uploading files.'; showToast(message, 'error'); setUploadError(message, files.length, targetPath, fileName); return; } const validationError = validateFilesForUpload(files); if (validationError) { showToast(validationError, 'error'); setUploadError(validationError, files.length, targetPath, fileName); return; } clearProgressTimer(); setOperationLoading(true); setUploadProgress({ status: 'uploading', progress: 0, fileCount: files.length, fileName, targetPath, }); let latestProgress = 0; try { const response = await uploadFormDataWithProgress( selectedProject.projectId, buildUploadFormData(files, targetPath), (progress) => { latestProgress = progress; setUploadProgress((current) => current && current.status === 'uploading' ? { ...current, progress } : current, ); }, ); const uploadedCount = typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length; const requestedFileCount = typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length; setUploadProgress({ status: 'complete', progress: 100, fileCount: requestedFileCount, uploadedCount, fileName, targetPath, }); showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success'); scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS); onRefresh(); } catch (err) { const message = err instanceof Error ? err.message : 'Upload failed'; console.error('Upload error:', err); showToast(message, 'error'); setUploadError(message, files.length, targetPath, fileName, latestProgress); } finally { setOperationLoading(false); setDropTarget(null); } }, [ clearProgressTimer, onRefresh, scheduleProgressClear, selectedProject, setUploadError, showToast, ], ); const handleFileSelect = useCallback( async (fileList: FileList | File[]) => { await uploadFiles(Array.from(fileList), ''); }, [uploadFiles], ); const handleDragEnter = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true); }, []); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); // Only set isDragOver to false if we're leaving the entire tree if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) { setIsDragOver(false); setDropTarget(null); } }, []); const handleDrop = useCallback( async (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); const targetPath = dropTarget || ''; try { const files = await collectDroppedFiles(e.dataTransfer); await uploadFiles(files, targetPath); } catch (err) { const message = err instanceof Error ? err.message : 'Could not read dropped files'; console.error('Upload error:', err); showToast(message, 'error'); setUploadError(message, 0, targetPath); setDropTarget(null); } }, [dropTarget, setUploadError, showToast, uploadFiles], ); const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => { e.preventDefault(); e.stopPropagation(); setDropTarget(itemPath); }, []); const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => { e.preventDefault(); e.stopPropagation(); setDropTarget(itemPath); }, []); return { isDragOver, dropTarget, operationLoading, uploadProgress, treeRef, handleFileSelect, handleDragEnter, handleDragOver, handleDragLeave, handleDrop, handleItemDragOver, handleItemDrop, setDropTarget, }; };