mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-12 14:38:02 +00:00
5d33382e30
* feat(backend,frontend): recursive delete of key prefixes in bulk actions
Bulk object selection previously only supported deleting individually
listed object keys — folders ("prefixes") could not be selected or
deleted, leaving no way to remove a directory and all of its contents.
Backend:
- Add S3Service.DeleteObjectsByPrefix, which recursively lists every
object under a prefix and batch-deletes them, returning the count.
- Extend the delete-multiple endpoint to accept a "prefixes" array
alongside "keys"; keys are batch-deleted and each prefix is deleted
recursively. Response now reports the total objects removed.
Frontend:
- Enable folder checkboxes and add a per-folder "Delete folder" action.
- Select-all now covers both files and folders.
- Route all bulk/folder deletes through a confirmation dialog that
spells out the file/folder counts and warns that folders are removed
recursively (previously bulk delete fired with no confirmation).
- api/hook send "prefixes"; optimistic update drops objects under any
deleted prefix.
* fix(backend): validate delete prefixes and count actual removals
Addresses maintainer review on the recursive prefix-delete endpoint:
- Reject blank/whitespace-only prefixes with a 400 instead of a 500, and
normalize each prefix to have a trailing "/" so "photos/2024" can no
longer also delete siblings like "photos/2024-old/..." on this
irreversible public endpoint.
- DeleteMultipleObjects now returns the number of objects actually
removed (requested keys minus failures) rather than assuming every
requested key was deleted; the handler sums real counts across the
keys and prefix paths. Draining the full RemoveObjects error channel
also fixes a potential sender-goroutine leak on early return.
- Add tests: blank-prefix -> 400, prefix trailing-slash normalization,
and S3Service.DeleteObjectsByPrefix (list-then-delete, empty prefix,
no-match, and list-error propagation).
* fix(frontend): align select-all with the active search filter
The header "select all" checkbox derived its checked state from the
filtered (searched) rows, but handleSelectAll operated on the full,
unfiltered object list — so with a search active it selected hidden
items and the checkbox state disagreed with the selection.
ObjectsTable now passes the keys of the currently visible (filtered)
rows to onSelectAll, and its checked state reflects whether every
visible row is selected. handleSelectAll toggles only those visible
rows, leaving any off-screen selection intact.
* fix(frontend): scope select-all to the visible page
After merging upstream's client-side deep-search pagination, the rendered
rows are pageObjects (one page slice) while select-all still operated on
filteredObjects (every match across hidden pages). Scope the header
checkbox's state and its select-all action to pageObjects so one click
never selects off-screen rows for a destructive bulk delete. In
normal/prefix browsing pageObjects === filteredObjects, so behavior there
is unchanged.
---------
Authored-by: Camilo Hollanda <775409+prem-prakash@users.noreply.github.com>
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { objectsApi } from '@/lib/api';
|
|
import type { S3Object, UploadTask } from '@/types';
|
|
import { toast } from 'sonner';
|
|
|
|
// 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);
|
|
const [isNavigating, setIsNavigating] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [isTruncated, setIsTruncated] = useState(false);
|
|
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);
|
|
} else if (isNav) {
|
|
setIsNavigating(true);
|
|
} else {
|
|
setIsLoading(true);
|
|
}
|
|
setError(null);
|
|
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 {
|
|
if (seq === fetchSeqRef.current) {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
setIsNavigating(false);
|
|
}
|
|
}
|
|
}, [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, debouncedSearch, deepSearch]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (clearTasksTimerRef.current) clearTimeout(clearTasksTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const uploadFiles = useCallback(async (files: File[]) => {
|
|
if (!bucketName) return false;
|
|
|
|
const hasRelativePaths = files.some((file) => !!file.webkitRelativePath);
|
|
|
|
const folders = new Set<string>();
|
|
files.forEach((file) => {
|
|
if (file.webkitRelativePath) {
|
|
const parts = file.webkitRelativePath.split('/');
|
|
if (parts.length > 1) {
|
|
folders.add(parts[0]);
|
|
}
|
|
}
|
|
});
|
|
|
|
const tasks: UploadTask[] = files.map((file, index) => {
|
|
const relativePath = file.webkitRelativePath || file.name;
|
|
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
|
return {
|
|
id: `${Date.now()}-${index}`,
|
|
file,
|
|
key,
|
|
bucket: bucketName,
|
|
progress: 0,
|
|
status: 'pending' as const,
|
|
};
|
|
});
|
|
|
|
setUploadTasks(tasks);
|
|
|
|
const results = await Promise.all(tasks.map(async (task) => {
|
|
try {
|
|
setUploadTasks(prev => prev.map(t =>
|
|
t.id === task.id ? { ...t, status: 'uploading' as const } : t
|
|
));
|
|
|
|
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
|
|
setUploadTasks(prev => prev.map(t => {
|
|
if (t.id !== task.id || t.progress === progress) return t;
|
|
return { ...t, progress };
|
|
}));
|
|
});
|
|
|
|
setUploadTasks(prev => prev.map(t =>
|
|
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
|
|
));
|
|
return true;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
|
setUploadTasks(prev => prev.map(t =>
|
|
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
|
|
));
|
|
console.error(`Failed to upload ${task.key}:`, error);
|
|
return false;
|
|
}
|
|
}));
|
|
|
|
const successCount = results.filter(Boolean).length;
|
|
const errorCount = results.length - successCount;
|
|
|
|
if (errorCount === 0) {
|
|
if (hasRelativePaths && folders.size > 0) {
|
|
const folderNames = Array.from(folders).join(', ');
|
|
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
|
|
} else {
|
|
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''}`);
|
|
}
|
|
} else if (successCount > 0) {
|
|
toast.warning(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}, ${errorCount} failed`);
|
|
} else {
|
|
toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`);
|
|
}
|
|
|
|
if (clearTasksTimerRef.current) clearTimeout(clearTasksTimerRef.current);
|
|
clearTasksTimerRef.current = setTimeout(() => {
|
|
setUploadTasks([]);
|
|
clearTasksTimerRef.current = null;
|
|
}, 3000);
|
|
|
|
await fetchObjects(currentContinuationToken, true);
|
|
return successCount > 0;
|
|
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
|
|
|
const deleteObject = useCallback(async (key: string) => {
|
|
if (!bucketName) return false;
|
|
|
|
try {
|
|
setObjects(prev => prev.filter(obj => obj.key !== key));
|
|
|
|
await objectsApi.delete(bucketName, key);
|
|
toast.success(`Object "${key}" deleted successfully`);
|
|
await fetchObjects(currentContinuationToken, true);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Delete object error:', error);
|
|
await fetchObjects(currentContinuationToken, true);
|
|
return false;
|
|
}
|
|
}, [bucketName, currentContinuationToken, fetchObjects]);
|
|
|
|
// Deletes the selected object keys and recursively deletes every object under
|
|
// each selected folder prefix.
|
|
const deleteMultipleObjects = useCallback(async (keys: string[], prefixes: string[] = []) => {
|
|
if (!bucketName || (keys.length === 0 && prefixes.length === 0)) return false;
|
|
|
|
try {
|
|
const keySet = new Set(keys);
|
|
setObjects(prev => prev.filter(obj =>
|
|
!keySet.has(obj.key) && !prefixes.some(prefix => obj.key.startsWith(prefix))
|
|
));
|
|
|
|
await objectsApi.deleteMultiple(bucketName, keys, prefixes);
|
|
|
|
const fileLabel = keys.length > 0 ? `${keys.length} file${keys.length > 1 ? 's' : ''}` : '';
|
|
const folderLabel = prefixes.length > 0 ? `${prefixes.length} folder${prefixes.length > 1 ? 's' : ''}` : '';
|
|
const summary = [fileLabel, folderLabel].filter(Boolean).join(' and ');
|
|
toast.success(`Successfully deleted ${summary}`);
|
|
|
|
await fetchObjects(currentContinuationToken, true);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Bulk delete error:', error);
|
|
await fetchObjects(currentContinuationToken, true);
|
|
return false;
|
|
}
|
|
}, [bucketName, currentContinuationToken, fetchObjects]);
|
|
|
|
const createDirectory = useCallback(async (dirName: string) => {
|
|
if (!bucketName) return false;
|
|
|
|
try {
|
|
const dirKey = currentPath ? `${currentPath}${dirName}/` : `${dirName}/`;
|
|
await objectsApi.createDirectory(bucketName, dirKey);
|
|
toast.success(`Directory "${dirName}" created successfully`);
|
|
await fetchObjects(currentContinuationToken, true);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Create directory error:', error);
|
|
return false;
|
|
}
|
|
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
|
|
|
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,
|
|
error,
|
|
isTruncated,
|
|
nextContinuationToken,
|
|
currentContinuationToken,
|
|
itemsPerPage,
|
|
setItemsPerPage,
|
|
fetchObjects,
|
|
uploadFiles,
|
|
uploadTasks,
|
|
deleteObject,
|
|
deleteMultipleObjects,
|
|
createDirectory,
|
|
};
|
|
}
|