mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
Update path selection as destination where to move files in the Storage File Explorer. ## What is the current behavior? Currently you need to write out the entire path by hand, which is error prone and quirky. <img width="727" height="436" alt="Screenshot 2026-09-14 at 15 49 11" src="https://github.com/user-attachments/assets/2bb8fc78-d973-4b17-9343-08df23b67d2a" /> ## What is the new behavior? This PR adds a ui that lets the user select any folder as the destination of the file move. <img width="923" height="606" alt="Screenshot 2026-09-15 at 11 40 00" src="https://github.com/user-attachments/assets/2f4c50f8-3c11-4896-832e-b1e99defc9af" /> https://github.com/user-attachments/assets/261ac24e-ec24-4bcf-ad47-72bc48e27bab To test: - go to Storage File explorer and pick a file to "move" (action in the dropdown menu) - mov file to any other folder in the same bucket selecting destination folder from the ui in the dialog - both empty folder or also a folder with sub-folders can be destinations, as any selected folder becomes the active destination (notice the cta changing when selecting a folder) - batch move multiple items via multi-select (already supported, but using the updated ui now) - only folders should appear in this ui - destination folders can be searched using the search input <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Replaced the move-items path field with an interactive folder browser. - Browse, select, and search folders by name or path, with pagination and loading or empty states. - Navigate using breadcrumbs, including collapsed-path menus for deeply nested folders. - Receive warnings when folder search results are incomplete for very large buckets. - See clearer destination labels and protection against moving items to their current location. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude <noreply@anthropic.com>
122 lines
3.8 KiB
TypeScript
122 lines
3.8 KiB
TypeScript
import { queryOptions } from '@tanstack/react-query'
|
|
|
|
import { listBucketObjects } from './bucket-objects-list-mutation'
|
|
import { storageKeys } from './keys'
|
|
import type { ResponseError } from '@/types'
|
|
|
|
/** Number of objects requested per folder listing */
|
|
const PAGE_LIMIT = 1000
|
|
/** Cap on how many listings the crawl issues */
|
|
const MAX_LISTINGS = 200
|
|
/** Cap on how many folders are collected */
|
|
const MAX_FOLDERS = 1000
|
|
/** How many listings are issued at a time while crawling */
|
|
const CONCURRENCY = 5
|
|
|
|
export type StorageFolder = {
|
|
name: string
|
|
path: string
|
|
}
|
|
|
|
export type BucketFoldersVariables = {
|
|
projectRef?: string
|
|
bucketId?: string
|
|
}
|
|
|
|
export type BucketFoldersData = {
|
|
folders: StorageFolder[]
|
|
/** True when the crawl hit its limits, so `folders` is only part of the bucket */
|
|
isTruncated: boolean
|
|
}
|
|
|
|
export type BucketFoldersError = ResponseError
|
|
|
|
/** Pages through a single folder, counting each page fetched against the shared `listingsState` */
|
|
const listFolderNames = async (
|
|
{ projectRef, bucketId, path }: { projectRef: string; bucketId: string; path: string },
|
|
listingsState: { count: number },
|
|
signal?: AbortSignal
|
|
): Promise<{ names: string[]; isTruncated: boolean }> => {
|
|
const names: string[] = []
|
|
let offset = 0
|
|
|
|
while (true) {
|
|
if (listingsState.count >= MAX_LISTINGS) {
|
|
return { names, isTruncated: true }
|
|
}
|
|
|
|
listingsState.count++
|
|
const objects = await listBucketObjects(
|
|
{
|
|
projectRef,
|
|
bucketId,
|
|
path,
|
|
options: { limit: PAGE_LIMIT, offset, sortBy: { column: 'name', order: 'asc' } },
|
|
},
|
|
signal
|
|
)
|
|
|
|
const page = objects ?? []
|
|
// Objects without an id are prefixes (folders) rather than files
|
|
names.push(...page.filter((object) => !object.id).map((object) => object.name))
|
|
|
|
if (page.length < PAGE_LIMIT) return { names, isTruncated: false }
|
|
offset += PAGE_LIMIT
|
|
}
|
|
}
|
|
|
|
/** Crawls a bucket breadth first for every folder, stopping early once `MAX_LISTINGS`/`MAX_FOLDERS` is hit */
|
|
async function getBucketFolders(
|
|
{ projectRef, bucketId }: BucketFoldersVariables,
|
|
signal?: AbortSignal
|
|
): Promise<BucketFoldersData> {
|
|
if (!projectRef) throw new Error('projectRef is required')
|
|
if (!bucketId) throw new Error('bucketId is required')
|
|
|
|
const folders: StorageFolder[] = []
|
|
let queue = ['']
|
|
const listingsState = { count: 0 }
|
|
let isTruncated = false
|
|
|
|
while (queue.length > 0) {
|
|
if (listingsState.count >= MAX_LISTINGS || folders.length >= MAX_FOLDERS) {
|
|
isTruncated = true
|
|
break
|
|
}
|
|
|
|
const batch = queue.slice(0, CONCURRENCY)
|
|
queue = queue.slice(CONCURRENCY)
|
|
|
|
const results = await Promise.all(
|
|
batch.map(async (path) => {
|
|
const { names, isTruncated: isPathTruncated } = await listFolderNames(
|
|
{ projectRef, bucketId, path },
|
|
listingsState,
|
|
signal
|
|
)
|
|
return { path, names, isTruncated: isPathTruncated }
|
|
})
|
|
)
|
|
|
|
for (const { path, names, isTruncated: isPathTruncated } of results) {
|
|
if (isPathTruncated) isTruncated = true
|
|
for (const name of names) {
|
|
const folderPath = path.length > 0 ? `${path}/${name}` : name
|
|
folders.push({ name, path: folderPath })
|
|
queue.push(folderPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
return { folders: folders.slice(0, MAX_FOLDERS), isTruncated: isTruncated || queue.length > 0 }
|
|
}
|
|
|
|
export const bucketFoldersQueryOptions = ({ projectRef, bucketId }: BucketFoldersVariables) =>
|
|
queryOptions({
|
|
queryKey: storageKeys.folders(projectRef, bucketId),
|
|
queryFn: ({ signal }) => getBucketFolders({ projectRef, bucketId }, signal),
|
|
enabled: typeof projectRef !== 'undefined' && typeof bucketId !== 'undefined',
|
|
// The crawl is expensive, so hold onto the result for the lifetime of a picker session
|
|
staleTime: 60 * 1000,
|
|
})
|