diff --git a/components/object/list.tsx b/components/object/list.tsx index f5b75b40..30192da6 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -39,7 +39,7 @@ import { getContentType } from "@/lib/mime-types" import { formatBytes } from "@/lib/functions" import { buildBucketPath } from "@/lib/bucket-path" import { TaskStatsButton } from "@/components/tasks/stats-button" -import { useAddDeleteKeys } from "@/contexts/task-context" +import { useAddDeleteKeys, useAddDeleteFolder, useTasks } from "@/contexts/task-context" import type { ColumnDef } from "@tanstack/react-table" import dayjs from "dayjs" import { saveAs } from "file-saver" @@ -76,6 +76,8 @@ export function ObjectList({ const { listObject, getSignedUrl, mapAllFiles } = useObject(bucket) const { getBucketVersioning } = useBucket() const addDeleteKeys = useAddDeleteKeys() + const addDeleteFolder = useAddDeleteFolder() + const tasks = useTasks() const [searchTerm, setSearchTerm] = React.useState("") const [showDeleted, setShowDeleted] = useLocalStorage("object-list-show-deleted", false) @@ -147,6 +149,25 @@ export function ObjectList({ } }, [bucket, prefix, pageSize, continuationToken, showDeleted, refreshTrigger, fetchObjects]) + const prevDeleteTaskIdsRef = React.useRef>(new Set()) + + React.useEffect(() => { + const currentDeleteTasks = tasks.filter((t) => t.kind === "delete" || t.kind === "delete-folder") + const completedDeleteTasks = currentDeleteTasks.filter((t) => t.status === "completed") + + const newlyCompleted = completedDeleteTasks.filter((t) => !prevDeleteTaskIdsRef.current.has(t.id)) + + if (newlyCompleted.length > 0) { + const anyActive = currentDeleteTasks.some((t) => ["pending", "running"].includes(t.status)) + if (!anyActive) { + // No more active delete tasks, refresh the list + void fetchObjects() + } + } + + prevDeleteTaskIdsRef.current = new Set(completedDeleteTasks.map((t) => t.id)) + }, [tasks, fetchObjects]) + React.useEffect(() => { const loadBucketVersioningStatus = async () => { try { @@ -306,28 +327,6 @@ export function ObjectList({ return base && key.startsWith(base) ? key.slice(base.length) : key } - const collectKeysForDeletion = async (keys: string[], forceDelete = false): Promise => { - const rowMap = new Map(data.map((item) => [item.Key, item])) - const collected: string[] = [] - - for (const key of keys) { - if (!key) continue - const row = rowMap.get(key) - if (row?.type === "prefix") { - collected.push(row.Key) - if (!forceDelete) { - await mapAllFiles(bucket, row.Key, (fileKey) => { - if (fileKey) collected.push(fileKey) - }) - } - } else { - collected.push(key) - } - } - - return Array.from(new Set(collected)) - } - const downloadMultiple = async () => { if (!checkedKeys.length) { message.warning(t("Please select at least one item")) @@ -430,13 +429,27 @@ export function ObjectList({ const handleDelete = async (keys: string[]) => { try { - const targets = await collectKeysForDeletion(keys) - if (!targets.length) { - message.success(t("Delete Success")) - } else { - addDeleteKeys(targets, bucket, undefined) - message.success(t("Delete task created")) + const rowMap = new Map(data.map((item) => [item.Key, item])) + const objectKeys: string[] = [] + const folderPrefixes: string[] = [] + + for (const key of keys) { + const row = rowMap.get(key) + if (row?.type === "prefix") { + folderPrefixes.push(key) + } else { + objectKeys.push(key) + } + } + + if (objectKeys.length > 0) { + addDeleteKeys(objectKeys, bucket, undefined) + } + for (const prefix of folderPrefixes) { + addDeleteFolder(prefix, bucket) } + + message.success(t("Delete task created")) table.resetRowSelection() } catch (err) { message.error((err as Error)?.message ?? t("Delete Failed")) @@ -445,13 +458,27 @@ export function ObjectList({ const handleDeleteAllVersions = async (keys: string[]) => { try { - const targets = await collectKeysForDeletion(keys, true) - if (!targets.length) { - message.success(t("Delete Success")) - } else { - addDeleteKeys(targets, bucket, undefined, { forceDelete: true }) - message.success(t("Delete task created")) + const rowMap = new Map(data.map((item) => [item.Key, item])) + const objectKeys: string[] = [] + const folderPrefixes: string[] = [] + + for (const key of keys) { + const row = rowMap.get(key) + if (row?.type === "prefix") { + folderPrefixes.push(key) + } else { + objectKeys.push(key) + } + } + + if (objectKeys.length > 0) { + addDeleteKeys(objectKeys, bucket, undefined, { forceDelete: true }) + } + for (const prefix of folderPrefixes) { + addDeleteFolder(prefix, bucket, { forceDelete: true }) } + + message.success(t("Delete task created")) table.resetRowSelection() } catch (err) { message.error((err as Error)?.message ?? t("Delete Failed")) diff --git a/contexts/task-context.tsx b/contexts/task-context.tsx index c9cef37d..381fd529 100644 --- a/contexts/task-context.tsx +++ b/contexts/task-context.tsx @@ -43,12 +43,14 @@ const TaskContext = React.createContext<{ taskManager: TaskManager addUploadFiles: (items: { file: File; key: string }[], bucketName: string) => void addDeleteKeys: (keys: string[], bucketName: string, prefix?: string, options?: { forceDelete?: boolean }) => void + addDeleteFolder: (prefix: string, bucketName: string, options?: { forceDelete?: boolean }) => void isTaskPanelOpen: boolean setTaskPanelOpen: (open: boolean) => void }>({ taskManager: emptyManager, addUploadFiles: () => {}, addDeleteKeys: () => {}, + addDeleteFolder: () => {}, isTaskPanelOpen: false, setTaskPanelOpen: () => {}, }) @@ -79,6 +81,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { handlers: { upload: uploadHelpers.handler as TaskHandler, delete: deleteHelpers.handler as TaskHandler, + "delete-folder": deleteHelpers.folderHandler as TaskHandler, }, maxConcurrent: 6, maxRetries: 3, @@ -117,15 +120,26 @@ export function TaskProvider({ children }: { children: React.ReactNode }) { [managerState], ) + const addDeleteFolder = React.useCallback( + (prefix: string, bucketName: string, options?: { forceDelete?: boolean }) => { + if (!managerState) return + const { manager, deleteHelpers } = managerState + const task = deleteHelpers.createFolderDeleteTask(prefix, bucketName, options) + manager.enqueue([task as AnyTask]) + }, + [managerState], + ) + const value = React.useMemo( () => ({ taskManager, addUploadFiles, addDeleteKeys, + addDeleteFolder, isTaskPanelOpen, setTaskPanelOpen, }), - [taskManager, addUploadFiles, addDeleteKeys, isTaskPanelOpen], + [taskManager, addUploadFiles, addDeleteKeys, addDeleteFolder, isTaskPanelOpen], ) return {children} @@ -156,6 +170,11 @@ export function useAddDeleteKeys() { return addDeleteKeys } +export function useAddDeleteFolder() { + const { addDeleteFolder } = React.useContext(TaskContext) + return addDeleteFolder +} + export function useTaskPanelOpen() { const { isTaskPanelOpen, setTaskPanelOpen } = React.useContext(TaskContext) return { isTaskPanelOpen, setTaskPanelOpen } diff --git a/lib/delete-task.ts b/lib/delete-task.ts index 3a9e5ac3..804a2269 100644 --- a/lib/delete-task.ts +++ b/lib/delete-task.ts @@ -1,4 +1,4 @@ -import { DeleteObjectCommand, S3Client } from "@aws-sdk/client-s3" +import { DeleteObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, ListObjectVersionsCommand, S3Client } from "@aws-sdk/client-s3" import type { ManagedTask, TaskHandler, TaskLifecycleStatus } from "./task-manager" export type DeleteStatus = "pending" | "running" | "completed" | "failed" | "canceled" @@ -15,6 +15,18 @@ export interface DeleteTask extends ManagedTask { subInfo: string } +export interface FolderDeleteTask extends ManagedTask { + kind: "delete-folder" + bucketName: string + prefix: string + forceDelete?: boolean + actionLabel: string + displayName: string + subInfo: string +} + +export type AnyDeleteTask = DeleteTask | FolderDeleteTask + export interface DeleteTaskConfig { maxRetries?: number retryDelay?: number @@ -22,6 +34,7 @@ export interface DeleteTaskConfig { export interface DeleteTaskHelpers { handler: TaskHandler + folderHandler: TaskHandler createTasks: ( keys: string[], bucketName: string, @@ -34,6 +47,11 @@ export interface DeleteTaskHelpers { prefix?: string, options?: { forceDelete?: boolean }, ) => DeleteTask[] + createFolderDeleteTask: ( + prefix: string, + bucketName: string, + options?: { forceDelete?: boolean }, + ) => FolderDeleteTask } const lifecycle: TaskLifecycleStatus = { @@ -44,7 +62,7 @@ const lifecycle: TaskLifecycleStatus = { canceled: "canceled", } -function attachForceDeleteHeader(command: DeleteObjectCommand) { +function attachForceDeleteHeader(command: DeleteObjectCommand | DeleteObjectsCommand) { // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(command.middlewareStack.add as any)( (next: (args: unknown) => Promise) => async (args: { request?: { headers?: Record } }) => { @@ -78,7 +96,80 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo task.progress = 100 } - const shouldRetry: TaskHandler["shouldRetry"] = (task, error) => { + const performFolder: TaskHandler["perform"] = async (task) => { + const { bucketName, prefix, forceDelete } = task + const abortController = new AbortController() + task.abortController = abortController + + if (forceDelete) { + let isTruncated = true + let keyMarker: string | undefined + let versionIdMarker: string | undefined + + while (isTruncated) { + const data = await s3Client.send( + new ListObjectVersionsCommand({ + Bucket: bucketName, + Prefix: prefix, + KeyMarker: keyMarker, + VersionIdMarker: versionIdMarker, + }), + { abortSignal: abortController.signal }, + ) + + const objectsToDelete: { Key: string; VersionId?: string }[] = [] + data.Versions?.forEach((v) => { + if (v.Key) objectsToDelete.push({ Key: v.Key, VersionId: v.VersionId }) + }) + data.DeleteMarkers?.forEach((m) => { + if (m.Key) objectsToDelete.push({ Key: m.Key, VersionId: m.VersionId }) + }) + + if (objectsToDelete.length > 0) { + const command = new DeleteObjectsCommand({ + Bucket: bucketName, + Delete: { Objects: objectsToDelete, Quiet: true }, + }) + attachForceDeleteHeader(command) + await s3Client.send(command, { abortSignal: abortController.signal }) + } + + isTruncated = data.IsTruncated ?? false + keyMarker = data.NextKeyMarker + versionIdMarker = data.NextVersionIdMarker + } + } else { + let isTruncated = true + let continuationToken: string | undefined + + while (isTruncated) { + const data = await s3Client.send( + new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + { abortSignal: abortController.signal }, + ) + + const objectsToDelete = (data.Contents ?? []).filter((item) => item.Key).map((item) => ({ Key: item.Key! })) + + if (objectsToDelete.length > 0) { + const command = new DeleteObjectsCommand({ + Bucket: bucketName, + Delete: { Objects: objectsToDelete, Quiet: true }, + }) + await s3Client.send(command, { abortSignal: abortController.signal }) + } + + isTruncated = data.IsTruncated ?? false + continuationToken = data.NextContinuationToken + } + } + task.progress = 100 + } + + const shouldRetry = (task: DeleteTask | FolderDeleteTask, error: unknown) => { if (task.status === lifecycle.canceled) return false if ((task.retryCount ?? 0) >= maxRetries) return false const errorMessage = String((error as Error)?.message ?? "").toLowerCase() @@ -96,6 +187,16 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo retryDelay, } + const folderHandler: TaskHandler = { + lifecycle, + perform: performFolder, + shouldRetry, + isCanceledError: (error) => + (error as Error)?.name === "AbortError" || String((error as Error)?.message ?? "").includes("canceled"), + maxRetries, + retryDelay, + } + const createTasksFromItems = ( items: { key: string; versionId?: string; forceDelete?: boolean }[], bucketName: string, @@ -138,5 +239,25 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo prefix, ) - return { handler, createTasks, createVersionedTasks } + const createFolderDeleteTask = ( + prefix: string, + bucketName: string, + options?: { forceDelete?: boolean }, + ): FolderDeleteTask => ({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${prefix}`, + kind: "delete-folder" as const, + bucketName, + prefix, + forceDelete: options?.forceDelete, + status: lifecycle.pending, + progress: 0, + actionLabel: "Delete Folder", + displayName: prefix, + subInfo: options?.forceDelete ? "Deleting all versions" : "Recursive delete", + error: undefined, + abortController: undefined, + retryCount: 0, + }) + + return { handler, folderHandler, createTasks, createVersionedTasks, createFolderDeleteTask } }