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,599 @@
import { ReactElement, useContext, useLayoutEffect, useMemo, useState, useRef, useEffect, useCallback } from 'react'
import './FileItem.scss'
import { GetIconElement } from '../../../utils/GetIconElement'
import { ContextMenu } from '../../ContextMenu/ContextMenu'
import { useContextMenu } from '../../../hooks/useContextMenu'
import { Context as SettingsContext } from '../../../../../providers/Settings'
import { ActionTag, DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
import { GetInfoModal } from '../../GetInfoModal/GetInfoModal'
import { VersionHistoryModal } from '../../VersionHistoryModal/VersionHistoryModal'
import { DeleteFileModal } from '../../DeleteFileModal/DeleteFileModal'
import { RenameFileModal } from '../../RenameFileModal/RenameFileModal'
import { buildGetInfoGroups } from '../../../utils/infoGroups'
import type { FilePropertyGroup } from '../../../utils/infoGroups'
import { useView } from '../../../../../pages/filemanager/ViewContext'
import type { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib'
import { Context as FMContext } from '../../../../../providers/FileManager'
import { DestroyDriveModal } from '../../DestroyDriveModal/DestroyDriveModal'
import { ConfirmModal } from '../../ConfirmModal/ConfirmModal'
import { capitalizeFirstLetter, Dir, formatBytes, isTrashed, safeSetState } from '../../../utils/common'
import { FileAction } from '../../../constants/transfers'
import { startDownloadingQueue, createDownloadAbort } from '../../../utils/download'
import { computeContextMenuPosition } from '../../../utils/ui'
import { getUsableStamps, handleDestroyDrive } from '../../../utils/bee'
import { PostageBatch } from '@ethersphere/bee-js'
interface FileItemProps {
fileInfo: FileInfo
onDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
showDriveColumn?: boolean
driveName: string
selected?: boolean
onToggleSelected?: (fi: FileInfo, checked: boolean) => void
bulkSelectedCount?: number
onBulk: {
download?: () => void
restore?: () => void
forget?: () => void
destroy?: () => void
delete?: () => void
}
setErrorMessage?: (error: string) => void
}
export function FileItem({
fileInfo,
onDownload,
showDriveColumn,
driveName,
selected = false,
onToggleSelected,
bulkSelectedCount,
onBulk,
setErrorMessage,
}: FileItemProps): ReactElement {
const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu<HTMLDivElement>()
const { fm, currentDrive, files, drives, setShowError } = useContext(FMContext)
const { beeApi } = useContext(SettingsContext)
const { view } = useView()
const [driveStamp, setDriveStamp] = useState<PostageBatch | undefined>(undefined)
const [safePos, setSafePos] = useState(pos)
const [dropDir, setDropDir] = useState<Dir>(Dir.Down)
const [showGetInfoModal, setShowGetInfoModal] = useState(false)
const [infoGroups, setInfoGroups] = useState<FilePropertyGroup[] | null>(null)
const [showVersionHistory, setShowVersionHistory] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showRenameModal, setShowRenameModal] = useState(false)
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
const [destroyDrive, setDestroyDrive] = useState<DriveInfo | null>(null)
const [confirmForget, setConfirmForget] = useState(false)
const isMountedRef = useRef(true)
const rafIdRef = useRef<number | null>(null)
const size = formatBytes(fileInfo.customMetadata?.size)
const dateMod = new Date(fileInfo.timestamp || 0).toLocaleDateString()
const isTrashedFile = isTrashed(fileInfo)
const statusLabel = isTrashedFile ? 'Trash' : 'Active'
useEffect(() => {
isMountedRef.current = true
const getStamps = async () => {
const stamps = await getUsableStamps(beeApi)
const driveStamp = stamps.find(s =>
drives.some(d => d.batchId.toString() === s.batchID.toString() && d.id === fileInfo.driveId),
)
safeSetState(isMountedRef, setDriveStamp)(driveStamp)
}
getStamps()
return () => {
isMountedRef.current = false
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
}
}, [beeApi, drives, fileInfo.driveId])
const openGetInfo = useCallback(async () => {
if (!fm || !isMountedRef.current) return
const groups = await buildGetInfoGroups(fm, fileInfo, driveName, driveStamp)
setInfoGroups(groups)
setShowGetInfoModal(true)
}, [fm, fileInfo, driveName, driveStamp])
const takenNames = useMemo(() => {
if (!currentDrive || !files) return new Set<string>()
const wanted = currentDrive.batchId.toString()
const sameDrive = files.filter(fi => fi.batchId.toString() === wanted)
const out = new Set<string>()
sameDrive.forEach(fi => {
if (fi.topic.toString() !== fileInfo.topic.toString()) out.add(fi.name)
})
return out
}, [files, currentDrive, fileInfo.topic])
const handleItemContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.shiftKey) return
handleContextMenu(e)
}
// TODO: handleOpen shall only be available for images, videos etc... -> do not download 10GB into memory
const handleDownload = useCallback(
async (isNewWindow?: boolean) => {
if (!fm || !beeApi) return
handleCloseContext()
const rawSize = fileInfo.customMetadata?.size
const expectedSize = rawSize ? Number(rawSize) : undefined
createDownloadAbort(fileInfo.name)
await startDownloadingQueue(
fm,
[fileInfo],
[onDownload({ name: fileInfo.name, size: formatBytes(rawSize), expectedSize })],
isNewWindow,
)
},
[handleCloseContext, fm, beeApi, fileInfo, onDownload],
)
// TODO: refactor doTrash, doRecover, doForget to a single function with action param and remove switch case mybe
const doTrash = useCallback(async () => {
if (!fm) return
const withMeta: FileInfo = {
...fileInfo,
customMetadata: {
...(fileInfo.customMetadata ?? {}),
lifecycle: capitalizeFirstLetter(ActionTag.Trashed),
lifecycleAt: new Date().toISOString(),
},
}
await fm.trashFile(withMeta)
}, [fm, fileInfo])
const doRecover = useCallback(async () => {
if (!fm) return
const withMeta: FileInfo = {
...fileInfo,
customMetadata: {
...(fileInfo.customMetadata ?? {}),
lifecycle: capitalizeFirstLetter(ActionTag.Recovered),
lifecycleAt: new Date().toISOString(),
},
}
await fm.recoverFile(withMeta)
}, [fm, fileInfo])
const doForget = useCallback(async () => {
if (!fm) return
await fm.forgetFile(fileInfo)
}, [fm, fileInfo])
const showDestroyDrive = useCallback(() => {
setDestroyDrive(currentDrive || null)
setShowDestroyDriveModal(true)
}, [currentDrive])
const doRename = useCallback(
async (newName: string) => {
if (!fm || !currentDrive) return
if (takenNames.has(newName)) throw new Error('name-taken')
try {
await fm.upload(
currentDrive,
{
name: newName,
topic: fileInfo.topic,
file: {
reference: fileInfo.file.reference,
historyRef: fileInfo.file.historyRef,
},
customMetadata: fileInfo.customMetadata,
files: [],
},
{
actHistoryAddress: fileInfo.file.historyRef,
},
)
} catch (e: unknown) {
setErrorMessage?.(`Error renaming file ${fileInfo.name}`)
setShowError(true)
}
},
[fm, currentDrive, fileInfo, takenNames, setErrorMessage, setShowError],
)
const MenuItem = ({
disabled,
danger,
onClick,
children,
}: {
disabled?: boolean
danger?: boolean
onClick?: () => void
children: React.ReactNode
}) => (
<div
className={`fm-context-item${danger ? ' red' : ''}`}
aria-disabled={disabled ? 'true' : 'false'}
style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
onClick={disabled ? undefined : onClick}
>
{children}
</div>
)
const isBulk = (bulkSelectedCount ?? 0) > 1
const renderContextMenuItems = useCallback(() => {
const viewItem = (
<MenuItem disabled={isBulk} onClick={() => handleDownload(true)}>
View / Open
</MenuItem>
)
const downloadItem = isBulk ? (
<MenuItem onClick={onBulk.download}>Download</MenuItem>
) : (
<MenuItem onClick={() => handleDownload(false)}>Download</MenuItem>
)
const getInfoItem = (
<MenuItem
disabled={isBulk}
onClick={() => {
handleCloseContext()
openGetInfo()
}}
>
Get info
</MenuItem>
)
if (view === ViewType.File) {
return (
<>
{viewItem}
{downloadItem}
<MenuItem
disabled={isBulk}
onClick={() => {
handleCloseContext()
setShowRenameModal(true)
}}
>
Rename
</MenuItem>
<div className="fm-context-item-border" />
<MenuItem
disabled={isBulk}
onClick={() => {
handleCloseContext()
setShowVersionHistory(true)
}}
>
Version history
</MenuItem>
<MenuItem
danger
onClick={() => {
handleCloseContext()
if (isBulk) onBulk.delete?.()
else setShowDeleteModal(true)
}}
>
Delete
</MenuItem>
<div className="fm-context-item-border" />
{getInfoItem}
</>
)
}
return (
<>
{viewItem}
{downloadItem}
<div className="fm-context-item-border" />
{isBulk ? (
<>
<MenuItem danger onClick={onBulk.restore}>
Restore
</MenuItem>
<MenuItem danger onClick={onBulk.destroy}>
Destroy
</MenuItem>
<MenuItem danger onClick={onBulk.forget}>
Forget permanently
</MenuItem>
</>
) : (
<>
<MenuItem
danger
onClick={() => {
handleCloseContext()
doRecover()
}}
>
Restore
</MenuItem>
<MenuItem
danger
onClick={() => {
handleCloseContext()
const parentDrive = drives.find(d => d.id.toString() === fileInfo.driveId.toString())
if (parentDrive) {
setDestroyDrive(parentDrive)
setShowDestroyDriveModal(true)
} else if (currentDrive) {
setDestroyDrive(currentDrive)
setShowDestroyDriveModal(true)
} else {
setErrorMessage?.('Unable to resolve drive for this file.')
setShowError(true)
}
}}
>
Destroy
</MenuItem>
<MenuItem
danger
onClick={() => {
handleCloseContext()
setConfirmForget(true)
}}
>
Forget permanently
</MenuItem>
</>
)}
<div className="fm-context-item-border" />
{getInfoItem}
</>
)
}, [
isBulk,
view,
handleDownload,
handleCloseContext,
openGetInfo,
doRecover,
onBulk,
currentDrive,
drives,
fileInfo.driveId,
setErrorMessage,
setShowError,
])
useLayoutEffect(() => {
if (!showContext) return
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
rafIdRef.current = requestAnimationFrame(() => {
const menu = contextRef.current
if (!menu) return
const menuRect = menu.getBoundingClientRect()
const containerEl = (menu.offsetParent as HTMLElement) ?? null
const containerRect = containerEl?.getBoundingClientRect() ?? null
const { safePos: s, dropDir: d } = computeContextMenuPosition({
clickPos: pos,
menuRect: menuRect,
viewport: { w: window.innerWidth, h: window.innerHeight },
margin: 8,
containerRect,
})
const topLeft = containerRect
? { x: Math.round(s.x - containerRect.left), y: Math.round(s.y - containerRect.top) }
: s
setSafePos(topLeft)
setDropDir(d)
rafIdRef.current = null
})
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
}
}, [showContext, pos, contextRef])
if (!currentDrive || !fm || !beeApi) {
return <div className="fm-file-item-content">Error</div>
}
return (
<div className="fm-file-item-content" onContextMenu={handleItemContextMenu} onClick={handleCloseContext}>
<div className="fm-file-item-content-item fm-checkbox">
<input
type="checkbox"
checked={selected}
onChange={e => onToggleSelected?.(fileInfo, e.target.checked)}
onClick={e => e.stopPropagation()}
/>
</div>
<div className="fm-file-item-content-item fm-name" onDoubleClick={() => handleDownload(true)}>
<GetIconElement icon={fileInfo.name} />
{fileInfo.name}
</div>
{showDriveColumn && (
<div className="fm-file-item-content-item fm-drive">
<span className="fm-drive-name">{driveName}</span>
<span className={`fm-pill ${isTrashedFile ? 'fm-pill--trash' : 'fm-pill--active'}`} title={statusLabel}>
{statusLabel}
</span>
</div>
)}
<div className="fm-file-item-content-item fm-size">{size}</div>
<div className="fm-file-item-content-item fm-date-mod">{dateMod}</div>
{showContext && (
<div
ref={contextRef}
className="fm-file-item-context-menu"
style={{ top: safePos.y, left: safePos.x }}
data-drop={dropDir}
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<ContextMenu>{renderContextMenuItems()}</ContextMenu>
</div>
)}
{showGetInfoModal && infoGroups && (
<GetInfoModal
name={fileInfo.name}
properties={infoGroups}
onCancelClick={() => {
setShowGetInfoModal(false)
}}
/>
)}
{showVersionHistory && (
<VersionHistoryModal
fileInfo={fileInfo}
onCancelClick={() => {
setShowVersionHistory(false)
}}
onDownload={onDownload}
/>
)}
{showDeleteModal && (
<DeleteFileModal
name={fileInfo.name}
currentDriveName={currentDrive.name}
onCancelClick={() => {
setShowDeleteModal(false)
}}
onProceed={action => {
setShowDeleteModal(false)
switch (action) {
case FileAction.Trash:
doTrash()
break
case FileAction.Forget:
setConfirmForget(true)
break
case FileAction.Destroy:
showDestroyDrive()
break
default:
break
}
}}
/>
)}
{showRenameModal && (
<RenameFileModal
currentName={fileInfo.name}
takenNames={(() => {
const sameDrive = files.filter(fi => fi.driveId.toString() === currentDrive.id.toString())
const names = sameDrive.map(fi => fi.name).filter(n => n && n !== fileInfo.name)
return new Set(names)
})()}
onCancelClick={() => {
setShowRenameModal(false)
}}
onProceed={async newName => {
try {
setShowRenameModal(false)
await doRename(newName)
} catch {
safeSetState(isMountedRef, setShowRenameModal)(true)
}
}}
/>
)}
{confirmForget && (
<ConfirmModal
title="Forget permanently?"
message={
<>
This removes <b title={fileInfo.name}>{fileInfo.name}</b> from your view.
<br />
The data remains on Swarm until the drive expires.
</>
}
confirmLabel="Forget"
cancelLabel="Cancel"
onConfirm={async () => {
await doForget()
safeSetState(isMountedRef, setConfirmForget)(false)
}}
onCancel={() => {
setConfirmForget(false)
}}
/>
)}
{showDestroyDriveModal && destroyDrive && (
<DestroyDriveModal
drive={destroyDrive}
onCancelClick={() => {
setShowDestroyDriveModal(false)
setDestroyDrive(null)
}}
doDestroy={async () => {
setShowDestroyDriveModal(false)
await handleDestroyDrive(
beeApi,
fm,
destroyDrive,
() => {
setShowDestroyDriveModal(false)
setDestroyDrive(null)
},
e => {
setShowDestroyDriveModal(false)
setErrorMessage?.(`Error destroying drive: ${destroyDrive.name}: ${e}`)
setShowError(true)
},
)
}}
/>
)}
</div>
)
}