feat: bulk actions — recursively delete folders (key prefixes) (#68)

* 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>
This commit is contained in:
Camilo Hollanda
2026-07-14 18:11:23 -03:00
committed by GitHub
parent 477b544be7
commit 5d33382e30
13 changed files with 581 additions and 87 deletions
@@ -5,6 +5,7 @@ import {Input} from '@/components/ui/input';
import {ObjectsTable} from './ObjectsTable';
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
import {DeleteObjectDialog} from './DeleteObjectDialog';
import {ConfirmDialog} from '@/components/ui/confirm-dialog';
import {UploadProgress} from './UploadProgress';
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, ScanSearch, Search, Trash, Upload} from 'lucide-react';
import {getBreadcrumbs} from '@/lib/file-utils';
@@ -28,7 +29,7 @@ interface ObjectBrowserViewProps {
onUploadFiles?: (files: File[]) => Promise<boolean>;
uploadTasks: UploadTask[];
onDeleteObject?: (key: string) => Promise<boolean>;
onDeleteMultipleObjects?: (keys: string[]) => Promise<boolean>;
onDeleteMultipleObjects?: (keys: string[], prefixes?: string[]) => Promise<boolean>;
onCreateDirectory?: (name: string) => Promise<boolean>;
onRefresh: () => Promise<void>;
onPageChange: (token?: string) => void;
@@ -72,6 +73,10 @@ export function ObjectBrowserView({
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
const [selectedFileKeys, setSelectedFileKeys] = useState<Set<string>>(new Set());
const [selectedFolderKeys, setSelectedFolderKeys] = useState<Set<string>>(new Set());
// Holds the keys/prefixes awaiting confirmation in the bulk-delete dialog.
const [pendingDelete, setPendingDelete] = useState<{ keys: string[]; prefixes: string[] } | null>(null);
const [bulkDeleting, setBulkDeleting] = useState(false);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles, _fileRejections, event) => {
@@ -133,33 +138,88 @@ export function ObjectBrowserView({
});
};
const selectedCount = selectedFileKeys.size + selectedFolderKeys.size;
const toggleInSet = (set: Set<string>, key: string) => {
const next = new Set(set);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
};
const handleToggleFileSelection = (key: string) => {
const newSelected = new Set(selectedFileKeys);
if (newSelected.has(key)) {
newSelected.delete(key);
} else {
newSelected.add(key);
}
setSelectedFileKeys(newSelected);
setSelectedFileKeys(prev => toggleInSet(prev, key));
};
const handleSelectAllFiles = () => {
const fileKeys = objects
.filter(obj => !obj.isFolder)
.map(obj => obj.key);
const handleToggleFolderSelection = (key: string) => {
setSelectedFolderKeys(prev => toggleInSet(prev, key));
};
if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) {
setSelectedFileKeys(new Set());
// Select/deselect the currently visible (filtered) rows. The table passes the
// keys it is actually showing so this stays aligned with the search filter
// instead of operating on the full, unfiltered object list.
const handleSelectAll = (fileKeys: string[], folderKeys: string[]) => {
const allVisibleSelected =
fileKeys.length + folderKeys.length > 0 &&
fileKeys.every(k => selectedFileKeys.has(k)) &&
folderKeys.every(k => selectedFolderKeys.has(k));
if (allVisibleSelected) {
// Drop only the visible rows, leaving any off-screen selection intact.
setSelectedFileKeys(prev => {
const next = new Set(prev);
fileKeys.forEach(k => next.delete(k));
return next;
});
setSelectedFolderKeys(prev => {
const next = new Set(prev);
folderKeys.forEach(k => next.delete(k));
return next;
});
} else {
setSelectedFileKeys(new Set(fileKeys));
setSelectedFileKeys(prev => new Set([...prev, ...fileKeys]));
setSelectedFolderKeys(prev => new Set([...prev, ...folderKeys]));
}
};
const handleBulkDeleteFiles = async () => {
if (!onDeleteMultipleObjects || selectedFileKeys.size === 0) return;
// Open the confirmation dialog for the current multi-selection.
const handleRequestBulkDelete = () => {
if (selectedCount === 0) return;
setPendingDelete({
keys: Array.from(selectedFileKeys),
prefixes: Array.from(selectedFolderKeys),
});
};
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
setSelectedFileKeys(new Set());
// Open the confirmation dialog for a single folder (recursive delete).
const handleDeleteFolder = (folderKey: string) => {
setPendingDelete({ keys: [], prefixes: [folderKey] });
};
const handleConfirmBulkDelete = async () => {
if (!pendingDelete || !onDeleteMultipleObjects) return;
setBulkDeleting(true);
const success = await onDeleteMultipleObjects(pendingDelete.keys, pendingDelete.prefixes);
setBulkDeleting(false);
if (success) {
// Drop the deleted folders/files from the live selection.
setSelectedFileKeys(prev => {
const next = new Set(prev);
pendingDelete.keys.forEach(k => next.delete(k));
return next;
});
setSelectedFolderKeys(prev => {
const next = new Set(prev);
pendingDelete.prefixes.forEach(k => next.delete(k));
return next;
});
setPendingDelete(null);
}
};
const handleDeleteObject = async (key: string): Promise<boolean> => {
@@ -237,14 +297,14 @@ export function ObjectBrowserView({
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
{onDeleteMultipleObjects && selectedFileKeys.size > 0 && (
{onDeleteMultipleObjects && selectedCount > 0 && (
<Button
onClick={handleBulkDeleteFiles}
title={`Delete ${selectedFileKeys.size} selected file(s)`}
onClick={handleRequestBulkDelete}
title={`Delete ${selectedCount} selected item(s)`}
className="bg-transparent border border-red-500 text-red-500 hover:bg-red-500/5"
>
<Trash className="h-4 w-4" />
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
Delete {selectedCount} item{selectedCount !== 1 ? 's' : ''}
</Button>
)}
{onUploadFiles && (
@@ -387,6 +447,7 @@ export function ObjectBrowserView({
filterQuery={filterQuery}
deepSearch={deepSearch}
selectedFileKeys={selectedFileKeys}
selectedFolderKeys={selectedFolderKeys}
isDragActive={isDragActive}
isLoading={isLoading && !isRefreshing && !isNavigating}
isTruncated={isTruncated}
@@ -397,8 +458,10 @@ export function ObjectBrowserView({
setSelectedObject(obj);
setDeleteObjectDialogOpen(true);
} : undefined}
onDeleteFolder={onDeleteMultipleObjects ? (obj) => handleDeleteFolder(obj.key) : undefined}
onToggleFileSelection={handleToggleFileSelection}
onSelectAllFiles={handleSelectAllFiles}
onToggleFolderSelection={handleToggleFolderSelection}
onSelectAll={handleSelectAll}
onPageChange={onPageChange}
onItemsPerPageChange={onItemsPerPageChange}
initialPageToken={initialPageToken}
@@ -424,6 +487,53 @@ export function ObjectBrowserView({
object={selectedObject}
onDeleteObject={handleDeleteObject}
/>
{/* Bulk / Folder Delete Confirmation */}
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open && !bulkDeleting) setPendingDelete(null);
}}
title={getBulkDeleteTitle(pendingDelete)}
description={getBulkDeleteDescription(pendingDelete)}
confirmLabel="Delete"
loading={bulkDeleting}
onConfirm={handleConfirmBulkDelete}
/>
</div>
);
}
// Builds a concise title summarising what the bulk-delete dialog will remove.
function getBulkDeleteTitle(pending: { keys: string[]; prefixes: string[] } | null): string {
if (!pending) return 'Delete items?';
const { keys, prefixes } = pending;
const total = keys.length + prefixes.length;
if (keys.length === 0 && prefixes.length === 1) {
return 'Delete folder?';
}
return `Delete ${total} item${total !== 1 ? 's' : ''}?`;
}
// Spells out the file/folder counts and warns that folders are removed recursively.
function getBulkDeleteDescription(
pending: { keys: string[]; prefixes: string[] } | null,
): string {
if (!pending) return '';
const { keys, prefixes } = pending;
const parts: string[] = [];
if (keys.length > 0) {
parts.push(`${keys.length} file${keys.length !== 1 ? 's' : ''}`);
}
if (prefixes.length > 0) {
parts.push(`${prefixes.length} folder${prefixes.length !== 1 ? 's' : ''}`);
}
const summary = parts.join(' and ');
if (prefixes.length > 0) {
return `This will permanently delete ${summary}. Every object stored inside the selected folder${
prefixes.length !== 1 ? 's' : ''
} will be removed recursively.`;
}
return `This will permanently delete ${summary}.`;
}
@@ -25,15 +25,22 @@ interface ObjectsTableProps {
filterQuery: string;
deepSearch: boolean;
selectedFileKeys: Set<string>;
selectedFolderKeys: Set<string>;
isDragActive: boolean;
isLoading?: boolean;
isTruncated?: boolean;
nextContinuationToken?: string;
itemsPerPage: number;
onNavigateToFolder: (key: string) => void;
// Optional so the parent can withhold them when the user lacks delete
// permission; canDelete (below) is derived from onDeleteObject.
onDeleteObject?: (object: S3Object) => void;
onDeleteFolder?: (object: S3Object) => void;
onToggleFileSelection: (key: string) => void;
onSelectAllFiles: () => void;
onToggleFolderSelection: (key: string) => void;
// Receives the keys of the currently *visible* (filtered) rows so selection
// stays aligned with what the search is actually showing.
onSelectAll: (fileKeys: string[], folderKeys: string[]) => void;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
initialPageToken?: string;
@@ -51,6 +58,7 @@ export function ObjectsTable({
filterQuery,
deepSearch,
selectedFileKeys,
selectedFolderKeys,
isDragActive,
isLoading = false,
isTruncated = false,
@@ -58,8 +66,10 @@ export function ObjectsTable({
itemsPerPage,
onNavigateToFolder,
onDeleteObject,
onDeleteFolder,
onToggleFileSelection,
onSelectAllFiles,
onToggleFolderSelection,
onSelectAll,
onPageChange,
onItemsPerPageChange,
initialPageToken,
@@ -218,12 +228,23 @@ export function ObjectsTable({
{canDelete && (
<TableHead className="w-[50px]">
<Checkbox
// Scope select-all to the rows actually on screen (pageObjects).
// In normal/prefix browsing this equals filteredObjects; in
// client-paginated deep search it is just the visible page, so
// one click never selects hidden matches for a destructive delete.
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
pageObjects.length > 0 &&
pageObjects.every(obj =>
obj.isFolder ? selectedFolderKeys.has(obj.key) : selectedFileKeys.has(obj.key),
)
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
onCheckedChange={() =>
onSelectAll(
pageObjects.filter(obj => !obj.isFolder).map(obj => obj.key),
pageObjects.filter(obj => obj.isFolder).map(obj => obj.key),
)
}
aria-label="Select all objects"
/>
</TableHead>
)}
@@ -277,10 +298,9 @@ export function ObjectsTable({
<TableCell className="w-[50px]">
{obj.isFolder ? (
<Checkbox
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
checked={selectedFolderKeys.has(obj.key)}
onCheckedChange={() => onToggleFolderSelection(obj.key)}
aria-label={`Select folder ${obj.key} (deletes its contents recursively)`}
/>
) : (
<Checkbox
@@ -379,7 +399,33 @@ export function ObjectsTable({
})() : null}
</TableCell>
<TableCell>
{!obj.isFolder && (
{obj.isFolder ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onNavigateToFolder(obj.key)}>
<FolderIcon className="h-4 w-4" />
Open
</DropdownMenuItem>
{onDeleteFolder && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteFolder(obj)}
>
<Trash2 className="h-4 w-4" />
Delete folder
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
+2 -2
View File
@@ -142,8 +142,8 @@ export function useDeleteMultipleObjects() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ bucket, keys, prefix }: { bucket: string; keys: string[]; prefix?: string }) =>
objectsApi.deleteMultiple(bucket, keys, prefix),
mutationFn: ({ bucket, keys, prefixes }: { bucket: string; keys: string[]; prefixes?: string[] }) =>
objectsApi.deleteMultiple(bucket, keys, prefixes),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
+16 -6
View File
@@ -216,14 +216,24 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
}
}, [bucketName, currentContinuationToken, fetchObjects]);
const deleteMultipleObjects = useCallback(async (keys: string[]) => {
if (!bucketName || keys.length === 0) return false;
// 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 {
setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
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 objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
toast.success(`Successfully deleted ${keys.length} file${keys.length > 1 ? 's' : ''}`);
await fetchObjects(currentContinuationToken, true);
return true;
} catch (error) {
@@ -231,7 +241,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
await fetchObjects(currentContinuationToken, true);
return false;
}
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
}, [bucketName, currentContinuationToken, fetchObjects]);
const createDirectory = useCallback(async (dirName: string) => {
if (!bucketName) return false;
+4 -2
View File
@@ -382,8 +382,10 @@ export const objectsApi = {
await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`);
},
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
const payload = { keys, ...(prefix && { prefix }) };
// Deletes the given object keys and/or recursively deletes every object under
// each folder prefix in a single request.
deleteMultiple: async (bucket: string, keys: string[], prefixes: string[] = []): Promise<void> => {
const payload = { keys, ...(prefixes.length > 0 && { prefixes }) };
await api.post(`/v1/buckets/${bucket}/objects/delete-multiple`, payload);
},