/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/components/widgets/FileUploader/FileUploader.tsx
626 строк
17 KB
Ken McGrady
Update copyright header to 2026 (#13491)
02 янв 2026, 16:21
Не верифицирован
02 янв 2026, 16:21
374540a
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { isEqual, zip } from "lodash-es" import { flushSync } from "react-dom" import { FileRejection } from "react-dropzone" import { FileUploader as FileUploaderProto, FileUploaderState as FileUploaderStateProto, FileURLs as FileURLsProto, IFileURLs, UploadedFileInfo as UploadedFileInfoProto, } from "@streamlit/protobuf" import { WidgetLabel, WidgetLabelHelpIcon, } from "~lib/components/widgets/BaseWidget" import { useFormClearHelper } from "~lib/components/widgets/Form" import { FileUploadClient } from "~lib/FileUploadClient" import { useCalculatedDimensions } from "~lib/hooks/useCalculatedDimensions" import { FileSize, getRejectedFileInfo, sizeConverter, } from "~lib/util/FileHelper" import { isNullOrUndefined, labelVisibilityProtoValueToEnum, } from "~lib/util/utils" import { WidgetStateManager } from "~lib/WidgetStateManager" import FileDropzone from "./FileDropzone" import { StyledFileUploader } from "./styled-components" import UploadedFiles from "./UploadedFiles" import { UploadedStatus, UploadFileInfo } from "./UploadFileInfo" type FilesUpdater = | UploadFileInfo[] | ((prev: UploadFileInfo[]) => UploadFileInfo[]) const createInitialFiles = ( element: FileUploaderProto, widgetMgr: WidgetStateManager ): { files: UploadFileInfo[]; nextLocalId: number } => { const widgetValue = widgetMgr.getFileUploaderStateValue(element) if (isNullOrUndefined(widgetValue)) { return { files: [], nextLocalId: 1 } } const { uploadedFileInfo } = widgetValue if (isNullOrUndefined(uploadedFileInfo) || uploadedFileInfo.length === 0) { return { files: [], nextLocalId: 1 } } let nextLocalId = 1 const files = uploadedFileInfo.map(f => { const name = f.name as string const size = f.size as number const fileId = f.fileId as string const fileUrls = f.fileUrls as FileURLsProto const uploadFile = new UploadFileInfo(name, size, nextLocalId, { type: "uploaded", fileId, fileUrls, }) nextLocalId += 1 return uploadFile }) return { files, nextLocalId } } /** * Convert a list of uploaded file info to the widget state. */ const toWidgetState = ( targetFiles: UploadFileInfo[] ): FileUploaderStateProto => { const uploadedFileInfo: UploadedFileInfoProto[] = targetFiles .filter(f => f.status.type === "uploaded") .map(f => { const { name, size, status } = f const { fileId, fileUrls } = status as UploadedStatus return new UploadedFileInfoProto({ fileId, fileUrls, name, size, }) }) return new FileUploaderStateProto({ uploadedFileInfo }) } type FileUploaderStatus = | "ready" // FileUploader can upload or delete files | "updating" // at least one file is being uploaded or deleted export interface Props { disabled: boolean element: FileUploaderProto widgetMgr: WidgetStateManager uploadClient: FileUploadClient fragmentId?: string } const FileUploader = ({ disabled, element, widgetMgr, uploadClient, fragmentId, }: Props): React.ReactElement => { const { width, elementRef } = useCalculatedDimensions() const { files: initialFiles, nextLocalId: initialNextLocalId } = useMemo( () => createInitialFiles(element, widgetMgr), [element, widgetMgr] ) const localFileIdCounterRef = useRef(initialNextLocalId) const [files, setFiles] = useState<UploadFileInfo[]>(() => initialFiles) const filesRef = useRef<UploadFileInfo[]>(files) useEffect(() => { filesRef.current = files }, [files]) const [isForceUpdating, setIsForceUpdating] = useState(false) /** * Generate a unique ID for a new file. */ const nextLocalFileId = useCallback((): number => { const id = localFileIdCounterRef.current localFileIdCounterRef.current += 1 return id }, []) const maxUploadSizeInBytes = useMemo(() => { const maxMbs = element.maxUploadSizeMb return sizeConverter(maxMbs, FileSize.Megabyte, FileSize.Byte) }, [element.maxUploadSizeMb]) /** * Set the files immediately. */ const setFilesImmediate = useCallback((updater: FilesUpdater): void => { /* eslint-disable-next-line @eslint-react/dom/no-flush-sync -- * Using flushSync here because we need the state to be immediately updated * before any subsequent file upload operations occur. Without this, React * can defer the commit and our upload callbacks (progress, completion, or * abort) may run while filesRef.current still points to the previous state. * Those callbacks rely on filesRef.current to locate the in-flight upload, * so deferring the update would cause them to no-op and break progress * tracking. */ flushSync(() => { setFiles(prev => { const next = typeof updater === "function" ? updater(prev) : updater filesRef.current = next return next }) }) }, []) const setForceUpdatingStatus = useCallback( (value: boolean): void => { /* eslint-disable-next-line @eslint-react/dom/no-flush-sync -- * We need the status flag to update synchronously so that subsequent * renders treat the widget as updating. Otherwise, the status could * briefly report as ready and trigger widget state propagation while * we're still replacing an existing file. */ flushSync(() => { setIsForceUpdating(value) }) }, [setIsForceUpdating] ) /** * Add a file to the list of files. */ const addFile = useCallback( (file: UploadFileInfo): void => { setFilesImmediate(prev => [...prev, file]) }, [setFilesImmediate] ) /** * Add multiple files to the list of files. */ const addFiles = useCallback( (filesToAdd: UploadFileInfo[]): void => { if (filesToAdd.length === 0) { return } setFilesImmediate(prev => [...prev, ...filesToAdd]) }, [setFilesImmediate] ) /** * Remove a file from the list of files. */ const removeFile = useCallback( (idToRemove: number): void => { setFilesImmediate(prev => prev.filter(file => file.id !== idToRemove)) }, [setFilesImmediate] ) /** * Update a file in the list of files. */ const updateFile = useCallback( (curFileId: number, newFile: UploadFileInfo): void => { setFilesImmediate(prev => prev.map(file => (file.id === curFileId ? newFile : file)) ) }, [setFilesImmediate] ) const getFile = useCallback((fileId: number): UploadFileInfo | undefined => { return filesRef.current.find(file => file.id === fileId) }, []) const status: FileUploaderStatus = files.some(file => file.status.type === "uploading") || isForceUpdating ? "updating" : "ready" /** * Set the initial widget value on mount. */ useEffect(() => { const prevWidgetValue = widgetMgr.getFileUploaderStateValue(element) if (prevWidgetValue === undefined) { widgetMgr.setFileUploaderStateValue( element, toWidgetState(filesRef.current), { fromUi: false, }, fragmentId ) } }, [widgetMgr, element, fragmentId]) /** * Set the widget value when the status is ready. */ useEffect(() => { if (status !== "ready") { return } const newWidgetValue = toWidgetState(files) const prevWidgetValue = widgetMgr.getFileUploaderStateValue(element) if (!isEqual(newWidgetValue, prevWidgetValue)) { widgetMgr.setFileUploaderStateValue( element, newWidgetValue, { fromUi: true, }, fragmentId ) } }, [status, files, widgetMgr, element, fragmentId]) const onFormCleared = useCallback((): void => { setFilesImmediate(() => []) const newWidgetValue = toWidgetState([]) widgetMgr.setFileUploaderStateValue( element, newWidgetValue, { fromUi: true }, fragmentId ) }, [element, fragmentId, setFilesImmediate, widgetMgr]) useFormClearHelper({ element, widgetMgr, onFormCleared, }) /** * Check if the file type is allowed. */ const isFileTypeAllowed = useCallback( (file: File): boolean => { const acceptedExtensions = element.type if (!acceptedExtensions || acceptedExtensions.length === 0) { return true } const fileName = file.name.toLowerCase() return acceptedExtensions.some(ext => fileName.endsWith(ext.toLowerCase()) ) }, [element.type] ) const filterDirectoryFiles = useCallback( ( filesToFilter: File[] ): { accepted: File[]; rejected: FileRejection[] } => { const accepted: File[] = [] const rejected: FileRejection[] = [] filesToFilter.forEach(file => { if (isFileTypeAllowed(file)) { accepted.push(file) } else { rejected.push({ file, errors: [ { code: "file-invalid-type", message: `${file.type} files are not allowed.`, }, ], }) } }) return { accepted, rejected } }, [isFileTypeAllowed] ) /** * Update the file status when the upload has finished. */ const onUploadComplete = useCallback( (localFileId: number, fileUrls: IFileURLs): void => { const curFile = getFile(localFileId) if (isNullOrUndefined(curFile) || curFile.status.type !== "uploading") { return } updateFile( curFile.id, curFile.setStatus({ type: "uploaded", fileId: fileUrls.fileId as string, fileUrls, }) ) }, [getFile, updateFile] ) /** * Update the file status when the upload has progressed. */ const onUploadProgress = useCallback( (event: ProgressEvent, fileId: number): void => { const file = getFile(fileId) if (isNullOrUndefined(file) || file.status.type !== "uploading") { return } const newProgress = Math.round((event.loaded * 100) / event.total) if (file.status.progress === newProgress) { return } updateFile( fileId, file.setStatus({ type: "uploading", abortController: file.status.abortController, progress: newProgress, }) ) }, [getFile, updateFile] ) /** * Upload a file to the backend. */ const uploadFile = useCallback( (fileURLs: IFileURLs, file: File): void => { const abortController = new AbortController() const fileName = file.webkitRelativePath || file.name const uploadingFileInfo = new UploadFileInfo( fileName, file.size, nextLocalFileId(), { type: "uploading", abortController, progress: 1, } ) addFile(uploadingFileInfo) uploadClient .uploadFile( element, fileURLs.uploadUrl as string, file, e => onUploadProgress(e, uploadingFileInfo.id), abortController.signal ) .then(() => onUploadComplete(uploadingFileInfo.id, fileURLs)) .catch(err => { if (!(err instanceof DOMException && err.name === "AbortError")) { updateFile( uploadingFileInfo.id, uploadingFileInfo.setStatus({ type: "error", errorMessage: err ? err.toString() : "Unknown error", }) ) } }) }, [ addFile, element, nextLocalFileId, onUploadComplete, onUploadProgress, updateFile, uploadClient, ] ) /** * Delete a file from the backend and client. */ const deleteFile = useCallback( (fileId: number): void => { if (disabled) { return } const file = getFile(fileId) if (isNullOrUndefined(file)) { return } if (file.status.type === "uploading") { file.status.abortController.abort() } if (file.status.type === "uploaded" && file.status.fileUrls.deleteUrl) { void uploadClient.deleteFile(file.status.fileUrls.deleteUrl) } removeFile(fileId) }, [disabled, getFile, removeFile, uploadClient] ) /** * Handle the drag-and-drop event for file-upload. */ const dropHandler = useCallback( ( acceptedFilesParam: File[], rejectedFilesParam: FileRejection[] ): void => { const { multipleFiles } = element const isDirectoryUpload = Boolean(element.acceptDirectory) let acceptedFiles = [...acceptedFilesParam] let rejectedFiles = [...rejectedFilesParam] if (isDirectoryUpload && acceptedFiles.length > 0) { const { accepted, rejected } = filterDirectoryFiles(acceptedFiles) acceptedFiles = accepted rejectedFiles = [...rejectedFiles, ...rejected] } if ( !multipleFiles && acceptedFiles.length === 0 && rejectedFiles.length > 1 ) { const firstFileIndex = rejectedFiles.findIndex( file => file.errors.length === 1 && file.errors[0].code === "too-many-files" ) if (firstFileIndex >= 0) { acceptedFiles.push(rejectedFiles[firstFileIndex].file) rejectedFiles.splice(firstFileIndex, 1) } } uploadClient .fetchFileURLs(acceptedFiles) .then((fileURLsArray: IFileURLs[]) => { if (!multipleFiles && acceptedFiles.length > 0) { const existingFile = filesRef.current.find( f => f.status.type !== "error" ) if (existingFile) { setForceUpdatingStatus(true) try { deleteFile(existingFile.id) } finally { setForceUpdatingStatus(false) } } } zip(fileURLsArray, acceptedFiles).forEach( ([fileURLs, acceptedFile]) => { uploadFile(fileURLs as FileURLsProto, acceptedFile as File) } ) }) .catch((errorMessage: string) => { addFiles( acceptedFiles.map( f => new UploadFileInfo(f.name, f.size, nextLocalFileId(), { type: "error", errorMessage, }) ) ) }) if (rejectedFiles.length > 0) { const rejectedInfos = rejectedFiles.map(rejected => getRejectedFileInfo( rejected, nextLocalFileId(), maxUploadSizeInBytes ) ) addFiles(rejectedInfos) } }, [ addFiles, deleteFile, element, filterDirectoryFiles, maxUploadSizeInBytes, nextLocalFileId, uploadClient, uploadFile, setForceUpdatingStatus, ] ) const newestToOldestFiles = useMemo(() => { return files.slice().reverse() }, [files]) const acceptedExtensions = element.type return ( <StyledFileUploader className="stFileUploader" data-testid="stFileUploader" width={width} ref={elementRef} > <WidgetLabel label={element.label} disabled={disabled} labelVisibility={labelVisibilityProtoValueToEnum( element.labelVisibility?.value )} > {element.help && ( <WidgetLabelHelpIcon content={element.help} label={element.label} /> )} </WidgetLabel> <FileDropzone onDrop={dropHandler} multiple={element.multipleFiles} acceptedExtensions={acceptedExtensions} maxSizeBytes={maxUploadSizeInBytes} label={element.label} disabled={disabled} acceptDirectory={Boolean(element.acceptDirectory)} /> {newestToOldestFiles.length > 0 && ( <UploadedFiles items={newestToOldestFiles} pageSize={3} onDelete={deleteFile} resetOnAdd disabled={disabled} /> )} </StyledFileUploader> ) } export default memo(FileUploader)