Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions public/locales/en/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@
"noPinsInProgress": "All done, no remote pins in progress.",
"remotePinningInProgress": "Remote pinning in progress:",
"selectAllEntries": "Select all entries",
"searchFiles": "Search files...",
"clearSearch": "Clear search",
"noFilesMatchFilter": "No files match your search",
"previewNotFound": {
"title": "IPFS can't find this item",
"helpTitle": "These are common troubleshooting steps might help:",
Expand Down
129 changes: 80 additions & 49 deletions src/files/files-grid/files-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback, type FC, type MouseEvent } from 'react'
import React, { useRef, useState, useEffect, useCallback, useMemo, type FC, type MouseEvent } from 'react'
import { Trans, withTranslation } from 'react-i18next'
import { useDrop } from 'react-dnd'
import { NativeTypes } from 'react-dnd-html5-backend'
Expand All @@ -9,6 +9,7 @@ import './files-grid.css'
import { TFunction } from 'i18next'
import type { ContextMenuFile, ExtendedFile, FileStream } from '../types'
import type { CID } from 'multiformats/cid'
import SearchFilter from '../search-filter/SearchFilter'

export interface FilesGridProps {
files: ContextMenuFile[]
Expand All @@ -34,14 +35,14 @@ interface FilesGridPropsConnected extends FilesGridProps {
onSelect: (fileName: string | string[], isSelected: boolean) => void
filesIsFetching: boolean
selected: string[]
modalOpen: boolean
}

const FilesGrid = ({
files, pins = [], remotePins = [], pendingPins = [], failedPins = [], filesPathInfo, t, onRemove, onRename, onNavigate, onAddFiles,
onMove, handleContextMenuClick, filesIsFetching, onSetPinning, onDismissFailedPin, selected = [], onSelect, modalOpen = false
onMove, handleContextMenuClick, filesIsFetching, onSetPinning, onDismissFailedPin, selected = [], onSelect
}: FilesGridPropsConnected) => {
const [focused, setFocused] = useState<string | null>(null)
const [filter, setFilter] = useState('')
const filesRefs = useRef<Record<string, HTMLDivElement>>({})
const gridRef = useRef<HTMLDivElement | null>(null)

Expand All @@ -63,17 +64,39 @@ const FilesGrid = ({
onAddFiles(normalizeFiles(files))
}

const filteredFiles = useMemo(() => {
if (!filter) return files

const filterLower = filter.toLowerCase()
return files.filter(file => {
// Search by name
if (file.name && file.name.toLowerCase().includes(filterLower)) {
return true
}
// Search by CID
if (file.cid && file.cid.toString().toLowerCase().includes(filterLower)) {
return true
}
// Search by type
if (file.type && file.type.toLowerCase().includes(filterLower)) {
return true
}
return false
})
}, [files, filter])

const handleSelect = useCallback((fileName: string, isSelected: boolean) => {
onSelect(fileName, isSelected)
}, [onSelect])

const keyHandler = useCallback((e: KeyboardEvent) => {
// Don't handle keyboard events when a modal is open
if (modalOpen) {
return
}
const handleFilterChange = useCallback((newFilter: string) => {
setFilter(newFilter)
// Clear focus when filtering to avoid issues
setFocused(null)
}, [])

const focusedFile = focused == null ? null : files.find(el => el.name === focused)
const keyHandler = useCallback((e: KeyboardEvent) => {
const focusedFile = focused == null ? null : filteredFiles.find(el => el.name === focused)

gridRef.current?.focus?.()

Expand All @@ -88,7 +111,7 @@ const FilesGrid = ({
}

if ((e.key === 'Delete' || e.key === 'Backspace') && selected.length > 0) {
const selectedFiles = files.filter(f => selected.includes(f.name))
const selectedFiles = filteredFiles.filter(f => selected.includes(f.name))
return onRemove(selectedFiles)
}

Expand All @@ -107,13 +130,13 @@ const FilesGrid = ({
if (isArrowKey) {
e.preventDefault()
const columns = Math.floor((gridRef.current?.clientWidth || window.innerWidth) / 220)
const currentIndex = files.findIndex(el => el.name === focusedFile?.name)
const currentIndex = filteredFiles.findIndex(el => el.name === focusedFile?.name)
let newIndex = currentIndex

switch (e.key) {
case 'ArrowDown':
if (currentIndex === -1) {
newIndex = files.length - 1 // if no focused file, set to last file
newIndex = filteredFiles.length - 1 // if no focused file, set to last file
} else {
newIndex = currentIndex + columns
}
Expand All @@ -126,15 +149,15 @@ const FilesGrid = ({
}
break
case 'ArrowRight':
if (currentIndex === -1 || currentIndex === files.length - 1) {
if (currentIndex === -1 || currentIndex === filteredFiles.length - 1) {
newIndex = 0 // if no focused file, set to last file
} else {
newIndex = currentIndex + 1
}
break
case 'ArrowLeft':
if (currentIndex === -1 || currentIndex === 0) {
newIndex = files.length - 1 // if no focused file, set to last file
newIndex = filteredFiles.length - 1 // if no focused file, set to last file
} else {
newIndex = currentIndex - 1
}
Expand All @@ -143,8 +166,8 @@ const FilesGrid = ({
break
}

if (newIndex >= 0 && newIndex < files.length) {
const name = files[newIndex].name
if (newIndex >= 0 && newIndex < filteredFiles.length) {
const name = filteredFiles[newIndex].name
setFocused(name)
const element = filesRefs.current[name]
if (element && element.scrollIntoView) {
Expand All @@ -154,7 +177,8 @@ const FilesGrid = ({
}
}
}
}, [files, focused, selected, onSelect, onRename, onRemove, onNavigate, handleSelect, modalOpen])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredFiles, focused])

useEffect(() => {
if (filesIsFetching) return
Expand All @@ -167,38 +191,45 @@ const FilesGrid = ({
const gridClassName = `files-grid${isOver && canDrop ? ' files-grid--drop-target' : ''}`

return (
<div ref={(el) => {
drop(el)
gridRef.current = el
}} className={gridClassName} tabIndex={0} role="grid" aria-label={t('filesGridLabel')}>
{files.map(file => (
<GridFile
key={file.name}
{...file}
refSetter={(r: HTMLDivElement | null) => { filesRefs.current[file.name] = r as HTMLDivElement }}
selected={selected.includes(file.name)}
focused={focused === file.name}
pinned={pins?.includes(file.cid?.toString())}
isRemotePin={remotePins?.includes(file.cid?.toString())}
isPendingPin={pendingPins?.includes(file.cid?.toString())}
isFailedPin={failedPins?.some(p => p?.includes(file.cid?.toString()))}
isMfs={filesPathInfo?.isMfs}
onNavigate={() => onNavigate({ path: file.path, cid: file.cid })}
onAddFiles={onAddFiles}
onMove={onMove}
onSetPinning={onSetPinning}
onDismissFailedPin={onDismissFailedPin}
handleContextMenuClick={handleContextMenuClick}
onSelect={handleSelect}
/>
))}
{files.length === 0 && !filesPathInfo?.isRoot && (
<Trans i18nKey='filesList.noFiles' t={t}>
<div className='pv3 b--light-gray files-grid-empty bt tc charcoal-muted f6 noselect'>
There are no available files. Add some!
</div>
</Trans>
)}
<div className="flex flex-column">
<SearchFilter
onFilterChange={handleFilterChange}
filteredCount={filteredFiles.length}
totalCount={files.length}
/>
<div ref={(el) => {
drop(el)
gridRef.current = el
}} className={gridClassName} tabIndex={0} role="grid" aria-label={t('filesGridLabel')}>
{filteredFiles.map(file => (
<GridFile
key={file.name}
{...file}
refSetter={(r: HTMLDivElement | null) => { filesRefs.current[file.name] = r as HTMLDivElement }}
selected={selected.includes(file.name)}
focused={focused === file.name}
pinned={pins?.includes(file.cid?.toString())}
isRemotePin={remotePins?.includes(file.cid?.toString())}
isPendingPin={pendingPins?.includes(file.cid?.toString())}
isFailedPin={failedPins?.some(p => p?.includes(file.cid?.toString()))}
isMfs={filesPathInfo?.isMfs}
onNavigate={() => onNavigate({ path: file.path, cid: file.cid })}
onAddFiles={onAddFiles}
onMove={onMove}
onSetPinning={onSetPinning}
onDismissFailedPin={onDismissFailedPin}
handleContextMenuClick={handleContextMenuClick}
onSelect={handleSelect}
/>
))}
{filteredFiles.length === 0 && (
<Trans i18nKey='filesList.noFiles' t={t}>
<div className='pv3 b--light-gray files-grid-empty bt tc gray f6'>
{filter ? t('noFilesMatchFilter') : 'There are no available files. Add some!'}
</div>
</Trans>
)}
</div>
</div>
)
}
Expand Down
76 changes: 52 additions & 24 deletions src/files/files-list/FilesList.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Checkbox from '../../components/checkbox/Checkbox.js'
// import SelectedActions from '../selected-actions/SelectedActions.js'
import File from '../file/File.js'
import LoadingAnimation from '../../components/loading-animation/LoadingAnimation.js'
import SearchFilter from '../search-filter/SearchFilter'

const addFiles = async (filesPromise, onAddFiles) => {
const files = await filesPromise
Expand Down Expand Up @@ -57,6 +58,7 @@ export const FilesList = ({
const [focused, setFocused] = useState(null)
const [firstVisibleRow, setFirstVisibleRow] = useState(null)
const [allFiles, setAllFiles] = useState(mergeRemotePinsIntoFiles(files, remotePins, pendingPins, failedPins))
const [filter, setFilter] = useState('')
const listRef = useRef()
const filesRefs = useRef([])
const refreshPinCache = true
Expand All @@ -78,6 +80,27 @@ export const FilesList = ({
canDrop: _ => filesPathInfo.isMfs
})

const filteredFiles = useMemo(() => {
if (!filter) return allFiles

const filterLower = filter.toLowerCase()
return allFiles.filter(file => {
// Search by name
if (file.name && file.name.toLowerCase().includes(filterLower)) {
return true
}
// Search by CID
if (file.cid && file.cid.toString().toLowerCase().includes(filterLower)) {
return true
}
// Search by type
if (file.type && file.type.toLowerCase().includes(filterLower)) {
return true
}
return false
})
}, [allFiles, filter])

const selectedFiles = useMemo(() =>
selected
.map(name => allFiles.find(el => el.name === name))
Expand All @@ -88,12 +111,18 @@ export const FilesList = ({
}))
, [allFiles, pins, selected])

const handleFilterChange = useCallback((newFilter) => {
setFilter(newFilter)
// Clear focus when filtering to avoid issues with virtualized list
setFocused(null)
}, [])

const toggleOne = useCallback((name, check) => {
onSelect(name, check)
}, [onSelect])

const keyHandler = useCallback((e) => {
const focusedFile = files.find(el => el.name === focused)
const focusedFile = filteredFiles.find(el => el.name === focused)

// Disable keyboard controls if fetching files
if (filesIsFetching) {
Expand Down Expand Up @@ -128,26 +157,26 @@ export const FilesList = ({
let index = 0

if (focused !== null) {
const prev = files.findIndex(el => el.name === focused)
const prev = filteredFiles.findIndex(el => el.name === focused)
index = (e.key === 'ArrowDown') ? prev + 1 : prev - 1
}

if (index === -1 || index >= files.length) {
if (index === -1 || index >= filteredFiles.length) {
return
}

let name = files[index].name
let name = filteredFiles[index].name

// If the file we are going to focus is out of view (removed
// from the DOM by react-virtualized), focus the first visible file
if (!filesRefs.current[name]) {
name = files[firstVisibleRow].name
name = filteredFiles[firstVisibleRow]?.name
}

setFocused(name)
}
}, [
files,
filteredFiles,
focused,
firstVisibleRow,
filesIsFetching,
Expand All @@ -174,7 +203,7 @@ export const FilesList = ({

const toggleAll = (checked) => {
if (checked) {
onSelect(allFiles.map(file => file.name), true)
onSelect(filteredFiles.map(file => file.name), true)
} else {
onSelect([], false)
}
Expand Down Expand Up @@ -235,23 +264,17 @@ export const FilesList = ({
listRef.current?.forceUpdateGrid?.()
}

const emptyRowsRenderer = () => {
if (filesPathInfo.isRoot) {
// Root has special more prominent message (AddFilesInfo)
return null
}
return (
<Trans i18nKey='filesList.noFiles' t={t}>
<div className='pv3 b--light-gray bt tc charcoal-muted f6 noselect'>
There are no available files. Add some!
</div>
</Trans>
)
}
const emptyRowsRenderer = () => (
<Trans i18nKey='filesList.noFiles' t={t}>
<div className='pv3 b--light-gray bt tc gray f6'>
{filter ? t('noFilesMatchFilter') : 'There are no available files. Add some!'}
</div>
</Trans>
)

const rowRenderer = ({ index, key, style }) => {
const pinsString = pins.map(p => p.toString())
const listItem = allFiles[index]
const listItem = filteredFiles[index]
const onNavigateHandler = () => {
if (listItem.type === 'unknown') return onInspect(listItem.cid)
return onNavigate({ path: listItem.path, cid: listItem.cid })
Expand All @@ -261,7 +284,7 @@ export const FilesList = ({
}

return (
<div key={key} style={style} ref={r => { filesRefs.current[allFiles[index].name] = r }}>
<div key={key} style={style} ref={r => { filesRefs.current[filteredFiles[index].name] = r }}>
<File
{...listItem}
pinned={pinsString.includes(listItem.cid.toString())}
Expand All @@ -283,8 +306,8 @@ export const FilesList = ({

const onRowsRendered = ({ startIndex }) => setFirstVisibleRow(startIndex)

const allSelected = selected.length !== 0 && selected.length === allFiles.length
const rowCount = allFiles.length
const allSelected = selected.length !== 0 && selected.length === filteredFiles.length
const rowCount = filteredFiles.length
const checkBoxCls = classnames({
'o-1': allSelected,
'o-70': !allSelected
Expand Down Expand Up @@ -340,6 +363,11 @@ export const FilesList = ({
</div>
<div className='pa2' style={{ width: '2.5rem' }} />
</header>
<SearchFilter
onFilterChange={handleFilterChange}
filteredCount={filteredFiles.length}
totalCount={allFiles.length}
/>
<WindowScroller>
{({ height, isScrolling, onChildScroll, scrollTop }) => (
<div className='flex-auto'>
Expand Down
Loading