mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-20 01:32:16 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user