diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx
index 594e087836f..f4e0fac0aa5 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx
@@ -11,11 +11,15 @@ const {
mockUseStorageExplorerStateSnapshot,
mockUseAsyncCheckPermissions,
mockUseStoragePreference,
+ mockGoUpOneLevel,
+ mockNavigateToPath,
} = vi.hoisted(() => ({
mockTrack: vi.fn(),
mockUseStorageExplorerStateSnapshot: vi.fn(),
mockUseAsyncCheckPermissions: vi.fn(),
mockUseStoragePreference: vi.fn(),
+ mockGoUpOneLevel: vi.fn(),
+ mockNavigateToPath: vi.fn(),
}))
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => mockTrack }))
@@ -26,6 +30,13 @@ vi.mock('@/hooks/misc/useCheckPermissions', () => ({
useAsyncCheckPermissions: () => mockUseAsyncCheckPermissions(),
}))
+vi.mock('./StorageExplorerNavigation', () => ({
+ useStorageExplorerNavigation: () => ({
+ goUpOneLevel: mockGoUpOneLevel,
+ navigateToPath: mockNavigateToPath,
+ }),
+}))
+
vi.mock('./useStoragePreference', () => ({
useStoragePreference: (...args: any[]) => mockUseStoragePreference(...args),
}))
@@ -77,6 +88,8 @@ describe('FileExplorerHeader', () => {
mockUseStorageExplorerStateSnapshot.mockReset()
mockUseAsyncCheckPermissions.mockReset()
mockUseStoragePreference.mockReset()
+ mockGoUpOneLevel.mockReset()
+ mockNavigateToPath.mockReset()
mockUseStorageExplorerStateSnapshot.mockReturnValue(createSnapshot())
mockUseStoragePreference.mockReturnValue(createPreference())
@@ -166,9 +179,7 @@ describe('FileExplorerHeader', () => {
await userEvent.click(screen.getByRole('button', { name: 'Go up one level' }))
- expect(snapshot.popColumn).toHaveBeenCalled()
- expect(snapshot.popOpenedFolders).toHaveBeenCalled()
- expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(undefined)
+ expect(mockGoUpOneLevel).toHaveBeenCalled()
})
it('opens path edit mode from Navigate and tracks the click', async () => {
@@ -189,7 +200,7 @@ describe('FileExplorerHeader', () => {
expect(screen.getByRole('button', { name: 'Navigate' })).toBeInTheDocument()
})
- it('submits a path, tracks the submission, and calls the existing path navigation flow', async () => {
+ it('submits a path, tracks the submission, and navigates to it', async () => {
const snapshot = createSnapshot()
mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
@@ -209,7 +220,7 @@ describe('FileExplorerHeader', () => {
await userEvent.click(screen.getByRole('button', { name: 'Navigate' }))
await waitFor(() => {
- expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({ paths: ['archive', '2025'] })
+ expect(mockNavigateToPath).toHaveBeenCalledWith(['archive', '2025'])
})
await waitFor(() => {
expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_submitted')
@@ -237,11 +248,9 @@ describe('FileExplorerHeader', () => {
await userEvent.click(screen.getByRole('button', { name: 'Navigate' }))
await waitFor(() => {
- expect(snapshot.popColumnAtIndex).toHaveBeenCalledWith(0)
+ expect(mockNavigateToPath).toHaveBeenCalledWith([])
})
- expect(snapshot.clearOpenedFolders).toHaveBeenCalled()
- expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(undefined)
expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_clicked')
expect(mockTrack).not.toHaveBeenCalledWith('storage_explorer_navigate_submitted')
})
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx
index a24aa30095c..41da3332a3a 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx
@@ -44,6 +44,7 @@ import { Input } from 'ui-patterns/DataInputs/Input'
import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS } from '../Storage.constants'
import { pageChromeRowClassName } from './storageExplorerChrome'
+import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
import { useFileExplorerHeaderShortcuts } from './useFileExplorerHeaderShortcuts'
import { useStoragePreference } from './useStoragePreference'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
@@ -143,7 +144,6 @@ export const FileExplorerHeader = ({
const track = useTrack()
const [pathString, setPathString] = useState('')
- const [loading, setLoading] = useState({ isLoading: false, message: '' })
const [isPathDialogOpen, setIsPathDialogOpen] = useState(false)
@@ -154,17 +154,13 @@ export const FileExplorerHeader = ({
const {
projectRef,
columns,
- popColumn,
- popColumnAtIndex,
- popOpenedFolders,
- fetchFoldersByPath,
refetchAllOpenedFolders,
refreshAll,
isRefreshing,
addNewFolderPlaceholder,
- clearOpenedFolders,
setSelectedFilePreview,
} = useStorageExplorerStateSnapshot()
+ const { goUpOneLevel, navigateToPath } = useStorageExplorerNavigation()
const {
view,
setView,
@@ -216,9 +212,7 @@ export const FileExplorerHeader = ({
}, [breadcrumbs])
const onSelectBack = () => {
- popColumn()
- popOpenedFolders()
- setSelectedFilePreview(undefined)
+ goUpOneLevel()
}
const onSelectUpload = () => {
@@ -238,7 +232,7 @@ export const FileExplorerHeader = ({
setPathString(event.target.value)
}
- const navigateByPathString = async (event?: SyntheticEvent) => {
+ const navigateByPathString = (event?: SyntheticEvent) => {
if (event) {
event.preventDefault()
event.stopPropagation()
@@ -246,26 +240,14 @@ export const FileExplorerHeader = ({
const paths = compact(pathString.split('/'))
setIsPathDialogOpen(false)
- await onSetPathByString(paths)
+ // Writing the URL is enough — the navigation provider rebuilds the columns from it.
+ navigateToPath(paths)
if (paths.length > 0) {
track('storage_explorer_navigate_submitted')
}
}
- const onSetPathByString = async (paths: string[]) => {
- if (paths.length === 0) {
- popColumnAtIndex(0)
- clearOpenedFolders()
- setSelectedFilePreview(undefined)
- } else {
- const pathString = paths.join('/')
- setLoading({ isLoading: true, message: `Navigating to ${pathString}...` })
- await fetchFoldersByPath({ paths })
- setLoading({ isLoading: false, message: '' })
- }
- }
-
const cancelSetPathString = () => {
setIsPathDialogOpen(false)
}
@@ -347,7 +329,7 @@ export const FileExplorerHeader = ({
variant="outline"
aria-label="Navigate"
className="w-7 px-1"
- disabled={isPathDialogOpen || loading.isLoading}
+ disabled={isPathDialogOpen}
onClick={onOpenNavigate}
/>
)}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
index 85e53daec36..dbd9a5711ca 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
@@ -39,6 +39,7 @@ import { StorageRowIcon } from '../StorageRowIcon'
import { useFileExplorerContextMenu } from './FileExplorerRowContextMenu'
import { FileExplorerRowEditing } from './FileExplorerRowEditing'
import { copyPathToFolder } from './StorageExplorer.utils'
+import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
import { useCopyUrl } from './useCopyUrl'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { formatBytes } from '@/lib/helpers'
@@ -65,20 +66,16 @@ export const FileExplorerRow = ({
selectedBucket,
selectedFilePreview,
openedFolders,
- popColumnAtIndex,
- popOpenedFoldersAtIndex,
- clearSelectedItems,
- setSelectedFilePreview,
setSelectedFileCustomExpiry,
setSelectedItems,
setSelectedItemsToDelete,
downloadFile,
setSelectedItemToRename,
setSelectedItemsToMove,
- openFolder,
downloadFolder,
selectRangeItems,
} = useStorageExplorerStateSnapshot()
+ const { openFolderAtIndex, setPreviewedFile, clearPreviewedFile } = useStorageExplorerNavigation()
const { onCopyUrl } = useCopyUrl()
const ctx = useFileExplorerContextMenu()
@@ -90,13 +87,6 @@ export const FileExplorerRow = ({
const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id)
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
- const onSelectFile = async (columnIndex: number) => {
- popColumnAtIndex(columnIndex)
- popOpenedFoldersAtIndex(columnIndex - 1)
- setSelectedFilePreview(itemWithColumnIndex)
- clearSelectedItems()
- }
-
const onCheckItem = (isShiftKeyHeld: boolean) => {
// Select a range if shift is held down
if (isShiftKeyHeld && selectedItems.length !== 0) {
@@ -110,7 +100,7 @@ export const FileExplorerRow = ({
} else {
setSelectedItems([...selectedItems, itemWithColumnIndex])
}
- setSelectedFilePreview(undefined)
+ clearPreviewedFile()
}
const rowOptions =
@@ -263,8 +253,8 @@ export const FileExplorerRow = ({
event.preventDefault()
if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) {
item.type === STORAGE_ROW_TYPES.FOLDER
- ? openFolder(columnIndex, item)
- : onSelectFile(columnIndex)
+ ? openFolderAtIndex(columnIndex, item)
+ : setPreviewedFile(itemWithColumnIndex)
}
}}
>
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx
index acab8371de5..d551847736b 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx
@@ -12,6 +12,7 @@ import {
import { URL_EXPIRY_DURATION } from '../Storage.constants'
import { StorageItem } from '../Storage.types'
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
+import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
import { useCopyUrl } from './useCopyUrl'
import { useFetchFileUrlQuery } from './useFetchFileUrlQuery'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
@@ -119,10 +120,10 @@ export const PreviewPane = () => {
selectedBucket,
selectedFilePreview: file,
setSelectedItemsToDelete,
- setSelectedFilePreview,
setSelectedFileCustomExpiry,
downloadFile,
} = useStorageExplorerStateSnapshot()
+ const { clearPreviewedFile } = useStorageExplorerNavigation()
const { onCopyUrl } = useCopyUrl()
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
@@ -141,8 +142,14 @@ export const PreviewPane = () => {
style={{ width }}
>
{/* Preview Header */}
-
-
setSelectedFilePreview(undefined)} />
+
+ }
+ aria-label="Close preview"
+ onClick={clearPreviewedFile}
+ />
{/* Preview Thumbnail*/}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
index 6bc07b56742..1ab2da94f98 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
@@ -1,198 +1,46 @@
import { useDebounce } from '@uidotdev/usehooks'
import { useParams } from 'common'
-import { compact, get, isEmpty, uniqBy } from 'lodash'
-import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'
+import { useState } from 'react'
import { useSelectedBucket } from '../FilesBuckets/useSelectedBucket'
-import { STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
-import { ConfirmDeleteModal } from './ConfirmDeleteModal'
-import { CustomExpiryModal } from './CustomExpiryModal'
-import { FileExplorer } from './FileExplorer'
-import { FileExplorerHeader } from './FileExplorerHeader'
-import { FileExplorerHeaderSelection } from './FileExplorerHeaderSelection'
-import { MoveItemsModal } from './MoveItemsModal'
-import { PreviewPane } from './PreviewPane'
-import { useStorageExplorerShortcuts } from './useStorageExplorerShortcuts'
-import { useStoragePreference } from './useStoragePreference'
+import { StorageExplorerContent } from './StorageExplorerContent'
+import { StorageExplorerNavigationProvider } from './StorageExplorerNavigation'
import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
-import type { Bucket } from '@/data/storage/buckets-query'
import { IS_PLATFORM } from '@/lib/constants'
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
export const StorageExplorer = () => {
const { ref, bucketId } = useParams()
- const storageExplorerRef = useRef(null)
- const {
- projectRef,
- columns,
- selectedItems,
- openedFolders,
- selectedItemsToMove,
- selectedBucket,
- fetchFolderContents,
- fetchMoreFolderContents,
- fetchFoldersByPath,
- uploadFiles,
- moveFiles,
- popColumnAtIndex,
- popOpenedFoldersAtIndex,
- setSelectedItems,
- clearSelectedItems,
- setSelectedFilePreview,
- setSelectedItemsToMove,
- setIsSearching,
- } = useStorageExplorerStateSnapshot()
- const { view } = useStoragePreference(projectRef)
+ const { selectedBucket } = useStorageExplorerStateSnapshot()
useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { data: bucket, isLoading: isBucketQueryLoading } = useSelectedBucket()
- // Detect when transitioning between buckets to avoid showing stale content from the previous bucket.
- // This happens because the bucket query and effects that update the store run after the first render.
- const isLoading = isBucketQueryLoading || (!!bucketId && bucketId !== selectedBucket.id)
+ // Guards the gap where the query has settled but the store still holds another bucket.
+ const isBucketReady = !isBucketQueryLoading && !!bucket && bucketId === selectedBucket.id
- // This state exists outside of the header because FileExplorerColumn needs to listen to these as well
- // Things like showing results from a search filter is "temporary", hence we use react state to manage
+ // 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)
- const handleClearSearch = useCallback(() => {
- setIsSearching(false)
- setItemSearchString('')
- }, [setIsSearching])
-
- useStorageExplorerShortcuts({ onClearSearch: handleClearSearch })
-
- const fetchContents = useEffectEvent(async (bucket: Bucket) => {
- if (view === STORAGE_VIEWS.LIST) {
- const currentFolderIdx = openedFolders.length - 1
- const currentFolder = openedFolders[currentFolderIdx]
-
- const folderId = !currentFolder ? bucket.id : currentFolder.id
- const folderName = !currentFolder ? bucket.name : currentFolder.name
- const index = !currentFolder ? -1 : currentFolderIdx
-
- await fetchFolderContents({
- bucketId: bucket.id,
- folderId,
- folderName,
- index,
- searchString: debouncedSearchString,
- })
- } else if (view === STORAGE_VIEWS.COLUMNS) {
- if (openedFolders.length > 0) {
- const paths = openedFolders.map((folder) => folder.name)
- fetchFoldersByPath({
- paths,
- searchString: debouncedSearchString,
- showLoading: true,
- })
- } else {
- await fetchFolderContents({
- bucketId: bucket.id,
- folderId: bucket.id,
- folderName: bucket.name,
- index: -1,
- searchString: debouncedSearchString,
- })
- }
- }
- })
-
- useEffect(() => {
- if (bucket && projectRef) fetchContents(bucket)
- }, [bucket, projectRef, debouncedSearchString, selectedBucket.id])
-
- /** Checkbox selection methods */
- /** [Joshen] We'll only support checkbox selection for files ONLY */
-
- const onSelectAllItemsInColumn = (columnIndex: number) => {
- const columnFiles = columns[columnIndex].items
- .filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
- .map((item) => {
- return { ...item, columnIndex }
- })
- const columnFilesId = compact(columnFiles.map((item) => item.id))
- const selectedItemsFromColumn = selectedItems.filter(
- (item) => item.id && columnFilesId.includes(item.id)
- )
-
- if (selectedItemsFromColumn.length === columnFiles.length) {
- // Deselect all items from column
- const updatedSelectedItems = selectedItems.filter(
- (item) => item.id && !columnFilesId.includes(item.id)
- )
- setSelectedItems(updatedSelectedItems)
- } else {
- // Select all items from column
- const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id')
- setSelectedItems(updatedSelectedItems)
- }
- }
-
- /** File manipulation methods */
-
- const onFilesUpload = async (event: any, columnIndex: number = -1) => {
- event.persist()
- const items = event.target.files || event.dataTransfer.items
- const isDrop = !isEmpty(get(event, ['dataTransfer', 'items'], []))
- await uploadFiles({ files: items, columnIndex, isDrop })
- event.target.value = ''
- }
-
- const onMoveSelectedFiles = async (newPath: string) => {
- await moveFiles(newPath)
- }
-
- /** Misc UI methods */
- const onSelectColumnEmptySpace = (columnIndex: number) => {
- popColumnAtIndex(columnIndex)
- popOpenedFoldersAtIndex(columnIndex - 1)
- setSelectedFilePreview(undefined)
- clearSelectedItems()
- }
-
return (
-
- {selectedItems.length === 0 ? (
-
+ {/* The skeleton swap is silent, and a live region must be mounted before it changes.
+ `aria-live` rather than `role="status"`: that role is how toasts announce
+ themselves here, and tests wait on it to tell when an upload has finished. */}
+
+ {isBucketReady ? 'Bucket contents loaded' : 'Loading bucket contents'}
+
+
+
- ) : (
-
- )}
-
-
- fetchMoreFolderContents({ index, column, searchString: itemSearchString })
- }
- />
-
-
-
-
-
- 0}
- projectRef={projectRef}
- bucketId={selectedBucket.id}
- bucketName={selectedBucket.name}
- selectedItemsToMove={selectedItemsToMove}
- openedFolders={openedFolders}
- onSelectCancel={() => setSelectedItemsToMove([])}
- onSelectMove={onMoveSelectedFiles}
- />
-
-
+
)
}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
index b0baa3aaa1f..6d4569da354 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
@@ -9,7 +9,9 @@ import type { StorageItem } from '@/components/interfaces/Storage/Storage.types'
import {
getPathAlongFoldersToIndex,
getPathAlongOpenedFolders,
+ parseStoragePath,
sanitizeNameForDuplicateInColumn,
+ serializeStoragePath,
validateFolderName,
} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
@@ -276,3 +278,43 @@ describe('sanitizeNameForDuplicateInColumn', () => {
})
})
})
+
+describe('parseStoragePath', () => {
+ it('returns an empty array for an absent or empty param', () => {
+ expect(parseStoragePath(null)).toEqual([])
+ expect(parseStoragePath(undefined)).toEqual([])
+ expect(parseStoragePath('')).toEqual([])
+ })
+
+ it('splits a slash-joined path into segments', () => {
+ expect(parseStoragePath('a/b/c')).toEqual(['a', 'b', 'c'])
+ })
+
+ it('tolerates leading, trailing and repeated slashes', () => {
+ expect(parseStoragePath('/a//b/')).toEqual(['a', 'b'])
+ expect(parseStoragePath('///')).toEqual([])
+ })
+
+ it('preserves spaces and unicode within a segment', () => {
+ expect(parseStoragePath('my folder/ünïcode 📁/x')).toEqual(['my folder', 'ünïcode 📁', 'x'])
+ })
+})
+
+describe('serializeStoragePath', () => {
+ it('returns an empty string for the bucket root so clearOnDefault strips the param', () => {
+ expect(serializeStoragePath([])).toBe('')
+ })
+
+ it('joins segments with a slash', () => {
+ expect(serializeStoragePath(['a', 'b', 'c'])).toBe('a/b/c')
+ })
+
+ it('drops empty segments', () => {
+ expect(serializeStoragePath(['a', '', 'b'])).toBe('a/b')
+ })
+
+ it('round-trips with parseStoragePath', () => {
+ const segments = ['images', 'my folder', '2024']
+ expect(parseStoragePath(serializeStoragePath(segments))).toEqual(segments)
+ })
+})
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
index 0459fd3c838..3ea6ed6cbfd 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
@@ -47,6 +47,19 @@ export function getPathAlongFoldersToIndex(
.join('/')
}
+/**
+ * Splits a `?path` query param value into folder segments.
+ */
+export function parseStoragePath(value: string | null | undefined): string[] {
+ if (!value) return []
+ return value.split('/').filter((segment) => segment.length > 0)
+}
+
+/** Joins folder segments into `?path`; empty at the root so nuqs' `clearOnDefault` strips it. */
+export function serializeStoragePath(paths: string[]): string {
+ return paths.filter((segment) => segment.length > 0).join('/')
+}
+
/**
* Returns an error message string if the folder name contains invalid characters,
* or null if the name is valid.
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerContent.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerContent.tsx
new file mode 100644
index 00000000000..10b97962418
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerContent.tsx
@@ -0,0 +1,134 @@
+import { compact, get, isEmpty, uniqBy } from 'lodash'
+import { useCallback } from 'react'
+
+import { STORAGE_ROW_TYPES } from '../Storage.constants'
+import { ConfirmDeleteModal } from './ConfirmDeleteModal'
+import { CustomExpiryModal } from './CustomExpiryModal'
+import { FileExplorer } from './FileExplorer'
+import { FileExplorerHeader } from './FileExplorerHeader'
+import { FileExplorerHeaderSelection } from './FileExplorerHeaderSelection'
+import { MoveItemsModal } from './MoveItemsModal'
+import { PreviewPane } from './PreviewPane'
+import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
+import { useStorageExplorerShortcuts } from './useStorageExplorerShortcuts'
+import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
+
+interface StorageExplorerContentProps {
+ itemSearchString: string
+ setItemSearchString: (value: string) => void
+ isLoading: boolean
+}
+
+export const StorageExplorerContent = ({
+ itemSearchString,
+ setItemSearchString,
+ isLoading,
+}: StorageExplorerContentProps) => {
+ const {
+ projectRef,
+ columns,
+ openedFolders,
+ selectedItems,
+ selectedItemsToMove,
+ selectedBucket,
+ fetchMoreFolderContents,
+ uploadFiles,
+ moveFiles,
+ setSelectedItems,
+ setSelectedItemsToMove,
+ setIsSearching,
+ } = useStorageExplorerStateSnapshot()
+ const { truncateToColumn } = useStorageExplorerNavigation()
+
+ const handleClearSearch = useCallback(() => {
+ setIsSearching(false)
+ setItemSearchString('')
+ }, [setIsSearching, setItemSearchString])
+
+ useStorageExplorerShortcuts({ onClearSearch: handleClearSearch })
+
+ /** Checkbox selection methods */
+ /** [Joshen] We'll only support checkbox selection for files ONLY */
+
+ const onSelectAllItemsInColumn = (columnIndex: number) => {
+ const columnFiles = columns[columnIndex].items
+ .filter((item) => item.type === STORAGE_ROW_TYPES.FILE)
+ .map((item) => {
+ return { ...item, columnIndex }
+ })
+ const columnFilesId = compact(columnFiles.map((item) => item.id))
+ const selectedItemsFromColumn = selectedItems.filter(
+ (item) => item.id && columnFilesId.includes(item.id)
+ )
+
+ if (selectedItemsFromColumn.length === columnFiles.length) {
+ // Deselect all items from column
+ const updatedSelectedItems = selectedItems.filter(
+ (item) => item.id && !columnFilesId.includes(item.id)
+ )
+ setSelectedItems(updatedSelectedItems)
+ } else {
+ // Select all items from column
+ const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id')
+ setSelectedItems(updatedSelectedItems)
+ }
+ }
+
+ /** File manipulation methods */
+
+ const onFilesUpload = async (event: any, columnIndex: number = -1) => {
+ event.persist()
+ const items = event.target.files || event.dataTransfer.items
+ const isDrop = !isEmpty(get(event, ['dataTransfer', 'items'], []))
+ await uploadFiles({ files: items, columnIndex, isDrop })
+ event.target.value = ''
+ }
+
+ const onMoveSelectedFiles = async (newPath: string) => {
+ await moveFiles(newPath)
+ }
+
+ return (
+ <>
+ {selectedItems.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ fetchMoreFolderContents({ index, column, searchString: itemSearchString })
+ }
+ />
+
+
+
+
+
+ 0}
+ projectRef={projectRef}
+ bucketId={selectedBucket.id}
+ bucketName={selectedBucket.name}
+ selectedItemsToMove={selectedItemsToMove}
+ openedFolders={openedFolders}
+ onSelectCancel={() => setSelectedItemsToMove([])}
+ onSelectMove={onMoveSelectedFiles}
+ />
+
+
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.test.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.test.tsx
new file mode 100644
index 00000000000..f5639d88ee3
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.test.tsx
@@ -0,0 +1,447 @@
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import type { PropsWithChildren } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
+import type { StorageItem } from '../Storage.types'
+import {
+ StorageExplorerNavigationProvider,
+ useStorageExplorerNavigation,
+} from './StorageExplorerNavigation'
+
+const { mockUseStorageExplorerStateSnapshot } = vi.hoisted(() => ({
+ mockUseStorageExplorerStateSnapshot: vi.fn(),
+}))
+
+vi.mock('@/state/storage-explorer', () => ({
+ useStorageExplorerStateSnapshot: () => mockUseStorageExplorerStateSnapshot(),
+}))
+
+function makeFolder(name: string): StorageItem {
+ return {
+ id: null,
+ name,
+ type: STORAGE_ROW_TYPES.FOLDER,
+ status: STORAGE_ROW_STATUS.READY,
+ metadata: null,
+ isCorrupted: false,
+ created_at: null,
+ updated_at: null,
+ last_accessed_at: null,
+ }
+}
+
+function makeFile(name: string): StorageItem {
+ return { ...makeFolder(name), id: name, type: STORAGE_ROW_TYPES.FILE }
+}
+
+function makeColumn(name: string, items: StorageItem[] = [], hasMoreItems = false) {
+ return { id: name, name, path: '', status: STORAGE_ROW_STATUS.READY, items, hasMoreItems }
+}
+
+/**
+ * `openedFolders` excludes the bucket root, so a store at `images/2024` has three
+ * columns and two opened folders.
+ */
+function createSnapshot({
+ openedFolders = [] as StorageItem[],
+ columns = [makeColumn('my-bucket')],
+ selectedFilePreview = undefined as any,
+} = {}) {
+ return {
+ selectedBucket: { id: 'bucket-id', name: 'my-bucket' },
+ columns,
+ openedFolders,
+ selectedFilePreview,
+ fetchFoldersByPath: vi.fn().mockResolvedValue({ missingPaths: [] }),
+ fetchFolderContents: vi.fn().mockResolvedValue(undefined),
+ openFolder: vi.fn().mockResolvedValue(undefined),
+ popColumn: vi.fn(),
+ popColumnAtIndex: vi.fn(),
+ popOpenedFolders: vi.fn(),
+ popOpenedFoldersAtIndex: vi.fn(),
+ clearSelectedItems: vi.fn(),
+ setSelectedFilePreview: vi.fn(),
+ }
+}
+
+/**
+ * The nuqs adapter has to sit outside the provider, so the wrapper is composed here
+ * rather than going through `customRenderHook` (whose `wrapper` option would replace
+ * the adapter instead of nesting inside it).
+ */
+function renderWithProvider({
+ searchParams = '',
+ searchString = '',
+ isBucketReady = true,
+ hasMemory = false,
+}: {
+ searchParams?: string
+ searchString?: string
+ isBucketReady?: boolean
+ /** Lets `setSearchParams` stand in for Back/forward or a pasted link. */
+ hasMemory?: boolean
+} = {}) {
+ const onUrlUpdate = vi.fn()
+ let currentSearchParams = searchParams
+ const utils = renderHook(() => useStorageExplorerNavigation(), {
+ wrapper: ({ children }: PropsWithChildren) => (
+
+
+ {children}
+
+
+ ),
+ })
+ return {
+ ...utils,
+ onUrlUpdate,
+ setSearchParams: (next: string) => {
+ currentSearchParams = next
+ utils.rerender()
+ },
+ }
+}
+
+describe('StorageExplorerNavigation', () => {
+ beforeEach(() => {
+ mockUseStorageExplorerStateSnapshot.mockReset()
+ })
+
+ it('restores the column stack from ?path on mount', async () => {
+ const snapshot = createSnapshot({ columns: [] })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ renderWithProvider({ searchParams: '?path=images/2024' })
+
+ await waitFor(() => {
+ expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({
+ paths: ['images', '2024'],
+ searchString: '',
+ showLoading: true,
+ })
+ })
+ expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(1)
+ })
+
+ it('fetches the bucket root when there is no ?path', async () => {
+ const snapshot = createSnapshot({ columns: [] })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ renderWithProvider()
+
+ await waitFor(() => {
+ expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({
+ paths: [],
+ searchString: '',
+ showLoading: true,
+ })
+ })
+ })
+
+ it('does not fetch until the bucket is ready', async () => {
+ const snapshot = createSnapshot({ columns: [] })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ renderWithProvider({ searchParams: '?path=images', isBucketReady: false })
+
+ await Promise.resolve()
+ expect(snapshot.fetchFoldersByPath).not.toHaveBeenCalled()
+ })
+
+ it('drills down through the store without re-fetching the whole path', async () => {
+ // Store and URL already agree on `images`; opening a child must not trigger a restore.
+ const snapshot = createSnapshot({
+ openedFolders: [makeFolder('images')],
+ columns: [makeColumn('my-bucket'), makeColumn('images')],
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { result } = renderWithProvider({ searchParams: '?path=images' })
+
+ await act(async () => {
+ await result.current.openFolderAtIndex(1, makeFolder('2024'))
+ })
+
+ expect(snapshot.openFolder).toHaveBeenCalledTimes(1)
+ expect(snapshot.openFolder).toHaveBeenCalledWith(1, expect.objectContaining({ name: '2024' }))
+ expect(snapshot.fetchFoldersByPath).not.toHaveBeenCalled()
+ })
+
+ it('writes the URL when jumping to a path and lets the restore effect fetch it', async () => {
+ const snapshot = createSnapshot({
+ openedFolders: [makeFolder('images')],
+ columns: [makeColumn('my-bucket'), makeColumn('images')],
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { result, onUrlUpdate } = renderWithProvider({ searchParams: '?path=images' })
+
+ act(() => {
+ result.current.navigateToPath(['archive', '2025'])
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const [update] = onUrlUpdate.mock.calls.at(-1)!
+ expect(update.queryString).toContain('path=archive/2025')
+ expect(update.options.history).toBe('push')
+ })
+
+ it('falls back to the bucket root when a path segment no longer exists', async () => {
+ const snapshot = createSnapshot({ columns: [] })
+ snapshot.fetchFoldersByPath
+ .mockResolvedValueOnce({ missingPaths: ['2024'] })
+ .mockResolvedValue({ missingPaths: [] })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { onUrlUpdate } = renderWithProvider({ searchParams: '?path=images/2024' })
+
+ // Re-fetches the root rather than leaving the store on the dead path
+ await waitFor(() => {
+ expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({
+ paths: [],
+ searchString: '',
+ showLoading: true,
+ })
+ })
+ // ...and corrects the URL to match, without a history entry
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const [update] = onUrlUpdate.mock.calls.at(-1)!
+ expect(update.queryString).not.toContain('path=')
+ expect(update.options.history).toBe('replace')
+ })
+
+ it('leaves ?preview alone when the listing is incomplete', async () => {
+ // The file may simply be on a later page — dropping the param would lose the deep link.
+ const snapshot = createSnapshot({
+ columns: [makeColumn('my-bucket', [makeFile('other.png')], true)],
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { onUrlUpdate } = renderWithProvider({ searchParams: '?preview=a.png' })
+
+ await Promise.resolve()
+ expect(onUrlUpdate).not.toHaveBeenCalled()
+ expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled()
+ })
+
+ it('resolves ?preview once a later page brings the file in', async () => {
+ // The file sits beyond the first LIMIT-sized page, so it is absent from the initial
+ // listing. The param is kept, and the preview opens when pagination loads it.
+ const snapshot = createSnapshot({
+ columns: [makeColumn('my-bucket', [makeFile('other.png')], true)],
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { rerender } = renderWithProvider({ searchParams: '?preview=a.png' })
+ expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled()
+
+ mockUseStorageExplorerStateSnapshot.mockReturnValue({
+ ...snapshot,
+ columns: [makeColumn('my-bucket', [makeFile('other.png'), makeFile('a.png')], false)],
+ })
+ await act(async () => {
+ rerender()
+ })
+
+ expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'a.png' })
+ )
+ })
+
+ it('drops ?preview when the file is gone from a complete listing', async () => {
+ const snapshot = createSnapshot({
+ columns: [makeColumn('my-bucket', [makeFile('other.png')])],
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { onUrlUpdate } = renderWithProvider({ searchParams: '?preview=a.png' })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const [update] = onUrlUpdate.mock.calls.at(-1)!
+ expect(update.queryString).not.toContain('preview=')
+ })
+
+ it('records an opened preview in ?preview without adding a history entry', async () => {
+ const snapshot = createSnapshot({
+ columns: [makeColumn('my-bucket', [makeFile('a.png')])],
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { result, onUrlUpdate } = renderWithProvider()
+
+ act(() => {
+ result.current.setPreviewedFile({ ...makeFile('a.png'), columnIndex: 0 })
+ })
+
+ expect(snapshot.setSelectedFilePreview).toHaveBeenCalled()
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const [update] = onUrlUpdate.mock.calls.at(-1)!
+ expect(update.queryString).toContain('preview=a.png')
+ expect(update.options.history).toBe('replace')
+ })
+
+ it('restores a previewed file from ?preview once its column has loaded', async () => {
+ const snapshot = createSnapshot({
+ columns: [makeColumn('my-bucket', [makeFile('a.png')])],
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ renderWithProvider({ searchParams: '?preview=a.png' })
+
+ await waitFor(() => {
+ expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'a.png', columnIndex: 0 })
+ )
+ })
+ })
+
+ it('does not let a stale ?preview revert a freshly previewed file', async () => {
+ // Clicking a file mutates the store synchronously; the URL is written a render later.
+ // On that in-between render `?preview` still names the *previous* file, which is very
+ // much still in the column — so without a "which side moved" guard this effect finds
+ // it and pushes the preview back, undoing the click.
+ const columns = [makeColumn('my-bucket', [makeFile('a.png'), makeFile('b.png')])]
+ const snapshot = createSnapshot({
+ columns,
+ selectedFilePreview: { ...makeFile('a.png'), columnIndex: 0 },
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { rerender } = renderWithProvider({ searchParams: '?preview=a.png' })
+ snapshot.setSelectedFilePreview.mockClear()
+
+ // Store now previews b.png while the URL still says a.png
+ mockUseStorageExplorerStateSnapshot.mockReturnValue({
+ ...snapshot,
+ selectedFilePreview: { ...makeFile('b.png'), columnIndex: 0 },
+ })
+ await act(async () => {
+ rerender()
+ })
+
+ expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled()
+ })
+
+ it('collapses the stack and records the file in a single URL write', async () => {
+ // The file sits in the bucket root while the store is a level deeper, so selecting it
+ // has to drop `path` and add `preview` at once — two writes would leave `images`
+ // paired with a root-level file in history.
+ const snapshot = createSnapshot({
+ openedFolders: [makeFolder('images')],
+ columns: [makeColumn('my-bucket', [makeFile('a.png')]), makeColumn('images')],
+ })
+ snapshot.popColumnAtIndex.mockImplementation((index: number) => {
+ snapshot.columns = snapshot.columns.slice(0, index + 1)
+ })
+ snapshot.popOpenedFoldersAtIndex.mockImplementation((index: number) => {
+ snapshot.openedFolders = snapshot.openedFolders.slice(0, index + 1)
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { result, onUrlUpdate } = renderWithProvider({ searchParams: '?path=images' })
+
+ act(() => {
+ result.current.setPreviewedFile({ ...makeFile('a.png'), columnIndex: 0 })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ expect(onUrlUpdate).toHaveBeenCalledTimes(1)
+ const [update] = onUrlUpdate.mock.calls.at(-1)!
+ expect(update.queryString).toContain('preview=a.png')
+ expect(update.queryString).not.toContain('path=images')
+ // Collapsing columns is a navigation, so Back returns to the deeper folder
+ expect(update.options.history).toBe('push')
+ expect(snapshot.popColumnAtIndex).toHaveBeenCalledWith(0)
+ })
+
+ it('applies a ?path change that lands mid-restore instead of overwriting it', async () => {
+ const releases: (() => void)[] = []
+ const snapshot = createSnapshot({ columns: [] })
+ snapshot.fetchFoldersByPath.mockImplementation(async ({ paths }: { paths: string[] }) => {
+ await new Promise((resolve) => releases.push(resolve))
+ // A restore always lands the store on the path it was started for
+ snapshot.openedFolders = paths.map(makeFolder)
+ snapshot.columns = [makeColumn('my-bucket'), ...paths.map((path) => makeColumn(path))]
+ return { missingPaths: [] }
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { onUrlUpdate, rerender, setSearchParams } = renderWithProvider({
+ searchParams: '?path=images/2024',
+ hasMemory: true,
+ })
+ await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(1))
+
+ // Back, while the first restore is still in flight
+ act(() => setSearchParams('?path=images'))
+ await act(async () => {
+ releases.shift()!()
+ })
+ act(() => rerender())
+
+ // Finishing the first restore must not write `images/2024` back over the newer URL
+ expect(onUrlUpdate).not.toHaveBeenCalled()
+ await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(2))
+ expect(snapshot.fetchFoldersByPath).toHaveBeenLastCalledWith({
+ paths: ['images'],
+ searchString: '',
+ showLoading: true,
+ })
+ })
+
+ it('swaps the preview when ?preview names a same-named file in another folder', async () => {
+ const snapshot = createSnapshot({
+ openedFolders: [makeFolder('archive')],
+ columns: [
+ makeColumn('my-bucket'),
+ makeColumn('archive', [{ ...makeFile('photo.png'), id: 'archive/photo.png' }]),
+ ],
+ selectedFilePreview: { ...makeFile('photo.png'), id: 'images/photo.png', columnIndex: 1 },
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ renderWithProvider({ searchParams: '?path=archive&preview=photo.png' })
+
+ await waitFor(() => {
+ expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'archive/photo.png', columnIndex: 1 })
+ )
+ })
+ })
+
+ it('leaves ?preview alone while a search is narrowing the folder listing', async () => {
+ const snapshot = createSnapshot({
+ columns: [makeColumn('my-bucket', [makeFile('other.png')])],
+ })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ const { onUrlUpdate } = renderWithProvider({
+ searchParams: '?preview=a.png',
+ searchString: 'other',
+ })
+
+ await act(async () => {})
+ expect(onUrlUpdate).not.toHaveBeenCalled()
+ expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled()
+ })
+
+ it('refetches the bucket root when switching buckets without a path', async () => {
+ // The provider is keyed per project, not per bucket, so the store can still hold
+ // the previous bucket's columns.
+ const snapshot = createSnapshot({ columns: [makeColumn('another-bucket')] })
+ mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot)
+
+ renderWithProvider()
+
+ await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalled())
+ })
+})
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.tsx
new file mode 100644
index 00000000000..17cb2e80ce2
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.tsx
@@ -0,0 +1,267 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useEffectEvent,
+ useMemo,
+ useRef,
+ type PropsWithChildren,
+} from 'react'
+
+import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
+import type { StorageItem, StorageItemWithColumn } from '../Storage.types'
+import { getPathAlongOpenedFolders, parseStoragePath } from './StorageExplorer.utils'
+import { useStorageExplorerUrlState } from './useStorageExplorerUrlState'
+import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
+
+interface StorageExplorerNavigationContextValue {
+ openFolderAtIndex: (columnIndex: number, folder: StorageItem) => Promise
+ navigateToPath: (paths: string[]) => void
+ goUpOneLevel: () => void
+ truncateToColumn: (columnIndex: number) => void
+ setPreviewedFile: (item: StorageItemWithColumn) => void
+ clearPreviewedFile: () => void
+}
+
+const StorageExplorerNavigationContext =
+ createContext(null)
+
+export const useStorageExplorerNavigation = () => {
+ const context = useContext(StorageExplorerNavigationContext)
+ if (!context) {
+ throw new Error(
+ 'useStorageExplorerNavigation must be used within a StorageExplorerNavigationProvider'
+ )
+ }
+ return context
+}
+
+interface StorageExplorerNavigationProviderProps {
+ isBucketReady: boolean
+ searchString: string
+}
+
+/**
+ * Syncs `?path` / `?preview` with the valtio store, which stays authoritative. Whichever
+ * side moved last decides the direction — one writer each way is what stops it looping.
+ */
+export const StorageExplorerNavigationProvider = ({
+ isBucketReady,
+ searchString,
+ children,
+}: PropsWithChildren) => {
+ const snap = useStorageExplorerStateSnapshot()
+ const { urlPath, urlFolderPaths, urlPreview, setUrlLocation, setUrlPreview } =
+ useStorageExplorerUrlState()
+
+ const storePath = getPathAlongOpenedFolders(snap, false)
+ const bucketName = snap.selectedBucket.name
+ const rootColumnName = snap.columns[0]?.name
+ const previewedFileName = snap.selectedFilePreview?.name
+ const previewedFileId = snap.selectedFilePreview?.id
+ const lastColumn = snap.columns[snap.columns.length - 1]
+ const isLastColumnReady = lastColumn?.status === STORAGE_ROW_STATUS.READY
+
+ const previousStorePathRef = useRef(storePath)
+ const previousSearchStringRef = useRef(null)
+ /** Marks a store move as deliberate navigation (push), not a mutation side effect (replace). */
+ const navigationHistoryModeRef = useRef<'push' | null>(null)
+ const previousPreviewRef = useRef(previewedFileName)
+ /** The `?path` a restore is in flight for; `openedFolders` only catches up when it resolves. */
+ const restoringPathRef = useRef(null)
+ /** A `?path` change that landed mid-restore, so it isn't mistaken for a newer one. */
+ const hasSupersededRestoreRef = useRef(false)
+
+ const restoreFromUrl = useEffectEvent(async () => {
+ restoringPathRef.current = urlPath
+ hasSupersededRestoreRef.current = false
+ try {
+ const { missingPaths } = await snap.fetchFoldersByPath({
+ paths: urlFolderPaths,
+ searchString,
+ showLoading: true,
+ })
+ // Skip the fallback once a newer URL has superseded this pass; its own restore runs.
+ if (missingPaths.length > 0 && !hasSupersededRestoreRef.current) {
+ // Dead folder path: reset store and URL together, or the reconcile effect writes
+ // the dead path straight back.
+ await snap.fetchFoldersByPath({ paths: [], searchString, showLoading: true })
+ setUrlLocation({ paths: [], preview: null }, { history: 'replace' })
+ }
+ } finally {
+ restoringPathRef.current = null
+ }
+ })
+
+ const refetchForSearch = useEffectEvent(async () => {
+ // Only the folder you are standing in is refetched — passing the term to every open
+ // column (as the old fetch did) made the current folder vanish from its own parent.
+ const currentIndex = snap.openedFolders.length - 1
+ const currentFolder = snap.openedFolders[currentIndex]
+ await snap.fetchFolderContents({
+ bucketId: snap.selectedBucket.id,
+ folderId: currentFolder?.id ?? snap.selectedBucket.id,
+ folderName: currentFolder?.name ?? snap.selectedBucket.name,
+ index: currentFolder ? currentIndex : -1,
+ searchString,
+ })
+ })
+
+ const reconcileUrlToStore = useEffectEvent((history: 'push' | 'replace') => {
+ setUrlLocation(
+ { paths: parseStoragePath(storePath), preview: snap.selectedFilePreview?.name ?? null },
+ { history }
+ )
+ })
+
+ useEffect(() => {
+ if (!isBucketReady) return
+ if (restoringPathRef.current !== null) {
+ // Hold on to a URL change that landed mid-restore; the pass after it is where it applies.
+ if (urlPath !== restoringPathRef.current) hasSupersededRestoreRef.current = true
+ return
+ }
+
+ const hasStoreChanged = storePath !== previousStorePathRef.current
+ previousStorePathRef.current = storePath
+
+ const hasSearchChanged = searchString !== previousSearchStringRef.current
+ previousSearchStringRef.current = searchString
+
+ // A superseded restore means the URL, not the store, is the side that moved last.
+ const hasUrlSupersededStore = hasSupersededRestoreRef.current
+ hasSupersededRestoreRef.current = false
+
+ // Nothing loaded yet, or the store still holds the bucket we navigated away from —
+ // the provider is keyed per project, so a bucket switch does not remount it.
+ const isStoreEmpty = snap.columns.length === 0
+ const isStoreOnAnotherBucket = rootColumnName !== bucketName
+ if (isStoreEmpty || isStoreOnAnotherBucket) {
+ restoreFromUrl()
+ return
+ }
+
+ if (storePath !== urlPath) {
+ if (hasStoreChanged && !hasUrlSupersededStore) {
+ const history = navigationHistoryModeRef.current ?? 'replace'
+ navigationHistoryModeRef.current = null
+ reconcileUrlToStore(history)
+ return
+ }
+ restoreFromUrl()
+ return
+ }
+
+ navigationHistoryModeRef.current = null
+
+ if (hasSearchChanged) refetchForSearch()
+ }, [
+ isBucketReady,
+ urlPath,
+ storePath,
+ searchString,
+ bucketName,
+ rootColumnName,
+ snap.columns.length,
+ ])
+
+ // Restoring `?preview` needs the full item, which only exists once its column has loaded.
+ useEffect(() => {
+ if (!isBucketReady || !isLastColumnReady) return
+
+ const hasPreviewChangedInStore = previewedFileName !== previousPreviewRef.current
+ previousPreviewRef.current = previewedFileName
+ if (hasPreviewChangedInStore) return
+
+ if (!urlPreview) {
+ if (previewedFileName) snap.setSelectedFilePreview(undefined)
+ return
+ }
+
+ const item = lastColumn.items.find(
+ (columnItem) => columnItem.name === urlPreview && columnItem.type === STORAGE_ROW_TYPES.FILE
+ )
+ if (!item) {
+ const isListingComplete = !searchString && !lastColumn.hasMoreItems
+ if (isListingComplete) setUrlPreview(null)
+ return
+ }
+
+ if (previewedFileId === item.id) return
+
+ snap.setSelectedFilePreview({ ...item, columnIndex: snap.columns.length - 1 })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ isBucketReady,
+ isLastColumnReady,
+ urlPreview,
+ previewedFileName,
+ previewedFileId,
+ lastColumn?.path,
+ lastColumn?.items.length,
+ searchString,
+ ])
+
+ const context = useMemo(() => {
+ const openFolderAtIndex = async (columnIndex: number, folder: StorageItem) => {
+ navigationHistoryModeRef.current = 'push'
+ await snap.openFolder(columnIndex, folder)
+ }
+
+ const goUpOneLevel = () => {
+ navigationHistoryModeRef.current = 'push'
+ snap.popColumn()
+ snap.popOpenedFolders()
+ snap.setSelectedFilePreview(undefined)
+ }
+
+ const truncateToColumn = (columnIndex: number) => {
+ navigationHistoryModeRef.current = 'push'
+ snap.popColumnAtIndex(columnIndex)
+ snap.popOpenedFoldersAtIndex(columnIndex - 1)
+ snap.setSelectedFilePreview(undefined)
+ snap.clearSelectedItems()
+ }
+
+ const navigateToPath = (paths: string[]) => {
+ setUrlLocation({ paths, preview: null }, { history: 'push' })
+ }
+
+ const setPreviewedFile = (item: StorageItemWithColumn) => {
+ const paths = snap.openedFolders.slice(0, item.columnIndex).map((folder) => folder.name)
+ // Collapsing back to the file's own column is a navigation; previewing in place isn't.
+ const isCollapsingColumns = item.columnIndex < snap.openedFolders.length
+
+ snap.popColumnAtIndex(item.columnIndex)
+ snap.popOpenedFoldersAtIndex(item.columnIndex - 1)
+ snap.clearSelectedItems()
+ snap.setSelectedFilePreview(item)
+ // One write, so the URL never pairs the new file with the old, deeper path — a
+ // separate `preview` write would leave exactly that pairing behind in history.
+ setUrlLocation(
+ { paths, preview: item.name },
+ { history: isCollapsingColumns ? 'push' : 'replace' }
+ )
+ }
+
+ const clearPreviewedFile = () => {
+ snap.setSelectedFilePreview(undefined)
+ setUrlPreview(null)
+ }
+
+ return {
+ openFolderAtIndex,
+ navigateToPath,
+ goUpOneLevel,
+ truncateToColumn,
+ setPreviewedFile,
+ clearPreviewedFile,
+ }
+ }, [setUrlLocation, setUrlPreview, snap])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerShortcuts.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerShortcuts.ts
index cbc863da12f..156f8f533ad 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerShortcuts.ts
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerShortcuts.ts
@@ -1,5 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
@@ -24,12 +25,13 @@ export function useStorageExplorerShortcuts({ onClearSearch }: UseStorageExplore
clearSelectedItems,
setSelectedItemsToDelete,
setSelectedItemsToMove,
- setSelectedFilePreview,
downloadFile,
downloadSelectedFiles,
refreshAll,
} = useStorageExplorerStateSnapshot()
+ const { clearPreviewedFile } = useStorageExplorerNavigation()
+
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_REFRESH, () => {
@@ -64,13 +66,9 @@ export function useStorageExplorerShortcuts({ onClearSearch }: UseStorageExplore
enabled: selectedItems.length > 0,
})
- useShortcut(
- SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_PREVIEW,
- () => setSelectedFilePreview(undefined),
- {
- enabled: selectedItems.length === 0 && !!selectedFilePreview,
- }
- )
+ useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_PREVIEW, clearPreviewedFile, {
+ enabled: selectedItems.length === 0 && !!selectedFilePreview,
+ })
useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_SEARCH, onClearSearch, {
enabled: selectedItems.length === 0 && !selectedFilePreview && isSearching,
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerUrlState.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerUrlState.ts
new file mode 100644
index 00000000000..f0e51199bc1
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerUrlState.ts
@@ -0,0 +1,44 @@
+import { parseAsString, useQueryStates, type Options } from 'nuqs'
+import { useMemo } from 'react'
+
+import { parseStoragePath, serializeStoragePath } from './StorageExplorer.utils'
+
+/**
+ * Owns the two query params that address a location inside a bucket:
+ * - `path` — the slash-joined folder path, e.g. `?path=images/2024`
+ * - `preview` — the name of the file whose preview panel is open in that folder
+ *
+ * Both live in a single `useQueryStates` so that opening a folder can clear `preview`
+ * and set `path` in one atomic URL write. Two separate `useQueryState` calls would push two
+ * history entries per drill-down, which makes the Back button useless.
+ *
+ * `path` pushes history — Back should walk back up the folder chain. `preview` replaces,
+ * because opening or closing a panel is not a navigation.
+ */
+export function useStorageExplorerUrlState() {
+ const [{ path, preview }, setParams] = useQueryStates(
+ { path: parseAsString.withDefault(''), preview: parseAsString.withDefault('') },
+ { history: 'push', clearOnDefault: true }
+ )
+
+ return useMemo(
+ () => ({
+ urlPath: path,
+ urlFolderPaths: parseStoragePath(path),
+ urlPreview: preview,
+
+ /**
+ * Writes both params at once. `preview` is always passed explicitly so a location
+ * write can carry an open preview along with it rather than clearing it.
+ */
+ setUrlLocation: (
+ { paths, preview: previewedName }: { paths: string[]; preview: string | null },
+ options?: Options
+ ) => setParams({ path: serializeStoragePath(paths), preview: previewedName ?? '' }, options),
+
+ setUrlPreview: (name: string | null) =>
+ setParams({ preview: name ?? '' }, { history: 'replace' }),
+ }),
+ [path, preview, setParams]
+ )
+}
diff --git a/e2e/studio/utils/storage-helpers.ts b/e2e/studio/utils/storage-helpers.ts
index 5da6e9b56c6..b881b0ee08e 100644
--- a/e2e/studio/utils/storage-helpers.ts
+++ b/e2e/studio/utils/storage-helpers.ts
@@ -176,7 +176,9 @@ export const uploadFile = async (page: Page, filePath: string, fileName: string)
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles(filePath)
- await expect(page.getByRole('status')).not.toBeVisible()
+ // Wait out the upload progress toast. Scoped to the toast itself — a page-wide
+ // `getByRole('status')` also matches any live region the explorer renders.
+ await expect(page.locator('[data-sonner-toast]')).not.toBeVisible()
// Verify file appears in the explorer by title
await expect(
page.getByTitle(fileName),