File Browser: add filter input and collapse-all button
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

Add a client-side filter above the File Browser table. Typing reduces
the visible rows to files/directories whose name matches, plus their
parent folders. A folder whose own name matches keeps its entire
subtree, and opening a matching folder shows its contents unfiltered.

* 'f' focuses the filter input, like the search inputs on other pages.
* An attached X button clears the filter; disabled while empty.
* A collapse-all (angle double up) button closes all open folders,
  clearing the ?folders= query param and deselecting hidden children.
* The filter row is right-aligned and double-width on desktop, and
  fills a single row on mobile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-03 11:17:11 -06:00
parent 2162396212
commit 8044fd1240
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 396 additions and 3 deletions

View file

@ -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 */

View file

@ -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() {
/>
</div>;
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 <Placeholder>
<PlaceholderLine/>
@ -493,11 +547,40 @@ export function FileBrowser() {
return <DragSelectionProvider
selectedPaths={selectedPaths}
setSelectedPaths={setSelectedPaths}
browseFiles={browseFiles}
browseFiles={filteredFiles}
openFolders={openFolders}
>
<div className='file-browser-filter-container'>
<Input
icon='filter'
iconPosition='left'
placeholder='Filter files...'
value={filterStr}
onChange={(e, {value}) => setFilterStr(value)}
aria-label='Filter files'
className='file-browser-filter'
ref={filterInputRef}
action={<Button
icon='close'
type='button'
onClick={() => setFilterStr('')}
disabled={!filterStr}
className='search-clear'
aria-label='Clear filter'
/>}
/>
<Button
icon='angle double up'
type='button'
onClick={onCollapseAll}
disabled={!openFolders || openFolders.length === 0}
title='Close all folders'
aria-label='Close all folders'
style={{marginLeft: '0.5em'}}
/>
</div>
<FileBrowserContent
browseFiles={browseFiles}
browseFiles={filteredFiles}
headers={headers}
onFolderClick={onFolderClick}
setPreviewFile={setPreviewFile}

View file

@ -1,6 +1,6 @@
import React from 'react';
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
import {FileBrowser} from './FileBrowser';
import {FileBrowser, filterBrowseFiles} from './FileBrowser';
import {renderWithProviders} from '../test-utils';
import {SettingsContext} from '../contexts/contexts';
import {FilePreviewContext} from './FilePreview';
@ -478,6 +478,293 @@ describe('FileBrowser', () => {
});
});
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(<FileBrowser/>);
// 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(<FileBrowser/>);
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(<FileBrowser/>);
// 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(
<AllContextsWrapper>
<FileBrowser/>
</AllContextsWrapper>
);
});
// 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(<FileBrowser/>);
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(<FileBrowser/>);
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(<FileBrowser/>);
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(<FileBrowser/>);
// 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(<FileBrowser/>);
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},