StorageFilePicker

This commit is contained in:
Francesco Sansalvadore committed 2026-03-25 18:22:01 +01:00
1 parent adf8b0c67c
commit 0f359ca22d
12 files changed
+883 -213

No files matched your search

@@ -12,7 +12,7 @@ import { useOAuthServerAppCreateMutation } from 'data/oauth-server-apps/oauth-se
import { useOAuthServerAppRegenerateSecretMutation } from 'data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation'
import { useOAuthServerAppUpdateMutation } from 'data/oauth-server-apps/oauth-server-app-update-mutation'
import { DOCS_URL } from 'lib/constants'
import { Plus, Trash2, Upload, X } from 'lucide-react'
import { FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react'
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -42,6 +42,8 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { StorageFilePicker } from '@/components/interfaces/Storage/StorageExplorer/StorageFilePicker'
interface CreateOrUpdateOAuthAppSheetProps {
visible: boolean
appToEdit?: OAuthClient
@@ -89,6 +91,7 @@ export const CreateOrUpdateOAuthAppSheet = ({
const uploadButtonRef = useRef<HTMLInputElement>(null)
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false)
const [storagePickerOpen, setStoragePickerOpen] = useState(false)
const [logoFile, setLogoFile] = useState<File>()
const [logoUrl, setLogoUrl] = useState<string>()
const [logoRemoved, setLogoRemoved] = useState(false)
@@ -136,6 +139,12 @@ export const CreateOrUpdateOAuthAppSheet = ({
},
})
useEffect(() => {
if (!visible) {
setStoragePickerOpen(false)
}
}, [visible])
useEffect(() => {
if (visible) {
setLogoFile(undefined)
@@ -240,14 +249,33 @@ export const CreateOrUpdateOAuthAppSheet = ({
}
const handleUploadLogo = () => uploadButtonRef.current?.click()
const handlePickLogoFromStorage = (uri: string) => {
setLogoFile(undefined)
setLogoUrl(uri)
setLogoRemoved(false)
form.setValue('logo_uri', uri)
}
const handleRemoveLogo = () => {
setLogoFile(undefined)
setLogoUrl(undefined)
setLogoRemoved(true)
form.setValue('logo_uri', '')
}
return (
<>
{projectRef ? (
<StorageFilePicker
open={storagePickerOpen}
onOpenChange={setStoragePickerOpen}
projectRef={projectRef}
returnValue="publicUrl"
title="Choose logo from Storage"
onSelect={handlePickLogoFromStorage}
/>
) : null}
<Sheet open={visible} onOpenChange={() => onCancel()}>
<SheetContent
size="lg"
@@ -293,46 +321,61 @@ export const CreateOrUpdateOAuthAppSheet = ({
control={form.control}
name="logo_uri"
render={() => (
<FormItemLayout label="Logo" description="Upload a logo for your OAuth app">
<FormItemLayout
label="Logo"
description="Upload an image, or pick a file from Storage (choose a bucket, then a file). A public URL is used as logo_uri when possible—use a public bucket for a stable logo URL."
>
<FormControl_Shadcn_>
<div className="flex gap-4 items-center">
<button
type="button"
onClick={handleUploadLogo}
className={cn(
'flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong'
)}
style={{
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
}}
>
{!hasLogo && <Upload size={14} />}
</button>
<div className="flex gap-2 items-center">
<Button
type="default"
size="tiny"
icon={<Upload size={14} />}
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex flex-wrap items-center gap-4">
<button
type="button"
onClick={handleUploadLogo}
className={cn(
'flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter hover:text-foreground-light overflow-hidden rounded-full bg-cover border hover:border-strong'
)}
style={{
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
}}
>
Upload
</Button>
{hasLogo && (
{!hasLogo && <Upload size={14} />}
</button>
<div className="flex flex-wrap items-center gap-2">
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
onClick={handleRemoveLogo}
/>
)}
icon={<Upload size={14} />}
onClick={handleUploadLogo}
>
Upload
</Button>
{projectRef ? (
<Button
type="default"
size="tiny"
icon={<FolderOpen size={14} />}
onClick={() => setStoragePickerOpen(true)}
>
Storage
</Button>
) : null}
{hasLogo && (
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
onClick={handleRemoveLogo}
/>
)}
</div>
<input
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={onFileUpload}
/>
</div>
<input
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={onFileUpload}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
@@ -1,3 +1,5 @@
import type { KeyboardEventHandler, MouseEventHandler } from 'react'
import { useBucketPolicyCount } from 'components/interfaces/Storage/useBucketPolicyCount'
import {
VirtualizedTableCell,
@@ -73,6 +75,8 @@ type BucketTableRowProps = {
bucket: Bucket
projectRef: string
formattedGlobalUploadLimit: string
/** When set, the row selects the bucket instead of navigating to the bucket page. */
onSelectBucket?: (bucket: Bucket) => void
}
export const BucketTableRow = ({
@@ -80,6 +84,7 @@ export const BucketTableRow = ({
bucket,
projectRef,
formattedGlobalUploadLimit,
onSelectBucket,
}: BucketTableRowProps) => {
const router = useRouter()
const { getPolicyCount } = useBucketPolicyCount()
@@ -92,14 +97,39 @@ export const BucketTableRow = ({
router
)
const handleRowActivate: MouseEventHandler<HTMLTableRowElement> = (e) => {
if (onSelectBucket) {
e.preventDefault()
onSelectBucket(bucket)
return
}
handleBucketNavigation(e)
}
const handleRowAuxClick: MouseEventHandler<HTMLTableRowElement> = (e) => {
if (onSelectBucket) return
handleBucketNavigation(e)
}
const handleRowKeyDown: KeyboardEventHandler<HTMLTableRowElement> = (e) => {
if (onSelectBucket) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelectBucket(bucket)
}
return
}
handleBucketNavigation(e)
}
return (
<BucketTableRow
key={bucket.id}
data-bucket-id={bucket.id}
className="relative cursor-pointer h-16 group inset-focus"
onClick={handleBucketNavigation}
onAuxClick={handleBucketNavigation}
onKeyDown={handleBucketNavigation}
onClick={handleRowActivate}
onAuxClick={handleRowAuxClick}
onKeyDown={handleRowKeyDown}
tabIndex={0}
>
<BucketTableCell className="w-2 pr-1">
@@ -13,6 +13,8 @@ type BucketsTableProps = {
filterString: string
formattedGlobalUploadLimit: string
pagination: BucketsTablePaginationProps
/** Picker mode: row click calls this instead of navigating to the bucket. */
onSelectBucket?: (bucket: Bucket) => void
}
export const BucketsTable = (props: BucketsTableProps) => {
@@ -30,6 +32,7 @@ const BucketsTableUnvirtualized = ({
filterString,
formattedGlobalUploadLimit,
pagination: { hasMore = false, isLoadingMore = false, onLoadMore },
onSelectBucket,
}: BucketsTableProps) => {
const showSearchEmptyState = buckets.length === 0 && filterString.length > 0
@@ -52,6 +55,7 @@ const BucketsTableUnvirtualized = ({
bucket={bucket}
projectRef={projectRef}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
onSelectBucket={onSelectBucket}
/>
))
)}
@@ -73,6 +77,7 @@ const BucketsTableVirtualized = ({
filterString,
formattedGlobalUploadLimit,
pagination: { hasMore = false, isLoadingMore = false, onLoadMore },
onSelectBucket,
}: BucketsTableProps) => {
const showSearchEmptyState = buckets.length === 0 && filterString.length > 0
const scrollContainerRef = useRef<HTMLDivElement>(null)
@@ -109,6 +114,7 @@ const BucketsTableVirtualized = ({
bucket={bucket}
projectRef={projectRef}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
onSelectBucket={onSelectBucket}
/>
)}
</VirtualizedTableBody>
@@ -47,8 +47,9 @@ export const FileExplorer = ({
<div
ref={fileExplorerRef}
className={cn(
'file-explorer flex flex-grow overflow-x-auto justify-between h-full w-full relative',
snap.view === STORAGE_VIEWS.LIST && 'flex-col'
'file-explorer relative flex min-h-0 min-w-0 w-full flex-1 justify-between overflow-x-auto',
snap.view === STORAGE_VIEWS.LIST && 'h-full min-h-0 flex-col',
snap.view === STORAGE_VIEWS.COLUMNS && 'h-full'
)}
>
<ColumnContextMenu id={CONTEXT_MENU_KEYS.STORAGE_COLUMN} />
@@ -60,7 +61,7 @@ export const FileExplorer = ({
column={{ id: '', name: '', items: [], status: STORAGE_ROW_STATUS.LOADING }}
/>
) : snap.view === STORAGE_VIEWS.COLUMNS ? (
<div className="flex">
<div className="flex h-full min-h-0 min-w-0">
{columns.map((column, index) => (
<FileExplorerColumn
key={`column-${index}`}
@@ -167,9 +167,10 @@ export const FileExplorerColumn = ({
<div
ref={fileExplorerColumnRef}
className={cn(
'hide-scrollbar relative flex flex-col',
fullWidth ? 'w-full' : 'w-64 border-r border-overlay',
snap.view === STORAGE_VIEWS.LIST && 'h-full',
'hide-scrollbar relative flex flex-shrink-0 flex-col overflow-auto'
snap.view === STORAGE_VIEWS.LIST && 'min-h-0 flex-1 overflow-hidden',
snap.view === STORAGE_VIEWS.COLUMNS && 'h-full flex-shrink-0 overflow-auto'
)}
onContextMenu={displayMenu}
onDragOver={onDragOver}
@@ -232,7 +233,7 @@ export const FileExplorerColumn = ({
{/* Column Interface */}
{columnItems.length > 0 && (
<InfiniteListDefault
className="h-full"
className={snap.view === STORAGE_VIEWS.LIST ? 'min-h-0 flex-1' : 'h-full'}
items={columnItems}
itemProps={itemProps}
getItemKey={getItemKey}
@@ -135,17 +135,22 @@ const HeaderBreadcrumbs = ({
)
}
interface FileExplorerHeader {
interface FileExplorerHeaderProps {
itemSearchString: string
setItemSearchString: (value: string) => void
onFilesUpload: (event: any, columnIndex?: number) => void
variant?: 'default' | 'picker'
/** When true with variant picker, hide column/list switch (list-only). */
forceListView?: boolean
}
export const FileExplorerHeader = ({
itemSearchString = '',
setItemSearchString = noop,
onFilesUpload = noop,
}: FileExplorerHeader) => {
variant = 'default',
forceListView = false,
}: FileExplorerHeaderProps) => {
const snap = useStorageExplorerStateSnapshot()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
@@ -179,6 +184,7 @@ export const FileExplorerHeader = ({
const breadcrumbs = columns.map((column) => column.name)
const backDisabled = columns.length <= 1
const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const isPicker = variant === 'picker'
useEffect(() => {
// [Joshen] Somehow toggle search triggers this despite breadcrumbs
@@ -347,105 +353,151 @@ export const FileExplorerHeader = ({
Reload
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
icon={
snap.view === 'LIST' ? (
<List size={16} strokeWidth={2} />
) : (
<Columns size={16} strokeWidth={2} />
)
}
>
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-0">
{VIEW_OPTIONS.map((option) => (
<DropdownMenuItem key={option.key} onClick={() => snap.setView(option.key)}>
<div className="flex items-center justify-between w-full">
<p>{option.name}</p>
{snap.view === option.key && (
<Check size={16} className="text-brand" strokeWidth={2} />
)}
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-44">
{SORT_BY_OPTIONS.map((option) => (
<DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
<div className="flex items-center justify-between w-full">
<p>{option.name}</p>
{sortBy === option.key && (
<Check size={16} className="text-brand" strokeWidth={2} />
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort order</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SORT_ORDER_OPTIONS.map((option) => (
<DropdownMenuItem key={option.key} onClick={() => setSortByOrder(option.key)}>
<div className="flex items-center justify-between w-full">
<p>{option.name}</p>
{sortByOrder === option.key && (
<Check size={16} className="text-brand" strokeWidth={2} />
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
{isPicker && forceListView ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<List size={16} strokeWidth={2} />}>
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 min-w-0">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-44">
{SORT_BY_OPTIONS.map((option) => (
<DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
<div className="flex items-center justify-between w-full">
<p>{option.name}</p>
{sortBy === option.key && (
<Check size={16} className="text-brand" strokeWidth={2} />
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort order</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SORT_ORDER_OPTIONS.map((option) => (
<DropdownMenuItem key={option.key} onClick={() => setSortByOrder(option.key)}>
<div className="flex items-center justify-between w-full">
<p>{option.name}</p>
{sortByOrder === option.key && (
<Check size={16} className="text-brand" strokeWidth={2} />
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
icon={
snap.view === 'LIST' ? (
<List size={16} strokeWidth={2} />
) : (
<Columns size={16} strokeWidth={2} />
)
}
>
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-0">
{VIEW_OPTIONS.map((option) => (
<DropdownMenuItem key={option.key} onClick={() => snap.setView(option.key)}>
<div className="flex items-center justify-between w-full">
<p>{option.name}</p>
{snap.view === option.key && (
<Check size={16} className="text-brand" strokeWidth={2} />
)}
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort by</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-44">
{SORT_BY_OPTIONS.map((option) => (
<DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
<div className="flex items-center justify-between w-full">
<p>{option.name}</p>
{sortBy === option.key && (
<Check size={16} className="text-brand" strokeWidth={2} />
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sort order</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SORT_ORDER_OPTIONS.map((option) => (
<DropdownMenuItem key={option.key} onClick={() => setSortByOrder(option.key)}>
<div className="flex items-center justify-between w-full">
<p>{option.name}</p>
{sortByOrder === option.key && (
<Check size={16} className="text-brand" strokeWidth={2} />
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="h-6 border-r border-control" />
<div className="flex items-center space-x-1 px-2">
<div className="hidden">
<input ref={uploadButtonRef} type="file" multiple onChange={onFilesUpload} />
</div>
<ButtonTooltip
icon={<Upload size={16} strokeWidth={2} />}
type="text"
disabled={!canUpdateStorage || breadcrumbs.length === 0}
onClick={onSelectUpload}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateStorage
? 'You need additional permissions to upload files'
: undefined,
},
}}
>
Upload files
</ButtonTooltip>
<ButtonTooltip
icon={<FolderPlus size={16} strokeWidth={2} />}
type="text"
disabled={!canUpdateStorage || breadcrumbs.length === 0}
onClick={() => addNewFolderPlaceholder(-1)}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateStorage
? 'You need additional permissions to create folders'
: undefined,
},
}}
>
Create folder
</ButtonTooltip>
</div>
{!isPicker && (
<>
<div className="h-6 border-r border-control" />
<div className="flex items-center space-x-1 px-2">
<div className="hidden">
<input ref={uploadButtonRef} type="file" multiple onChange={onFilesUpload} />
</div>
<ButtonTooltip
icon={<Upload size={16} strokeWidth={2} />}
type="text"
disabled={!canUpdateStorage || breadcrumbs.length === 0}
onClick={onSelectUpload}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateStorage
? 'You need additional permissions to upload files'
: undefined,
},
}}
>
Upload files
</ButtonTooltip>
<ButtonTooltip
icon={<FolderPlus size={16} strokeWidth={2} />}
type="text"
disabled={!canUpdateStorage || breadcrumbs.length === 0}
onClick={() => addNewFolderPlaceholder(-1)}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateStorage
? 'You need additional permissions to create folders'
: undefined,
},
}}
>
Create folder
</ButtonTooltip>
</div>
</>
)}
<div className="h-6 border-r border-control" />
<div className="flex items-center px-2">
@@ -481,7 +533,7 @@ export const FileExplorerHeader = ({
)}
</div>
{isNewAPIDocsEnabled && (
{isNewAPIDocsEnabled && !isPicker && (
<>
<div className="h-6 border-r border-control" />
<div className="mx-2">
@@ -20,6 +20,7 @@ import {
} from 'lucide-react'
import type { CSSProperties } from 'react'
import { useContextMenu } from 'react-contexify'
import { toast } from 'sonner'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import {
Checkbox,
@@ -47,8 +48,11 @@ import {
} from '../Storage.constants'
import { StorageItemWithColumn, type StorageItem } from '../Storage.types'
import { FileExplorerRowEditing } from './FileExplorerRowEditing'
import { copyPathToFolder } from './StorageExplorer.utils'
import { useStorageExplorerPicker } from './StorageExplorerPickerContext'
import { copyPathToFolder, getPathAlongFoldersToIndex } from './StorageExplorer.utils'
import { useCopyUrl } from './useCopyUrl'
import { fetchFileUrl } from './useFetchFileUrlQuery'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
export const RowIcon = ({
view,
@@ -110,6 +114,7 @@ export const FileExplorerRow = ({
style,
}: FileExplorerRowProps) => {
const {
projectRef,
selectedBucket,
selectedFilePreview,
openedFolders,
@@ -127,6 +132,8 @@ export const FileExplorerRow = ({
downloadFolder,
selectRangeItems,
} = useStorageExplorerStateSnapshot()
const picker = useStorageExplorerPicker()
const { hostEndpoint, customEndpoint } = useProjectApiUrl({ projectRef })
const { show } = useContextMenu()
const { onCopyUrl } = useCopyUrl()
@@ -145,6 +152,37 @@ export const FileExplorerRow = ({
clearSelectedItems()
}
const resolveObjectPathForItem = () => {
if (item.path) return item.path
const pathToFile = getPathAlongFoldersToIndex({ openedFolders }, columnIndex)
return pathToFile.length > 0 ? `${pathToFile}/${item.name}` : item.name
}
const pickFile = async () => {
if (!picker || item.isCorrupted) return
try {
const objectPath = resolveObjectPathForItem()
if (picker.returnValue === 'objectPath') {
picker.onPick(objectPath)
return
}
let url = await fetchFileUrl(
objectPath,
projectRef,
selectedBucket.id,
selectedBucket.public
)
const isCustomDomainActive = !!customEndpoint
if (isCustomDomainActive && hostEndpoint) {
url = url.replace(hostEndpoint, customEndpoint)
}
picker.onPick(url)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to resolve file URL'
toast.error(message)
}
}
const onCheckItem = (isShiftKeyHeld: boolean) => {
// Select a range if shift is held down
if (isShiftKeyHeld && selectedItems.length !== 0) {
@@ -303,6 +341,10 @@ export const FileExplorerRow = ({
className="h-full border-b border-default"
onContextMenu={(event) => {
event.stopPropagation()
if (picker) {
event.preventDefault()
return
}
item.type === STORAGE_ROW_TYPES.FILE
? displayMenu(event, STORAGE_ROW_TYPES.FILE)
: displayMenu(event, STORAGE_ROW_TYPES.FOLDER)
@@ -320,10 +362,17 @@ export const FileExplorerRow = ({
onClick={(event) => {
event.stopPropagation()
event.preventDefault()
if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) {
item.type === STORAGE_ROW_TYPES.FOLDER
? openFolder(columnIndex, item)
: onSelectFile(columnIndex)
if (item.status === STORAGE_ROW_STATUS.LOADING) return
if (item.type === STORAGE_ROW_TYPES.FOLDER) {
if (!isOpened && !isPreviewed) openFolder(columnIndex, item)
return
}
if (picker) {
void pickFile()
return
}
if (!isOpened && !isPreviewed) {
onSelectFile(columnIndex)
}
}}
>
@@ -337,7 +386,7 @@ export const FileExplorerRow = ({
{!isSelected && (
<div
className={`absolute ${
item.type === STORAGE_ROW_TYPES.FILE ? 'group-hover:hidden' : ''
item.type === STORAGE_ROW_TYPES.FILE && !picker ? 'group-hover:hidden' : ''
}`}
style={{ top: '2px' }}
>
@@ -401,7 +450,7 @@ export const FileExplorerRow = ({
size={14}
strokeWidth={2}
/>
) : (
) : picker ? null : (
<DropdownMenu>
<DropdownMenuTrigger>
<div className="storage-row-menu opacity-0">
@@ -2,11 +2,11 @@ import { useDebounce } from '@uidotdev/usehooks'
import { useParams } from 'common'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import type { Bucket } from 'data/storage/buckets-query'
import { useLatest } from 'hooks/misc/useLatest'
import { IS_PLATFORM } from 'lib/constants'
import { compact, get, isEmpty, uniqBy } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import { cn } from 'ui'
import { useSelectedBucket } from '../FilesBuckets/useSelectedBucket'
import { STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
@@ -17,9 +17,31 @@ import { FileExplorerHeader } from './FileExplorerHeader'
import { FileExplorerHeaderSelection } from './FileExplorerHeaderSelection'
import { MoveItemsModal } from './MoveItemsModal'
import { PreviewPane } from './PreviewPane'
import {
StorageExplorerPickerProvider,
type StoragePickerReturnValue,
} from './StorageExplorerPickerContext'
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
export const StorageExplorer = () => {
export type StorageExplorerProps = {
variant?: 'default' | 'picker'
pickerReturnValue?: StoragePickerReturnValue
onPickerPick?: (value: string) => void
/** Mobile sheet: list layout only; hides column/list switch (sort remains available). */
forceListView?: boolean
/** When set (embedded picker), loading gate uses bucket id from store instead of the route. */
expectedBucketId?: string
className?: string
}
export const StorageExplorer = ({
variant = 'default',
pickerReturnValue = 'objectPath',
onPickerPick,
forceListView = false,
expectedBucketId,
className,
}: StorageExplorerProps = {}) => {
const { ref, bucketId } = useParams()
const storageExplorerRef = useRef(null)
const {
@@ -43,15 +65,18 @@ export const StorageExplorer = () => {
setSelectedItemsToMove,
} = useStorageExplorerStateSnapshot()
useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { data: bucket, isLoading: isBucketQueryLoading } = useSelectedBucket()
const isPicker = variant === 'picker'
// 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)
useProjectStorageConfigQuery({ projectRef }, { enabled: IS_PLATFORM && !!projectRef })
const { data: routeBucket, isLoading: isRouteBucketLoading } = useSelectedBucket()
const isLoading = isPicker
? !selectedBucket?.id ||
(expectedBucketId !== undefined && expectedBucketId !== selectedBucket.id)
: isRouteBucketLoading || (!!bucketId && bucketId !== selectedBucket.id)
const bucketForFetch = isPicker ? (selectedBucket as Bucket | undefined) : routeBucket
// 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
const [itemSearchString, setItemSearchString] = useState('')
const debouncedSearchString = useDebounce(itemSearchString, 500)
@@ -92,11 +117,10 @@ export const StorageExplorer = () => {
})
useEffect(() => {
if (bucket && projectRef) fetchContents(bucket)
}, [bucket, projectRef, debouncedSearchString, selectedBucket.id, fetchContents])
/** Checkbox selection methods */
/** [Joshen] We'll only support checkbox selection for files ONLY */
const b = bucketForFetch as Bucket | undefined
if (!projectRef || !b?.id) return
void fetchContents(b)
}, [bucketForFetch, projectRef, debouncedSearchString, selectedBucket.id, view, fetchContents])
const onSelectAllItemsInColumn = (columnIndex: number) => {
const columnFiles = columns[columnIndex].items
@@ -110,20 +134,16 @@ export const StorageExplorer = () => {
)
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
@@ -136,7 +156,6 @@ export const StorageExplorer = () => {
await moveFiles(newPath)
}
/** Misc UI methods */
const onSelectColumnEmptySpace = (columnIndex: number) => {
popColumnAtIndex(columnIndex)
popOpenedFoldersAtIndex(columnIndex - 1)
@@ -144,21 +163,26 @@ export const StorageExplorer = () => {
clearSelectedItems()
}
return (
const explorerTree = (
<div
ref={storageExplorerRef}
className="bg-studio border rounded-md border-overlay flex h-full w-full flex-col"
className={cn(
'bg-studio border border-overlay flex h-full min-h-0 w-full min-w-0 flex-1 flex-col rounded-md',
className
)}
>
{selectedItems.length === 0 ? (
<FileExplorerHeader
itemSearchString={itemSearchString}
setItemSearchString={setItemSearchString}
onFilesUpload={onFilesUpload}
variant={variant}
forceListView={forceListView}
/>
) : (
<FileExplorerHeaderSelection />
)}
<div className="flex flex-1 min-h-0">
<div className="file-explorer flex min-h-0 min-w-0 flex-1">
<FileExplorer
columns={columns}
selectedItems={selectedItems}
@@ -171,22 +195,40 @@ export const StorageExplorer = () => {
fetchMoreFolderContents({ index, column, searchString: itemSearchString })
}
/>
<PreviewPane />
{!isPicker && <PreviewPane />}
</div>
<ConfirmDeleteModal />
{!isPicker && (
<>
<ConfirmDeleteModal />
<MoveItemsModal
bucketName={selectedBucket.name}
visible={selectedItemsToMove.length > 0}
selectedItemsToMove={selectedItemsToMove}
onSelectCancel={() => setSelectedItemsToMove([])}
onSelectMove={onMoveSelectedFiles}
/>
<MoveItemsModal
bucketName={selectedBucket.name}
visible={selectedItemsToMove.length > 0}
selectedItemsToMove={selectedItemsToMove}
onSelectCancel={() => setSelectedItemsToMove([])}
onSelectMove={onMoveSelectedFiles}
/>
<CustomExpiryModal />
<CustomExpiryModal />
</>
)}
</div>
)
if (isPicker && onPickerPick) {
return (
<StorageExplorerPickerProvider
returnValue={pickerReturnValue}
onPick={onPickerPick}
forceListView={forceListView}
>
{explorerTree}
</StorageExplorerPickerProvider>
)
}
return explorerTree
}
StorageExplorer.displayName = 'StorageExplorer'
@@ -0,0 +1,36 @@
import { createContext, useContext, type PropsWithChildren } from 'react'
export type StoragePickerReturnValue = 'objectPath' | 'publicUrl'
type StorageExplorerPickerContextValue = {
isPicker: true
returnValue: StoragePickerReturnValue
onPick: (value: string) => void
/** When true, hide column view and related chrome (e.g. mobile sheet). */
forceListView: boolean
}
const StorageExplorerPickerContext = createContext<StorageExplorerPickerContextValue | null>(null)
export function StorageExplorerPickerProvider({
children,
returnValue,
onPick,
forceListView,
}: PropsWithChildren<
Pick<StorageExplorerPickerContextValue, 'returnValue' | 'onPick' | 'forceListView'>
>) {
const value: StorageExplorerPickerContextValue = {
isPicker: true,
returnValue,
onPick,
forceListView,
}
return (
<StorageExplorerPickerContext.Provider value={value}>{children}</StorageExplorerPickerContext.Provider>
)
}
export function useStorageExplorerPicker() {
return useContext(StorageExplorerPickerContext)
}
@@ -0,0 +1,305 @@
import { useDebounce } from '@uidotdev/usehooks'
import { useBreakpoint } from 'common'
import { BucketsTable } from 'components/interfaces/Storage/FilesBuckets/BucketsTable'
import AlertError from 'components/ui/AlertError'
import { InlineLink } from 'components/ui/InlineLink'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import type { Bucket } from 'data/storage/buckets-query'
import { usePaginatedBucketsQuery } from 'data/storage/buckets-query'
import { IS_PLATFORM } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import { ChevronLeft, Search } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
Card,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { STORAGE_VIEWS } from '../Storage.constants'
import { StorageExplorer } from './StorageExplorer'
import type { StoragePickerReturnValue } from './StorageExplorerPickerContext'
import {
StorageExplorerEmbeddedStateProvider,
useStorageExplorerStateSnapshot,
} from '@/state/storage-explorer'
export type StorageFilePickerProps = {
open: boolean
onOpenChange: (open: boolean) => void
projectRef: string
returnValue?: StoragePickerReturnValue
onSelect: (value: string) => void
title?: string
}
/** Keeps list view when the picker is in mobile layout (sheet). */
function StoragePickerListViewSync({ enforceList }: { enforceList: boolean }) {
const { setView } = useStorageExplorerStateSnapshot()
useEffect(() => {
if (enforceList) {
setView(STORAGE_VIEWS.LIST)
}
}, [enforceList, setView])
return null
}
function StorageFilePickerExplorer({
projectRef,
bucketId,
returnValue,
onSelect,
forceListView,
}: {
projectRef: string
bucketId: string
returnValue: StoragePickerReturnValue
onSelect: (value: string) => void
forceListView: boolean
}) {
return (
<StorageExplorerEmbeddedStateProvider
projectRef={projectRef}
bucketId={bucketId}
persistExplorerPreferences={false}
initialView={forceListView ? STORAGE_VIEWS.LIST : undefined}
>
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
<StoragePickerListViewSync enforceList={forceListView} />
<StorageExplorer
variant="picker"
pickerReturnValue={returnValue}
onPickerPick={onSelect}
forceListView={forceListView}
expectedBucketId={bucketId}
className="h-full min-h-0 flex-1"
/>
</div>
</StorageExplorerEmbeddedStateProvider>
)
}
function StorageFilePickerBucketsStep({
projectRef,
open,
onSelectBucket,
}: {
projectRef: string
open: boolean
onSelectBucket: (bucket: Bucket) => void
}) {
const [filterString, setFilterString] = useState('')
const debouncedFilter = useDebounce(filterString, 250)
const normalizedSearch = debouncedFilter.trim()
const { data: storageConfig } = useProjectStorageConfigQuery(
{ projectRef },
{ enabled: IS_PLATFORM && !!projectRef }
)
const formattedGlobalUploadLimit = formatBytes(storageConfig?.fileSizeLimit ?? 0)
const {
data: bucketsData,
error: bucketsError,
isError: isErrorBuckets,
isPending: isLoadingBuckets,
isSuccess: isSuccessBuckets,
isFetching: isFetchingBuckets,
fetchNextPage,
hasNextPage,
} = usePaginatedBucketsQuery(
{
projectRef,
search: normalizedSearch.length > 0 ? normalizedSearch : undefined,
sortColumn: 'name',
sortOrder: 'asc',
},
{ enabled: !!projectRef }
)
const buckets = useMemo(() => bucketsData?.pages.flatMap((page) => page) ?? [], [bucketsData])
const fileBuckets = useMemo(
() => buckets.filter((b) => !('type' in b) || b.type === 'STANDARD'),
[buckets]
)
const hasNoBuckets = fileBuckets.length === 0 && normalizedSearch.length === 0
const hasNoApiKeys =
isErrorBuckets && bucketsError.message.includes('Project has no active API keys')
const handleLoadMoreBuckets = useCallback(() => {
if (hasNextPage && !isFetchingBuckets) {
fetchNextPage()
}
}, [hasNextPage, isFetchingBuckets, fetchNextPage])
return (
<div className="flex flex-1 flex-col gap-3 h-screen max-h-[600px] min-h-[300px] w-full">
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<>
{hasNoApiKeys ? (
<Admonition type="warning" title="Project has no active API keys enabled">
<p className="!leading-normal text-sm">
The Dashboard needs active API keys to list buckets.{' '}
<InlineLink href={`/project/${projectRef}/settings/api-keys/new`}>
Create API keys
</InlineLink>
.
</p>
</Admonition>
) : (
<AlertError error={bucketsError} subject="Failed to retrieve buckets" />
)}
</>
)}
{isSuccessBuckets && (
<>
{hasNoBuckets ? (
<p className="text-sm text-foreground-light px-1">
No file buckets yet.{' '}
<InlineLink href={`/project/${projectRef}/storage/files`}>Open Storage</InlineLink> to
create one.
</p>
) : (
<>
<Input
size="tiny"
className="w-full max-w-sm"
placeholder="Search buckets"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
<Card className="min-h-0 flex-1 overflow-hidden">
<BucketsTable
buckets={fileBuckets}
projectRef={projectRef}
filterString={filterString}
formattedGlobalUploadLimit={formattedGlobalUploadLimit}
pagination={{
hasMore: hasNextPage,
isLoadingMore: isFetchingBuckets,
onLoadMore: handleLoadMoreBuckets,
}}
onSelectBucket={onSelectBucket}
/>
</Card>
</>
)}
</>
)}
</div>
)
}
export function StorageFilePicker({
open,
onOpenChange,
projectRef,
returnValue = 'objectPath',
onSelect,
title = 'Choose a file',
}: StorageFilePickerProps) {
const isMobileLayout = useBreakpoint('lg')
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null)
useEffect(() => {
if (!open) {
setSelectedBucket(null)
}
}, [open])
const handleSelect = (value: string) => {
onSelect(value)
onOpenChange(false)
}
const pickerTitle = selectedBucket
? `${title} — ${selectedBucket.id}`
: `${title} — choose bucket`
const headerActions = selectedBucket ? (
<Button
type="text"
size="tiny"
icon={<ChevronLeft size={16} />}
onClick={() => setSelectedBucket(null)}
className="shrink-0"
>
Buckets
</Button>
) : null
const body = selectedBucket ? (
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-col">
<StorageFilePickerExplorer
projectRef={projectRef}
bucketId={selectedBucket.id}
returnValue={returnValue}
onSelect={handleSelect}
forceListView={isMobileLayout}
/>
</div>
) : (
<StorageFilePickerBucketsStep
projectRef={projectRef}
open={open}
onSelectBucket={setSelectedBucket}
/>
)
if (isMobileLayout) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="flex h-[85vh] flex-col gap-0 p-0">
<SheetHeader className="flex flex-row items-center gap-2 border-b border-overlay px-4 py-3 text-left">
{headerActions}
<SheetTitle className="min-w-0 flex-1 text-left">{pickerTitle}</SheetTitle>
</SheetHeader>
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col px-2 pb-3 pt-2">{body}</div>
</SheetContent>
</Sheet>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="xxxlarge"
className="flex max-h-[85vh] max-w-6xl flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="flex flex-row items-center gap-2 border-b border-overlay px-6 py-4">
{headerActions}
<DialogTitle className="min-w-0 flex-1">{pickerTitle}</DialogTitle>
</DialogHeader>
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-3">{body}</div>
</DialogContent>
</Dialog>
)
}
export function useStorageFilePicker() {
const [open, setOpen] = useState(false)
return {
open,
setOpen,
pickProps: {
open,
onOpenChange: setOpen,
} as const,
}
}
+13 -2
View File
@@ -11,6 +11,7 @@ import { components } from 'api-types'
import { get, handleError } from 'data/fetchers'
import { MAX_RETRY_FAILURE_COUNT } from 'data/query-client'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { PROJECT_STATUS } from 'lib/constants'
import {
ResponseError,
@@ -117,11 +118,21 @@ export type BucketsData = Awaited<ReturnType<typeof getBuckets>>
export type BucketsWithPaginationData = Awaited<ReturnType<typeof getBucketsPaginated>>
export type BucketsError = ResponseError
type UseBucketQueryOptions<TData> = UseCustomQueryOptions<BucketData, BucketsError, TData> & {
/** When set, project health/active checks use this ref (e.g. embedded pickers) instead of the route project. */
healthCheckProjectRef?: string
}
export const useBucketQuery = <TData = BucketData>(
{ projectRef, bucketId }: GetBucketParams,
{ enabled = true, ...options }: UseCustomQueryOptions<BucketData, BucketsError, TData>
{ enabled = true, healthCheckProjectRef, ...options }: UseBucketQueryOptions<TData> = {}
) => {
const { data: project } = useSelectedProjectQuery()
const { data: routeProject } = useSelectedProjectQuery({ enabled: !healthCheckProjectRef })
const { data: explicitProject } = useProjectDetailQuery(
{ ref: healthCheckProjectRef },
{ enabled: !!healthCheckProjectRef }
)
const project = healthCheckProjectRef ? explicitProject : routeProject
const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
return useQuery<BucketData, BucketsError, TData>({
+123 -29
View File
@@ -45,7 +45,8 @@ import { deleteBucketObject } from '@/data/storage/bucket-object-delete-mutation
import { signBucketObjects } from '@/data/storage/bucket-object-sign-mutation'
import { listBucketObjects, StorageObject } from '@/data/storage/bucket-objects-list-mutation'
import { deleteBucketPrefix } from '@/data/storage/bucket-prefix-delete-mutation'
import type { Bucket } from '@/data/storage/buckets-query'
import { useBucketQuery, type Bucket } from '@/data/storage/buckets-query'
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
import { moveStorageObject } from '@/data/storage/object-move-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
@@ -80,23 +81,37 @@ if (typeof window !== 'undefined') {
abortController = new AbortController()
}
function createStorageExplorerState({
projectRef,
connectionString,
bucket,
resumableUploadUrl,
clientEndpoint,
}: {
projectRef: string
connectionString: string
bucket?: Bucket
resumableUploadUrl: string
clientEndpoint: string
}) {
export type CreateStorageExplorerStateOptions = {
/** When false, view/sort preferences are not read from or written to localStorage (e.g. embedded file picker). */
persistExplorerPreferences?: boolean
initialView?: STORAGE_VIEWS
}
function createStorageExplorerState(
{
projectRef,
connectionString,
bucket,
resumableUploadUrl,
clientEndpoint,
}: {
projectRef: string
connectionString: string
bucket?: Bucket
resumableUploadUrl: string
clientEndpoint: string
},
explorerOptions?: CreateStorageExplorerStateOptions
) {
const persistExplorerPreferences = explorerOptions?.persistExplorerPreferences ?? true
const localStorageKey = LOCAL_STORAGE_KEYS.STORAGE_PREFERENCE(projectRef)
const { view, sortBy, sortByOrder, sortBucket } =
(typeof window !== 'undefined' && tryParseJson(localStorage?.getItem(localStorageKey))) ||
DEFAULT_PREFERENCES
const storedPrefs =
persistExplorerPreferences && typeof window !== 'undefined'
? tryParseJson(localStorage?.getItem(localStorageKey))
: null
const mergedPrefs = storedPrefs || DEFAULT_PREFERENCES
const view = explorerOptions?.initialView ?? mergedPrefs.view
const { sortBy, sortByOrder, sortBucket } = mergedPrefs
const state = proxy({
projectRef,
@@ -210,12 +225,10 @@ function createStorageExplorerState({
setSelectedFileCustomExpiry: (item?: StorageItem) => (state.selectedFileCustomExpiry = item),
updateExplorerPreference: () => {
const localStorageKey = LOCAL_STORAGE_KEYS.STORAGE_PREFERENCE(projectRef)
if (!persistExplorerPreferences) return
const lsKey = LOCAL_STORAGE_KEYS.STORAGE_PREFERENCE(projectRef)
const { view, sortBy, sortByOrder, sortBucket } = state
localStorage.setItem(
localStorageKey,
JSON.stringify({ view, sortBy, sortByOrder, sortBucket })
)
localStorage.setItem(lsKey, JSON.stringify({ view, sortBy, sortByOrder, sortBucket }))
},
// Functions that manage the UI of the Storage Explorer
@@ -1908,13 +1921,16 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild
const resumableUploadUrl = `${clientEndpoint}/storage/v1/upload/resumable`
setState(
createStorageExplorerState({
projectRef: project.ref,
connectionString: project.connectionString ?? '',
bucket,
resumableUploadUrl,
clientEndpoint,
})
createStorageExplorerState(
{
projectRef: project.ref,
connectionString: project.connectionString ?? '',
bucket,
resumableUploadUrl,
clientEndpoint,
},
{ persistExplorerPreferences: true }
)
)
}
}, [
@@ -1935,6 +1951,84 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild
)
}
export type StorageExplorerEmbeddedStateProviderProps = PropsWithChildren<{
projectRef: string
bucketId: string
persistExplorerPreferences?: boolean
initialView?: STORAGE_VIEWS
}>
/**
* Isolated storage explorer state for embedded UIs (e.g. file picker) that resolves the bucket by id
* without requiring `bucketId` in the route.
*/
export const StorageExplorerEmbeddedStateProvider = ({
children,
projectRef,
bucketId,
persistExplorerPreferences = false,
initialView,
}: StorageExplorerEmbeddedStateProviderProps) => {
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: !!projectRef }
)
const isPaused = project?.status === PROJECT_STATUS.INACTIVE
const { data: bucket } = useBucketQuery(
{ projectRef, bucketId },
{
enabled: !!projectRef && !!bucketId,
healthCheckProjectRef: projectRef,
}
)
const [state, setState] = useState(() => createStorageExplorerState(DEFAULT_STATE_CONFIG))
const {
storageEndpoint,
hostEndpoint,
isSuccess: isSuccessSettings,
} = useProjectApiUrl({ projectRef: project?.ref })
useEffect(() => {
const hasDataReady = !!project && !!bucket
if (!isPaused && hasDataReady && isSuccessSettings) {
const clientEndpoint = storageEndpoint ?? hostEndpoint ?? ''
const resumableUploadUrl = `${clientEndpoint}/storage/v1/upload/resumable`
setState(
createStorageExplorerState(
{
projectRef: project.ref,
connectionString: project.connectionString ?? '',
bucket,
resumableUploadUrl,
clientEndpoint,
},
{ persistExplorerPreferences, initialView }
)
)
}
}, [
isPaused,
project,
bucket,
bucketId,
isSuccessSettings,
hostEndpoint,
storageEndpoint,
persistExplorerPreferences,
initialView,
])
return (
<StorageExplorerStateContext.Provider value={state}>
{children}
</StorageExplorerStateContext.Provider>
)
}
export function useStorageExplorerStateSnapshot(options?: Parameters<typeof useSnapshot>[1]) {
const state = useContext(StorageExplorerStateContext)
return useSnapshot(state, options) as StorageExplorerState