diff --git a/public/locales/en/files.json b/public/locales/en/files.json index c44340915..b4e0c8b6b 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -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:", diff --git a/src/files/files-grid/files-grid.tsx b/src/files/files-grid/files-grid.tsx index 26a4e6927..cde745115 100644 --- a/src/files/files-grid/files-grid.tsx +++ b/src/files/files-grid/files-grid.tsx @@ -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' @@ -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[] @@ -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(null) + const [filter, setFilter] = useState('') const filesRefs = useRef>({}) const gridRef = useRef(null) @@ -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?.() @@ -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) } @@ -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 } @@ -126,7 +149,7 @@ 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 @@ -134,7 +157,7 @@ const FilesGrid = ({ 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 } @@ -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) { @@ -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 @@ -167,38 +191,45 @@ const FilesGrid = ({ const gridClassName = `files-grid${isOver && canDrop ? ' files-grid--drop-target' : ''}` return ( -
{ - drop(el) - gridRef.current = el - }} className={gridClassName} tabIndex={0} role="grid" aria-label={t('filesGridLabel')}> - {files.map(file => ( - { 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 && ( - -
- There are no available files. Add some! -
-
- )} +
+ +
{ + drop(el) + gridRef.current = el + }} className={gridClassName} tabIndex={0} role="grid" aria-label={t('filesGridLabel')}> + {filteredFiles.map(file => ( + { 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 && ( + +
+ {filter ? t('noFilesMatchFilter') : 'There are no available files. Add some!'} +
+
+ )} +
) } diff --git a/src/files/files-list/FilesList.js b/src/files/files-list/FilesList.js index a34b117f0..dd7d0eebd 100644 --- a/src/files/files-list/FilesList.js +++ b/src/files/files-list/FilesList.js @@ -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 @@ -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 @@ -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)) @@ -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) { @@ -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, @@ -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) } @@ -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 ( - -
- There are no available files. Add some! -
-
- ) - } + const emptyRowsRenderer = () => ( + +
+ {filter ? t('noFilesMatchFilter') : 'There are no available files. Add some!'} +
+
+ ) 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 }) @@ -261,7 +284,7 @@ export const FilesList = ({ } return ( -
{ filesRefs.current[allFiles[index].name] = r }}> +
{ filesRefs.current[filteredFiles[index].name] = r }}> 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 @@ -340,6 +363,11 @@ export const FilesList = ({
+ {({ height, isScrolling, onChildScroll, scrollTop }) => (
diff --git a/src/files/search-filter/SearchFilter.tsx b/src/files/search-filter/SearchFilter.tsx new file mode 100644 index 000000000..ca5b8a115 --- /dev/null +++ b/src/files/search-filter/SearchFilter.tsx @@ -0,0 +1,58 @@ +import React, { useState, useCallback } from 'react' +import { withTranslation } from 'react-i18next' +import classnames from 'classnames' +import { TFunction } from 'i18next' + +interface SearchFilterProps { + onFilterChange: (filter: string) => void + filteredCount: number + totalCount: number + className?: string +} + +const SearchFilter = ({ onFilterChange, filteredCount, totalCount, t, className = '' }: SearchFilterProps & { t: TFunction }) => { + const [filter, setFilter] = useState('') + + const handleFilterChange = useCallback((e) => { + const value = e.target.value + setFilter(value) + onFilterChange(value) + }, [onFilterChange]) + + const clearFilter = useCallback(() => { + setFilter('') + onFilterChange('') + }, [onFilterChange]) + + return ( +
+
+ + {filter && ( + + )} +
+ {filter && ( +
+ {filteredCount} / {totalCount} +
+ )} +
+ ) +} + +export default withTranslation('files')(SearchFilter)