From d9cfdcd741dc90b4c3bf4794d90f250c85346324 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 18 Sep 2026 09:45:32 +0200 Subject: [PATCH] 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 Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> --- .../FileExplorerHeader.test.tsx | 25 +- .../StorageExplorer/FileExplorerHeader.tsx | 32 +- .../StorageExplorer/FileExplorerRow.tsx | 20 +- .../Storage/StorageExplorer/PreviewPane.tsx | 13 +- .../StorageExplorer/StorageExplorer.tsx | 194 +------- .../StorageExplorer.utils.test.ts | 42 ++ .../StorageExplorer/StorageExplorer.utils.tsx | 13 + .../StorageExplorerContent.tsx | 134 ++++++ .../StorageExplorerNavigation.test.tsx | 447 ++++++++++++++++++ .../StorageExplorerNavigation.tsx | 267 +++++++++++ .../useStorageExplorerShortcuts.ts | 14 +- .../useStorageExplorerUrlState.ts | 44 ++ e2e/studio/utils/storage-helpers.ts | 4 +- 13 files changed, 1016 insertions(+), 233 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerContent.tsx create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.test.tsx create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.tsx create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerUrlState.ts 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)} /> +
+
{/* 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),