Feat: FileManager (#98) (#703)

* feat: add file manager module

- Complete file manager implementation with UI/UX
- Add drive management functionality
- Add file upload/download with progress tracking
- Add stamp integration and handling
- Add bulk operations and context menus

Co-authored-by: Roland Seres <roland.seres90@gmail.com>
Co-authored-by: nidishk <nidishkrishnan45@gmail.com>
This commit is contained in:
Bálint Ujvári
2025-11-12 11:26:00 +01:00
committed by GitHub
parent 1249c0df71
commit 5bfe2a0331
107 changed files with 21529 additions and 5578 deletions
@@ -0,0 +1,705 @@
import { useCallback, useState, useContext, useRef, useEffect } from 'react'
import { Context as FMContext } from '../../../providers/FileManager'
import type { FileInfo, FileInfoOptions, UploadProgress } from '@solarpunkltd/file-manager-lib'
import { ConflictAction, useUploadConflictDialog } from './useUploadConflictDialog'
import { formatBytes, safeSetState } from '../utils/common'
import {
DownloadProgress,
DownloadState,
FileTransferType,
TrackDownloadProps,
TransferStatus,
} from '../constants/transfers'
import { calculateStampCapacityMetrics } from '../utils/bee'
import { isTrashed } from '../utils/common'
import { abortDownload } from '../utils/download'
import { AbortManager } from '../utils/abortManager'
const SAMPLE_WINDOW_MS = 500
const ETA_SMOOTHING = 0.3
type ResolveResult = {
cancelled: boolean
finalName?: string
isReplace?: boolean
replaceTopic?: string
replaceHistory?: string
}
type TransferItem = {
name: string
size?: string
percent: number
status: TransferStatus
kind?: FileTransferType
driveName?: string
startedAt?: number
etaSec?: number
elapsedSec?: number
}
type ETAState = {
lastTs?: number
lastProcessed: number
lastEta?: number
}
type UploadMeta = Record<string, string | number>
type UploadTask = {
file: File
finalName: string
prettySize?: string
isReplace: boolean
replaceTopic?: string
replaceHistory?: string
}
const normalizeCustomMetadata = (meta: UploadMeta): Record<string, string> => {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(meta)) out[k] = typeof v === 'string' ? v : String(v)
return out
}
const buildUploadMeta = (files: File[] | FileList, path?: string): UploadMeta => {
const arr = Array.from(files as File[])
const totalSize = arr.reduce((acc, f) => acc + (f.size || 0), 0)
const primary = arr[0]
const meta: UploadMeta = {
size: String(totalSize),
fileCount: String(arr.length),
mime: primary?.type || 'application/octet-stream',
}
if (path) meta.path = path
return meta
}
const calculateETA = (
etaState: ETAState,
progress: UploadProgress,
startedAt: number,
now: number,
): { etaSec?: number; updatedState: ETAState } => {
const dt = etaState.lastTs ? (now - etaState.lastTs) / 1000 : 0
if (dt >= SAMPLE_WINDOW_MS / 1000) {
const dBytes = Math.max(0, progress.processed - etaState.lastProcessed)
const instSpeed = dBytes > 0 && dt > 0 ? dBytes / dt : 0
const remaining = Math.max(0, progress.total - progress.processed)
const rawEta = instSpeed > 0 ? remaining / instSpeed : undefined
const avgDt = (now - startedAt) / 1000
const avgSpeed = avgDt > 0 && progress.processed > 0 ? progress.processed / avgDt : 0
const avgEta = avgSpeed > 0 ? remaining / avgSpeed : undefined
const freshEta = rawEta ?? avgEta
let etaSec: number | undefined
if (freshEta !== undefined) {
etaSec =
etaState.lastEta !== undefined ? (1 - ETA_SMOOTHING) * etaState.lastEta + ETA_SMOOTHING * freshEta : freshEta
}
return {
etaSec,
updatedState: {
lastTs: now,
lastProcessed: progress.processed,
lastEta: etaSec,
},
}
}
return {
etaSec: etaState.lastEta,
updatedState: etaState,
}
}
const updateTransferItems = <T extends TransferItem>(items: T[], name: string, update: Partial<T>): T[] => {
return items.map(item => (item.name === name ? { ...item, ...update } : item))
}
const createTransferItem = (
name: string,
size: string | undefined,
kind: FileTransferType,
driveName?: string,
status: TransferStatus = TransferStatus.Uploading,
): TransferItem => ({
name,
size,
percent: 0,
status,
kind,
driveName,
startedAt: status === TransferStatus.Queued ? undefined : Date.now(),
etaSec: undefined,
elapsedSec: undefined,
})
interface TransferProps {
setErrorMessage?: (error: string) => void
}
export function useTransfers({ setErrorMessage }: TransferProps) {
const { fm, currentDrive, currentStamp, files, setShowError, refreshStamp } = useContext(FMContext)
const [openConflict, conflictPortal] = useUploadConflictDialog()
const isMountedRef = useRef(true)
const uploadAbortsRef = useRef<AbortManager>(new AbortManager())
const queueRef = useRef<UploadTask[]>([])
const runningRef = useRef(false)
const cancelledNamesRef = useRef<Set<string>>(new Set())
const cancelledUploadingRef = useRef<Set<string>>(new Set())
const cancelledDownloadingRef = useRef<Set<string>>(new Set())
const [uploadItems, setUploadItems] = useState<TransferItem[]>([])
const [downloadItems, setDownloadItems] = useState<TransferItem[]>([])
const isUploading = uploadItems.some(
i => i.status !== TransferStatus.Done && i.status !== TransferStatus.Error && i.status !== TransferStatus.Cancelled,
)
const isDownloading = downloadItems.some(
i => i.status !== TransferStatus.Done && i.status !== TransferStatus.Error && i.status !== TransferStatus.Cancelled,
)
const clearAllFlagsFor = useCallback((name: string) => {
cancelledNamesRef.current.delete(name)
cancelledUploadingRef.current.delete(name)
uploadAbortsRef.current.abort(name)
queueRef.current = queueRef.current.filter(t => t.finalName !== name)
}, [])
const ensureQueuedRow = useCallback(
(name: string, kind: FileTransferType, size?: string, driveName?: string) => {
clearAllFlagsFor(name)
safeSetState(
isMountedRef,
setUploadItems,
)(prev => {
const idx = prev.findIndex(p => p.name === name)
const base = createTransferItem(name, size, kind, driveName, TransferStatus.Queued)
if (idx === -1) return [...prev, base]
const copy = [...prev]
copy[idx] = base
return copy
})
},
[clearAllFlagsFor],
)
const collectSameDrive = useCallback(
(id: string): FileInfo[] => files.filter(fi => fi.driveId.toString() === id),
[files],
)
const resolveConflict = useCallback(
async (originalName: string, sameDrive: FileInfo[], allTakenNames: Set<string>): Promise<ResolveResult> => {
const taken = sameDrive.filter(fi => fi.name === originalName)
if (!taken.length && !allTakenNames.has(originalName)) {
return { cancelled: false, finalName: originalName, isReplace: false }
}
const existing = taken[0]
const isTrashedExisting = existing ? isTrashed(existing) : false
const choice = await openConflict({
originalName,
existingNames: allTakenNames,
isTrashedExisting,
})
if (choice.action === ConflictAction.Cancel) {
return { cancelled: true }
}
if (choice.action === ConflictAction.KeepBoth) {
return { cancelled: false, finalName: (choice.newName ?? '').trim(), isReplace: false }
}
return {
cancelled: false,
finalName: originalName,
isReplace: true,
replaceTopic: existing?.topic.toString(),
replaceHistory: existing?.file.historyRef.toString(),
}
},
[openConflict],
)
const trackUpload = useCallback(
(name: string, size?: string, kind: FileTransferType = FileTransferType.Upload) => {
if (!isMountedRef.current) {
return () => {
// no-op
}
}
const driveName = currentDrive?.name
const startedAt = Date.now()
let etaState: ETAState = {
lastTs: startedAt,
lastProcessed: 0,
lastEta: undefined,
}
setUploadItems(prev => {
const idx = prev.findIndex(p => p.name === name)
const base = createTransferItem(name, size, kind, driveName, TransferStatus.Uploading)
if (idx === -1) return [...prev, base]
const copy = [...prev]
copy[idx] = base
return copy
})
const onProgress = (progress: UploadProgress) => {
if (cancelledUploadingRef.current.has(name) || !isMountedRef.current) return
if (progress.total > 0) {
const now = Date.now()
const chunkPercentage = Math.floor((progress.processed / progress.total) * 100)
const { etaSec, updatedState } = calculateETA(etaState, progress, startedAt, now)
etaState = updatedState
setUploadItems(prev =>
updateTransferItems(prev, name, {
percent: Math.max(prev.find(it => it.name === name)?.percent || 0, chunkPercentage),
kind,
etaSec,
}),
)
if (progress.processed >= progress.total) {
const finishedAt = Date.now()
setUploadItems(prev =>
updateTransferItems(prev, name, {
percent: 100,
status: TransferStatus.Done,
etaSec: 0,
elapsedSec: Math.round((finishedAt - startedAt) / 1000),
}),
)
}
}
}
return onProgress
},
[currentDrive?.name],
)
const processUploadTask = useCallback(
async (task: UploadTask) => {
if (!fm || !currentDrive) return
const info: FileInfoOptions = {
name: task.finalName,
files: [task.file],
customMetadata: normalizeCustomMetadata(buildUploadMeta([task.file])),
topic: task.isReplace ? task.replaceTopic : undefined,
}
const progressCb = trackUpload(
task.finalName,
task.prettySize,
task.isReplace ? FileTransferType.Update : FileTransferType.Upload,
)
safeSetState(
isMountedRef,
setUploadItems,
)(prev =>
updateTransferItems(prev, task.finalName, {
status: TransferStatus.Uploading,
kind: task.isReplace ? FileTransferType.Update : FileTransferType.Upload,
startedAt: Date.now(),
}),
)
uploadAbortsRef.current.create(task.finalName)
try {
await fm.upload(
currentDrive,
{ ...info, onUploadProgress: progressCb },
{ actHistoryAddress: task.isReplace ? task.replaceHistory : undefined },
)
if (currentStamp) {
await refreshStamp(currentStamp.batchID.toString())
}
} catch {
const wasCancelled = cancelledUploadingRef.current.has(task.finalName)
safeSetState(
isMountedRef,
setUploadItems,
)(prev =>
updateTransferItems(prev, task.finalName, {
status: wasCancelled ? TransferStatus.Cancelled : TransferStatus.Error,
}),
)
} finally {
uploadAbortsRef.current.abort(task.finalName)
cancelledUploadingRef.current.delete(task.finalName)
cancelledNamesRef.current.delete(task.finalName)
}
},
[fm, currentDrive, currentStamp, trackUpload, refreshStamp],
)
const trackDownload = useCallback(
(props: TrackDownloadProps) => {
if (!isMountedRef.current) {
return () => {
// No-op function for unmounted component
}
}
const driveName = currentDrive?.name
let startedAt: number | undefined
let etaState: ETAState = {
lastTs: undefined,
lastProcessed: 0,
lastEta: undefined,
}
setDownloadItems(prev => {
const row = createTransferItem(
props.name,
props.size,
FileTransferType.Download,
driveName,
TransferStatus.Downloading,
)
row.startedAt = undefined // Downloads start timing when first progress is received
const idx = prev.findIndex(p => p.name === props.name)
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
}
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.name, {
percent: Math.max(prev.find(it => it.name === props.name)?.percent || 0, percent),
etaSec,
startedAt: prev.find(it => it.name === props.name)?.startedAt ?? startedAt,
}),
)
if (!dp.isDownloading) {
const finishedAt = Date.now()
setDownloadItems(prev => {
const currentItem = prev.find(it => it.name === props.name)
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.name)
cancelledDownloadingRef.current.delete(props.name)
return updateTransferItems(prev, props.name, {
status: wasCancelled ? TransferStatus.Cancelled : TransferStatus.Error,
etaSec: undefined,
elapsedSec: 0,
percent: currentItem?.percent ?? 0,
})
}
return updateTransferItems(prev, props.name, {
percent: 100,
status: TransferStatus.Done,
etaSec: 0,
elapsedSec,
})
})
}
}
return onProgress
},
[currentDrive?.name],
)
const uploadFiles = useCallback(
(picked: FileList | File[]): void => {
const filesArr = Array.from(picked)
if (filesArr.length === 0 || !fm || !currentDrive) return
const preflight = async (): Promise<UploadTask[]> => {
const progressNames = new Set<string>(uploadItems.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[] = []
let remainingBytes = calculateStampCapacityMetrics(currentStamp || null, currentDrive).remainingBytes
const processFile = async (file: File): Promise<UploadTask | null> => {
if (!currentStamp || !currentStamp.usable) {
setErrorMessage?.('Stamp is not usable.')
setShowError(true)
return null
}
const meta = buildUploadMeta([file])
const prettySize = formatBytes(meta.size)
const allTaken = new Set<string>([
...Array.from(onDiskNames),
...Array.from(reserved),
...Array.from(progressNames),
])
if (file.size > remainingBytes) {
// eslint-disable-next-line no-console
console.log(
'Skipping upload - insufficient space:',
file.name,
'size:',
file.size,
'remaining:',
remainingBytes,
)
setErrorMessage?.('There is not enough space to upload: ' + file.name)
setShowError(true)
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)
remainingBytes -= file.size
ensureQueuedRow(
finalName,
isReplace ? FileTransferType.Update : FileTransferType.Upload,
prettySize,
currentDrive.name,
)
return {
file,
finalName,
prettySize,
isReplace: Boolean(isReplace),
replaceTopic,
replaceHistory,
}
}
}
return null
}
for (const file of filesArr) {
const task = await processFile(file)
if (task) {
tasks.push(task)
} else if (file.size > remainingBytes) {
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 = cancelledNamesRef.current.has(task.finalName)
if (isCancelled) {
safeSetState(
isMountedRef,
setUploadItems,
)(prev => updateTransferItems(prev, task.finalName, { status: TransferStatus.Cancelled }))
cancelledNamesRef.current.delete(task.finalName)
queueRef.current.shift()
} else {
await processUploadTask(task)
queueRef.current.shift()
}
}
} finally {
runningRef.current = false
}
}
void (async () => {
const tasks = await preflight()
queueRef.current = queueRef.current.concat(tasks)
runQueue()
})()
},
[
fm,
currentDrive,
currentStamp,
collectSameDrive,
resolveConflict,
ensureQueuedRow,
processUploadTask,
uploadItems,
setShowError,
setErrorMessage,
],
)
const cancelOrDismissUpload = useCallback(
(name: string) => {
safeSetState(
isMountedRef,
setUploadItems,
)(prev => {
const row = prev.find(r => r.name === name)
if (!row) return prev
if (row.status === TransferStatus.Queued) {
cancelledNamesRef.current.add(name)
queueRef.current = queueRef.current.filter(t => t.finalName !== name)
return prev.map(r => (r.name === name ? { ...r, status: TransferStatus.Cancelled } : r))
}
if (row.status === TransferStatus.Uploading) {
cancelledUploadingRef.current.add(name)
uploadAbortsRef.current.abort(name)
return prev.map(r => (r.name === name ? { ...r, status: TransferStatus.Cancelled } : r))
}
clearAllFlagsFor(name)
return prev.filter(r => r.name !== name)
})
},
[clearAllFlagsFor],
)
const cancelOrDismissDownload = useCallback((name: string) => {
safeSetState(
isMountedRef,
setDownloadItems,
)(prev => {
const row = prev.find(r => r.name === name)
if (!row) return prev
if (row.status === TransferStatus.Downloading) {
cancelledDownloadingRef.current.add(name)
abortDownload(name)
return prev.map(r => (r.name === name ? { ...r, status: TransferStatus.Cancelled } : r))
}
cancelledDownloadingRef.current.delete(name)
return prev.filter(r => r.name !== name)
})
}, [])
const dismissAllUploads = useCallback(() => {
setUploadItems([])
cancelledNamesRef.current.clear()
cancelledUploadingRef.current.clear()
}, [])
const dismissAllDownloads = useCallback(() => {
setDownloadItems([])
cancelledDownloadingRef.current.clear()
}, [])
useEffect(() => {
return () => {
isMountedRef.current = false
}
}, [])
return {
uploadFiles,
isUploading,
uploadItems,
trackDownload,
isDownloading,
downloadItems,
conflictPortal,
cancelOrDismissUpload,
cancelOrDismissDownload,
dismissAllUploads,
dismissAllDownloads,
}
}