mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-11 08:16:00 +08:00
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.
455 lines
13 KiB
TypeScript
455 lines
13 KiB
TypeScript
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<UploadResponse>((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<File[]> => {
|
|
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<FileSystemEntry[]>((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<File>((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<File>((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<string | null>(null);
|
|
const [operationLoading, setOperationLoading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
|
|
const treeRef = useRef<HTMLDivElement>(null);
|
|
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
|
|
};
|
|
};
|