Fix: file-manager and swarm-desktop bugs (#714)
- drive capacity display with stamp polling - download/upload progress handling - overlay and tooltip issues - FileMaganger readme - ultra-light mode handling - account feed view page - download media files - remove not found syncing link - fix ultra light node wallet page - tooltip issues --------- Co-authored-by: Andrei Mitrea <andrei.mitrea.hq@gmail.com> Co-authored-by: nidishk <nidishkrishnan45@gmail.com> Co-authored-by: Ferenc Sárai <sarai.ferenc@gmail.com> Co-authored-by: Nándor Komlódi <nandor.komlodi@gmail.com> Co-authored-by: rolandlor <33499567+rolandlor@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { BatchId, Bee, BZZ, Duration, PostageBatch, RedundancyLevel, Size } from '@ethersphere/bee-js'
|
||||
import { FileManagerBase, DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import {
|
||||
FileManagerBase,
|
||||
DriveInfo,
|
||||
estimateDriveListMetadataSize,
|
||||
estimateFileInfoMetadataSize,
|
||||
FileInfo,
|
||||
} from '@solarpunkltd/file-manager-lib'
|
||||
import { getHumanReadableFileSize } from '../../../utils/file'
|
||||
import { ActionTag } from '../constants/transfers'
|
||||
|
||||
export const getUsableStamps = async (bee: Bee | null): Promise<PostageBatch[]> => {
|
||||
if (!bee) {
|
||||
@@ -16,6 +23,19 @@ export const getUsableStamps = async (bee: Bee | null): Promise<PostageBatch[]>
|
||||
}
|
||||
}
|
||||
|
||||
export const validateStampStillExists = async (bee: Bee, batchId: BatchId): Promise<boolean> => {
|
||||
try {
|
||||
const stamp = await bee.getPostageBatch(batchId.toString())
|
||||
|
||||
return stamp.usable
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Failed to validate stamp ${batchId.toString().slice(0, 8)}...:`, error)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const fmGetStorageCost = async (
|
||||
capacity: number,
|
||||
validityEndDate: Date,
|
||||
@@ -50,6 +70,7 @@ export const fmFetchCost = async (
|
||||
beeApi: Bee | null,
|
||||
setCost: (cost: BZZ) => void,
|
||||
currentFetch: React.MutableRefObject<Promise<void> | null>,
|
||||
onError?: (error: unknown) => void,
|
||||
) => {
|
||||
if (currentFetch.current) {
|
||||
await currentFetch.current
|
||||
@@ -58,10 +79,22 @@ export const fmFetchCost = async (
|
||||
let isCurrentFetch = true
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const cost = await fmGetStorageCost(capacity, validityEndDate, encryption, erasureCodeLevel, beeApi)
|
||||
try {
|
||||
const cost = await fmGetStorageCost(capacity, validityEndDate, encryption, erasureCodeLevel, beeApi)
|
||||
|
||||
if (isCurrentFetch) {
|
||||
setCost(cost ?? BZZ.fromDecimalString('0'))
|
||||
if (isCurrentFetch) {
|
||||
if (cost) {
|
||||
setCost(cost)
|
||||
} else {
|
||||
setCost(BZZ.fromDecimalString('0'))
|
||||
onError?.(new Error('Storage cost unavailable - node may be syncing'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCurrentFetch) {
|
||||
setCost(BZZ.fromDecimalString('0'))
|
||||
onError?.(error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -72,32 +105,95 @@ export const fmFetchCost = async (
|
||||
currentFetch.current = null
|
||||
}
|
||||
|
||||
export const handleCreateDrive = async (
|
||||
beeApi: Bee | null,
|
||||
fm: FileManagerBase | null,
|
||||
size: Size,
|
||||
duration: Duration,
|
||||
label: string,
|
||||
encryption: boolean,
|
||||
erasureCodeLevel: RedundancyLevel,
|
||||
isAdmin: boolean,
|
||||
resetState: boolean,
|
||||
existingBatch: PostageBatch | null,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): Promise<void> => {
|
||||
if (!beeApi || !fm) return
|
||||
export interface CreateDriveOptions {
|
||||
beeApi: Bee | null
|
||||
fm: FileManagerBase | null
|
||||
size: Size
|
||||
duration: Duration
|
||||
label: string
|
||||
encryption: boolean
|
||||
redundancyLevel: RedundancyLevel
|
||||
adminRedundancy: RedundancyLevel
|
||||
isAdmin: boolean
|
||||
resetState: boolean
|
||||
existingBatch: PostageBatch | null
|
||||
onSuccess?: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const handleCreateDrive = async (options: CreateDriveOptions): Promise<void> => {
|
||||
const {
|
||||
beeApi,
|
||||
fm,
|
||||
size,
|
||||
duration,
|
||||
label,
|
||||
encryption,
|
||||
redundancyLevel,
|
||||
adminRedundancy,
|
||||
isAdmin,
|
||||
resetState,
|
||||
existingBatch,
|
||||
onSuccess,
|
||||
onError,
|
||||
} = { ...options }
|
||||
|
||||
if (!beeApi || !fm) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error creating drive: Bee API or FM is invalid!')
|
||||
|
||||
onError?.('Error creating drive: Bee API or FM is invalid!')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let batchId: BatchId
|
||||
|
||||
if (!existingBatch) {
|
||||
batchId = await beeApi.buyStorage(size, duration, { label }, undefined, encryption, erasureCodeLevel)
|
||||
if (!isAdmin) {
|
||||
if (!fm.adminStamp) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error creating drive: admin stamp is not available')
|
||||
|
||||
throw new Error('Error creating drive: admin stamp is not available')
|
||||
}
|
||||
|
||||
verifyDriveSpace({
|
||||
fm,
|
||||
redundancyLevel,
|
||||
stamp: fm.adminStamp,
|
||||
adminRedundancy,
|
||||
cb: err => {
|
||||
throw new Error(err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
batchId = await beeApi.buyStorage(size, duration, { label }, undefined, encryption, redundancyLevel)
|
||||
} else {
|
||||
const isValid = await validateStampStillExists(beeApi, existingBatch.batchID)
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(
|
||||
'The stamp is no longer valid or has been deleted. Please select a different stamp from the list.',
|
||||
)
|
||||
}
|
||||
|
||||
verifyDriveSpace({
|
||||
fm,
|
||||
redundancyLevel,
|
||||
stamp: existingBatch,
|
||||
adminRedundancy,
|
||||
cb: err => {
|
||||
throw new Error(err)
|
||||
},
|
||||
})
|
||||
|
||||
batchId = existingBatch.batchID
|
||||
}
|
||||
|
||||
await fm.createDrive(batchId, label, isAdmin, erasureCodeLevel, resetState)
|
||||
await fm.createDrive(batchId, label, isAdmin, redundancyLevel, resetState)
|
||||
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
@@ -107,68 +203,22 @@ export const handleCreateDrive = async (
|
||||
}
|
||||
}
|
||||
|
||||
interface StampCapacityMetrics {
|
||||
capacityPct: number
|
||||
usedSize: string
|
||||
totalSize: string
|
||||
usedBytes: number
|
||||
totalBytes: number
|
||||
remainingBytes: number
|
||||
export interface DestroyDriveOptions {
|
||||
beeApi?: Bee | null
|
||||
fm: FileManagerBase | null
|
||||
drive: DriveInfo
|
||||
adminDrive: DriveInfo | null
|
||||
isDestroy: boolean
|
||||
onSuccess?: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const calculateStampCapacityMetrics = (
|
||||
stamp: PostageBatch | null,
|
||||
drive?: DriveInfo | null,
|
||||
): StampCapacityMetrics => {
|
||||
if (!stamp) {
|
||||
return {
|
||||
capacityPct: 0,
|
||||
usedSize: '—',
|
||||
totalSize: '—',
|
||||
usedBytes: 0,
|
||||
totalBytes: 0,
|
||||
remainingBytes: 0,
|
||||
}
|
||||
}
|
||||
export const handleDestroyAndForgetDrive = async (options: DestroyDriveOptions): Promise<void> => {
|
||||
const { beeApi, fm, adminDrive, drive, isDestroy, onSuccess, onError } = { ...options }
|
||||
|
||||
let usedBytes = 0
|
||||
let totalBytes = 0
|
||||
let capacityPct = 0
|
||||
let remainingBytes = 0
|
||||
if (!beeApi || !fm || !fm.adminStamp || !adminDrive) {
|
||||
onError?.('Error destroying drive: Admin Drive, Bee API or FM is invalid!')
|
||||
|
||||
if (drive) {
|
||||
totalBytes = stamp.calculateSize(false, drive.redundancyLevel).toBytes()
|
||||
remainingBytes = stamp.calculateRemainingSize(false, drive.redundancyLevel).toBytes()
|
||||
usedBytes = totalBytes - remainingBytes
|
||||
capacityPct = ((totalBytes - remainingBytes) / totalBytes) * 100
|
||||
} else {
|
||||
capacityPct = stamp.usage * 100
|
||||
usedBytes = stamp.size.toBytes() - stamp.remainingSize.toBytes()
|
||||
totalBytes = stamp.size.toBytes()
|
||||
remainingBytes = totalBytes - usedBytes
|
||||
}
|
||||
|
||||
const usedSize = getHumanReadableFileSize(usedBytes)
|
||||
const totalSize = getHumanReadableFileSize(totalBytes)
|
||||
|
||||
return {
|
||||
capacityPct,
|
||||
usedSize,
|
||||
totalSize,
|
||||
usedBytes,
|
||||
totalBytes,
|
||||
remainingBytes,
|
||||
}
|
||||
}
|
||||
|
||||
export const handleDestroyDrive = async (
|
||||
beeApi: Bee | null,
|
||||
fm: FileManagerBase | null,
|
||||
drive: DriveInfo,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): Promise<void> => {
|
||||
if (!beeApi || !fm) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,11 +229,26 @@ export const handleDestroyDrive = async (
|
||||
throw new Error(`Postage stamp (${drive.batchId}) for the current drive (${drive.name}) not found`)
|
||||
}
|
||||
|
||||
verifyDriveSpace({
|
||||
fm,
|
||||
driveId: drive.id.toString(),
|
||||
redundancyLevel: drive.redundancyLevel,
|
||||
stamp: fm.adminStamp,
|
||||
isRemove: true,
|
||||
adminRedundancy: adminDrive.redundancyLevel,
|
||||
cb: err => {
|
||||
throw new Error(err)
|
||||
},
|
||||
})
|
||||
|
||||
const ttlDays = stamp.duration.toDays()
|
||||
|
||||
if (ttlDays <= 2) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Stamp TTL ${ttlDays} <= 2 days, skipping drive destruction: forgetting the drive.`)
|
||||
if (ttlDays <= 2 || !isDestroy) {
|
||||
if (isDestroy) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Stamp TTL ${ttlDays} <= 2 days, skipping drive destruction: forgetting the drive.`)
|
||||
}
|
||||
|
||||
await fm.forgetDrive(drive)
|
||||
|
||||
return
|
||||
@@ -197,18 +262,156 @@ export const handleDestroyDrive = async (
|
||||
}
|
||||
}
|
||||
|
||||
export const handleForgetDrive = async (
|
||||
fm: FileManagerBase | null,
|
||||
drive: DriveInfo,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): Promise<void> => {
|
||||
if (!fm) return
|
||||
export interface StampCapacityMetrics {
|
||||
capacityPct: number
|
||||
usedSize: string
|
||||
stampSize: string
|
||||
usedBytes: number
|
||||
stampSizeBytes: number
|
||||
remainingBytes: number
|
||||
}
|
||||
|
||||
try {
|
||||
await fm.forgetDrive(drive)
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
export const calculateStampCapacityMetrics = (
|
||||
stamp: PostageBatch,
|
||||
files: FileInfo[],
|
||||
redundancyLevel?: RedundancyLevel,
|
||||
useReportedOnly?: boolean,
|
||||
): StampCapacityMetrics => {
|
||||
let stampSizeBytes = 0
|
||||
let remainingReportedBytes = 0
|
||||
|
||||
if (redundancyLevel !== undefined) {
|
||||
stampSizeBytes = stamp.calculateSize(false, redundancyLevel).toBytes()
|
||||
remainingReportedBytes = stamp.calculateRemainingSize(false, redundancyLevel).toBytes()
|
||||
} else {
|
||||
stampSizeBytes = stamp.size.toBytes()
|
||||
remainingReportedBytes = stamp.remainingSize.toBytes()
|
||||
}
|
||||
|
||||
const usedBytesReported = stampSizeBytes - remainingReportedBytes
|
||||
const pctReportedStampUsage = stamp.usage * 100
|
||||
|
||||
let usedSizeMaxBytes = usedBytesReported
|
||||
let pctFromDriveUsage = pctReportedStampUsage
|
||||
let remainingBytes = remainingReportedBytes
|
||||
|
||||
if (!useReportedOnly) {
|
||||
const usedBytesFromFiles = files
|
||||
.map(f => {
|
||||
let rawSize = 0
|
||||
|
||||
const lifecycle = (f.customMetadata?.lifecycle || '').toString().toLowerCase()
|
||||
const isLifecycleOperation =
|
||||
lifecycle === ActionTag.Trashed || lifecycle === ActionTag.Recovered || lifecycle === ActionTag.Restored
|
||||
|
||||
if (
|
||||
(f.customMetadata?.size && !isLifecycleOperation) ||
|
||||
(f.customMetadata?.size && !f.customMetadata?.accumulatedSize)
|
||||
) {
|
||||
rawSize = Number(f.customMetadata.size)
|
||||
}
|
||||
const accumulatedSize = Number(f.customMetadata?.accumulatedSize || rawSize || 0)
|
||||
|
||||
return accumulatedSize
|
||||
})
|
||||
.reduce((acc, current) => acc + current, 0)
|
||||
|
||||
const remainingBytesFromFiles = stampSizeBytes - usedBytesFromFiles > 0 ? stampSizeBytes - usedBytesFromFiles : 0
|
||||
remainingBytes = Math.min(remainingReportedBytes, remainingBytesFromFiles)
|
||||
|
||||
usedSizeMaxBytes = Math.max(usedBytesFromFiles, usedBytesReported)
|
||||
pctFromDriveUsage = stampSizeBytes > 0 ? (usedSizeMaxBytes / stampSizeBytes) * 100 : 0
|
||||
}
|
||||
|
||||
const usedSizeMax = getHumanReadableFileSize(usedSizeMaxBytes)
|
||||
const capacityPct = Math.max(pctFromDriveUsage, pctReportedStampUsage)
|
||||
const stampSize = getHumanReadableFileSize(stampSizeBytes)
|
||||
|
||||
return {
|
||||
capacityPct,
|
||||
usedSize: usedSizeMax,
|
||||
stampSize,
|
||||
usedBytes: usedSizeMaxBytes,
|
||||
stampSizeBytes,
|
||||
remainingBytes,
|
||||
}
|
||||
}
|
||||
|
||||
export interface DriveSpaceOptions {
|
||||
fm: FileManagerBase
|
||||
driveId?: string
|
||||
redundancyLevel: RedundancyLevel
|
||||
stamp: PostageBatch
|
||||
adminRedundancy?: RedundancyLevel
|
||||
useInfoSize?: boolean
|
||||
isRemove?: boolean
|
||||
fileSize?: number
|
||||
fileCount?: number
|
||||
cb?: (msg: string) => void
|
||||
}
|
||||
|
||||
export const verifyDriveSpace = (
|
||||
options: DriveSpaceOptions,
|
||||
): { remainingBytes: number; totalSizeBytes: number; ok: boolean } => {
|
||||
const { fm, driveId, redundancyLevel, stamp, adminRedundancy, useInfoSize, isRemove, fileSize, fileCount, cb } = {
|
||||
...options,
|
||||
}
|
||||
|
||||
const drives = [...fm.driveList]
|
||||
let filesPerDrives: FileInfo[] = []
|
||||
|
||||
// new drivelist state size calc.
|
||||
if (isRemove) {
|
||||
const driveIx = drives.findIndex(d => d.id.toString() === driveId?.toString())
|
||||
|
||||
if (driveIx === -1) {
|
||||
cb?.(`Admin drive not found during stamp verification`)
|
||||
|
||||
return { remainingBytes: 0, totalSizeBytes: 0, ok: false }
|
||||
}
|
||||
|
||||
drives.splice(driveIx, 1)
|
||||
filesPerDrives = fm.fileInfoList.filter(fi => fi.driveId !== driveId)
|
||||
} else {
|
||||
filesPerDrives = driveId ? fm.fileInfoList.filter(fi => fi.driveId === driveId) : []
|
||||
}
|
||||
|
||||
// admin stamp capacity calcl., needed for forget, destroy, create
|
||||
if (adminRedundancy !== undefined && fm.adminStamp) {
|
||||
// upper limit estimate on the drivelist metadata state size based on the number of drives and files
|
||||
const estimatedDlSizeBytes = estimateDriveListMetadataSize(drives) * drives.length
|
||||
const { remainingBytes: remainingAdminBytes } = calculateStampCapacityMetrics(fm.adminStamp, [], adminRedundancy)
|
||||
|
||||
const ok = remainingAdminBytes >= estimatedDlSizeBytes
|
||||
|
||||
if (!ok) {
|
||||
cb?.(
|
||||
`Insufficient admin drive capacity. Required: ~${getHumanReadableFileSize(
|
||||
estimatedDlSizeBytes,
|
||||
)} bytes, Available: ${getHumanReadableFileSize(
|
||||
remainingAdminBytes,
|
||||
)} bytes. Please top up the admin drive/stamp.`,
|
||||
)
|
||||
|
||||
return { remainingBytes: remainingAdminBytes, totalSizeBytes: estimatedDlSizeBytes, ok }
|
||||
}
|
||||
}
|
||||
|
||||
// other fileinfo metadata size calc.
|
||||
const estimatedFiSize = estimateFileInfoMetadataSize()
|
||||
const count = fileCount ?? 1
|
||||
const estimateReqSizeBytes = Number(Boolean(useInfoSize)) * estimatedFiSize * count + (fileSize ? fileSize : 0)
|
||||
const { remainingBytes } = calculateStampCapacityMetrics(stamp, filesPerDrives, redundancyLevel)
|
||||
|
||||
const ok = remainingBytes >= estimateReqSizeBytes
|
||||
|
||||
if (!ok) {
|
||||
cb?.(
|
||||
`Insufficient capacity. Required: ~${getHumanReadableFileSize(
|
||||
estimateReqSizeBytes,
|
||||
)} bytes, Available: ${getHumanReadableFileSize(remainingBytes)} bytes. Please top up the drive/stamp.`,
|
||||
)
|
||||
}
|
||||
|
||||
return { remainingBytes, totalSizeBytes: estimateReqSizeBytes, ok }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrivateKey } from '@ethersphere/bee-js'
|
||||
import { FileInfo, FileStatus } from '@solarpunkltd/file-manager-lib'
|
||||
import { keccak256 } from '@ethersproject/keccak256'
|
||||
import { toUtf8Bytes } from '@ethersproject/strings'
|
||||
import { lifetimeAdjustments } from '../constants/stamps'
|
||||
|
||||
export function getDaysLeft(expiryDate: Date): number {
|
||||
const now = new Date()
|
||||
@@ -22,14 +23,6 @@ export const fromBytesConversion = (size: number, metric: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const lifetimeAdjustments = new Map<number, (date: Date) => void>([
|
||||
[0, date => date.setDate(date.getDate() + 7)],
|
||||
[1, date => date.setMonth(date.getMonth() + 1)],
|
||||
[2, date => date.setMonth(date.getMonth() + 3)],
|
||||
[3, date => date.setMonth(date.getMonth() + 6)],
|
||||
[4, date => date.setFullYear(date.getFullYear() + 1)],
|
||||
])
|
||||
|
||||
export function getExpiryDateByLifetime(lifetimeValue: number, actualValidity?: Date): Date {
|
||||
const now = actualValidity || new Date()
|
||||
|
||||
@@ -65,13 +58,13 @@ export const formatBytes = (v?: string | number): string | undefined => {
|
||||
|
||||
if (!Number.isFinite(n) || n < 0) return undefined
|
||||
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1000) return `${n} B`
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB'] as const
|
||||
let val = n / 1024
|
||||
let val = n / 1000
|
||||
let i = 0
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024
|
||||
while (val >= 1000 && i < units.length - 1) {
|
||||
val /= 1000
|
||||
i++
|
||||
}
|
||||
|
||||
@@ -135,3 +128,15 @@ export const safeSetState =
|
||||
(value: React.SetStateAction<T>) => {
|
||||
if (ref.current) setter(value)
|
||||
}
|
||||
|
||||
export const truncateNameMiddle = (s: string, max = 40, start?: number, end?: number): string => {
|
||||
if (s.length <= max) return s
|
||||
|
||||
if (start && end && start + end < s.length) {
|
||||
return `${s.slice(0, start)}…${s.slice(-end)}`
|
||||
}
|
||||
|
||||
const half = Math.floor((max - 1) / 2)
|
||||
|
||||
return `${s.slice(0, half)}…${s.slice(-half)}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FileInfo, FileManager } from '@solarpunkltd/file-manager-lib'
|
||||
import { getExtensionFromName, guessMime, VIEWERS } from './view'
|
||||
import { guessMime, VIEWERS } from './view'
|
||||
import { AbortManager } from './abortManager'
|
||||
import { DownloadProgress, DownloadState } from '../constants/transfers'
|
||||
|
||||
@@ -48,15 +48,17 @@ const processStream = async (
|
||||
onDownloadProgress?.({ progress, isDownloading: !done })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if ((e as { name?: string }).name === Errors.AbortError) {
|
||||
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Cancelled })
|
||||
const isAbort = (e as { name?: string }).name === Errors.AbortError
|
||||
|
||||
return
|
||||
if (isAbort) {
|
||||
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Cancelled })
|
||||
} else {
|
||||
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Error })
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to process stream: ', e)
|
||||
}
|
||||
|
||||
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Error })
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to process stream: ', e)
|
||||
throw e
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
|
||||
@@ -66,8 +68,10 @@ const processStream = async (
|
||||
} else {
|
||||
await writable?.close()
|
||||
}
|
||||
} catch {
|
||||
} catch (e: unknown) {
|
||||
/* no-op */
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('filehandle close/abort error: ', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,15 +146,24 @@ const getSingleFileHandle = async (
|
||||
info: FileInfo,
|
||||
defaultDownloadFolder: string,
|
||||
): Promise<FileInfoWithHandle[] | undefined> => {
|
||||
const mimeType = guessMime(info.name, info.customMetadata)
|
||||
const { mime, ext } = guessMime(info.name, info.customMetadata)
|
||||
|
||||
const pickerOptions: {
|
||||
suggestedName: string
|
||||
startIn: string
|
||||
types?: Array<{ accept: Record<string, string[]> }>
|
||||
} = {
|
||||
suggestedName: info.name,
|
||||
startIn: defaultDownloadFolder,
|
||||
}
|
||||
|
||||
if (ext) {
|
||||
pickerOptions.types = [{ accept: { [mime]: [`.${ext}`] } }]
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handle = (await (window as any).showSaveFilePicker({
|
||||
suggestedName: info.name,
|
||||
startIn: defaultDownloadFolder,
|
||||
types: [{ accept: { [mimeType]: [`.${getExtensionFromName(info.name)}`] } }],
|
||||
})) as FileSystemFileHandle
|
||||
const handle = (await (window as any).showSaveFilePicker(pickerOptions)) as FileSystemFileHandle
|
||||
|
||||
return [{ info, handle }]
|
||||
} catch (error: unknown) {
|
||||
@@ -222,16 +235,20 @@ const downloadToDisk = async (
|
||||
handle: FileSystemFileHandle,
|
||||
onDownloadProgress?: (progress: DownloadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
for (const stream of streams) {
|
||||
await processStream(stream, handle, onDownloadProgress, signal)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
if ((error as { name?: string }).name !== Errors.AbortError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error during download to disk: ', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,30 +258,36 @@ const downloadToBlob = async (
|
||||
onDownloadProgress?: (progress: DownloadProgress) => void,
|
||||
isOpenWindow?: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
for (const stream of streams) {
|
||||
const mime = guessMime(info.name, info.customMetadata)
|
||||
const { mime } = guessMime(info.name, info.customMetadata)
|
||||
const blob = await streamToBlob(stream, mime, onDownloadProgress, signal)
|
||||
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
let opened = false
|
||||
if (!blob) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isOpenWindow) {
|
||||
opened = openNewWindow(info.name, mime, url)
|
||||
}
|
||||
const url = URL.createObjectURL(blob)
|
||||
let opened = false
|
||||
|
||||
if (!opened) {
|
||||
downloadFromUrl(url, info.name)
|
||||
}
|
||||
if (isOpenWindow) {
|
||||
opened = openNewWindow(info.name, mime, url)
|
||||
}
|
||||
|
||||
if (!opened) {
|
||||
downloadFromUrl(url, info.name)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
if ((error as { name?: string }).name !== Errors.AbortError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error during download and open: ', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,29 +342,50 @@ export const startDownloadingQueue = async (
|
||||
try {
|
||||
if (fh.cancelled) {
|
||||
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Cancelled })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const dataStreams = (await fm.download(fh.info)) as ReadableStream<Uint8Array>[]
|
||||
|
||||
if (!dataStreams || dataStreams.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No data streams returned for ${name}`)
|
||||
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Error })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let success = false
|
||||
|
||||
if (isOpenWindow || !fh.handle) {
|
||||
success = await downloadToBlob(dataStreams, fh.info, tracker, isOpenWindow, signal)
|
||||
} else {
|
||||
const dataStreams = (await fm.download(fh.info)) as ReadableStream<Uint8Array>[]
|
||||
success = await downloadToDisk(dataStreams, fh.handle, tracker, signal)
|
||||
}
|
||||
|
||||
if (isOpenWindow || !fh.handle) {
|
||||
await downloadToBlob(dataStreams, fh.info, tracker, isOpenWindow, signal)
|
||||
} else {
|
||||
await downloadToDisk(dataStreams, fh.handle, tracker, signal)
|
||||
}
|
||||
if (!tracker) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the tracker shows completion
|
||||
if (tracker) {
|
||||
const size = fh.info.customMetadata?.size
|
||||
const finalProgress = size ? Number(size) : 0
|
||||
if (success) {
|
||||
const size = fh.info.customMetadata?.size
|
||||
const finalProgress = size ? Number(size) : 0
|
||||
tracker({ progress: finalProgress, isDownloading: false })
|
||||
|
||||
tracker({ progress: finalProgress, isDownloading: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!signal?.aborted) {
|
||||
tracker({ progress: 0, isDownloading: false, state: DownloadState.Error })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const isAbortError = (error as { name?: string }).name === Errors.AbortError
|
||||
|
||||
// Ensure the tracker shows completion
|
||||
if (!isAbortError) {
|
||||
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Error })
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('download queue error: ', error)
|
||||
} else {
|
||||
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Cancelled })
|
||||
}
|
||||
@@ -350,7 +394,8 @@ export const startDownloadingQueue = async (
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// Errors are handled per-file in the map above
|
||||
} catch (e: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('An error happened in the download queue: ', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import type { FileManagerBase } from '@solarpunkltd/file-manager-lib'
|
||||
import type { PostageBatch, RedundancyLevel } from '@ethersphere/bee-js'
|
||||
import { verifyDriveSpace } from './bee'
|
||||
import { capitalizeFirstLetter } from './common'
|
||||
import { ActionTag } from '../constants/transfers'
|
||||
|
||||
export enum FileOperation {
|
||||
Trash = 'trash',
|
||||
Recover = 'recover',
|
||||
Forget = 'forget',
|
||||
}
|
||||
|
||||
interface FileOperationOptions {
|
||||
fm: FileManagerBase
|
||||
file: FileInfo
|
||||
redundancyLevel: RedundancyLevel
|
||||
driveId: string
|
||||
stamp: PostageBatch
|
||||
adminStamp?: PostageBatch
|
||||
adminRedundancy?: RedundancyLevel
|
||||
operation: FileOperation
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export async function performFileOperation({
|
||||
fm,
|
||||
file,
|
||||
redundancyLevel,
|
||||
driveId,
|
||||
stamp,
|
||||
adminStamp,
|
||||
adminRedundancy,
|
||||
operation,
|
||||
onError,
|
||||
onSuccess,
|
||||
}: FileOperationOptions): Promise<boolean> {
|
||||
try {
|
||||
const isForget = operation === FileOperation.Forget
|
||||
const verifyStamp = isForget ? adminStamp || stamp : stamp
|
||||
|
||||
const { ok } = verifyDriveSpace({
|
||||
fm,
|
||||
redundancyLevel,
|
||||
stamp: verifyStamp,
|
||||
useInfoSize: !isForget,
|
||||
adminRedundancy: isForget ? adminRedundancy : undefined,
|
||||
driveId,
|
||||
fileSize: 0,
|
||||
cb: err => {
|
||||
onError?.(err || `Could not ${operation} file due to insufficient space: ${file.name}`)
|
||||
},
|
||||
})
|
||||
|
||||
if (!ok) return false
|
||||
|
||||
const lifecycleTag = operation === FileOperation.Trash ? ActionTag.Trashed : ActionTag.Recovered
|
||||
const withMeta: FileInfo = {
|
||||
...file,
|
||||
customMetadata: {
|
||||
...(file.customMetadata ?? {}),
|
||||
lifecycle: capitalizeFirstLetter(lifecycleTag),
|
||||
lifecycleAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case FileOperation.Trash:
|
||||
await fm.trashFile(withMeta)
|
||||
break
|
||||
case FileOperation.Recover:
|
||||
await fm.recoverFile(withMeta)
|
||||
break
|
||||
case FileOperation.Forget:
|
||||
await fm.forgetFile(file)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown operation: ${operation}`)
|
||||
}
|
||||
|
||||
onSuccess?.()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error.message : `Failed to ${operation} file: ${file.name}`)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function performBulkFileOperation({
|
||||
fm,
|
||||
files,
|
||||
operation,
|
||||
stamps,
|
||||
adminStamp,
|
||||
adminDrive,
|
||||
onError,
|
||||
onFileComplete,
|
||||
}: {
|
||||
fm: FileManagerBase
|
||||
files: FileInfo[]
|
||||
operation: FileOperation
|
||||
stamps: PostageBatch[]
|
||||
adminStamp?: PostageBatch
|
||||
adminDrive?: DriveInfo
|
||||
onError?: (error: string) => void
|
||||
onFileComplete?: (file: FileInfo, index: number) => void
|
||||
}): Promise<void> {
|
||||
if (!fm || !files?.length) return
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const defaultErrorMsg = `Could not ${operation} file due to insufficient space: ${file.name}`
|
||||
|
||||
try {
|
||||
const currentStamp = stamps.find(s => s.batchID.toString() === file.batchId.toString())
|
||||
|
||||
if (!currentStamp && operation !== FileOperation.Forget) {
|
||||
onError?.(`Stamp not found for file: ${file.name}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentStamp) return
|
||||
|
||||
const success = await performFileOperation({
|
||||
fm,
|
||||
file,
|
||||
redundancyLevel: file.redundancyLevel || 0,
|
||||
driveId: file.driveId,
|
||||
stamp: currentStamp,
|
||||
adminStamp,
|
||||
operation,
|
||||
adminRedundancy: adminDrive?.redundancyLevel,
|
||||
onError,
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
throw new Error(defaultErrorMsg)
|
||||
}
|
||||
|
||||
onFileComplete?.(file, i)
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
onError?.(errorMsg || defaultErrorMsg)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import GeneralIcon from 'remixicon-react/FileTextLineIcon'
|
||||
import CalendarIcon from 'remixicon-react/CalendarLineIcon'
|
||||
import AccessIcon from 'remixicon-react/ShieldKeyholeLineIcon'
|
||||
import HardDriveIcon from 'remixicon-react/HardDrive2LineIcon'
|
||||
import { indexStrToBigint } from './common'
|
||||
import { indexStrToBigint, truncateNameMiddle } from './common'
|
||||
import { FEED_INDEX_ZERO, erasureCodeMarks } from '../constants/common'
|
||||
|
||||
export type FileProperty = { key: string; label: string; value: string; raw?: string }
|
||||
@@ -100,7 +100,7 @@ function buildGeneralGroup(
|
||||
{ key: 'type', label: 'Type', value: mime ?? dash },
|
||||
{ key: 'size', label: 'Size', value: size != null ? formatBytes(size) : dash },
|
||||
{ key: 'count', label: 'Items', value: fileCount ?? '1' },
|
||||
{ key: 'path', label: 'Location', value: path || dash },
|
||||
{ key: 'path', label: 'Location', value: truncateNameMiddle(path || dash, 35, 10, 10) },
|
||||
{
|
||||
key: 'hash',
|
||||
label: 'Swarm hash',
|
||||
@@ -162,7 +162,7 @@ function buildAccessGroup(fi: FileInfo, granteeCount?: number): FilePropertyGrou
|
||||
|
||||
function buildStorageGroup(fi: FileInfo, driveName: string, stamp?: PostageBatch): FilePropertyGroup {
|
||||
const stampValue = stamp
|
||||
? stamp.label + ' (' + truncateMiddle(fi.batchId.toString(), 4, 4) + ')'
|
||||
? truncateNameMiddle(stamp.label, 35, 10, 10) + ' (' + truncateMiddle(fi.batchId.toString(), 4, 4) + ')'
|
||||
: truncateMiddle(fi.batchId.toString())
|
||||
|
||||
const redundancyLabel =
|
||||
@@ -180,7 +180,7 @@ function buildStorageGroup(fi: FileInfo, driveName: string, stamp?: PostageBatch
|
||||
value: stampValue,
|
||||
raw: fi.batchId.toString(),
|
||||
},
|
||||
{ key: 'drive', label: 'Drive', value: driveName },
|
||||
{ key: 'drive', label: 'Drive', value: truncateNameMiddle(driveName, 35, 10, 10) },
|
||||
{ key: 'redundancy', label: 'Redundancy', value: redundancyLabel },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function computeContextMenuPosition(args: {
|
||||
|
||||
const spaceBelow = vh - pos.y
|
||||
|
||||
if (spaceBelow < rect.height * 1.4) {
|
||||
if (spaceBelow < rect.height * 1.8) {
|
||||
top = Math.max(margin, pos.y - rect.height - margin)
|
||||
dir = Dir.Up
|
||||
} else {
|
||||
|
||||
@@ -24,17 +24,21 @@ const EXT_TO_MIME: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function getExtensionFromName(name: string): string {
|
||||
return name.split('.').pop()?.toLowerCase() || ''
|
||||
const ext = name.split('.').pop()?.toLowerCase() || ''
|
||||
const hasExtension = name.includes('.') && ext && ext !== name
|
||||
|
||||
return hasExtension ? ext : ''
|
||||
}
|
||||
|
||||
export function guessMime(name: string, mtdt?: Record<string, string> | undefined): string {
|
||||
export function guessMime(name: string, mtdt?: Record<string, string> | undefined): { mime: string; ext: string } {
|
||||
const md = mtdt?.mimeType || mtdt?.mime || mtdt?.['content-type']
|
||||
|
||||
if (md) return md
|
||||
|
||||
const ext = getExtensionFromName(name)
|
||||
|
||||
return EXT_TO_MIME[ext] || 'application/octet-stream'
|
||||
if (md) return { mime: md, ext }
|
||||
|
||||
const mime = EXT_TO_MIME[ext] || 'application/octet-stream'
|
||||
|
||||
return { mime, ext }
|
||||
}
|
||||
|
||||
export type Viewer = {
|
||||
|
||||
Reference in New Issue
Block a user