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'