Files
supabase/apps/studio/data/storage/bucket-folders-query.ts
Francesco SansalvadoreandClaude 92fdb1d3c5 feat: update storage move UI (#50346)
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>
2026-09-16 09:38:15 +02:00

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,
})