diff --git a/app/src/components/FileBrowser.js b/app/src/components/FileBrowser.js index 902478e8..2d98025e 100644 --- a/app/src/components/FileBrowser.js +++ b/app/src/components/FileBrowser.js @@ -158,6 +158,8 @@ export function FileBrowser() { const [singleDirectorySelected, setSingleDirectorySelected] = React.useState(false); // Only true if single directory is selected, and it is in settings['ignored_directories'] const [isDirectoryIgnored, setIsDirectoryIgnored] = React.useState(false); + // Tracks folder that was opened while selected - its children will be auto-selected when they load + const pendingAutoSelectRef = React.useRef(null); const {settings, fetchSettings} = React.useContext(SettingsContext); const {inverted} = React.useContext(ThemeContext); @@ -195,6 +197,39 @@ export function FileBrowser() { } }, [selectedPaths, settings]); + // Auto-select immediate children when a selected folder is opened + useEffect(() => { + const folderToAutoSelect = pendingAutoSelectRef.current; + if (folderToAutoSelect && browseFiles && typeof browseFiles === 'object') { + // browseFiles is a dict: {'map/': {path: 'map/', children: {...}}, ...} + // children is also a dict: {'file.txt': {path: 'map/file.txt', ...}, ...} + const findFolderChildren = (files, targetFolder) => { + if (!files || typeof files !== 'object') return null; + // Convert dict to array of values for iteration + const fileList = Array.isArray(files) ? files : Object.values(files); + for (const file of fileList) { + if (file.path === targetFolder && file.children) { + // Return children as array of values + return Array.isArray(file.children) ? file.children : Object.values(file.children); + } + if (file.children) { + const found = findFolderChildren(file.children, targetFolder); + if (found) return found; + } + } + return null; + }; + + const children = findFolderChildren(browseFiles, folderToAutoSelect); + if (children && children.length > 0) { + const childPaths = children.map(child => child.path); + const newSelectedPaths = [...new Set([...selectedPaths, ...childPaths])]; + setSelectedPaths(newSelectedPaths); + } + pendingAutoSelectRef.current = null; + } + }, [browseFiles]); + const onIgnore = async () => { setIgnoreDirectoryOpen(false); await fetchSettings(); @@ -337,9 +372,18 @@ export function FileBrowser() { if (openFolders.indexOf(folder) >= 0) { // Remove the folder that was clicked on, as well as its sub-folders. newFolders = openFolders.filter(i => !i.startsWith(folder)); + // Unselect any children of the closed folder (they're no longer visible) + const newSelectedPaths = selectedPaths.filter(p => !p.startsWith(folder) || p === folder); + if (newSelectedPaths.length !== selectedPaths.length) { + setSelectedPaths(newSelectedPaths); + } } else { // Add the new folder to the opened folders. newFolders = [...openFolders, folder]; + // If folder is selected, mark it for auto-selection of its children + if (selectedPaths.includes(folder)) { + pendingAutoSelectRef.current = folder; + } } console.debug(`newFolders=${newFolders}`); setOpenFolders(newFolders); diff --git a/app/src/components/FileBrowser.test.js b/app/src/components/FileBrowser.test.js index bcc5358e..528ea81d 100644 --- a/app/src/components/FileBrowser.test.js +++ b/app/src/components/FileBrowser.test.js @@ -176,6 +176,211 @@ describe('FileBrowser', () => { }); }); + describe('Auto-select directory contents', () => { + it('selects all children when opening a selected directory', async () => { + const mockSetOpenFolders = jest.fn(); + const mockFetchFiles = jest.fn(); + + // Start with folder not open (no children loaded) + useBrowseFiles.mockReturnValue({ + browseFiles: [ + {path: 'testdir/', children: null, is_empty: false}, + ], + openFolders: [], + setOpenFolders: mockSetOpenFolders, + fetchFiles: mockFetchFiles, + }); + + const {rerender} = renderFileBrowser(); + + // Select the directory checkbox + const checkbox = screen.getByRole('checkbox'); + await act(async () => { + fireEvent.click(checkbox); + }); + + // Verify directory is selected + expect(checkbox).toBeChecked(); + + // Click to expand the directory (clicking on the folder name cell) + const folderCell = screen.getByText('testdir/'); + await act(async () => { + fireEvent.click(folderCell); + }); + + // Verify setOpenFolders was called to open the directory + expect(mockSetOpenFolders).toHaveBeenCalledWith(['testdir/']); + + // Simulate browseFiles update with children (as would happen after fetch) + useBrowseFiles.mockReturnValue({ + browseFiles: [ + {path: 'testdir/', children: [ + {path: 'testdir/file1.txt', size: 100}, + {path: 'testdir/file2.txt', size: 200}, + ], is_empty: false}, + ], + openFolders: ['testdir/'], + setOpenFolders: mockSetOpenFolders, + fetchFiles: mockFetchFiles, + }); + + // Rerender to trigger the useEffect with new browseFiles + await act(async () => { + rerender( + + + + ); + }); + + // Verify all checkboxes are checked (parent + 2 children) + await waitFor(() => { + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBe(3); + checkboxes.forEach(cb => expect(cb).toBeChecked()); + }); + }); + + it('children remain selected when parent is unselected', async () => { + // Setup: directory with children, parent and children are all selected + useBrowseFiles.mockReturnValue({ + browseFiles: [ + {path: 'testdir/', children: [ + {path: 'testdir/file1.txt', size: 100}, + {path: 'testdir/file2.txt', size: 200}, + ], is_empty: false}, + ], + openFolders: ['testdir/'], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + // Select all items (parent + children) + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBe(3); + + // Select parent first + await act(async () => { + fireEvent.click(checkboxes[0]); // testdir/ + }); + // Select children + await act(async () => { + fireEvent.click(checkboxes[1]); // file1.txt + }); + await act(async () => { + fireEvent.click(checkboxes[2]); // file2.txt + }); + + // Verify all are selected + checkboxes.forEach(cb => expect(cb).toBeChecked()); + + // Unselect the parent directory + await act(async () => { + fireEvent.click(checkboxes[0]); + }); + + // Parent should be unchecked, but children should remain checked + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + }); + + it('unselects children when parent directory is closed', async () => { + // Setup: directory with children, already open + useBrowseFiles.mockReturnValue({ + browseFiles: [ + {path: 'testdir/', children: [ + {path: 'testdir/file1.txt', size: 100}, + {path: 'testdir/file2.txt', size: 200}, + ], is_empty: false}, + ], + openFolders: ['testdir/'], + setOpenFolders: jest.fn(), + fetchFiles: jest.fn(), + }); + + renderFileBrowser(); + + // Select parent and children + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBe(3); + + await act(async () => { + fireEvent.click(checkboxes[0]); // testdir/ + }); + await act(async () => { + fireEvent.click(checkboxes[1]); // file1.txt + }); + await act(async () => { + fireEvent.click(checkboxes[2]); // file2.txt + }); + + // Verify all are selected + checkboxes.forEach(cb => expect(cb).toBeChecked()); + + // Click to close the directory + const folderCell = screen.getByText('testdir/'); + await act(async () => { + fireEvent.click(folderCell); + }); + + // Parent should still be selected, but children should be unselected + // (children are no longer visible after closing) + expect(checkboxes[0]).toBeChecked(); + }); + + it('does not auto-select children when opening unselected directory', async () => { + const mockSetOpenFolders = jest.fn(); + const mockFetchFiles = jest.fn(); + + // Start with folder not open + useBrowseFiles.mockReturnValue({ + browseFiles: [ + {path: 'testdir/', children: null, is_empty: false}, + ], + openFolders: [], + setOpenFolders: mockSetOpenFolders, + fetchFiles: mockFetchFiles, + }); + + const {rerender} = renderFileBrowser(); + + // Click to expand WITHOUT selecting first + const folderCell = screen.getByText('testdir/'); + await act(async () => { + fireEvent.click(folderCell); + }); + + // Simulate browseFiles update with children + useBrowseFiles.mockReturnValue({ + browseFiles: [ + {path: 'testdir/', children: [ + {path: 'testdir/file1.txt', size: 100}, + {path: 'testdir/file2.txt', size: 200}, + ], is_empty: false}, + ], + openFolders: ['testdir/'], + setOpenFolders: mockSetOpenFolders, + fetchFiles: mockFetchFiles, + }); + + await act(async () => { + rerender( + + + + ); + }); + + // Verify NO checkboxes are checked + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBe(3); + checkboxes.forEach(cb => expect(cb).not.toBeChecked()); + }); + }); + describe('Delete functionality', () => { it('removes deleted directory from openFolders', async () => { const mockSetOpenFolders = jest.fn();