Files
supabase/apps/studio/components/interfaces/App/CommandMenu/StorageSearchResults.tsx
Charis b7203bc349 fix(studio): use paginated buckets endpoint for command menu (#42080)
The command menu search option uses the unpaginated version of the
buckets endpoint, which times out / causes jankiness when users have
tends of thousands of buckets.

Switch to using the paginated version. The old behaviour is preserved,
where only the first 10 results are shown, and the user has to specify a
more specific search query to get other buckets. This is for UX
simplicity, since this is a convenience feature, and fully
infinite-loading search is available in the full Storage interface.
2026-01-23 15:18:44 +00:00

326 lines
9.7 KiB
TypeScript

'use client'
import { useDebounce } from '@uidotdev/usehooks'
import { Loader2 } from 'lucide-react'
import { useCallback, useMemo } from 'react'
import { useParams } from 'common'
import {
useIsAnalyticsBucketsEnabled,
useIsVectorBucketsEnabled,
} from 'data/config/project-storage-config-query'
import {
useAnalyticsBucketsQuery,
type AnalyticsBucket,
} from 'data/storage/analytics-buckets-query'
import {
useBucketNumberEstimateQuery,
usePaginatedBucketsQuery,
type Bucket,
} from 'data/storage/buckets-query'
import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query'
import { AnalyticsBucket as AnalyticsBucketIcon, FilesBucket, Storage, VectorBucket } from 'icons'
import {
EmptyState,
ResultsList,
SkeletonResults,
type SearchResult,
} from './ContextSearchResults.shared'
interface StorageSearchResultsProps {
query: string
}
type ExtendedSearchResult = SearchResult & {
bucketType?: 'file' | 'analytics' | 'vector'
bucket?: unknown
}
function filterBuckets<T>(
buckets: T[] | null | undefined,
query: string,
filterFn: (bucket: T, searchLower: string) => boolean,
mapFn: (bucket: T) => ExtendedSearchResult
): ExtendedSearchResult[] {
if (!buckets) return []
const trimmedQuery = query.trim()
const filtered = trimmedQuery
? buckets.filter((bucket) => filterFn(bucket, trimmedQuery.toLowerCase()))
: buckets
return filtered.slice(0, 10).map(mapFn)
}
export function StorageSearchResults({ query }: StorageSearchResultsProps) {
const { ref: projectRef } = useParams()
const isAnalyticsBucketsEnabled = useIsAnalyticsBucketsEnabled({ projectRef })
const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef })
// Debounce the search query to avoid excessive API calls
const debouncedQuery = useDebounce(query.trim(), 300)
const {
data: fileBucketsData,
isLoading: isLoadingFileBuckets,
isError: isErrorFileBuckets,
} = usePaginatedBucketsQuery(
{
projectRef: projectRef ?? undefined,
limit: 10,
search: debouncedQuery.length > 0 ? debouncedQuery : undefined,
},
{
enabled: !!projectRef,
}
)
const fileBuckets = useMemo(
() => fileBucketsData?.pages.flatMap((page) => page) ?? [],
[fileBucketsData]
)
const {
data: analyticsBuckets,
isLoading: isLoadingAnalyticsBuckets,
isError: isErrorAnalyticsBuckets,
} = useAnalyticsBucketsQuery(
{
projectRef: projectRef ?? undefined,
},
{
enabled: !!projectRef && isAnalyticsBucketsEnabled,
}
)
const {
data: vectorBucketsData,
isLoading: isLoadingVectorBuckets,
isError: isErrorVectorBuckets,
} = useVectorBucketsQuery(
{
projectRef: projectRef ?? undefined,
},
{
enabled: !!projectRef && isVectorBucketsEnabled,
}
)
const vectorBuckets = useMemo(() => vectorBucketsData?.vectorBuckets ?? [], [vectorBucketsData])
const { data: fileBucketsEstimate } = useBucketNumberEstimateQuery({
projectRef,
})
const isLoading =
isLoadingFileBuckets ||
(isAnalyticsBucketsEnabled && isLoadingAnalyticsBuckets) ||
(isVectorBucketsEnabled && isLoadingVectorBuckets)
const isError =
isErrorFileBuckets ||
(isAnalyticsBucketsEnabled && isErrorAnalyticsBuckets) ||
(isVectorBucketsEnabled && isErrorVectorBuckets)
const fileBucketResults: ExtendedSearchResult[] = useMemo(() => {
// Server-side search is already applied, no need for client-side filtering
if (!fileBuckets) return []
return fileBuckets.map((bucket) => {
const displayName = bucket.name || bucket.id || 'Untitled Bucket'
const visibility = bucket.public ? 'Public' : 'Private'
const description = `File bucket • ${visibility}`
return {
id: `file-bucket-${bucket.id || bucket.name}`,
name: displayName,
description,
bucketType: 'file' as const,
bucket,
}
})
}, [fileBuckets])
const analyticsBucketResults: ExtendedSearchResult[] = useMemo(() => {
return filterBuckets(
analyticsBuckets,
debouncedQuery, // Use debounced query for consistency
(bucket, searchLower) => {
const bucketName = bucket.name?.toLowerCase() || ''
return bucketName.includes(searchLower)
},
(bucket) => {
const displayName = bucket.name || 'Untitled Bucket'
const description = 'Analytics bucket'
return {
id: `analytics-bucket-${bucket.name}`,
name: displayName,
description,
bucketType: 'analytics' as const,
bucket,
}
}
)
}, [analyticsBuckets, debouncedQuery])
const vectorBucketResults: ExtendedSearchResult[] = useMemo(() => {
return filterBuckets(
vectorBuckets,
debouncedQuery, // Use debounced query for consistency
(bucket, searchLower) => {
const bucketName = bucket.vectorBucketName?.toLowerCase() || ''
return bucketName.includes(searchLower)
},
(bucket) => {
const displayName = bucket.vectorBucketName || 'Untitled Bucket'
const description = 'Vector bucket'
return {
id: `vector-bucket-${bucket.vectorBucketName}`,
name: displayName,
description,
bucketType: 'vector' as const,
bucket,
}
}
)
}, [vectorBuckets, debouncedQuery])
const allResults: ExtendedSearchResult[] = useMemo(() => {
const results = [fileBucketResults]
if (isAnalyticsBucketsEnabled) {
results.push(analyticsBucketResults)
}
if (isVectorBucketsEnabled) {
results.push(vectorBucketResults)
}
return results.flat().slice(0, 20)
}, [
fileBucketResults,
analyticsBucketResults,
vectorBucketResults,
isAnalyticsBucketsEnabled,
isVectorBucketsEnabled,
])
const getRoute = useCallback(
(result: SearchResult) => {
if (!projectRef) return '/storage/files' as `/${string}`
const extendedResult = result as ExtendedSearchResult
if (extendedResult.bucketType && extendedResult.bucket) {
const bucketType = extendedResult.bucketType
if (bucketType === 'file') {
const fileBucket = extendedResult.bucket as Bucket
return `/project/${projectRef}/storage/files/buckets/${encodeURIComponent(fileBucket.name)}` as `/${string}`
}
if (bucketType === 'analytics') {
const analyticsBucket = extendedResult.bucket as AnalyticsBucket
return `/project/${projectRef}/storage/analytics/buckets/${encodeURIComponent(analyticsBucket.name)}` as `/${string}`
}
if (bucketType === 'vector') {
const vectorBucket = extendedResult.bucket as { vectorBucketName: string }
return `/project/${projectRef}/storage/vectors/buckets/${encodeURIComponent(vectorBucket.vectorBucketName)}` as `/${string}`
}
}
return `/project/${projectRef}/storage/files` as `/${string}`
},
[projectRef]
)
const totalBucketsEstimate = useMemo(() => {
const fileBucketCount = fileBucketsEstimate ?? 0
const analyticsBucketCount = isAnalyticsBucketsEnabled ? analyticsBuckets?.length ?? 0 : 0
const vectorBucketCount = isVectorBucketsEnabled ? vectorBuckets?.length ?? 0 : 0
return fileBucketCount + analyticsBucketCount + vectorBucketCount
}, [
fileBucketsEstimate,
analyticsBuckets?.length,
vectorBuckets?.length,
isAnalyticsBucketsEnabled,
isVectorBucketsEnabled,
])
const getIcon = useCallback((result: SearchResult) => {
const extendedResult = result as ExtendedSearchResult
if (extendedResult.bucketType === 'file') return FilesBucket
if (extendedResult.bucketType === 'analytics') return AnalyticsBucketIcon
if (extendedResult.bucketType === 'vector') return VectorBucket
return Storage
}, [])
const renderFooter = () => (
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between min-h-9 h-9 px-4 border-t bg-surface-200 text-xs text-foreground-light z-10">
<div className="flex items-center gap-x-2">
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" /> Loading...
</span>
) : (
<span>
Total: {totalBucketsEstimate.toLocaleString()} bucket
{totalBucketsEstimate !== 1 ? 's' : ''} (estimate)
</span>
)}
</div>
</div>
)
if (isLoading) {
return (
<div className="relative h-full flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden">
<SkeletonResults />
</div>
{renderFooter()}
</div>
)
}
if (isError) {
return (
<div className="relative h-full flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full flex flex-col items-center justify-center py-12 px-4 gap-4 text-center text-foreground-lighter">
<Storage className="h-6 w-6" strokeWidth={1.5} />
<p className="text-sm">Failed to load storage buckets</p>
</div>
</div>
{renderFooter()}
</div>
)
}
if (allResults.length === 0) {
return (
<div className="relative h-full flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden">
<EmptyState icon={Storage} label="Storage" query={debouncedQuery} />
</div>
{renderFooter()}
</div>
)
}
return (
<div className="relative h-full flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden">
<ResultsList
results={allResults}
icon={Storage}
getIcon={getIcon}
getRoute={getRoute}
className="pb-9"
/>
</div>
{renderFooter()}
</div>
)
}