diff --git a/docs/nginx-subpath-template.conf b/docs/nginx-subpath-template.conf index 15f4f067..e4132615 100644 --- a/docs/nginx-subpath-template.conf +++ b/docs/nginx-subpath-template.conf @@ -72,7 +72,7 @@ http { set $cloudcli_upstream http://127.0.0.1:3001; # Allow larger file uploads through the code editor/project file APIs. - client_max_body_size 100m; + client_max_body_size 200m; # Redirect /ai to /ai/ so relative browser URL resolution is stable. # [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath. diff --git a/server/index.js b/server/index.js index 58c4ce74..cb8ecc31 100755 --- a/server/index.js +++ b/server/index.js @@ -84,6 +84,9 @@ const __dirname = getModuleDir(import.meta.url); // Resolving the app root once keeps every repo-level lookup below aligned across both layouts. const APP_ROOT = findAppRoot(__dirname); const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; +const MAX_FILE_UPLOAD_SIZE_MB = 200; +const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024; +const MAX_FILE_UPLOAD_COUNT = 20; console.log('SERVER_PORT from env:', process.env.SERVER_PORT); @@ -897,27 +900,27 @@ const uploadFilesHandler = async (req, res) => { } }), limits: { - fileSize: 50 * 1024 * 1024, // 50MB limit - files: 20 // Max 20 files at once + fileSize: MAX_FILE_UPLOAD_SIZE_BYTES, + files: MAX_FILE_UPLOAD_COUNT } }); // Use multer middleware - uploadMiddleware.array('files', 20)(req, res, async (err) => { + uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => { if (err) { console.error('Multer error:', err); if (err.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' }); + return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` }); } if (err.code === 'LIMIT_FILE_COUNT') { - return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' }); + return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` }); } return res.status(500).json({ error: err.message }); } try { const { projectId } = req.params; - const { targetPath, relativePaths } = req.body; + const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body; // Parse relative paths if provided (for folder uploads) let filePaths = []; @@ -941,6 +944,11 @@ const uploadFilesHandler = async (req, res) => { return res.status(400).json({ error: 'No files provided' }); } + const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10); + const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0 + ? parsedRequestedFileCount + : req.files.length; + // Resolve the project directory through the DB using the new projectId. const projectRoot = await projectsDb.getProjectPathById(projectId); if (!projectRoot) { @@ -1019,8 +1027,10 @@ const uploadFilesHandler = async (req, res) => { res.json({ success: true, files: uploadedFiles, + uploadedCount: uploadedFiles.length, + requestedFileCount, targetPath: resolvedTargetDir, - message: `Uploaded ${uploadedFiles.length} file(s) successfully` + message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully` }); } catch (error) { console.error('Error uploading files:', error); diff --git a/src/components/file-tree/constants/constants.ts b/src/components/file-tree/constants/constants.ts index a56bf6bd..71cc9f35 100644 --- a/src/components/file-tree/constants/constants.ts +++ b/src/components/file-tree/constants/constants.ts @@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed'; export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed']; +export const MAX_FILE_UPLOAD_SIZE_MB = 200; + +export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024; + +export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`; + +export const MAX_FILE_UPLOAD_COUNT = 20; + export const IMAGE_FILE_EXTENSIONS = new Set([ 'png', 'jpg', diff --git a/src/components/file-tree/hooks/useFileTreeUpload.ts b/src/components/file-tree/hooks/useFileTreeUpload.ts index 6879e3ae..37cd936d 100644 --- a/src/components/file-tree/hooks/useFileTreeUpload.ts +++ b/src/components/file-tree/hooks/useFileTreeUpload.ts @@ -1,6 +1,13 @@ -import { useCallback, useState, useRef } from 'react'; +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 { api } from '../../../utils/api'; +import { + MAX_FILE_UPLOAD_COUNT, + MAX_FILE_UPLOAD_SIZE_BYTES, + MAX_FILE_UPLOAD_SIZE_LABEL, +} from '../constants/constants'; type UseFileTreeUploadOptions = { selectedProject: Project | null; @@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = { 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[] = []; @@ -57,6 +199,48 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, 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, @@ -65,20 +249,150 @@ export const useFileTreeUpload = ({ 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 handleDragEnter = useCallback((e: React.DragEvent) => { + 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: React.DragEvent) => { + const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); - const handleDragLeave = useCallback((e: React.DragEvent) => { + const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); // Only set isDragOver to false if we're leaving the entire tree @@ -88,103 +402,35 @@ export const useFileTreeUpload = ({ } }, []); - const handleDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); + const handleDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); - const targetPath = dropTarget || ''; - setOperationLoading(true); + const targetPath = dropTarget || ''; - try { - const files: File[] = []; - - // Use DataTransferItemList for folder support - const items = e.dataTransfer.items; - if (items) { - for (const item of Array.from(items)) { - if (item.kind === 'file') { - const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; - - if (entry) { - 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); - } - } - } - } - } else { - // Fallback for browsers that don't support webkitGetAsEntry - const fileList = e.dataTransfer.files; - for (const file of Array.from(fileList)) { - files.push(file); - } - } - - if (files.length === 0) { - setOperationLoading(false); + 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); - return; } + }, + [dropTarget, setUploadError, showToast, uploadFiles], + ); - const formData = new FormData(); - formData.append('targetPath', targetPath); - - // Store relative paths separately since FormData strips path info from File.name - const relativePaths: string[] = []; - files.forEach((file) => { - // Create a new file with just the filename (without path) for FormData - // but store the relative path separately - const cleanFile = new File([file], file.name.split('/').pop()!, { - type: file.type, - lastModified: file.lastModified - }); - formData.append('files', cleanFile); - relativePaths.push(file.name); // Keep the full relative path - }); - - // Send relative paths as a JSON array - formData.append('relativePaths', JSON.stringify(relativePaths)); - - const response = await api.post( - // File upload endpoint is keyed by DB projectId post-migration. - `/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`, - formData - ); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Upload failed'); - } - - showToast( - `Uploaded ${files.length} file(s)`, - 'success' - ); - onRefresh(); - } catch (err) { - console.error('Upload error:', err); - showToast(err instanceof Error ? err.message : 'Upload failed', 'error'); - } finally { - setOperationLoading(false); - setDropTarget(null); - } - }, [dropTarget, selectedProject, onRefresh, showToast]); - - const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => { + const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => { e.preventDefault(); e.stopPropagation(); setDropTarget(itemPath); }, []); - const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => { + const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => { e.preventDefault(); e.stopPropagation(); setDropTarget(itemPath); @@ -194,7 +440,9 @@ export const useFileTreeUpload = ({ isDragOver, dropTarget, operationLoading, + uploadProgress, treeRef, + handleFileSelect, handleDragEnter, handleDragOver, handleDragLeave, diff --git a/src/components/file-tree/view/FileTree.tsx b/src/components/file-tree/view/FileTree.tsx index b42e1014..bd90c36b 100644 --- a/src/components/file-tree/view/FileTree.tsx +++ b/src/components/file-tree/view/FileTree.tsx @@ -1,6 +1,7 @@ import { useCallback, useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react'; + import { cn } from '../../../lib/utils'; import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons'; import { useExpandedDirectories } from '../hooks/useExpandedDirectories'; @@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types'; import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils'; import { Project } from '../../../types/app'; import { ScrollArea, Input } from '../../../shared/view/ui'; + import FileTreeBody from './FileTreeBody'; import FileTreeDetailedColumns from './FileTreeDetailedColumns'; import FileTreeHeader from './FileTreeHeader'; import FileTreeLoadingState from './FileTreeLoadingState'; +import FileTreeUploadProgress from './FileTreeUploadProgress'; import ImageViewer from './ImageViewer'; @@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) onRefresh: refreshFiles, showToast, }); + const operationLoading = operations.operationLoading || upload.operationLoading; // Focus input when creating new item useEffect(() => { @@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) onViewModeChange={changeViewMode} searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} + onUploadFiles={upload.handleFileSelect} onNewFile={() => operations.handleStartCreate('', 'file')} onNewFolder={() => operations.handleStartCreate('', 'directory')} onRefresh={refreshFiles} onCollapseAll={collapseAll} loading={loading} - operationLoading={operations.operationLoading} + operationLoading={operationLoading} + isUploading={upload.uploadProgress?.status === 'uploading'} + uploadProgress={upload.uploadProgress?.progress ?? null} /> + + {viewMode === 'detailed' && filteredFiles.length > 0 && } @@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) }, 100); }} className="h-6 flex-1 text-sm" - disabled={operations.operationLoading} + disabled={operationLoading} /> )} @@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) handleConfirmRename={operations.handleConfirmRename} handleCancelRename={operations.handleCancelRename} renameInputRef={renameInputRef} - operationLoading={operations.operationLoading} + operationLoading={operationLoading} /> @@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
diff --git a/src/components/file-tree/view/FileTreeHeader.tsx b/src/components/file-tree/view/FileTreeHeader.tsx index 80516215..db2bd2f8 100644 --- a/src/components/file-tree/view/FileTreeHeader.tsx +++ b/src/components/file-tree/view/FileTreeHeader.tsx @@ -1,7 +1,11 @@ -import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react'; +import { useRef } from 'react'; +import type { ChangeEvent } from 'react'; +import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; + import { Button, Input } from '../../../shared/view/ui'; import { cn } from '../../../lib/utils'; +import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants'; import type { FileTreeViewMode } from '../types/types'; type FileTreeHeaderProps = { @@ -12,11 +16,14 @@ type FileTreeHeaderProps = { // Toolbar actions onNewFile?: () => void; onNewFolder?: () => void; + onUploadFiles?: (files: FileList) => void; onRefresh?: () => void; onCollapseAll?: () => void; // Loading state loading?: boolean; operationLoading?: boolean; + isUploading?: boolean; + uploadProgress?: number | null; }; export default function FileTreeHeader({ @@ -26,12 +33,24 @@ export default function FileTreeHeader({ onSearchQueryChange, onNewFile, onNewFolder, + onUploadFiles, onRefresh, onCollapseAll, loading, operationLoading, + isUploading, + uploadProgress, }: FileTreeHeaderProps) { const { t } = useTranslation(); + const uploadInputRef = useRef(null); + + const handleUploadInputChange = (event: ChangeEvent) => { + const { files } = event.target; + if (files && files.length > 0) { + onUploadFiles?.(files); + } + event.target.value = ''; + }; return (
@@ -40,6 +59,50 @@ export default function FileTreeHeader({

{t('fileTree.files')}

{/* Action buttons */} + {onUploadFiles && ( + <> + + + + )} {onNewFile && (