From c235b05e1d3b626667dba4043b685512e3cd3d5d Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:52:09 +0300 Subject: [PATCH] feat: add file tree upload progress Users need a visible upload path from the explorer itself, not only drag and drop behavior with no progress feedback. Routing picker and drop uploads through one XHR-backed hook keeps progress, validation, refresh, and success counts consistent for every upload source. The 200MB limit is mirrored in the client, multer, and nginx template so large uploads fail predictably instead of being blocked by whichever layer sees the request first. The server also returns explicit requested and uploaded counts so partial or multi-file batches can render accurate status text. --- docs/nginx-subpath-template.conf | 2 +- server/index.js | 24 +- .../file-tree/constants/constants.ts | 8 + .../file-tree/hooks/useFileTreeUpload.ts | 432 ++++++++++++++---- src/components/file-tree/view/FileTree.tsx | 21 +- .../file-tree/view/FileTreeHeader.tsx | 65 ++- .../file-tree/view/FileTreeUploadProgress.tsx | 90 ++++ 7 files changed, 535 insertions(+), 107 deletions(-) create mode 100644 src/components/file-tree/view/FileTreeUploadProgress.tsx 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 && (