From 5de366693092609b978bd6b4d3df96e91ef7daf2 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:52:32 +0200 Subject: [PATCH] Fix: storage explorer ignore current filter after mutations (#51174) ## Problem When users trigger actions such as deleting an item, the storage explorer reloads the opened folders but ignore the currently applied filter. ## Solution Move the filter state in Valtio so that its other functions are aware of it. ## Review instructions 1. Create a Supabase project and upload objects in Storage with date prefixes (e.g., 202608XX) 2. Navigate to Storage, select a bucket with multi-dated/prefixed objects 3. Enter a filter in the search box (e.g., 20260820) to show only matching objects 4. Select one or more filtered objects and delete them Observe the file list after deletion - it should show filtered contents according to the search box value ## Summary by CodeRabbit * **Bug Fixes** * Storage search now stays in sync as you open folders and refresh their contents. * When restoring open folders, search results are filtered in the deepest open folder rather than hiding ancestor folders. * Deleting a file from filtered results keeps the search applied and displays the remaining matches correctly. * Search results remain consistent across folder navigation, refreshes, and file deletion. --- .../StorageExplorer/StorageExplorer.tsx | 8 +--- apps/studio/state/storage-explorer.tsx | 40 ++++++++++++++++- e2e/studio/features/files/another-file.txt | 3 ++ e2e/studio/features/storage.spec.ts | 45 +++++++++++++++++++ 4 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 e2e/studio/features/files/another-file.txt diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx index 1ab2da94f98..310cd7ccf8e 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx @@ -1,6 +1,4 @@ -import { useDebounce } from '@uidotdev/usehooks' import { useParams } from 'common' -import { useState } from 'react' import { useSelectedBucket } from '../FilesBuckets/useSelectedBucket' import { StorageExplorerContent } from './StorageExplorerContent' @@ -11,8 +9,8 @@ import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer' export const StorageExplorer = () => { const { ref, bucketId } = useParams() - const { selectedBucket } = useStorageExplorerStateSnapshot() - + const { selectedBucket, debouncedSearchString, itemSearchString, setItemSearchString } = + useStorageExplorerStateSnapshot() useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM }) const { data: bucket, isLoading: isBucketQueryLoading } = useSelectedBucket() @@ -20,8 +18,6 @@ export const StorageExplorer = () => { const isBucketReady = !isBucketQueryLoading && !!bucket && bucketId === selectedBucket.id // Deliberately not in the URL, so a shared link points at a folder, not someone's filter. - const [itemSearchString, setItemSearchString] = useState('') - const debouncedSearchString = useDebounce(itemSearchString, 500) return (
diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 4d8cb3c12b3..4fc99bb8201 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -1,6 +1,17 @@ import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js' import { IS_PLATFORM } from 'common' -import { capitalize, chunk, compact, find, findIndex, has, isObject, uniq, uniqBy } from 'lodash' +import { + capitalize, + chunk, + compact, + debounce, + find, + findIndex, + has, + isObject, + uniq, + uniqBy, +} from 'lodash' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' import { useLatest } from 'react-use' import { toast } from 'sonner' @@ -91,6 +102,15 @@ export function createStorageExplorerState({ const state = proxy({ projectRef, connectionString, + itemSearchString: '', + debouncedSearchString: '', + setItemSearchString: (value: string) => { + state.itemSearchString = value + state.setDebouncedSearchString(value) + }, + setDebouncedSearchString: debounce((value: string) => { + state.debouncedSearchString = value + }, 500), resumableUploadUrl, uploadProgresses: [] as UploadProgress[], selectedBucket: bucket as Bucket, @@ -400,7 +420,22 @@ export function createStorageExplorerState({ refetchAllOpenedFolders: async () => { const paths = state.openedFolders.map((folder) => folder.name) - await state.fetchFoldersByPath({ paths }) + + if (state.itemSearchString && paths.length === 0) { + await state.fetchFoldersByPath({ paths, searchString: state.itemSearchString }) + } else if (state.itemSearchString) { + await state.fetchFoldersByPath({ paths }) + // Reapply the filter to the current column only. + await state.fetchFolderContents({ + bucketId: state.selectedBucket.id, + folderId: state.openedFolders[paths.length - 1].id, + folderName: paths[paths.length - 1], + index: paths.length - 1, + searchString: state.itemSearchString, + }) + } else { + await state.fetchFoldersByPath({ paths }) + } }, refreshAll: async () => { @@ -777,6 +812,7 @@ export function createStorageExplorerState({ folderId: folder.id, folderName: folder.name, index: columnIndex, + searchString: state.itemSearchString, }) }, diff --git a/e2e/studio/features/files/another-file.txt b/e2e/studio/features/files/another-file.txt new file mode 100644 index 00000000000..eba8160d606 --- /dev/null +++ b/e2e/studio/features/files/another-file.txt @@ -0,0 +1,3 @@ +This is a test file for e2e storage testing. +It contains some sample content to verify file uploads work correctly. + diff --git a/e2e/studio/features/storage.spec.ts b/e2e/studio/features/storage.spec.ts index a6acb389cd3..aaf92753c96 100644 --- a/e2e/studio/features/storage.spec.ts +++ b/e2e/studio/features/storage.spec.ts @@ -557,6 +557,51 @@ test.describe('Storage', () => { await deleteItem(page, fileName) }) + test('Deleting a file preserves the current filter', async ({ page, ref }) => { + const bucketName = `${bucketNamePrefix}_delete_file_filter` + const fileName = 'test-file.txt' + + // Create a bucket via API, navigate to it, and upload a file + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) + await navigateToStorageFiles(page, ref) + await navigateToBucket(page, ref, bucketName) + + await uploadFile( + page, + path.join(import.meta.dirname, 'files', 'test-file.txt'), + 'test-file.txt' + ) + await uploadFile( + page, + path.join(import.meta.dirname, 'files', 'test-file-2.txt'), + 'test-file-2.txt' + ) + await uploadFile( + page, + path.join(import.meta.dirname, 'files', 'another-file.txt'), + 'another-file.txt' + ) + await page.getByPlaceholder('Search in root directory...').fill('test') + await expect(page.getByTitle('another-file.txt')).toHaveCount(0) + await expect(page.getByTitle('test-file.txt')).toHaveCount(1) + await expect(page.getByTitle('test-file-2.txt')).toHaveCount(1) + + // Delete the file + await deleteItem(page, fileName) + + // Check the filter is still applied + await expect(page.getByTitle('test-file.txt')).toHaveCount(0) + await expect(page.getByTitle('test-file-2.txt')).toHaveCount(1) + await expect(page.getByTitle('another-file.txt')).toHaveCount(0) + }) + test('can delete a folder', async ({ page, ref }) => { const bucketName = `${bucketNamePrefix}_delete_folder` const folderName = 'test_folder'