fix: upload q and drive size error (#220)

* refactor: usetransfers hook functions
* refactor: bulk actions code style and readability
* fix: use upload q inflight size for subsequent uploads
* refactor: runUploadQueue execution
This commit is contained in:
Bálint Ujvári
2026-03-10 12:42:34 +01:00
parent bc2c0addbb
commit 8992c189fd
8 changed files with 348 additions and 328 deletions
@@ -16,7 +16,7 @@ import { useView } from '../../../../pages/filemanager/ViewContext'
import { Context as FMContext } from '../../../../providers/FileManager' import { Context as FMContext } from '../../../../providers/FileManager'
import { Context as SettingsContext } from '../../../../providers/Settings' import { Context as SettingsContext } from '../../../../providers/Settings'
import { FileAction, FileTransferType, TransferStatus, ViewType } from '../../constants/transfers' 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 { useContextMenu } from '../../hooks/useContextMenu'
import { useDragAndDrop } from '../../hooks/useDragAndDrop' import { useDragAndDrop } from '../../hooks/useDragAndDrop'
import { useFileFiltering } from '../../hooks/useFileFiltering' import { useFileFiltering } from '../../hooks/useFileFiltering'
@@ -118,7 +118,7 @@ type FileBrowserContextMenuBlockProps = {
dropDir: Dir dropDir: Dir
drives: DriveInfo[] drives: DriveInfo[]
view: ViewType view: ViewType
bulk: ReturnType<typeof useBulkActions> bulk: BulkActionsResult
adminStamp: PostageBatch | undefined adminStamp: PostageBatch | undefined
doRefresh: () => void doRefresh: () => void
onContextUploadFile: () => void onContextUploadFile: () => void
@@ -162,11 +162,11 @@ function FileBrowserContextMenuBlock({
onRefresh={doRefresh} onRefresh={doRefresh}
enableRefresh={Boolean(adminStamp)} enableRefresh={Boolean(adminStamp)}
onUploadFile={onContextUploadFile} onUploadFile={onContextUploadFile}
onBulkDownload={() => bulk.bulkDownload(bulk.selectedFiles)} onBulkDownload={() => bulk.download(bulk.selectedFiles)}
onBulkRestore={() => setConfirmBulkRestore(true)} onBulkRestore={() => setConfirmBulkRestore(true)}
onBulkDelete={() => setShowBulkDeleteModal(true)} onBulkDelete={() => setShowBulkDeleteModal(true)}
onBulkDestroy={() => setShowDestroyDriveModal(true)} onBulkDestroy={() => setShowDestroyDriveModal(true)}
onBulkForget={() => bulk.bulkForget(bulk.selectedFiles)} onBulkForget={() => bulk.forget(bulk.selectedFiles)}
/> />
</div> </div>
) )
@@ -196,7 +196,6 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
const [safePos, setSafePos] = useState<Point>(pos) const [safePos, setSafePos] = useState<Point>(pos)
const [dropDir, setDropDir] = useState<Dir>(Dir.Down) const [dropDir, setDropDir] = useState<Dir>(Dir.Down)
const legacyUploadRef = useRef<HTMLInputElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null) const contentRef = useRef<HTMLDivElement | null>(null)
const bodyRef = useRef<HTMLDivElement | null>(null) const bodyRef = useRef<HTMLDivElement | null>(null)
const isMountedRef = useRef(true) const isMountedRef = useRef(true)
@@ -281,20 +280,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
} }
const onContextUploadFile = () => { const onContextUploadFile = () => {
const el = legacyUploadRef.current bulk.uploadFromPicker()
if (!el) return
try {
if (typeof (el as HTMLInputElement).showPicker === 'function') {
;(el as HTMLInputElement).showPicker()
} else {
el.click()
}
} catch {
el.click()
}
requestAnimationFrame(() => handleCloseContext()) requestAnimationFrame(() => handleCloseContext())
} }
@@ -323,7 +309,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
setShowBulkDeleteModal(false) setShowBulkDeleteModal(false)
if (action === FileAction.Trash) { if (action === FileAction.Trash) {
return await bulk.bulkTrash(bulk.selectedFiles) return await bulk.trash(bulk.selectedFiles)
} }
if (action === FileAction.Forget) { if (action === FileAction.Forget) {
@@ -489,9 +475,9 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
const onBulk = useMemo( const onBulk = useMemo(
() => ({ () => ({
download: () => bulk.bulkDownload(bulk.selectedFiles), download: () => bulk.download(bulk.selectedFiles),
restore: () => setConfirmBulkRestore(true), restore: () => setConfirmBulkRestore(true),
forget: () => bulk.bulkForget(bulk.selectedFiles), forget: () => bulk.forget(bulk.selectedFiles),
destroy: () => setShowDestroyDriveModal(true), destroy: () => setShowDestroyDriveModal(true),
delete: () => setShowBulkDeleteModal(true), delete: () => setShowBulkDeleteModal(true),
}), }),
@@ -502,7 +488,6 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
<> <>
{conflictPortal} {conflictPortal}
<input type="file" ref={legacyUploadRef} style={{ display: 'none' }} onChange={onFileSelected} multiple />
<input type="file" ref={bulk.fileInputRef} style={{ display: 'none' }} onChange={onFileSelected} multiple /> <input type="file" ref={bulk.fileInputRef} style={{ display: 'none' }} onChange={onFileSelected} multiple />
<div className="fm-file-browser-container" data-search-mode={isSearchMode ? 'true' : 'false'}> <div className="fm-file-browser-container" data-search-mode={isSearchMode ? 'true' : 'false'}>
@@ -605,12 +590,12 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
onDeleteCancel={() => setShowBulkDeleteModal(false)} onDeleteCancel={() => setShowBulkDeleteModal(false)}
onDeleteProceed={handleDeleteModalProceed} onDeleteProceed={handleDeleteModalProceed}
onForgetConfirm={async () => { onForgetConfirm={async () => {
await bulk.bulkForget(bulk.selectedFiles) await bulk.forget(bulk.selectedFiles)
setConfirmBulkForget(false) setConfirmBulkForget(false)
}} }}
onForgetCancel={() => setConfirmBulkForget(false)} onForgetCancel={() => setConfirmBulkForget(false)}
onRestoreConfirm={async () => { onRestoreConfirm={async () => {
await bulk.bulkRestore(bulk.selectedFiles) await bulk.restore(bulk.selectedFiles)
setConfirmBulkRestore(false) setConfirmBulkRestore(false)
}} }}
onRestoreCancel={() => setConfirmBulkRestore(false)} onRestoreCancel={() => setConfirmBulkRestore(false)}
@@ -1,14 +1,14 @@
import { ReactElement } from 'react' import { ReactElement } from 'react'
import DownIcon from 'remixicon-react/ArrowDownSLineIcon' import DownIcon from 'remixicon-react/ArrowDownSLineIcon'
import { useBulkActions } from '../../../hooks/useBulkActions' import { BulkActionsResult } from '../../../hooks/useBulkActions'
import { SortDir, SortKey } from '../../../hooks/useSorting' import { SortDir, SortKey } from '../../../hooks/useSorting'
import { capitalizeFirstLetter } from '@/modules/filemanager/utils/common' import { capitalizeFirstLetter } from '@/modules/filemanager/utils/common'
interface FileBrowserHeaderProps { interface FileBrowserHeaderProps {
isSearchMode: boolean isSearchMode: boolean
bulk: ReturnType<typeof useBulkActions> bulk: BulkActionsResult
sortKey: SortKey sortKey: SortKey
sortDir: SortDir sortDir: SortDir
onSortName: () => void onSortName: () => void
@@ -435,7 +435,15 @@ export function VersionsList({ versions, headFi, restoreVersion, onDownload }: V
await startDownloadingQueue( await startDownloadingQueue(
fm, fm,
[{ uuid, info: fileInfo }], [{ 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], [handleCloseContext, fm, beeApi, onDownload, drives, currentDrive],
@@ -49,7 +49,7 @@ export type TrackDownloadProps = {
name: string name: string
size?: string size?: string
expectedSize?: number expectedSize?: number
driveName?: string driveName: string
} }
export interface DownloadProgress { export interface DownloadProgress {
+55 -30
View File
@@ -1,6 +1,6 @@
import { PostageBatch } from '@ethersphere/bee-js' import { PostageBatch } from '@ethersphere/bee-js'
import type { FileInfo } from '@solarpunkltd/file-manager-lib' 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 FMContext } from '../../../providers/FileManager'
import { Context as SettingsContext } from '../../../providers/Settings' import { Context as SettingsContext } from '../../../providers/Settings'
@@ -9,16 +9,33 @@ import { DownloadProgress, TrackDownloadProps } from '../constants/transfers'
import { getUsableStamps } from '../utils/bee' import { getUsableStamps } from '../utils/bee'
import { formatBytes, getFileId, safeSetState } from '../utils/common' import { formatBytes, getFileId, safeSetState } from '../utils/common'
import { FileInfoWithUUID, startDownloadingQueue } from '../utils/download' 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[] listToRender: FileInfo[]
trackDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void trackDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
setErrorMessage?: (error: string) => void setErrorMessage?: (error: string) => void
} }
export function useBulkActions({ listToRender, setErrorMessage, trackDownload }: BulkOptions) { export interface BulkActionsResult {
const { fm, adminDrive, drives, refreshStamp, setShowError } = useContext(FMContext) selectedIds: Set<string>
selectedFiles: FileInfo[]
selectedCount: number
allChecked: boolean
someChecked: boolean
fileInputRef: RefObject<HTMLInputElement | null>
toggleOne: (fi: FileInfo, checked: boolean) => void
selectAll: () => void
clearAll: () => void
uploadFromPicker: () => void
download: (list: FileInfo[]) => Promise<void>
trash: (list: FileInfo[]) => Promise<void>
restore: (list: FileInfo[]) => Promise<void>
forget: (list: FileInfo[]) => Promise<void>
}
export function useBulkActions({ listToRender, trackDownload, setErrorMessage }: BulkOptions): BulkActionsResult {
const { fm, adminDrive, currentDrive, drives, refreshStamp, setShowError } = useContext(FMContext)
const { beeApi } = useContext(SettingsContext) const { beeApi } = useContext(SettingsContext)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -69,11 +86,23 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }:
const selectAll = useCallback(() => setSelectedIds(new Set(allIds)), [allIds]) const selectAll = useCallback(() => setSelectedIds(new Set(allIds)), [allIds])
const clearAll = useCallback(() => setSelectedIds(new Set()), []) const clearAll = useCallback(() => setSelectedIds(new Set()), [])
const bulkUploadFromPicker = useCallback(() => { const uploadFromPicker = useCallback(() => {
fileInputRef.current?.click() 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[]) => { async (list: FileInfo[]) => {
if (!fm || !list?.length) return 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 rawSize = fi.customMetadata?.size as string | number | undefined
const prettySize = formatBytes(rawSize) const prettySize = formatBytes(rawSize)
const expected = rawSize ? Number(rawSize) : undefined 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() const uuid = uuidV4()
infoListWitIDs[i] = { uuid, info: fi } infoListWitIDs[i] = { uuid, info: fi }
@@ -94,16 +123,16 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }:
name: fi.name, name: fi.name,
size: prettySize, size: prettySize,
expectedSize: expected, expectedSize: expected,
driveName, driveName: driveName ?? 'unknown',
}) })
} }
await startDownloadingQueue(fm, infoListWitIDs, trackers) await startDownloadingQueue(fm, infoListWitIDs, trackers)
}, },
[fm, trackDownload, drives], [fm, currentDrive, trackDownload, drives],
) )
const bulkTrash = useCallback( const trash = useCallback(
async (list: FileInfo[]) => { async (list: FileInfo[]) => {
if (!fm || !list?.length) return if (!fm || !list?.length) return
@@ -126,7 +155,7 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }:
[fm, driveStamps, clearAll, refreshStamp, setErrorMessage, setShowError], [fm, driveStamps, clearAll, refreshStamp, setErrorMessage, setShowError],
) )
const bulkRestore = useCallback( const restore = useCallback(
async (list: FileInfo[]) => { async (list: FileInfo[]) => {
if (!fm || !list?.length) return if (!fm || !list?.length) return
@@ -149,7 +178,7 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }:
[fm, driveStamps, refreshStamp, clearAll, setErrorMessage, setShowError], [fm, driveStamps, refreshStamp, clearAll, setErrorMessage, setShowError],
) )
const bulkForget = useCallback( const forget = useCallback(
async (list: FileInfo[]) => { async (list: FileInfo[]) => {
if (!fm || !fm.adminStamp || !adminDrive || !list?.length) return 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], [fm, adminDrive, driveStamps, clearAll, refreshStamp, setErrorMessage, setShowError],
) )
return useMemo( return useMemo<BulkActionsResult>(
() => ({ () => ({
// selection
selectedIds, selectedIds,
setSelectedIds,
selectedFiles, selectedFiles,
selectedCount, selectedCount,
allChecked, allChecked,
someChecked, someChecked,
fileInputRef,
toggleOne, toggleOne,
selectAll, selectAll,
clearAll, clearAll,
// file input (for bulk upload) uploadFromPicker,
fileInputRef, download,
bulkUploadFromPicker, trash,
// actions restore,
bulkDownload, forget,
bulkTrash,
bulkRestore,
bulkForget,
}), }),
[ [
selectedIds, selectedIds,
@@ -206,11 +231,11 @@ export function useBulkActions({ listToRender, setErrorMessage, trackDownload }:
toggleOne, toggleOne,
selectAll, selectAll,
clearAll, clearAll,
bulkUploadFromPicker, uploadFromPicker,
bulkDownload, download,
bulkTrash, trash,
bulkRestore, restore,
bulkForget, forget,
], ],
) )
} }
+261 -263
View File
@@ -1,5 +1,5 @@
import type { FileInfo, FileInfoOptions, UploadProgress } from '@solarpunkltd/file-manager-lib' import type { DriveInfo, FileInfo, FileInfoOptions, UploadProgress } from '@solarpunkltd/file-manager-lib'
import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { Context as FMContext } from '../../../providers/FileManager' import { Context as FMContext } from '../../../providers/FileManager'
import { Context as SettingsContext } from '../../../providers/Settings' import { Context as SettingsContext } from '../../../providers/Settings'
@@ -27,7 +27,7 @@ const ABORT_EVENT = 'abort'
type ResolveResult = { type ResolveResult = {
cancelled: boolean cancelled: boolean
finalName?: string finalName?: string
isReplace?: boolean isReplace: boolean
replaceTopic?: string replaceTopic?: string
replaceHistory?: string replaceHistory?: string
} }
@@ -65,6 +65,18 @@ type UploadTask = {
driveName: string 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<string, string> => { const normalizeCustomMetadata = (meta: UploadMeta): Record<string, string> => {
const out: Record<string, string> = {} const out: Record<string, string> = {}
for (const [k, v] of Object.entries(meta)) out[k] = typeof v === 'string' ? v : String(v) 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 isMountedRef = useRef(true)
const uploadAbortsRef = useRef<AbortManager>(new AbortManager()) const uploadAbortsRef = useRef<AbortManager>(new AbortManager())
const queueRef = useRef<UploadTask[]>([]) const uploadTaskQueueRef = useRef<UploadTask[]>([])
const runningRef = useRef(false) const runningRef = useRef(false)
const cancelledQueuedRef = useRef<Set<string>>(new Set()) const cancelledQueuedRef = useRef<Set<string>>(new Set())
const cancelledUploadingRef = useRef<Set<string>>(new Set()) const cancelledUploadingRef = useRef<Set<string>>(new Set())
@@ -195,7 +207,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) {
cancelledQueuedRef.current.delete(uuid) cancelledQueuedRef.current.delete(uuid)
cancelledUploadingRef.current.delete(uuid) cancelledUploadingRef.current.delete(uuid)
uploadAbortsRef.current.abort(uuid) uploadAbortsRef.current.abort(uuid)
queueRef.current = queueRef.current.filter(t => { uploadTaskQueueRef.current = uploadTaskQueueRef.current.filter(t => {
return t.uuid !== uuid return t.uuid !== uuid
}) })
}, []) }, [])
@@ -250,7 +262,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) {
}) })
if (choice.action === ConflictAction.Cancel) { if (choice.action === ConflictAction.Cancel) {
return { cancelled: true } return { cancelled: true, isReplace: false }
} }
if (choice.action === ConflictAction.KeepBoth) { if (choice.action === ConflictAction.KeepBoth) {
@@ -347,7 +359,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) {
[], [],
) )
const processUploadTask = useCallback( const executeUploadTask = useCallback(
async (task: UploadTask) => { async (task: UploadTask) => {
if (!fm) return if (!fm) return
@@ -447,115 +459,266 @@ export function useTransfers({ setErrorMessage }: TransferProps) {
[fm, files, currentStamp, trackUpload, refreshStamp, setShowError, setErrorMessage], [fm, files, currentStamp, trackUpload, refreshStamp, setShowError, setErrorMessage],
) )
const trackDownload = useCallback( const trackDownload = useCallback((props: TrackDownloadProps) => {
(props: TrackDownloadProps) => { if (!isMountedRef.current) {
if (!isMountedRef.current) { return () => {
return () => { // No-op function for unmounted component
// 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 if (props.expectedSize && props.expectedSize > 0 && dp.progress >= 0) {
let etaState: ETAState = { percent = Math.floor((dp.progress / props.expectedSize) * 100)
lastTs: undefined, const result = calculateETA(etaState, { processed: dp.progress, total: props.expectedSize }, startedAt, now)
lastProcessed: 0, etaSec = result.etaSec
lastEta: undefined, etaState = result.updatedState
} }
setDownloadItems(prev => { setDownloadItems(prev =>
const row = createTransferItem( updateTransferItems(prev, props.uuid, {
props.uuid, percent: Math.max(prev.find(it => it.uuid === props.uuid)?.percent || 0, percent),
props.name, etaSec,
props.size, startedAt: prev.find(it => it.uuid === props.uuid)?.startedAt ?? startedAt,
FileTransferType.Download, }),
driveName, )
TransferStatus.Downloading,
)
row.startedAt = undefined
const idx = prev.findIndex(p => p.uuid === props.uuid)
if (idx === -1) return [...prev, row] if (!dp.isDownloading) {
const out = [...prev] const finishedAt = Date.now()
out[idx] = { ...row, startedAt: prev[idx].startedAt ?? row.startedAt }
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 (dp.state === DownloadState.Cancelled || dp.state === DownloadState.Error) {
if (!isMountedRef.current) return const wasCancelled = dp.state === DownloadState.Cancelled || cancelledDownloadingRef.current.has(props.uuid)
const now = Date.now() cancelledDownloadingRef.current.delete(props.uuid)
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,
})
}
return updateTransferItems(prev, props.uuid, { return updateTransferItems(prev, props.uuid, {
percent: 100, status: wasCancelled ? TransferStatus.Cancelled : TransferStatus.Error,
status: TransferStatus.Done, etaSec: undefined,
etaSec: 0, elapsedSec: 0,
elapsedSec, 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<string>(uploadItems.filter(u => u.driveName === currentDrive.name).map(u => u.name))
const sameDrive = collectSameDrive(currentDrive.id.toString())
const onDiskNames = new Set<string>(sameDrive.map((fi: FileInfo) => fi.name))
const reserved = new Set<string>()
const allTaken = new Set<string>([
...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<UploadTask | null> => {
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<string>([...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 [allTaken, ensureQueuedRow, reserved, resolveConflict, sameDrive],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
) )
const preflight = useCallback(
async (filesArr: File[]): Promise<UploadTask[]> => {
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( const uploadFiles = useCallback(
(picked: FileList | File[]): void => { (picked: FileList | File[]): void => {
const filesArr = Array.from(picked) const filesArr = Array.from(picked)
if (filesArr.length === 0 || !fm || !currentDrive || !currentStamp) return if (filesArr.length === 0 || !fm || !currentDrive || !currentStamp) return
const currentlyQueued = queueRef.current.length const currentlyQueued = uploadTaskQueueRef.current.length
const newFilesCount = filesArr.length const newFilesCount = filesArr.length
const totalAfterAdd = currentlyQueued + newFilesCount const totalAfterAdd = currentlyQueued + newFilesCount
@@ -567,158 +730,6 @@ export function useTransfers({ setErrorMessage }: TransferProps) {
return return
} }
// TODO: move out this function from the cb and use as a util for better readaility
const preflight = async (): Promise<UploadTask[]> => {
const progressNames = new Set<string>(
uploadItems.filter(u => u.driveName === currentDrive.name).map(u => u.name),
)
const sameDrive = collectSameDrive(currentDrive.id.toString())
const onDiskNames = new Set<string>(sameDrive.map((fi: FileInfo) => fi.name))
const reserved = new Set<string>()
const tasks: UploadTask[] = []
const allTaken = new Set<string>([
...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<UploadTask | null> => {
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<string>([...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 () => { void (async () => {
if (!currentStamp || !currentStamp.usable) { if (!currentStamp || !currentStamp.usable) {
@@ -741,25 +752,12 @@ export function useTransfers({ setErrorMessage }: TransferProps) {
} }
} }
const tasks = await preflight() const tasks = await preflight(filesArr)
queueRef.current = queueRef.current.concat(tasks) uploadTaskQueueRef.current = uploadTaskQueueRef.current.concat(tasks)
runQueue() runUploadQueue()
})() })()
}, },
[ [fm, currentStamp, currentDrive, beeApi, setShowError, setErrorMessage, preflight, runUploadQueue],
fm,
currentDrive,
currentStamp,
collectSameDrive,
resolveConflict,
ensureQueuedRow,
processUploadTask,
uploadItems,
adminDrive,
setShowError,
setErrorMessage,
beeApi,
],
) )
const cancelOrDismissUpload = useCallback( const cancelOrDismissUpload = useCallback(
@@ -774,7 +772,7 @@ export function useTransfers({ setErrorMessage }: TransferProps) {
if (row.status === TransferStatus.Queued) { if (row.status === TransferStatus.Queued) {
cancelledQueuedRef.current.add(row.uuid) 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)) 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(() => { const dismissAllUploads = useCallback(() => {
uploadAbortsRef.current.clear() uploadAbortsRef.current.clear()
queueRef.current = [] uploadTaskQueueRef.current = []
cancelledQueuedRef.current.clear() cancelledQueuedRef.current.clear()
cancelledUploadingRef.current.clear() cancelledUploadingRef.current.clear()
setUploadItems([]) setUploadItems([])
+1 -6
View File
@@ -3,6 +3,7 @@ import { FileInfo, FileManager } from '@solarpunkltd/file-manager-lib'
import { DownloadProgress, DownloadState } from '../constants/transfers' import { DownloadProgress, DownloadState } from '../constants/transfers'
import { AbortManager } from './abortManager' import { AbortManager } from './abortManager'
import { isDirectoryPickerSupported, isPickerSupported } from './fileOperations'
import { guessMime, VIEWERS } from './view' import { guessMime, VIEWERS } from './view'
const downloadAborts = new AbortManager() const downloadAborts = new AbortManager()
@@ -141,12 +142,6 @@ interface FileInfoWithHandle {
cancelled?: boolean 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 isUserCancellation = (error: unknown): boolean => {
const errName = (error as { name?: string })?.name const errName = (error as { name?: string })?.name
@@ -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'