feat(studio): deep-link folders and files in the storage explorer (#50413)

| | PR | Base | Branch |
| --- | --- | --- | --- |
| 1 | #50476 | `master` | pre-existing correctness fixes |
| 2 | **this PR** | `fix/storage-explorer-listing-and-scroll` |
`?path`/`?preview` deep-linking |
| 3 | #50478 | `feat/storage-nav-improvement` | end-to-end deep-link
test |
| 4 | #50480 | `test/storage-deep-link-e2e` | copy path / copy link row
actions |

## What is the current behavior?
The file explorer doesn't keep track of folder navigation.
Files and folders paths aren't shareable

## What is the new behavior?
With this PR:
- nav state is stored via params
  - "path" to store folder path (if nested folder paths)
  - "preview" to store the selected filename
- back/forward nav history
- file url opens correct folder/file


[https://github.com/user-attachments/assets/](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[0cfb7fcc-2c6e](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[528d5c1d-a1b9](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[-](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[4f5a-950d](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[4061-9b67](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[-](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[060c8eb2027b](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[a41dd98716e0](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)

## Steps to review
- Open bucket in Storage File Explorer
- navigate between files and folders and notice url params change
- reload page, it should reopen where you left off
- hitting back/forward on the browser history should follow file/folder
navigation history

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-18 09:45:32 +02:00
1 parent 222127b5c2
commit d9cfdcd741
13 files changed
+1016 -233

No files matched your search

@@ -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')
})
@@ -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}
/>
)}
@@ -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)
}
}}
>
@@ -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 */}
<div className="flex w-full justify-end text-foreground-lighter transition-colors hover:text-foreground">
<X className="cursor-pointer" size={14} onClick={() => setSelectedFilePreview(undefined)} />
<div className="flex w-full justify-end">
<Button
variant="text"
className="w-6 h-6 text-foreground-lighter hover:text-foreground"
icon={<X size={14} />}
aria-label="Close preview"
onClick={clearPreviewedFile}
/>
</div>
{/* Preview Thumbnail*/}
@@ -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 (
<div ref={storageExplorerRef} className="bg-studio flex h-full w-full flex-col">
{selectedItems.length === 0 ? (
<FileExplorerHeader
<div className="bg-studio flex h-full w-full flex-col">
{/* 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. */}
<span aria-live="polite" aria-atomic="true" className="sr-only">
{isBucketReady ? 'Bucket contents loaded' : 'Loading bucket contents'}
</span>
<StorageExplorerNavigationProvider
isBucketReady={isBucketReady}
searchString={debouncedSearchString}
>
<StorageExplorerContent
itemSearchString={itemSearchString}
setItemSearchString={setItemSearchString}
onFilesUpload={onFilesUpload}
isLoading={!isBucketReady}
/>
) : (
<FileExplorerHeaderSelection />
)}
<div className="flex flex-1 min-h-0">
<FileExplorer
columns={columns}
selectedItems={selectedItems}
itemSearchString={itemSearchString}
isLoading={isLoading}
onFilesUpload={onFilesUpload}
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
onSelectColumnEmptySpace={onSelectColumnEmptySpace}
onColumnLoadMore={(index, column) =>
fetchMoreFolderContents({ index, column, searchString: itemSearchString })
}
/>
<PreviewPane />
</div>
<ConfirmDeleteModal />
<MoveItemsModal
visible={selectedItemsToMove.length > 0}
projectRef={projectRef}
bucketId={selectedBucket.id}
bucketName={selectedBucket.name}
selectedItemsToMove={selectedItemsToMove}
openedFolders={openedFolders}
onSelectCancel={() => setSelectedItemsToMove([])}
onSelectMove={onMoveSelectedFiles}
/>
<CustomExpiryModal />
</StorageExplorerNavigationProvider>
</div>
)
}
@@ -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)
})
})
@@ -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.
@@ -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 ? (
<FileExplorerHeader
itemSearchString={itemSearchString}
setItemSearchString={setItemSearchString}
onFilesUpload={onFilesUpload}
/>
) : (
<FileExplorerHeaderSelection />
)}
<div className="flex flex-1 min-h-0">
<FileExplorer
columns={columns}
selectedItems={selectedItems}
itemSearchString={itemSearchString}
isLoading={isLoading}
onFilesUpload={onFilesUpload}
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
onSelectColumnEmptySpace={truncateToColumn}
onColumnLoadMore={(index, column) =>
fetchMoreFolderContents({ index, column, searchString: itemSearchString })
}
/>
<PreviewPane />
</div>
<ConfirmDeleteModal />
<MoveItemsModal
visible={selectedItemsToMove.length > 0}
projectRef={projectRef}
bucketId={selectedBucket.id}
bucketName={selectedBucket.name}
selectedItemsToMove={selectedItemsToMove}
openedFolders={openedFolders}
onSelectCancel={() => setSelectedItemsToMove([])}
onSelectMove={onMoveSelectedFiles}
/>
<CustomExpiryModal />
</>
)
}
@@ -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) => (
<NuqsTestingAdapter
searchParams={currentSearchParams}
onUrlUpdate={onUrlUpdate}
hasMemory={hasMemory}
>
<StorageExplorerNavigationProvider
isBucketReady={isBucketReady}
searchString={searchString}
>
{children}
</StorageExplorerNavigationProvider>
</NuqsTestingAdapter>
),
})
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<void>((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())
})
})
@@ -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<void>
navigateToPath: (paths: string[]) => void
goUpOneLevel: () => void
truncateToColumn: (columnIndex: number) => void
setPreviewedFile: (item: StorageItemWithColumn) => void
clearPreviewedFile: () => void
}
const StorageExplorerNavigationContext =
createContext<StorageExplorerNavigationContextValue | null>(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<StorageExplorerNavigationProviderProps>) => {
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<string | null>(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<string | null>(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 (
<StorageExplorerNavigationContext.Provider value={context}>
{children}
</StorageExplorerNavigationContext.Provider>
)
}
@@ -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,
@@ -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]
)
}
+3 -1
View File
@@ -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),