feat: impl file/dir restoration from trash

This commit is contained in:
2025-10-05 14:29:45 +00:00
parent 4978a173a8
commit 33b235517c
5 changed files with 215 additions and 17 deletions

View File

@@ -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 })
},
})

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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 }),

View File

@@ -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,