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:
Noste
2026-07-11 17:09:27 +02:00
committed by GitHub
parent b28e975a56
commit 3098e474f2
12 changed files with 494 additions and 56 deletions
+66 -7
View File
@@ -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,