From 33b235517ce74f575e0dd688886a6b5ed59a370d Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 5 Oct 2025 14:29:45 +0000 Subject: [PATCH] feat: impl file/dir restoration from trash --- packages/convex/filesystem.ts | 9 +++ packages/convex/model/directories.ts | 43 ++++++++++++ packages/convex/model/files.ts | 43 ++++++++++++ packages/convex/model/filesystem.ts | 69 ++++++++++++++++--- .../trash.directories.$directoryId.tsx | 68 ++++++++++++++++-- 5 files changed, 215 insertions(+), 17 deletions(-) diff --git a/packages/convex/filesystem.ts b/packages/convex/filesystem.ts index 4b860cf..4681183 100644 --- a/packages/convex/filesystem.ts +++ b/packages/convex/filesystem.ts @@ -127,3 +127,12 @@ export const permanentlyDeleteItems = authenticatedMutation({ 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 }) + }, +}) diff --git a/packages/convex/model/directories.ts b/packages/convex/model/directories.ts index 8ccd487..0a9c8ce 100644 --- a/packages/convex/model/directories.ts +++ b/packages/convex/model/directories.ts @@ -347,3 +347,46 @@ export async function deletePermanently( 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> => + 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 } +} diff --git a/packages/convex/model/files.ts b/packages/convex/model/files.ts index cdbacee..e716cea 100644 --- a/packages/convex/model/files.ts +++ b/packages/convex/model/files.ts @@ -148,3 +148,46 @@ export async function deletePermanently( 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> => + 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 } +} diff --git a/packages/convex/model/filesystem.ts b/packages/convex/model/filesystem.ts index bb35835..68bff4d 100644 --- a/packages/convex/model/filesystem.ts +++ b/packages/convex/model/filesystem.ts @@ -77,13 +77,16 @@ export const VFileHandle = v.object({ }) 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, { handles }: { handles: FileSystemHandle[] }, -) { - // Collect all items to delete (including nested items) - const fileHandlesToDelete: FileHandle[] = [] - const directoryHandlesToDelete: DirectoryHandle[] = [] +): Promise<{ fileHandles: FileHandle[]; directoryHandles: DirectoryHandle[] }> { + const fileHandles: FileHandle[] = [] + const directoryHandles: DirectoryHandle[] = [] // Process each handle to collect files and directories for (const handle of handles) { @@ -93,16 +96,16 @@ export async function deleteItemsPermanently( while (queue.length > 0) { const currentHandle = queue.shift()! - // Add current item to appropriate deletion collection + // Add current item to appropriate collection if (currentHandle.kind === FileType.File) { - fileHandlesToDelete.push(currentHandle) + fileHandles.push(currentHandle) } else { - directoryHandlesToDelete.push(currentHandle) + directoryHandles.push(currentHandle) } // If it's a directory, collect all children and add them to the queue 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 .query("directories") .withIndex("byParentId", (q) => @@ -113,7 +116,7 @@ export async function deleteItemsPermanently( ) .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 .query("files") .withIndex("byDirectoryId", (q) => @@ -133,12 +136,56 @@ export async function deleteItemsPermanently( // Add child files to file handles collection for (const childFile of childFiles) { 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 const [filesResult, directoriesResult] = await Promise.all([ Files.deletePermanently(ctx, { items: fileHandlesToDelete }), diff --git a/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx b/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx index 4d77019..ad8456e 100644 --- a/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx +++ b/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx @@ -11,9 +11,9 @@ import { useMutation as useConvexMutation, useQuery as useConvexQuery, } 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 { useCallback } from "react" +import { useCallback, useEffect } from "react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { @@ -37,6 +37,7 @@ import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-pa import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb" import { FilePreviewDialog } from "@/files/file-preview-dialog" import type { FileDragInfo } from "@/files/use-file-drop" +import { backgroundTaskProgressAtom } from "../../../dashboard/state" export const Route = createFileRoute( "/_authenticated/_sidebar-layout/trash/directories/$directoryId", @@ -140,10 +141,7 @@ function TableContextMenu({ children }: React.PropsWithChildren) { {children} - - - Restore - + { @@ -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 ( + + + Restore + + ) +} + function EmptyTrashButton() { const setIsDeleteConfirmationDialogOpen = useSetAtom( isDeleteConfirmationDialogOpenAtom,