mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-25 11:56:48 +00:00
feat(backend,frontend): implement prefix and recursive substring search for bucket objects (#89)
* feat(search): implement recursive substring search for bucket objects * fix: update key formatting to remove trailing slashes in ObjectsTable * feat(search): implement debounced search functionality and improve UI text clarity * test(objects): add test for handling search error response
This commit is contained in:
@@ -6,7 +6,7 @@ import {ObjectsTable} from './ObjectsTable';
|
||||
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
|
||||
import {DeleteObjectDialog} from './DeleteObjectDialog';
|
||||
import {UploadProgress} from './UploadProgress';
|
||||
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
|
||||
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, ScanSearch, Search, Trash, Upload} from 'lucide-react';
|
||||
import {getBreadcrumbs} from '@/lib/file-utils';
|
||||
import type {S3Object, UploadTask} from '@/types';
|
||||
|
||||
@@ -15,11 +15,14 @@ interface ObjectBrowserViewProps {
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
filterQuery: string;
|
||||
deepSearch: boolean;
|
||||
isLoading?: boolean;
|
||||
isTruncated?: boolean;
|
||||
nextContinuationToken?: string;
|
||||
itemsPerPage: number;
|
||||
onSearchChange: (query: string) => void;
|
||||
onDeepSearchChange: (enabled: boolean) => void;
|
||||
onNavigateToFolder: (path: string) => void;
|
||||
onBackToBuckets: () => void;
|
||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
||||
@@ -41,11 +44,14 @@ export function ObjectBrowserView({
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
filterQuery,
|
||||
deepSearch,
|
||||
isLoading = false,
|
||||
isTruncated = false,
|
||||
nextContinuationToken,
|
||||
itemsPerPage,
|
||||
onSearchChange,
|
||||
onDeepSearchChange,
|
||||
onNavigateToFolder,
|
||||
onBackToBuckets,
|
||||
onUploadFiles,
|
||||
@@ -199,14 +205,31 @@ export function ObjectBrowserView({
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search objects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
<div className="flex flex-1 items-center gap-2 max-w-full sm:max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={deepSearch ? 'Deep search names…' : 'Search by name prefix…'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={deepSearch ? 'primary' : 'secondary'}
|
||||
onClick={() => onDeepSearchChange(!deepSearch)}
|
||||
aria-pressed={deepSearch}
|
||||
title={
|
||||
deepSearch
|
||||
? 'Deep search: ON. Matches names anywhere and descends into subfolders. Scans the bucket, results may be partial on very large buckets. Click for fast prefix search.'
|
||||
: 'Fast prefix search: matches the start of object names in this folder (like the AWS S3 / Cloudflare R2 console). Click to enable deep search (substring + subfolders).'
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ScanSearch className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Deep</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedFileKeys.size > 0 && (
|
||||
@@ -352,6 +375,8 @@ export function ObjectBrowserView({
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
filterQuery={filterQuery}
|
||||
deepSearch={deepSearch}
|
||||
selectedFileKeys={selectedFileKeys}
|
||||
isDragActive={isDragActive}
|
||||
isLoading={isLoading && !isRefreshing && !isNavigating}
|
||||
|
||||
@@ -22,6 +22,8 @@ interface ObjectsTableProps {
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
filterQuery: string;
|
||||
deepSearch: boolean;
|
||||
selectedFileKeys: Set<string>;
|
||||
isDragActive: boolean;
|
||||
isLoading?: boolean;
|
||||
@@ -46,6 +48,8 @@ export function ObjectsTable({
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
filterQuery,
|
||||
deepSearch,
|
||||
selectedFileKeys,
|
||||
isDragActive,
|
||||
isLoading = false,
|
||||
@@ -86,7 +90,9 @@ export function ObjectsTable({
|
||||
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
||||
|
||||
const filteredObjects = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
// Filter on the debounced query, not the raw input, so the list only
|
||||
// updates once typing pauses (matches the debounced server request).
|
||||
const query = filterQuery.toLowerCase();
|
||||
const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query));
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aIsFolder = a.isFolder ? 1 : 0;
|
||||
@@ -96,8 +102,8 @@ export function ObjectsTable({
|
||||
let compareValue = 0;
|
||||
switch (sortColumn) {
|
||||
case 'name': {
|
||||
const aName = a.key.replace(currentPath, '').replace('/', '').toLowerCase();
|
||||
const bName = b.key.replace(currentPath, '').replace('/', '').toLowerCase();
|
||||
const aName = a.key.replace(currentPath, '').replace(/\/$/, '').toLowerCase();
|
||||
const bName = b.key.replace(currentPath, '').replace(/\/$/, '').toLowerCase();
|
||||
compareValue = aName.localeCompare(bName);
|
||||
break;
|
||||
}
|
||||
@@ -114,13 +120,15 @@ export function ObjectsTable({
|
||||
|
||||
return sortDirection === 'asc' ? compareValue : -compareValue;
|
||||
});
|
||||
}, [objects, searchQuery, sortColumn, sortDirection, currentPath]);
|
||||
}, [objects, filterQuery, sortColumn, sortDirection, currentPath]);
|
||||
|
||||
// Effect 2: Reset pagination ONLY on path navigation
|
||||
// Effect 2: Reset pagination on path navigation or when a search begins/ends.
|
||||
// Search results are a single flat list, so page-token state must not leak
|
||||
// across the search/browse boundary.
|
||||
useEffect(() => {
|
||||
setPageTokens([undefined]);
|
||||
setCurrentPageIndex(0);
|
||||
}, [currentPath]);
|
||||
}, [currentPath, searchQuery, deepSearch]);
|
||||
|
||||
// Update page tokens when we get a new next token
|
||||
useEffect(() => {
|
||||
@@ -137,11 +145,33 @@ export function ObjectsTable({
|
||||
}
|
||||
}, [nextContinuationToken, isTruncated, currentPageIndex]);
|
||||
|
||||
const hasPrevious = currentPageIndex > 0;
|
||||
const hasNext = isTruncated;
|
||||
// Prefix search and normal browsing are server-paginated (query folded into
|
||||
// the prefix; continuation tokens for pages). Deep search loads the whole
|
||||
// capped result set in one response, so we paginate that on the client by
|
||||
// itemsPerPage instead of dumping every match at once.
|
||||
const isDeepSearching = deepSearch && searchQuery.trim().length > 0;
|
||||
const clientPaginated = isDeepSearching;
|
||||
const totalPages = clientPaginated
|
||||
? Math.max(1, Math.ceil(filteredObjects.length / itemsPerPage))
|
||||
: 1;
|
||||
// Clamp during render (not via a setState effect) so a shrinking result set
|
||||
// or a larger page size can't strand us on an out-of-range page.
|
||||
const pageIndex = clientPaginated ? Math.min(currentPageIndex, totalPages - 1) : currentPageIndex;
|
||||
const pageObjects = clientPaginated
|
||||
? filteredObjects.slice(pageIndex * itemsPerPage, (pageIndex + 1) * itemsPerPage)
|
||||
: filteredObjects;
|
||||
const hasPrevious = pageIndex > 0;
|
||||
const hasNext = clientPaginated ? pageIndex < totalPages - 1 : isTruncated;
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasNext && nextContinuationToken) {
|
||||
if (!hasNext) return;
|
||||
// Client-paginated (deep search): just advance the slice, no server fetch.
|
||||
if (clientPaginated) {
|
||||
setCurrentPageIndex(pageIndex + 1);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
if (nextContinuationToken) {
|
||||
const nextIndex = currentPageIndex + 1;
|
||||
setCurrentPageIndex(nextIndex);
|
||||
onPageChange(nextContinuationToken);
|
||||
@@ -150,13 +180,16 @@ export function ObjectsTable({
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (hasPrevious) {
|
||||
const prevIndex = currentPageIndex - 1;
|
||||
setCurrentPageIndex(prevIndex);
|
||||
const previousToken = pageTokens[prevIndex];
|
||||
onPageChange(previousToken);
|
||||
if (!hasPrevious) return;
|
||||
if (clientPaginated) {
|
||||
setCurrentPageIndex(pageIndex - 1);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
const prevIndex = currentPageIndex - 1;
|
||||
setCurrentPageIndex(prevIndex);
|
||||
onPageChange(pageTokens[prevIndex]);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
@@ -235,7 +268,7 @@ export function ObjectsTable({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredObjects.map((obj) => (
|
||||
pageObjects.map((obj) => (
|
||||
<TableRow key={obj.key}>
|
||||
<TableCell className="w-[50px]">
|
||||
{obj.isFolder ? (
|
||||
@@ -265,7 +298,7 @@ export function ObjectsTable({
|
||||
onClick={() => onNavigateToFolder(obj.key)}
|
||||
className="font-medium cursor-pointer underline hover:text-primary"
|
||||
>
|
||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||
{obj.key.replace(currentPath, '').replace(/\/$/, '')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -395,7 +428,9 @@ export function ObjectsTable({
|
||||
{/* Pagination info and controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPageIndex + 1} • Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''}
|
||||
{isDeepSearching
|
||||
? `Page ${pageIndex + 1} of ${totalPages} • ${filteredObjects.length} match${filteredObjects.length !== 1 ? 'es' : ''}${isTruncated ? ' (capped, refine to narrow)' : ''}`
|
||||
: `Page ${pageIndex + 1} • Showing ${pageObjects.length} item${pageObjects.length !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -3,7 +3,11 @@ import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object, UploadTask } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
|
||||
// How long to wait after the last keystroke before actually searching. Keeps
|
||||
// typing from firing a request (and a client-side re-filter) on every key.
|
||||
const SEARCH_DEBOUNCE_MS = 750;
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '', searchQuery: string = '', deepSearch: boolean = false) {
|
||||
const [objects, setObjects] = useState<S3Object[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -13,13 +17,25 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const previousPathRef = useRef<string>(currentPath);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
const clearTasksTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Monotonic sequence guarding against stale responses: when a newer fetch or
|
||||
// search starts, older in-flight responses are discarded instead of clobbering
|
||||
// the current view (e.g. a slow search resolving after the query was cleared).
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
// Prefix search (the default) narrows the Garage listing to keys starting with
|
||||
// the query, within the current folder — server-side, paginated, and O(matches)
|
||||
// like the AWS S3 / R2 consoles. Deep search instead uses a recursive scan
|
||||
// (see searchObjects) and does not touch listPrefix.
|
||||
const listPrefix = debouncedSearch && !deepSearch ? currentPath + debouncedSearch : currentPath;
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
|
||||
const seq = ++fetchSeqRef.current;
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setIsRefreshing(true);
|
||||
@@ -29,30 +45,70 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
const response = await objectsApi.list(bucketName, currentPath, itemsPerPage, continuationToken);
|
||||
const response = await objectsApi.list(bucketName, listPrefix, itemsPerPage, continuationToken);
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setObjects(response.objects);
|
||||
setIsTruncated(response.isTruncated);
|
||||
setNextContinuationToken(response.nextContinuationToken);
|
||||
setCurrentContinuationToken(continuationToken);
|
||||
} catch (err) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch objects:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setIsNavigating(false);
|
||||
if (seq === fetchSeqRef.current) {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setIsNavigating(false);
|
||||
}
|
||||
}
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
}, [bucketName, listPrefix, itemsPerPage]);
|
||||
|
||||
const searchObjects = useCallback(async (query: string) => {
|
||||
if (!bucketName) return;
|
||||
|
||||
const seq = ++fetchSeqRef.current;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await objectsApi.search(bucketName, query, currentPath || undefined);
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setObjects(response.objects);
|
||||
setIsTruncated(response.isTruncated);
|
||||
// Search results are not token-paginated.
|
||||
setNextContinuationToken(undefined);
|
||||
setCurrentContinuationToken(undefined);
|
||||
} catch (err) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setError(err as Error);
|
||||
console.error('Failed to search objects:', err);
|
||||
} finally {
|
||||
if (seq === fetchSeqRef.current) setIsLoading(false);
|
||||
}
|
||||
}, [bucketName, currentPath]);
|
||||
|
||||
// Debounce the search query so we don't fire a recursive scan per keystroke.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchQuery.trim()), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Deep search: recursive substring scan across the current subtree.
|
||||
if (debouncedSearch && deepSearch) {
|
||||
searchObjects(debouncedSearch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal listing, or prefix-filtered listing (listPrefix carries the query).
|
||||
const isPathChange = previousPathRef.current !== currentPath && objects.length > 0;
|
||||
previousPathRef.current = currentPath;
|
||||
|
||||
fetchObjects(undefined, false, isPathChange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
}, [bucketName, currentPath, itemsPerPage, debouncedSearch, deepSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -194,6 +250,9 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
|
||||
return {
|
||||
objects,
|
||||
// The debounced query the current results reflect — use this (not the raw
|
||||
// input) to filter/label results so the view waits instead of twitching.
|
||||
debouncedSearch,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
|
||||
@@ -293,6 +293,39 @@ export const objectsApi = {
|
||||
};
|
||||
},
|
||||
|
||||
// Recursive, best-effort substring search across all objects under `prefix`.
|
||||
// The backend scans and filters (S3/Garage has no server-side substring
|
||||
// search), so this finds matches regardless of which page they'd be on.
|
||||
search: async (bucket: string, query: string, prefix?: string): Promise<ObjectListResponse> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params: any = { search: query };
|
||||
if (prefix) params.prefix = prefix;
|
||||
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects`, { params });
|
||||
const data = response.data.data;
|
||||
|
||||
// Search returns a flat list of matching objects (no folders/prefixes).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const objects: S3Object[] = data.objects?.map((obj: any) => ({
|
||||
key: obj.key,
|
||||
size: obj.size,
|
||||
lastModified: obj.last_modified,
|
||||
etag: obj.etag,
|
||||
contentType: obj.content_type,
|
||||
storageClass: obj.storage_class,
|
||||
isFolder: false,
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
bucket: data.bucket,
|
||||
objects,
|
||||
prefixes: [],
|
||||
count: data.count,
|
||||
isTruncated: data.is_truncated || false,
|
||||
nextContinuationToken: data.next_continuation_token,
|
||||
};
|
||||
},
|
||||
|
||||
get: async (bucket: string, key: string): Promise<Blob> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, {
|
||||
responseType: 'blob'
|
||||
|
||||
@@ -662,7 +662,7 @@ export function AccessControl() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle>API key created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy your secret access key now — this is the only time it will be shown.
|
||||
Copy your secret access key now, this is the only time it will be shown.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
@@ -742,7 +742,7 @@ export function AccessControl() {
|
||||
<div className="flex-1">
|
||||
<div className="text-[13.5px] font-medium">Grant bucket permissions now</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Optional — you can also do this later from the key's edit menu.
|
||||
Optional, you can also do this later from the key's edit menu.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -10,6 +10,7 @@ export function BucketObjects() {
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [deepSearch, setDeepSearch] = useState(false);
|
||||
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
|
||||
searchParams.get('page') ?? undefined,
|
||||
);
|
||||
@@ -27,6 +28,7 @@ export function BucketObjects() {
|
||||
|
||||
const {
|
||||
objects,
|
||||
debouncedSearch,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
@@ -40,10 +42,13 @@ export function BucketObjects() {
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
fetchObjects,
|
||||
} = useBucketObjects(bucketName, currentPath);
|
||||
} = useBucketObjects(bucketName, currentPath, searchQuery, deepSearch);
|
||||
|
||||
const handleNavigateToFolder = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
// Navigating to a folder should show that folder's contents, not a stale
|
||||
// filter carried over from the folder we came from.
|
||||
setSearchQuery('');
|
||||
const next = new URLSearchParams();
|
||||
if (path) next.set('prefix', path);
|
||||
setSearchParams(next);
|
||||
@@ -97,11 +102,14 @@ export function BucketObjects() {
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
filterQuery={debouncedSearch}
|
||||
deepSearch={deepSearch}
|
||||
isLoading={isLoading}
|
||||
isTruncated={isTruncated}
|
||||
nextContinuationToken={nextContinuationToken}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onSearchChange={setSearchQuery}
|
||||
onDeepSearchChange={setDeepSearch}
|
||||
onNavigateToFolder={handleNavigateToFolder}
|
||||
onBackToBuckets={handleBackToBuckets}
|
||||
onUploadFiles={uploadFiles}
|
||||
|
||||
Reference in New Issue
Block a user