diff --git a/app/src/App.css b/app/src/App.css index 822987dd..0a22b92b 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -454,10 +454,33 @@ tr.drag-deselecting td { -ms-user-select: none; } +/* Filter input and collapse-all button: right-aligned on desktop, full width on mobile (below). */ +.file-browser-filter-container { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5em; +} + +/* Double the default input width on desktop. */ +.ui.input.file-browser-filter { + width: 34em; + max-width: 100%; +} + @media only screen and (max-width: 700px) { .hide-on-mobile { display: none !important; } + + .file-browser-filter-container { + justify-content: flex-start; + } + + /* Fill the row, but stay on one line with the collapse-all button. */ + .ui.input.file-browser-filter { + flex: 1 1 auto; + width: auto; + } } /* Ensure step icons are always visible, even when disabled */ diff --git a/app/src/components/FileBrowser.js b/app/src/components/FileBrowser.js index 494e1b1e..b0d85b1a 100644 --- a/app/src/components/FileBrowser.js +++ b/app/src/components/FileBrowser.js @@ -24,6 +24,7 @@ import { } from "./Common"; import React, {useContext, useEffect, useState} from "react"; import {useDropzone} from "react-dropzone"; +import {useHotkeys} from "react-hotkeys-hook"; import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api"; import _ from 'lodash'; import {SortableTable} from "./SortableTable"; @@ -67,6 +68,32 @@ export function splitPathParentAndName(path) { return [path.substring(0, slashIndex), path.substring(slashIndex + 1, path.length)]; } +export function filterBrowseFiles(browseFiles, filterStr) { + // Reduce the file tree to the rows whose name matches `filterStr`, keeping the ancestor + // folders of every match. A folder whose own name matches keeps its entire subtree. + if (!filterStr) { + return browseFiles; + } + const needle = filterStr.toLowerCase(); + const filterList = (files) => { + // `files` may be a dict keyed by name (from the API) or an array. + const fileList = Array.isArray(files) ? files : Object.values(files); + const matches = []; + for (const file of fileList) { + if (pathName(file.path).toLowerCase().includes(needle)) { + matches.push(file); + } else if (file.children) { + const children = filterList(file.children); + if (children.length > 0) { + matches.push({...file, children}); + } + } + } + return matches; + }; + return filterList(browseFiles); +} + function Folder({folder, onFolderClick, sortData, onFileClick, disabled, loadingFolders}) { const {handleDragStart, handleDragMove, getDragState, shouldAllowClick, selectedPaths, onSelect} = useContext(DragSelectionContext); // Creates a single table row for a folder, or a row for itself and indented rows for its children. @@ -219,6 +246,17 @@ export function FileBrowser() { const pendingAutoSelectRef = React.useRef(null); // Tagged file groups that would be deleted - shown in confirmation modal const [taggedFileGroups, setTaggedFileGroups] = React.useState(null); + // Reduces the visible rows to those matching, and their parent folders. + const [filterStr, setFilterStr] = React.useState(''); + const filterInputRef = React.useRef(); + + // 'f' focuses the filter input, like the search inputs on other pages. + useHotkeys('f', (e) => { + e.preventDefault(); + if (filterInputRef.current) { + filterInputRef.current.focus(); + } + }, {enableOnFormTags: false}); const {settings, fetchSettings} = React.useContext(SettingsContext); const {inverted} = React.useContext(ThemeContext); @@ -457,6 +495,17 @@ export function FileBrowser() { /> ; + const onCollapseAll = () => { + if (!openFolders) return; + // Deselect any paths that will no longer be visible (descendants of the open folders). + const newSelectedPaths = selectedPaths.filter(p => + !openFolders.some(folder => p.startsWith(folder) && p !== folder)); + if (newSelectedPaths.length !== selectedPaths.length) { + setSelectedPaths(newSelectedPaths); + } + setOpenFolders(null); + } + const onFolderClick = async (folder) => { let newFolders; if (openFolders.indexOf(folder) >= 0) { @@ -481,6 +530,11 @@ export function FileBrowser() { setOpenFolders(newFolders); } + const filteredFiles = React.useMemo( + () => browseFiles ? filterBrowseFiles(browseFiles, filterStr) : browseFiles, + [browseFiles, filterStr], + ); + if (browseFiles === null) { return @@ -493,11 +547,40 @@ export function FileBrowser() { return +
+ setFilterStr(value)} + aria-label='Filter files' + className='file-browser-filter' + ref={filterInputRef} + action={
{ }); }); + describe('Filter', () => { + // foo/ and bar/ are open, their children are loaded. + const nestedBrowseFiles = [ + { + path: 'foo/', is_empty: false, children: [ + {path: 'foo/foo1.txt', size: 100}, + {path: 'foo/foo2.txt', size: 200}, + ] + }, + { + path: 'bar/', is_empty: false, children: [ + {path: 'bar/bar.1.txt', size: 300}, + ] + }, + ]; + + describe('filterBrowseFiles', () => { + it('returns files unchanged when filter is empty', () => { + expect(filterBrowseFiles(nestedBrowseFiles, '')).toBe(nestedBrowseFiles); + }); + + it('keeps a matching folder and its entire subtree', () => { + const result = filterBrowseFiles(nestedBrowseFiles, 'bar'); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('bar/'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].path).toBe('bar/bar.1.txt'); + }); + + it('keeps the parent folders of a matching file', () => { + const result = filterBrowseFiles(nestedBrowseFiles, 'foo1'); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('foo/'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].path).toBe('foo/foo1.txt'); + }); + + it('matches case-insensitively', () => { + const result = filterBrowseFiles(nestedBrowseFiles, 'FOO1'); + expect(result).toHaveLength(1); + expect(result[0].children[0].path).toBe('foo/foo1.txt'); + }); + + it('handles the dict format returned by the API', () => { + const dictFiles = { + 'foo/': { + path: 'foo/', is_empty: false, children: { + 'foo1.txt': {path: 'foo/foo1.txt', size: 100}, + } + }, + 'bar.txt': {path: 'bar.txt', size: 300}, + }; + const result = filterBrowseFiles(dictFiles, 'bar'); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('bar.txt'); + }); + + it('returns no rows when nothing matches', () => { + expect(filterBrowseFiles(nestedBrowseFiles, 'does-not-exist')).toHaveLength(0); + }); + }); + + it('reduces the rows to matches and their tree', async () => { + useBrowseFiles.mockReturnValue({ + browseFiles: nestedBrowseFiles, + openFolders: ['foo/', 'bar/'], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + // All rows are visible before filtering. + expect(screen.getByText('foo1.txt')).toBeInTheDocument(); + expect(screen.getByText('bar.1.txt')).toBeInTheDocument(); + + const filterInput = screen.getByPlaceholderText('Filter files...'); + await act(async () => { + fireEvent.change(filterInput, {target: {value: 'bar'}}); + }); + + // Only bar/ and its child remain. + expect(screen.getByText('bar/')).toBeInTheDocument(); + expect(screen.getByText('bar.1.txt')).toBeInTheDocument(); + expect(screen.queryByText('foo/')).not.toBeInTheDocument(); + expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument(); + expect(screen.queryByText('foo2.txt')).not.toBeInTheDocument(); + }); + + it('restores all rows when the filter is cleared', async () => { + useBrowseFiles.mockReturnValue({ + browseFiles: nestedBrowseFiles, + openFolders: ['foo/', 'bar/'], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + const filterInput = screen.getByPlaceholderText('Filter files...'); + await act(async () => { + fireEvent.change(filterInput, {target: {value: 'bar'}}); + }); + expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.change(filterInput, {target: {value: ''}}); + }); + expect(screen.getByText('foo1.txt')).toBeInTheDocument(); + expect(screen.getByText('bar.1.txt')).toBeInTheDocument(); + }); + + it('does not filter the contents of an opened folder that matches the filter', async () => { + const mockSetOpenFolders = jest.fn(); + + // ebooks/ is closed, no children loaded yet. + useBrowseFiles.mockReturnValue({ + browseFiles: [ + {path: 'ebooks/', children: null, is_empty: false}, + {path: 'other.txt', size: 100}, + ], + openFolders: [], + setOpenFolders: mockSetOpenFolders, + fetchFiles: jest.fn(), + }); + + const {rerender} = renderFileBrowser(); + + // Filter for the folder's name. + const filterInput = screen.getByPlaceholderText('Filter files...'); + await act(async () => { + fireEvent.change(filterInput, {target: {value: 'ebooks'}}); + }); + expect(screen.queryByText('other.txt')).not.toBeInTheDocument(); + + // Open the matching folder. + await act(async () => { + fireEvent.click(screen.getByText('ebooks/')); + }); + expect(mockSetOpenFolders).toHaveBeenCalledWith(['ebooks/']); + + // Simulate the folder's children loading; their names do NOT match the filter. + useBrowseFiles.mockReturnValue({ + browseFiles: [ + {path: 'ebooks/', children: [ + {path: 'ebooks/novel.epub', size: 100}, + {path: 'ebooks/cover.jpg', size: 200}, + ], is_empty: false}, + {path: 'other.txt', size: 100}, + ], + openFolders: ['ebooks/'], + setOpenFolders: mockSetOpenFolders, + fetchFiles: jest.fn(), + }); + await act(async () => { + rerender( + + + + ); + }); + + // The matching folder's contents are shown unfiltered. + expect(screen.getByText('ebooks/')).toBeInTheDocument(); + expect(screen.getByText('novel.epub')).toBeInTheDocument(); + expect(screen.getByText('cover.jpg')).toBeInTheDocument(); + // Non-matching rows outside the folder stay hidden. + expect(screen.queryByText('other.txt')).not.toBeInTheDocument(); + }); + + it('focuses the filter input when f is pressed', async () => { + useBrowseFiles.mockReturnValue({ + browseFiles: nestedBrowseFiles, + openFolders: ['foo/', 'bar/'], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + const filterInput = screen.getByPlaceholderText('Filter files...'); + expect(filterInput).not.toHaveFocus(); + + await act(async () => { + fireEvent.keyDown(document.body, {key: 'f', code: 'KeyF'}); + }); + + expect(filterInput).toHaveFocus(); + }); + + it('clear button clears the filter and shows all rows again', async () => { + useBrowseFiles.mockReturnValue({ + browseFiles: nestedBrowseFiles, + openFolders: ['foo/', 'bar/'], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + const clearButton = screen.getByLabelText('Clear filter'); + expect(clearButton).toBeDisabled(); + + const filterInput = screen.getByPlaceholderText('Filter files...'); + await act(async () => { + fireEvent.change(filterInput, {target: {value: 'bar'}}); + }); + expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument(); + expect(clearButton).not.toBeDisabled(); + + await act(async () => { + fireEvent.click(clearButton); + }); + + expect(filterInput).toHaveValue(''); + expect(screen.getByText('foo1.txt')).toBeInTheDocument(); + expect(screen.getByText('foo2.txt')).toBeInTheDocument(); + expect(screen.getByText('bar.1.txt')).toBeInTheDocument(); + }); + + it('collapse-all button is disabled when no folders are open', async () => { + useBrowseFiles.mockReturnValue({ + browseFiles: nestedBrowseFiles, + openFolders: [], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + expect(screen.getByLabelText('Close all folders')).toBeDisabled(); + }); + + it('collapse-all button closes all folders and deselects their hidden contents', async () => { + const mockSetOpenFolders = jest.fn(); + useBrowseFiles.mockReturnValue({ + browseFiles: nestedBrowseFiles, + openFolders: ['foo/', 'bar/'], + setOpenFolders: mockSetOpenFolders, + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + // Select the foo/ folder and a file inside it. + const checkboxes = screen.getAllByRole('checkbox'); + await act(async () => { + fireEvent.click(checkboxes[0]); // foo/ + }); + await act(async () => { + fireEvent.click(checkboxes[1]); // foo/foo1.txt + }); + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + + const collapseButton = screen.getByLabelText('Close all folders'); + expect(collapseButton).not.toBeDisabled(); + await act(async () => { + fireEvent.click(collapseButton); + }); + + // All folders are closed (clears the ?folders= query param). + expect(mockSetOpenFolders).toHaveBeenCalledWith(null); + // The folder stays selected, but its now-hidden child does not. + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + }); + + it('shows no results when nothing matches', async () => { + useBrowseFiles.mockReturnValue({ + browseFiles: nestedBrowseFiles, + openFolders: ['foo/', 'bar/'], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + const filterInput = screen.getByPlaceholderText('Filter files...'); + await act(async () => { + fireEvent.change(filterInput, {target: {value: 'does-not-exist'}}); + }); + + expect(screen.getByText('No results')).toBeInTheDocument(); + }); + }); + describe('Drag Selection', () => { const mockBrowseFilesMultiple = [ {path: 'file1.txt', size: 100},