feat: implement recursive folder deletion with task management and auto-refresh #67 (#69)

This commit is contained in:
cxymds
2026-02-28 15:32:53 +08:00
committed by GitHub
parent 1dc6a3e048
commit 588e80ceb3
3 changed files with 207 additions and 40 deletions
+62 -35
View File
@@ -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<Set<string>>(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<string[]> => {
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"))
+20 -1
View File
@@ -43,12 +43,14 @@ const TaskContext = React.createContext<{
taskManager: TaskManager<AnyTask, string>
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<unknown, string>,
delete: deleteHelpers.handler as TaskHandler<unknown, string>,
"delete-folder": deleteHelpers.folderHandler as TaskHandler<unknown, string>,
},
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 <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
@@ -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 }
+125 -4
View File
@@ -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<DeleteStatus> {
subInfo: string
}
export interface FolderDeleteTask extends ManagedTask<DeleteStatus> {
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<DeleteTask, DeleteStatus>
folderHandler: TaskHandler<FolderDeleteTask, DeleteStatus>
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<DeleteStatus> = {
@@ -44,7 +62,7 @@ const lifecycle: TaskLifecycleStatus<DeleteStatus> = {
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<unknown>) => async (args: { request?: { headers?: Record<string, string> } }) => {
@@ -78,7 +96,80 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
task.progress = 100
}
const shouldRetry: TaskHandler<DeleteTask, DeleteStatus>["shouldRetry"] = (task, error) => {
const performFolder: TaskHandler<FolderDeleteTask, DeleteStatus>["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<FolderDeleteTask, DeleteStatus> = {
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 }
}