diff --git a/src/modules/filemanager/components/FileBrowser/FileBrowser.tsx b/src/modules/filemanager/components/FileBrowser/FileBrowser.tsx index 3a4079d..879490e 100644 --- a/src/modules/filemanager/components/FileBrowser/FileBrowser.tsx +++ b/src/modules/filemanager/components/FileBrowser/FileBrowser.tsx @@ -16,7 +16,7 @@ import { useView } from '../../../../pages/filemanager/ViewContext' import { Context as FMContext } from '../../../../providers/FileManager' import { Context as SettingsContext } from '../../../../providers/Settings' import { FileAction, FileTransferType, TransferStatus, ViewType } from '../../constants/transfers' -import { useBulkActions } from '../../hooks/useBulkActions' +import { BulkActionsResult, useBulkActions } from '../../hooks/useBulkActions' import { useContextMenu } from '../../hooks/useContextMenu' import { useDragAndDrop } from '../../hooks/useDragAndDrop' import { useFileFiltering } from '../../hooks/useFileFiltering' @@ -118,7 +118,7 @@ type FileBrowserContextMenuBlockProps = { dropDir: Dir drives: DriveInfo[] view: ViewType - bulk: ReturnType + bulk: BulkActionsResult adminStamp: PostageBatch | undefined doRefresh: () => void onContextUploadFile: () => void @@ -162,11 +162,11 @@ function FileBrowserContextMenuBlock({ onRefresh={doRefresh} enableRefresh={Boolean(adminStamp)} onUploadFile={onContextUploadFile} - onBulkDownload={() => bulk.bulkDownload(bulk.selectedFiles)} + onBulkDownload={() => bulk.download(bulk.selectedFiles)} onBulkRestore={() => setConfirmBulkRestore(true)} onBulkDelete={() => setShowBulkDeleteModal(true)} onBulkDestroy={() => setShowDestroyDriveModal(true)} - onBulkForget={() => bulk.bulkForget(bulk.selectedFiles)} + onBulkForget={() => bulk.forget(bulk.selectedFiles)} /> ) @@ -196,7 +196,6 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps) const [safePos, setSafePos] = useState(pos) const [dropDir, setDropDir] = useState(Dir.Down) - const legacyUploadRef = useRef(null) const contentRef = useRef(null) const bodyRef = useRef(null) const isMountedRef = useRef(true) @@ -281,20 +280,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps) } const onContextUploadFile = () => { - const el = legacyUploadRef.current - - if (!el) return - - try { - if (typeof (el as HTMLInputElement).showPicker === 'function') { - ;(el as HTMLInputElement).showPicker() - } else { - el.click() - } - } catch { - el.click() - } - + bulk.uploadFromPicker() requestAnimationFrame(() => handleCloseContext()) } @@ -323,7 +309,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps) setShowBulkDeleteModal(false) if (action === FileAction.Trash) { - return await bulk.bulkTrash(bulk.selectedFiles) + return await bulk.trash(bulk.selectedFiles) } if (action === FileAction.Forget) { @@ -489,9 +475,9 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps) const onBulk = useMemo( () => ({ - download: () => bulk.bulkDownload(bulk.selectedFiles), + download: () => bulk.download(bulk.selectedFiles), restore: () => setConfirmBulkRestore(true), - forget: () => bulk.bulkForget(bulk.selectedFiles), + forget: () => bulk.forget(bulk.selectedFiles), destroy: () => setShowDestroyDriveModal(true), delete: () => setShowBulkDeleteModal(true), }), @@ -502,7 +488,6 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps) <> {conflictPortal} -
@@ -605,12 +590,12 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps) onDeleteCancel={() => setShowBulkDeleteModal(false)} onDeleteProceed={handleDeleteModalProceed} onForgetConfirm={async () => { - await bulk.bulkForget(bulk.selectedFiles) + await bulk.forget(bulk.selectedFiles) setConfirmBulkForget(false) }} onForgetCancel={() => setConfirmBulkForget(false)} onRestoreConfirm={async () => { - await bulk.bulkRestore(bulk.selectedFiles) + await bulk.restore(bulk.selectedFiles) setConfirmBulkRestore(false) }} onRestoreCancel={() => setConfirmBulkRestore(false)} diff --git a/src/modules/filemanager/components/FileBrowser/FileBrowserHeader/FileBrowserHeader.tsx b/src/modules/filemanager/components/FileBrowser/FileBrowserHeader/FileBrowserHeader.tsx index 6889103..c0b7093 100644 --- a/src/modules/filemanager/components/FileBrowser/FileBrowserHeader/FileBrowserHeader.tsx +++ b/src/modules/filemanager/components/FileBrowser/FileBrowserHeader/FileBrowserHeader.tsx @@ -1,14 +1,14 @@ import { ReactElement } from 'react' import DownIcon from 'remixicon-react/ArrowDownSLineIcon' -import { useBulkActions } from '../../../hooks/useBulkActions' +import { BulkActionsResult } from '../../../hooks/useBulkActions' import { SortDir, SortKey } from '../../../hooks/useSorting' import { capitalizeFirstLetter } from '@/modules/filemanager/utils/common' interface FileBrowserHeaderProps { isSearchMode: boolean - bulk: ReturnType + bulk: BulkActionsResult sortKey: SortKey sortDir: SortDir onSortName: () => void diff --git a/src/modules/filemanager/components/VersionHistoryModal/VersionList/VersionList.tsx b/src/modules/filemanager/components/VersionHistoryModal/VersionList/VersionList.tsx index ed4fd46..35a2e44 100644 --- a/src/modules/filemanager/components/VersionHistoryModal/VersionList/VersionList.tsx +++ b/src/modules/filemanager/components/VersionHistoryModal/VersionList/VersionList.tsx @@ -435,7 +435,15 @@ export function VersionsList({ versions, headFi, restoreVersion, onDownload }: V await startDownloadingQueue( fm, [{ uuid, info: fileInfo }], - [onDownload({ uuid, name: fileInfo.name, size: formatBytes(rawSize), expectedSize, driveName })], + [ + onDownload({ + uuid, + name: fileInfo.name, + size: formatBytes(rawSize), + expectedSize, + driveName: driveName ?? 'unknown', + }), + ], ) }, [handleCloseContext, fm, beeApi, onDownload, drives, currentDrive], diff --git a/src/modules/filemanager/constants/transfers.ts b/src/modules/filemanager/constants/transfers.ts index 6173c7b..d07ea46 100644 --- a/src/modules/filemanager/constants/transfers.ts +++ b/src/modules/filemanager/constants/transfers.ts @@ -49,7 +49,7 @@ export type TrackDownloadProps = { name: string size?: string expectedSize?: number - driveName?: string + driveName: string } export interface DownloadProgress { diff --git a/src/modules/filemanager/hooks/useBulkActions.ts b/src/modules/filemanager/hooks/useBulkActions.ts index 93976f4..9eefcb3 100644 --- a/src/modules/filemanager/hooks/useBulkActions.ts +++ b/src/modules/filemanager/hooks/useBulkActions.ts @@ -1,6 +1,6 @@ import { PostageBatch } from '@ethersphere/bee-js' import type { FileInfo } from '@solarpunkltd/file-manager-lib' -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { type RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { Context as FMContext } from '../../../providers/FileManager' import { Context as SettingsContext } from '../../../providers/Settings' @@ -9,16 +9,33 @@ import { DownloadProgress, TrackDownloadProps } from '../constants/transfers' import { getUsableStamps } from '../utils/bee' import { formatBytes, getFileId, safeSetState } from '../utils/common' import { FileInfoWithUUID, startDownloadingQueue } from '../utils/download' -import { FileOperation, performBulkFileOperation } from '../utils/fileOperations' +import { FileOperation, isElementPickerSupported, performBulkFileOperation } from '../utils/fileOperations' -interface BulkOptions { +export interface BulkOptions { listToRender: FileInfo[] trackDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void setErrorMessage?: (error: string) => void } -export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: BulkOptions) { - const { fm, adminDrive, drives, refreshStamp, setShowError } = useContext(FMContext) +export interface BulkActionsResult { + selectedIds: Set + selectedFiles: FileInfo[] + selectedCount: number + allChecked: boolean + someChecked: boolean + fileInputRef: RefObject + toggleOne: (fi: FileInfo, checked: boolean) => void + selectAll: () => void + clearAll: () => void + uploadFromPicker: () => void + download: (list: FileInfo[]) => Promise + trash: (list: FileInfo[]) => Promise + restore: (list: FileInfo[]) => Promise + forget: (list: FileInfo[]) => Promise +} + +export function useBulkActions({ listToRender, trackDownload, setErrorMessage }: BulkOptions): BulkActionsResult { + const { fm, adminDrive, currentDrive, drives, refreshStamp, setShowError } = useContext(FMContext) const { beeApi } = useContext(SettingsContext) const [selectedIds, setSelectedIds] = useState>(new Set()) @@ -69,11 +86,23 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: const selectAll = useCallback(() => setSelectedIds(new Set(allIds)), [allIds]) const clearAll = useCallback(() => setSelectedIds(new Set()), []) - const bulkUploadFromPicker = useCallback(() => { - fileInputRef.current?.click() + const uploadFromPicker = useCallback(() => { + const el = fileInputRef.current + + if (!el) return + + try { + if (isElementPickerSupported(el)) { + el.showPicker() + } else { + el.click() + } + } catch { + el.click() + } }, []) - const bulkDownload = useCallback( + const download = useCallback( async (list: FileInfo[]) => { if (!fm || !list?.length) return @@ -85,7 +114,7 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: const rawSize = fi.customMetadata?.size as string | number | undefined const prettySize = formatBytes(rawSize) const expected = rawSize ? Number(rawSize) : undefined - const driveName = drives.find(d => d.id.toString() === fi.driveId.toString())?.name + const driveName = drives.find(d => d.id.toString() === fi.driveId.toString())?.name ?? currentDrive?.name const uuid = uuidV4() infoListWitIDs[i] = { uuid, info: fi } @@ -94,16 +123,16 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: name: fi.name, size: prettySize, expectedSize: expected, - driveName, + driveName: driveName ?? 'unknown', }) } await startDownloadingQueue(fm, infoListWitIDs, trackers) }, - [fm, trackDownload, drives], + [fm, currentDrive, trackDownload, drives], ) - const bulkTrash = useCallback( + const trash = useCallback( async (list: FileInfo[]) => { if (!fm || !list?.length) return @@ -126,7 +155,7 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: [fm, driveStamps, clearAll, refreshStamp, setErrorMessage, setShowError], ) - const bulkRestore = useCallback( + const restore = useCallback( async (list: FileInfo[]) => { if (!fm || !list?.length) return @@ -149,7 +178,7 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: [fm, driveStamps, refreshStamp, clearAll, setErrorMessage, setShowError], ) - const bulkForget = useCallback( + const forget = useCallback( async (list: FileInfo[]) => { if (!fm || !fm.adminStamp || !adminDrive || !list?.length) return @@ -176,26 +205,22 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: [fm, adminDrive, driveStamps, clearAll, refreshStamp, setErrorMessage, setShowError], ) - return useMemo( + return useMemo( () => ({ - // selection selectedIds, - setSelectedIds, selectedFiles, selectedCount, allChecked, someChecked, + fileInputRef, toggleOne, selectAll, clearAll, - // file input (for bulk upload) - fileInputRef, - bulkUploadFromPicker, - // actions - bulkDownload, - bulkTrash, - bulkRestore, - bulkForget, + uploadFromPicker, + download, + trash, + restore, + forget, }), [ selectedIds, @@ -206,11 +231,11 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: toggleOne, selectAll, clearAll, - bulkUploadFromPicker, - bulkDownload, - bulkTrash, - bulkRestore, - bulkForget, + uploadFromPicker, + download, + trash, + restore, + forget, ], ) } diff --git a/src/modules/filemanager/hooks/useTransfers.ts b/src/modules/filemanager/hooks/useTransfers.ts index 4de7739..ea3244d 100644 --- a/src/modules/filemanager/hooks/useTransfers.ts +++ b/src/modules/filemanager/hooks/useTransfers.ts @@ -1,5 +1,5 @@ -import type { FileInfo, FileInfoOptions, UploadProgress } from '@solarpunkltd/file-manager-lib' -import { useCallback, useContext, useEffect, useRef, useState } from 'react' +import type { DriveInfo, FileInfo, FileInfoOptions, UploadProgress } from '@solarpunkltd/file-manager-lib' +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { Context as FMContext } from '../../../providers/FileManager' import { Context as SettingsContext } from '../../../providers/Settings' @@ -27,7 +27,7 @@ const ABORT_EVENT = 'abort' type ResolveResult = { cancelled: boolean finalName?: string - isReplace?: boolean + isReplace: boolean replaceTopic?: string replaceHistory?: string } @@ -65,6 +65,18 @@ type UploadTask = { driveName: string } +const isNameInvalid = ( + finalName: string, + isReplace: boolean, + replaceHistory?: string, + replaceTopic?: string, +): boolean => { + const invalidCombo = isReplace && (!replaceHistory || !replaceTopic) + const invalidName = !finalName || finalName.trim().length === 0 + + return invalidCombo || invalidName +} + const normalizeCustomMetadata = (meta: UploadMeta): Record => { const out: Record = {} for (const [k, v] of Object.entries(meta)) out[k] = typeof v === 'string' ? v : String(v) @@ -175,7 +187,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) { const isMountedRef = useRef(true) const uploadAbortsRef = useRef(new AbortManager()) - const queueRef = useRef([]) + const uploadTaskQueueRef = useRef([]) const runningRef = useRef(false) const cancelledQueuedRef = useRef>(new Set()) const cancelledUploadingRef = useRef>(new Set()) @@ -195,7 +207,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) { cancelledQueuedRef.current.delete(uuid) cancelledUploadingRef.current.delete(uuid) uploadAbortsRef.current.abort(uuid) - queueRef.current = queueRef.current.filter(t => { + uploadTaskQueueRef.current = uploadTaskQueueRef.current.filter(t => { return t.uuid !== uuid }) }, []) @@ -250,7 +262,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) { }) if (choice.action === ConflictAction.Cancel) { - return { cancelled: true } + return { cancelled: true, isReplace: false } } if (choice.action === ConflictAction.KeepBoth) { @@ -347,7 +359,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) { [], ) - const processUploadTask = useCallback( + const executeUploadTask = useCallback( async (task: UploadTask) => { if (!fm) return @@ -447,115 +459,266 @@ export function useTransfers({ setErrorMessage }: TransferProps) { [fm, files, currentStamp, trackUpload, refreshStamp, setShowError, setErrorMessage], ) - const trackDownload = useCallback( - (props: TrackDownloadProps) => { - if (!isMountedRef.current) { - return () => { - // No-op function for unmounted component - } + const trackDownload = useCallback((props: TrackDownloadProps) => { + if (!isMountedRef.current) { + return () => { + // No-op function for unmounted component + } + } + + let startedAt: number | undefined + let etaState: ETAState = { + lastTs: undefined, + lastProcessed: 0, + lastEta: undefined, + } + + setDownloadItems(prev => { + const row = createTransferItem( + props.uuid, + props.name, + props.size, + FileTransferType.Download, + props.driveName, + TransferStatus.Downloading, + ) + row.startedAt = undefined + const idx = prev.findIndex(p => p.uuid === props.uuid) + + if (idx === -1) return [...prev, row] + const out = [...prev] + out[idx] = { ...row, startedAt: prev[idx].startedAt ?? row.startedAt } + + return out + }) + + const onProgress = (dp: DownloadProgress) => { + if (!isMountedRef.current) return + + const now = Date.now() + + if (!startedAt) { + startedAt = now + etaState.lastTs = now } - const driveName = props.driveName ?? currentDrive?.name + let percent = 0 + let etaSec: number | undefined - let startedAt: number | undefined - let etaState: ETAState = { - lastTs: undefined, - lastProcessed: 0, - lastEta: undefined, + if (props.expectedSize && props.expectedSize > 0 && dp.progress >= 0) { + percent = Math.floor((dp.progress / props.expectedSize) * 100) + const result = calculateETA(etaState, { processed: dp.progress, total: props.expectedSize }, startedAt, now) + etaSec = result.etaSec + etaState = result.updatedState } - setDownloadItems(prev => { - const row = createTransferItem( - props.uuid, - props.name, - props.size, - FileTransferType.Download, - driveName, - TransferStatus.Downloading, - ) - row.startedAt = undefined - const idx = prev.findIndex(p => p.uuid === props.uuid) + setDownloadItems(prev => + updateTransferItems(prev, props.uuid, { + percent: Math.max(prev.find(it => it.uuid === props.uuid)?.percent || 0, percent), + etaSec, + startedAt: prev.find(it => it.uuid === props.uuid)?.startedAt ?? startedAt, + }), + ) - if (idx === -1) return [...prev, row] - const out = [...prev] - out[idx] = { ...row, startedAt: prev[idx].startedAt ?? row.startedAt } + if (!dp.isDownloading) { + const finishedAt = Date.now() - return out - }) + setDownloadItems(prev => { + const currentItem = prev.find(it => it.uuid === props.uuid) + const elapsedSec = currentItem?.startedAt ? Math.round((finishedAt - currentItem.startedAt) / 1000) : 0 - const onProgress = (dp: DownloadProgress) => { - if (!isMountedRef.current) return + if (dp.state === DownloadState.Cancelled || dp.state === DownloadState.Error) { + const wasCancelled = dp.state === DownloadState.Cancelled || cancelledDownloadingRef.current.has(props.uuid) - const now = Date.now() - - if (!startedAt) { - startedAt = now - etaState.lastTs = now - } - - let percent = 0 - let etaSec: number | undefined - - if (props.expectedSize && props.expectedSize > 0 && dp.progress >= 0) { - percent = Math.floor((dp.progress / props.expectedSize) * 100) - const result = calculateETA(etaState, { processed: dp.progress, total: props.expectedSize }, startedAt, now) - etaSec = result.etaSec - etaState = result.updatedState - } - - setDownloadItems(prev => - updateTransferItems(prev, props.uuid, { - percent: Math.max(prev.find(it => it.uuid === props.uuid)?.percent || 0, percent), - etaSec, - startedAt: prev.find(it => it.uuid === props.uuid)?.startedAt ?? startedAt, - }), - ) - - if (!dp.isDownloading) { - const finishedAt = Date.now() - - setDownloadItems(prev => { - const currentItem = prev.find(it => it.uuid === props.uuid) - const elapsedSec = currentItem?.startedAt ? Math.round((finishedAt - currentItem.startedAt) / 1000) : 0 - - if (dp.state === DownloadState.Cancelled || dp.state === DownloadState.Error) { - const wasCancelled = - dp.state === DownloadState.Cancelled || cancelledDownloadingRef.current.has(props.uuid) - - cancelledDownloadingRef.current.delete(props.uuid) - - return updateTransferItems(prev, props.uuid, { - status: wasCancelled ? TransferStatus.Cancelled : TransferStatus.Error, - etaSec: undefined, - elapsedSec: 0, - percent: currentItem?.percent ?? 0, - }) - } + cancelledDownloadingRef.current.delete(props.uuid) return updateTransferItems(prev, props.uuid, { - percent: 100, - status: TransferStatus.Done, - etaSec: 0, - elapsedSec, + status: wasCancelled ? TransferStatus.Cancelled : TransferStatus.Error, + etaSec: undefined, + elapsedSec: 0, + percent: currentItem?.percent ?? 0, }) + } + + return updateTransferItems(prev, props.uuid, { + percent: 100, + status: TransferStatus.Done, + etaSec: 0, + elapsedSec, }) - } + }) + } + } + + return onProgress + }, []) + + const { allTaken, reserved, sameDrive } = useMemo(() => { + if (!currentDrive) { + return { allTaken: new Set(''), reserved: new Set(''), sameDrive: [] } + } + + const progressNames = new Set(uploadItems.filter(u => u.driveName === currentDrive.name).map(u => u.name)) + const sameDrive = collectSameDrive(currentDrive.id.toString()) + const onDiskNames = new Set(sameDrive.map((fi: FileInfo) => fi.name)) + const reserved = new Set() + + const allTaken = new Set([ + ...Array.from(onDiskNames), + ...Array.from(reserved), + ...Array.from(progressNames), + ]) + + return { + allTaken, + reserved, + sameDrive, + } + }, [currentDrive, collectSameDrive, uploadItems]) + + const createUploadTask = useCallback( + async (file: File, drive: DriveInfo): Promise => { + const uuid = uuidV4() + + const meta = buildUploadMeta([file]) + const prettySize = formatBytes(meta.size) + + let { finalName, isReplace, replaceTopic, replaceHistory } = await resolveConflict(file.name, sameDrive, allTaken) + finalName = finalName ?? '' + + if (isNameInvalid(finalName, isReplace, replaceHistory, replaceTopic)) { + return null } - return onProgress + if (reserved.has(finalName)) { + const retryTaken = new Set([...Array.from(allTaken), finalName]) + const retry = await resolveConflict(finalName, sameDrive, retryTaken) + finalName = retry.finalName ?? '' + isReplace = retry.isReplace + replaceTopic = retry.replaceTopic + replaceHistory = retry.replaceHistory + } + + if (isNameInvalid(finalName, isReplace, replaceHistory, replaceTopic)) { + return null + } + + reserved.add(finalName) + + ensureQueuedRow( + uuid, + finalName, + isReplace ? FileTransferType.Update : FileTransferType.Upload, + prettySize, + drive.name, + ) + + return { + uuid, + file, + finalName, + prettySize, + isReplace: Boolean(isReplace), + replaceTopic, + replaceHistory, + driveId: drive.id.toString(), + driveName: drive.name, + } }, - // currentDrive casues rerenders and flickering during the progress tracking - // eslint-disable-next-line react-hooks/exhaustive-deps - [], + [allTaken, ensureQueuedRow, reserved, resolveConflict, sameDrive], ) + const preflight = useCallback( + async (filesArr: File[]): Promise => { + if (!currentDrive || !fm) return [] + + if (!currentStamp || !currentStamp.usable) { + setErrorMessage?.('Stamp is not usable.') + setShowError(true) + + return [] + } + + const tasks: UploadTask[] = [] + const inFlightSize = uploadTaskQueueRef.current.reduce((sum, t) => sum + t.file.size, 0) + const inFlightCount = uploadTaskQueueRef.current.length + let currentFileSizeSum = inFlightSize + + for (let i = 0; i < filesArr.length; i++) { + const file = filesArr[i] + currentFileSizeSum += file.size + const fileCount = inFlightCount + i + 1 + + const { ok } = verifyDriveSpace({ + fm, + redundancyLevel: currentDrive.redundancyLevel, + stamp: currentStamp, + useInfoSize: true, + driveId: currentDrive.id.toString(), + adminRedundancy: adminDrive?.redundancyLevel, + fileSize: currentFileSizeSum, + fileCount, + cb: err => { + setErrorMessage?.(err + ' (' + truncateNameMiddle(file.name) + ')') + setShowError(true) + }, + }) + + if (!ok) { + break + } + + const task = await createUploadTask(file, currentDrive) + + if (!task) { + break + } + + tasks.push(task) + } + + return tasks + }, + [fm, currentDrive, currentStamp, adminDrive, createUploadTask, setErrorMessage, setShowError], + ) + + const runUploadQueue = useCallback(async () => { + if (runningRef.current) return + runningRef.current = true + + while (uploadTaskQueueRef.current.length > 0) { + const task = uploadTaskQueueRef.current[0] + + if (!task) break + + if (cancelledQueuedRef.current.has(task.uuid)) { + safeSetState( + isMountedRef, + setUploadItems, + )(prev => updateTransferItems(prev, task.uuid, { status: TransferStatus.Cancelled })) + cancelledQueuedRef.current.delete(task.uuid) + } else { + await executeUploadTask(task) + } + + uploadTaskQueueRef.current.shift() + } + + runningRef.current = false + + // Race guard: uploadFiles may have appended tasks and called runUploadQueue() again + if (uploadTaskQueueRef.current.length > 0) { + runUploadQueue() + } + }, [executeUploadTask]) + const uploadFiles = useCallback( (picked: FileList | File[]): void => { const filesArr = Array.from(picked) if (filesArr.length === 0 || !fm || !currentDrive || !currentStamp) return - const currentlyQueued = queueRef.current.length + const currentlyQueued = uploadTaskQueueRef.current.length const newFilesCount = filesArr.length const totalAfterAdd = currentlyQueued + newFilesCount @@ -567,158 +730,6 @@ export function useTransfers({ setErrorMessage }: TransferProps) { return } - // TODO: move out this function from the cb and use as a util for better readaility - const preflight = async (): Promise => { - const progressNames = new Set( - uploadItems.filter(u => u.driveName === currentDrive.name).map(u => u.name), - ) - const sameDrive = collectSameDrive(currentDrive.id.toString()) - const onDiskNames = new Set(sameDrive.map((fi: FileInfo) => fi.name)) - const reserved = new Set() - const tasks: UploadTask[] = [] - - const allTaken = new Set([ - ...Array.from(onDiskNames), - ...Array.from(reserved), - ...Array.from(progressNames), - ]) - - // Track cumulative file sizes for capacity verification - let fileSizeSum = 0 - let fileCount = 0 - - const processFile = async (file: File): Promise => { - if (!currentStamp || !currentStamp.usable) { - setErrorMessage?.('Stamp is not usable.') - setShowError(true) - - return null - } - - const uuid = uuidV4() - - const meta = buildUploadMeta([file]) - const prettySize = formatBytes(meta.size) - - fileSizeSum += file.size - fileCount += 1 - - const { ok } = verifyDriveSpace({ - fm, - redundancyLevel: currentDrive.redundancyLevel, - stamp: currentStamp, - useInfoSize: true, - driveId: currentDrive.id.toString(), - adminRedundancy: adminDrive?.redundancyLevel, - fileSize: fileSizeSum, - fileCount, - cb: err => { - setErrorMessage?.(err + ' (' + truncateNameMiddle(file.name) + ')') - setShowError(true) - }, - }) - - if (!ok) { - return null - } - - let { finalName, isReplace, replaceTopic, replaceHistory } = await resolveConflict( - file.name, - sameDrive, - allTaken, - ) - finalName = finalName ?? '' - - const invalidCombo = Boolean(isReplace) && (!replaceHistory || !replaceTopic) - const invalidName = !finalName || finalName.trim().length === 0 - - if (!invalidCombo && !invalidName) { - if (reserved.has(finalName)) { - const retryTaken = new Set([...Array.from(allTaken), finalName]) - const retry = await resolveConflict(finalName, sameDrive, retryTaken) - finalName = retry.finalName ?? '' - isReplace = retry.isReplace - replaceTopic = retry.replaceTopic - replaceHistory = retry.replaceHistory - } - - const retryInvalidCombo = Boolean(isReplace) && (!replaceHistory || !replaceTopic) - const retryInvalidName = !finalName || finalName.trim().length === 0 - - if (!retryInvalidCombo && !retryInvalidName) { - reserved.add(finalName) - - ensureQueuedRow( - uuid, - finalName, - isReplace ? FileTransferType.Update : FileTransferType.Upload, - prettySize, - currentDrive.name, - ) - - return { - uuid, - file, - finalName, - prettySize, - isReplace: Boolean(isReplace), - replaceTopic, - replaceHistory, - driveId: currentDrive.id.toString(), - driveName: currentDrive.name, - } - } - } - - return null - } - - for (const file of filesArr) { - const task = await processFile(file) - - if (task) { - tasks.push(task) - } else { - // Stop processing remaining files if capacity check failed - break - } - } - - return tasks - } - - const runQueue = async () => { - if (runningRef.current) return - runningRef.current = true - - try { - while (queueRef.current.length > 0) { - const task = queueRef.current[0] - - if (!task) break - - const isCancelled = cancelledQueuedRef.current.has(task.uuid) - - if (isCancelled) { - safeSetState( - isMountedRef, - setUploadItems, - )(prev => updateTransferItems(prev, task.uuid, { status: TransferStatus.Cancelled })) - cancelledQueuedRef.current.delete(task.uuid) - queueRef.current.shift() - } else { - await processUploadTask(task) - queueRef.current.shift() - } - } - } finally { - runningRef.current = false - - if (queueRef.current.length > 0) { - runQueue() - } - } - } void (async () => { if (!currentStamp || !currentStamp.usable) { @@ -741,25 +752,12 @@ export function useTransfers({ setErrorMessage }: TransferProps) { } } - const tasks = await preflight() - queueRef.current = queueRef.current.concat(tasks) - runQueue() + const tasks = await preflight(filesArr) + uploadTaskQueueRef.current = uploadTaskQueueRef.current.concat(tasks) + runUploadQueue() })() }, - [ - fm, - currentDrive, - currentStamp, - collectSameDrive, - resolveConflict, - ensureQueuedRow, - processUploadTask, - uploadItems, - adminDrive, - setShowError, - setErrorMessage, - beeApi, - ], + [fm, currentStamp, currentDrive, beeApi, setShowError, setErrorMessage, preflight, runUploadQueue], ) const cancelOrDismissUpload = useCallback( @@ -774,7 +772,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) { if (row.status === TransferStatus.Queued) { cancelledQueuedRef.current.add(row.uuid) - queueRef.current = queueRef.current.filter(t => t.uuid !== row.uuid) + uploadTaskQueueRef.current = uploadTaskQueueRef.current.filter(t => t.uuid !== row.uuid) return prev.map(r => (r.uuid === row.uuid ? { ...r, status: TransferStatus.Cancelled } : r)) } @@ -818,7 +816,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) { const dismissAllUploads = useCallback(() => { uploadAbortsRef.current.clear() - queueRef.current = [] + uploadTaskQueueRef.current = [] cancelledQueuedRef.current.clear() cancelledUploadingRef.current.clear() setUploadItems([]) diff --git a/src/modules/filemanager/utils/download.ts b/src/modules/filemanager/utils/download.ts index 55642bb..262ab7a 100644 --- a/src/modules/filemanager/utils/download.ts +++ b/src/modules/filemanager/utils/download.ts @@ -3,6 +3,7 @@ import { FileInfo, FileManager } from '@solarpunkltd/file-manager-lib' import { DownloadProgress, DownloadState } from '../constants/transfers' import { AbortManager } from './abortManager' +import { isDirectoryPickerSupported, isPickerSupported } from './fileOperations' import { guessMime, VIEWERS } from './view' const downloadAborts = new AbortManager() @@ -141,12 +142,6 @@ interface FileInfoWithHandle { cancelled?: boolean } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const isPickerSupported = (): boolean => typeof (window as any).showSaveFilePicker === 'function' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const isDirectoryPickerSupported = (): boolean => typeof (window as any).showDirectoryPicker === 'function' - const isUserCancellation = (error: unknown): boolean => { const errName = (error as { name?: string })?.name diff --git a/src/modules/filemanager/utils/fileOperations.ts b/src/modules/filemanager/utils/fileOperations.ts index b9ba689..a543a83 100644 --- a/src/modules/filemanager/utils/fileOperations.ts +++ b/src/modules/filemanager/utils/fileOperations.ts @@ -151,3 +151,12 @@ export async function performBulkFileOperation({ } } } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isPickerSupported = (): boolean => typeof (window as any).showSaveFilePicker === 'function' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isDirectoryPickerSupported = (): boolean => typeof (window as any).showDirectoryPicker === 'function' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isElementPickerSupported = (el: any): boolean => typeof (el as HTMLInputElement).showPicker === 'function'