Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 62 additions & 35 deletions components/object/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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"))
Expand All @@ -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"))
Expand Down
21 changes: 20 additions & 1 deletion contexts/task-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {},
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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 }
Expand Down
129 changes: 125 additions & 4 deletions lib/delete-task.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -15,13 +15,26 @@ 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
}

export interface DeleteTaskHelpers {
handler: TaskHandler<DeleteTask, DeleteStatus>
folderHandler: TaskHandler<FolderDeleteTask, DeleteStatus>
createTasks: (
keys: string[],
bucketName: string,
Expand All @@ -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> = {
Expand All @@ -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> } }) => {
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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 }
}