mirror of
https://github.com/get-drexa/drive.git
synced 2025-12-01 05:51:39 +00:00
feat: impl file/dir restoration from trash
This commit is contained in:
@@ -127,3 +127,12 @@ export const permanentlyDeleteItems = authenticatedMutation({
|
|||||||
return await FileSystem.deleteItemsPermanently(ctx, { handles })
|
return await FileSystem.deleteItemsPermanently(ctx, { handles })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const restoreItems = authenticatedMutation({
|
||||||
|
args: {
|
||||||
|
handles: v.array(VFileSystemHandle),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { handles }) => {
|
||||||
|
return await FileSystem.restoreItems(ctx, { handles })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -347,3 +347,46 @@ export async function deletePermanently(
|
|||||||
|
|
||||||
return { deleted: successfulDeletions, errors }
|
return { deleted: successfulDeletions, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restore(
|
||||||
|
ctx: AuthenticatedMutationCtx,
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: DirectoryHandle[]
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToBeRestored = await Promise.allSettled(
|
||||||
|
items.map((item) => ctx.db.get(item.id)),
|
||||||
|
).then((results) =>
|
||||||
|
results.filter(
|
||||||
|
(result): result is PromiseFulfilledResult<Doc<"directories">> =>
|
||||||
|
result.status === "fulfilled" && result.value !== null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const restoreDirectoryPromises = itemsToBeRestored.map((item) =>
|
||||||
|
ctx.db.patch(item.value._id, {
|
||||||
|
deletedAt: undefined,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const restoreResults = await Promise.allSettled(restoreDirectoryPromises)
|
||||||
|
|
||||||
|
const errors: Err.ApplicationErrorData[] = []
|
||||||
|
let successfulRestorations = 0
|
||||||
|
for (const result of restoreResults) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
errors.push(Err.createJson(Err.Code.Internal))
|
||||||
|
} else {
|
||||||
|
successfulRestorations += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { restored: successfulRestorations, errors }
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,3 +148,46 @@ export async function deletePermanently(
|
|||||||
|
|
||||||
return { deleted: successfulDeletions, errors }
|
return { deleted: successfulDeletions, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restore(
|
||||||
|
ctx: AuthenticatedMutationCtx,
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: FileHandle[]
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToBeRestored = await Promise.allSettled(
|
||||||
|
items.map((item) => ctx.db.get(item.id)),
|
||||||
|
).then((results) =>
|
||||||
|
results.filter(
|
||||||
|
(result): result is PromiseFulfilledResult<Doc<"files">> =>
|
||||||
|
result.status === "fulfilled" && result.value !== null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const restoreFilePromises = itemsToBeRestored.map((item) =>
|
||||||
|
ctx.db.patch(item.value._id, {
|
||||||
|
deletedAt: undefined,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const restoreResults = await Promise.allSettled(restoreFilePromises)
|
||||||
|
|
||||||
|
const errors: Err.ApplicationErrorData[] = []
|
||||||
|
let successfulRestorations = 0
|
||||||
|
for (const result of restoreResults) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
errors.push(Err.createJson(Err.Code.Internal))
|
||||||
|
} else {
|
||||||
|
successfulRestorations += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { restored: successfulRestorations, errors }
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,13 +77,16 @@ export const VFileHandle = v.object({
|
|||||||
})
|
})
|
||||||
export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle)
|
export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle)
|
||||||
|
|
||||||
export async function deleteItemsPermanently(
|
/**
|
||||||
|
* Recursively collects all file and directory handles from the given handles,
|
||||||
|
* including all nested items. Only includes items that are in trash (deletedAt >= 0).
|
||||||
|
*/
|
||||||
|
async function collectAllHandlesRecursively(
|
||||||
ctx: AuthenticatedMutationCtx,
|
ctx: AuthenticatedMutationCtx,
|
||||||
{ handles }: { handles: FileSystemHandle[] },
|
{ handles }: { handles: FileSystemHandle[] },
|
||||||
) {
|
): Promise<{ fileHandles: FileHandle[]; directoryHandles: DirectoryHandle[] }> {
|
||||||
// Collect all items to delete (including nested items)
|
const fileHandles: FileHandle[] = []
|
||||||
const fileHandlesToDelete: FileHandle[] = []
|
const directoryHandles: DirectoryHandle[] = []
|
||||||
const directoryHandlesToDelete: DirectoryHandle[] = []
|
|
||||||
|
|
||||||
// Process each handle to collect files and directories
|
// Process each handle to collect files and directories
|
||||||
for (const handle of handles) {
|
for (const handle of handles) {
|
||||||
@@ -93,16 +96,16 @@ export async function deleteItemsPermanently(
|
|||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const currentHandle = queue.shift()!
|
const currentHandle = queue.shift()!
|
||||||
|
|
||||||
// Add current item to appropriate deletion collection
|
// Add current item to appropriate collection
|
||||||
if (currentHandle.kind === FileType.File) {
|
if (currentHandle.kind === FileType.File) {
|
||||||
fileHandlesToDelete.push(currentHandle)
|
fileHandles.push(currentHandle)
|
||||||
} else {
|
} else {
|
||||||
directoryHandlesToDelete.push(currentHandle)
|
directoryHandles.push(currentHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a directory, collect all children and add them to the queue
|
// If it's a directory, collect all children and add them to the queue
|
||||||
if (currentHandle.kind === FileType.Directory) {
|
if (currentHandle.kind === FileType.Directory) {
|
||||||
// Get all child directories that are in trash (deletedAt > 0)
|
// Get all child directories that are in trash (deletedAt >= 0)
|
||||||
const childDirectories = await ctx.db
|
const childDirectories = await ctx.db
|
||||||
.query("directories")
|
.query("directories")
|
||||||
.withIndex("byParentId", (q) =>
|
.withIndex("byParentId", (q) =>
|
||||||
@@ -113,7 +116,7 @@ export async function deleteItemsPermanently(
|
|||||||
)
|
)
|
||||||
.collect()
|
.collect()
|
||||||
|
|
||||||
// Get all child files that are in trash (deletedAt > 0)
|
// Get all child files that are in trash (deletedAt >= 0)
|
||||||
const childFiles = await ctx.db
|
const childFiles = await ctx.db
|
||||||
.query("files")
|
.query("files")
|
||||||
.withIndex("byDirectoryId", (q) =>
|
.withIndex("byDirectoryId", (q) =>
|
||||||
@@ -133,12 +136,56 @@ export async function deleteItemsPermanently(
|
|||||||
// Add child files to file handles collection
|
// Add child files to file handles collection
|
||||||
for (const childFile of childFiles) {
|
for (const childFile of childFiles) {
|
||||||
const childFileHandle = newFileHandle(childFile._id)
|
const childFileHandle = newFileHandle(childFile._id)
|
||||||
fileHandlesToDelete.push(childFileHandle)
|
fileHandles.push(childFileHandle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { fileHandles, directoryHandles }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores deleted items by unsetting the deletedAt field recursively.
|
||||||
|
* This includes all nested files and directories within the given handles.
|
||||||
|
*/
|
||||||
|
export async function restoreItems(
|
||||||
|
ctx: AuthenticatedMutationCtx,
|
||||||
|
{ handles }: { handles: FileSystemHandle[] },
|
||||||
|
) {
|
||||||
|
// Collect all items to restore (including nested items)
|
||||||
|
const { fileHandles, directoryHandles } = await collectAllHandlesRecursively(
|
||||||
|
ctx,
|
||||||
|
{ handles },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore files and directories by unsetting deletedAt
|
||||||
|
const [filesResult, directoriesResult] = await Promise.all([
|
||||||
|
Files.restore(ctx, { items: fileHandles }),
|
||||||
|
Directories.restore(ctx, { items: directoryHandles }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Combine results, handling null responses
|
||||||
|
return {
|
||||||
|
restored: {
|
||||||
|
files: filesResult?.restored || 0,
|
||||||
|
directories: directoriesResult?.restored || 0,
|
||||||
|
},
|
||||||
|
errors: [
|
||||||
|
...(filesResult?.errors || []),
|
||||||
|
...(directoriesResult?.errors || []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteItemsPermanently(
|
||||||
|
ctx: AuthenticatedMutationCtx,
|
||||||
|
{ handles }: { handles: FileSystemHandle[] },
|
||||||
|
) {
|
||||||
|
// Collect all items to delete (including nested items)
|
||||||
|
const { fileHandles: fileHandlesToDelete, directoryHandles: directoryHandlesToDelete } =
|
||||||
|
await collectAllHandlesRecursively(ctx, { handles })
|
||||||
|
|
||||||
// Delete files and directories using their respective models
|
// Delete files and directories using their respective models
|
||||||
const [filesResult, directoriesResult] = await Promise.all([
|
const [filesResult, directoriesResult] = await Promise.all([
|
||||||
Files.deletePermanently(ctx, { items: fileHandlesToDelete }),
|
Files.deletePermanently(ctx, { items: fileHandlesToDelete }),
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
useMutation as useConvexMutation,
|
useMutation as useConvexMutation,
|
||||||
useQuery as useConvexQuery,
|
useQuery as useConvexQuery,
|
||||||
} from "convex/react"
|
} from "convex/react"
|
||||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
|
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
|
||||||
import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react"
|
import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react"
|
||||||
import { useCallback } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +37,7 @@ import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-pa
|
|||||||
import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb"
|
import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb"
|
||||||
import { FilePreviewDialog } from "@/files/file-preview-dialog"
|
import { FilePreviewDialog } from "@/files/file-preview-dialog"
|
||||||
import type { FileDragInfo } from "@/files/use-file-drop"
|
import type { FileDragInfo } from "@/files/use-file-drop"
|
||||||
|
import { backgroundTaskProgressAtom } from "../../../dashboard/state"
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
"/_authenticated/_sidebar-layout/trash/directories/$directoryId",
|
"/_authenticated/_sidebar-layout/trash/directories/$directoryId",
|
||||||
@@ -140,10 +141,7 @@ function TableContextMenu({ children }: React.PropsWithChildren) {
|
|||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem>
|
<RestoreContextMenuItem />
|
||||||
<UndoIcon />
|
|
||||||
Restore
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -158,6 +156,64 @@ function TableContextMenu({ children }: React.PropsWithChildren) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RestoreContextMenuItem() {
|
||||||
|
const store = useStore()
|
||||||
|
const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom)
|
||||||
|
const restoreItemsMutation = useConvexMutation(api.filesystem.restoreItems)
|
||||||
|
const { mutate: restoreItems, isPending: isRestoring } = useMutation({
|
||||||
|
mutationFn: restoreItemsMutation,
|
||||||
|
onMutate: ({ handles }) => {
|
||||||
|
setOptimisticRemovedItems(
|
||||||
|
new Set(handles.map((handle) => handle.id)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: ({ restored, errors }) => {
|
||||||
|
if (errors.length === 0) {
|
||||||
|
if (restored.files > 0 && restored.directories > 0) {
|
||||||
|
toast.success(
|
||||||
|
`Restored ${restored.files} files and ${restored.directories} directories`,
|
||||||
|
)
|
||||||
|
} else if (restored.files > 0) {
|
||||||
|
toast.success(`Restored ${restored.files} files`)
|
||||||
|
} else if (restored.directories > 0) {
|
||||||
|
toast.success(
|
||||||
|
`Restored ${restored.directories} directories`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.warning(
|
||||||
|
`Restored ${restored.files} files and ${restored.directories} directories; failed to restore ${errors.length} items`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRestoring) {
|
||||||
|
setBackgroundTaskProgress({
|
||||||
|
label: "Restoring items…",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setBackgroundTaskProgress(null)
|
||||||
|
}
|
||||||
|
}, [isRestoring, setBackgroundTaskProgress])
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
const targetItems = store.get(contextMenuTargetItemsAtom)
|
||||||
|
restoreItems({
|
||||||
|
handles: targetItems.map(newFileSystemHandle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenuItem onClick={onClick}>
|
||||||
|
<UndoIcon />
|
||||||
|
Restore
|
||||||
|
</ContextMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyTrashButton() {
|
function EmptyTrashButton() {
|
||||||
const setIsDeleteConfirmationDialogOpen = useSetAtom(
|
const setIsDeleteConfirmationDialogOpen = useSetAtom(
|
||||||
isDeleteConfirmationDialogOpenAtom,
|
isDeleteConfirmationDialogOpenAtom,
|
||||||
|
|||||||
Reference in New Issue
Block a user