From 19e52feebb5b57794cccc73432273977670b4969 Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 5 Oct 2025 00:41:59 +0000 Subject: [PATCH] impl: permanent file deletion implement trash page and permanent file deletion logic Co-authored-by: Ona --- packages/convex/filesystem.ts | 11 + packages/convex/model/directories.ts | 40 ++++ packages/convex/model/files.ts | 50 ++++- packages/convex/model/filesystem.ts | 84 ++++++++ .../directory-content-table.tsx | 21 +- .../directories.$directoryId.tsx | 6 + .../trash.directories.$directoryId.tsx | 198 +++++++++++++++++- 7 files changed, 396 insertions(+), 14 deletions(-) diff --git a/packages/convex/filesystem.ts b/packages/convex/filesystem.ts index beccbe6..4b860cf 100644 --- a/packages/convex/filesystem.ts +++ b/packages/convex/filesystem.ts @@ -8,12 +8,14 @@ import type { FileHandle, FileSystemItem, } from "./model/filesystem" +import * as FileSystem from "./model/filesystem" import { type FileSystemHandle, FileType, VDirectoryHandle, VFileSystemHandle, } from "./model/filesystem" + export const moveItems = authenticatedMutation({ args: { targetDirectory: VDirectoryHandle, @@ -116,3 +118,12 @@ export const fetchDirectoryContent = authenticatedQuery({ return await Directories.fetchContent(ctx, { directoryId, trashed }) }, }) + +export const permanentlyDeleteItems = authenticatedMutation({ + args: { + handles: v.array(VFileSystemHandle), + }, + handler: async (ctx, { handles }) => { + return await FileSystem.deleteItemsPermanently(ctx, { handles }) + }, +}) diff --git a/packages/convex/model/directories.ts b/packages/convex/model/directories.ts index fe4dcba..8ccd487 100644 --- a/packages/convex/model/directories.ts +++ b/packages/convex/model/directories.ts @@ -307,3 +307,43 @@ export async function moveToTrashRecursive( await Promise.all([...filePatches, ...directoryPatches]) } + +export async function deletePermanently( + ctx: AuthenticatedMutationCtx, + { + items, + }: { + items: DirectoryHandle[] + }, +) { + if (items.length === 0) { + return null + } + + const itemsToBeDeleted = 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 deleteDirectoryPromises = itemsToBeDeleted.map((item) => + ctx.db.delete(item.value._id), + ) + + const deleteResults = await Promise.allSettled(deleteDirectoryPromises) + + const errors: Err.ApplicationErrorData[] = [] + let successfulDeletions = 0 + for (const result of deleteResults) { + if (result.status === "rejected") { + errors.push(Err.createJson(Err.Code.Internal)) + } else { + successfulDeletions += 1 + } + } + + return { deleted: successfulDeletions, errors } +} diff --git a/packages/convex/model/files.ts b/packages/convex/model/files.ts index 2f58636..cdbacee 100644 --- a/packages/convex/model/files.ts +++ b/packages/convex/model/files.ts @@ -1,4 +1,4 @@ -import type { Id } from "../_generated/dataModel" +import type { Doc, Id } from "../_generated/dataModel" import type { AuthenticatedMutationCtx } from "../functions" import * as Err from "./error" import type { DirectoryHandle, FileHandle } from "./filesystem" @@ -90,7 +90,10 @@ export async function move( const results = await Promise.allSettled( okFiles.map((handle) => - ctx.db.patch(handle.id, { directoryId: targetDirectoryHandle.id, updatedAt: Date.now() }), + ctx.db.patch(handle.id, { + directoryId: targetDirectoryHandle.id, + updatedAt: Date.now(), + }), ), ) @@ -102,3 +105,46 @@ export async function move( return { moved: okFiles, errors } } + +export async function deletePermanently( + ctx: AuthenticatedMutationCtx, + { + items, + }: { + items: FileHandle[] + }, +) { + if (items.length === 0) { + return null + } + + const itemsToBeDeleted = 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 deleteFilePromises = itemsToBeDeleted.map((item) => + Promise.all([ + ctx.db.delete(item.value._id), + ctx.storage.delete(item.value.storageId), + ]), + ) + + const deleteResults = await Promise.allSettled(deleteFilePromises) + + const errors: Err.ApplicationErrorData[] = [] + let successfulDeletions = 0 + for (const result of deleteResults) { + if (result.status === "rejected") { + errors.push(Err.createJson(Err.Code.Internal)) + } else { + successfulDeletions += 1 + } + } + + return { deleted: successfulDeletions, errors } +} diff --git a/packages/convex/model/filesystem.ts b/packages/convex/model/filesystem.ts index 4a8ae05..bb35835 100644 --- a/packages/convex/model/filesystem.ts +++ b/packages/convex/model/filesystem.ts @@ -1,5 +1,8 @@ import { v } from "convex/values" import type { Doc, Id } from "../_generated/dataModel" +import type { AuthenticatedMutationCtx } from "../functions" +import * as Directories from "./directories" +import * as Files from "./files" export enum FileType { File = "File", @@ -73,3 +76,84 @@ export const VFileHandle = v.object({ id: v.id("files"), }) export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle) + +export async function deleteItemsPermanently( + ctx: AuthenticatedMutationCtx, + { handles }: { handles: FileSystemHandle[] }, +) { + // Collect all items to delete (including nested items) + const fileHandlesToDelete: FileHandle[] = [] + const directoryHandlesToDelete: DirectoryHandle[] = [] + + // Process each handle to collect files and directories + for (const handle of handles) { + // Use a queue to process items iteratively instead of recursively + const queue: FileSystemHandle[] = [handle] + + while (queue.length > 0) { + const currentHandle = queue.shift()! + + // Add current item to appropriate deletion collection + if (currentHandle.kind === FileType.File) { + fileHandlesToDelete.push(currentHandle) + } else { + directoryHandlesToDelete.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) + const childDirectories = await ctx.db + .query("directories") + .withIndex("byParentId", (q) => + q + .eq("userId", ctx.user._id) + .eq("parentId", currentHandle.id) + .gte("deletedAt", 0), + ) + .collect() + + // Get all child files that are in trash (deletedAt > 0) + const childFiles = await ctx.db + .query("files") + .withIndex("byDirectoryId", (q) => + q + .eq("userId", ctx.user._id) + .eq("directoryId", currentHandle.id) + .gte("deletedAt", 0), + ) + .collect() + + // Add child directories to queue for processing + for (const childDir of childDirectories) { + const childHandle = newDirectoryHandle(childDir._id) + queue.push(childHandle) + } + + // Add child files to file handles collection + for (const childFile of childFiles) { + const childFileHandle = newFileHandle(childFile._id) + fileHandlesToDelete.push(childFileHandle) + } + } + } + } + + // Delete files and directories using their respective models + const [filesResult, directoriesResult] = await Promise.all([ + Files.deletePermanently(ctx, { items: fileHandlesToDelete }), + Directories.deletePermanently(ctx, { items: directoryHandlesToDelete }), + ]) + + // Combine results, handling null responses + return { + deleted: { + files: filesResult?.deleted || 0, + directories: directoriesResult?.deleted || 0, + }, + errors: [ + ...(filesResult?.errors || []), + ...(directoriesResult?.errors || []), + ], + } +} diff --git a/packages/web/src/directories/directory-page/directory-content-table.tsx b/packages/web/src/directories/directory-page/directory-content-table.tsx index 96f3181..cf17803 100644 --- a/packages/web/src/directories/directory-page/directory-content-table.tsx +++ b/packages/web/src/directories/directory-page/directory-content-table.tsx @@ -42,6 +42,7 @@ import { DirectoryPageContext } from "./context" type DirectoryContentTableProps = { filterFn: (item: FileSystemItem) => boolean + directoryUrlFn: (directory: Doc<"directories">) => string fileDragInfoAtom: PrimitiveAtom onContextMenu: ( row: Row, @@ -62,6 +63,7 @@ function formatFileSize(bytes: number): string { function useTableColumns( onOpenFile: (file: Doc<"files">) => void, + directoryUrlFn: (directory: Doc<"directories">) => string, ): ColumnDef[] { return useMemo( () => [ @@ -106,6 +108,7 @@ function useTableColumns( return ( ) } @@ -142,12 +145,13 @@ function useTableColumns( }, }, ], - [onOpenFile], + [onOpenFile, directoryUrlFn], ) } export function DirectoryContentTable({ filterFn, + directoryUrlFn, onContextMenu, fileDragInfoAtom, onOpenFile, @@ -158,7 +162,7 @@ export function DirectoryContentTable({ const table = useReactTable({ data: directoryContent || [], - columns: useTableColumns(onOpenFile), + columns: useTableColumns(onOpenFile, directoryUrlFn), getCoreRowModel: getCoreRowModel(), enableRowSelection: true, enableGlobalFilter: true, @@ -366,14 +370,17 @@ function FileItemRow({ ) } -function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) { +function DirectoryNameCell({ + directory, + directoryUrlFn, +}: { + directory: Doc<"directories"> + directoryUrlFn: (directory: Doc<"directories">) => string +}) { return (
- + {directory.name}
diff --git a/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index 67075d1..e8a4625 100644 --- a/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -99,6 +99,11 @@ function RouteComponent() { [setOpenedFile], ) + const directoryUrlFn = useCallback( + (directory: Doc<"directories">) => `/directories/${directory._id}`, + [], + ) + const handleContextMenuRequest = ( row: Row, table: Table, @@ -133,6 +138,7 @@ function RouteComponent() {
([]) +const fileDragInfoAtom = atom(null) +const isDeleteConfirmationDialogOpenAtom = atom(false) +const optimisticRemovedItemsAtom = atom( + new Set | Id<"directories">>(), +) + function RouteComponent() { const { directoryId } = Route.useParams() const rootDirectory = useConvexQuery(api.files.fetchRootDirectory) @@ -26,11 +62,31 @@ function RouteComponent() { trashed: true, }, ) + const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) + + const directoryUrlFn = useCallback( + (directory: Doc<"directories">) => + `/trash/directories/${directory._id}`, + [], + ) if (!directory || !directoryContent || !rootDirectory) { return } + const handleContextMenuRequest = ( + row: Row, + table: Table, + ) => { + if (row.getIsSelected()) { + setContextMenuTargetItems( + table.getSelectedRowModel().rows.map((row) => row.original), + ) + } else { + setContextMenuTargetItems([row.original]) + } + } + return ( - {/* */} + +
+ true} + directoryUrlFn={directoryUrlFn} + fileDragInfoAtom={fileDragInfoAtom} + onContextMenu={handleContextMenuRequest} + onOpenFile={() => {}} + /> +
+
+ +
) } -function EmptyTrashButton() { +function TableContextMenu({ children }: React.PropsWithChildren) { + const setIsDeleteConfirmationDialogOpen = useSetAtom( + isDeleteConfirmationDialogOpenAtom, + ) + return ( - ) } + +function DeleteConfirmationDialog() { + const [targetItems, setTargetItems] = useAtom(contextMenuTargetItemsAtom) + const [isDeleteConfirmationDialogOpen, setIsDeleteConfirmationDialogOpen] = + useAtom(isDeleteConfirmationDialogOpenAtom) + const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom) + + const deletePermanentlyMutation = useConvexMutation( + api.filesystem.permanentlyDeleteItems, + ) + const { mutate: deletePermanently, isPending: isDeleting } = useMutation({ + mutationFn: deletePermanentlyMutation, + onMutate: ({ handles }) => { + setOptimisticRemovedItems( + (prev) => + new Set([...prev, ...handles.map((handle) => handle.id)]), + ) + }, + onSuccess: ({ deleted, errors }, { handles }) => { + setOptimisticRemovedItems((prev) => { + const newSet = new Set(prev) + for (const handle of handles) { + newSet.delete(handle.id) + } + return newSet + }) + if (errors.length === 0) { + toast.success( + `Deleted ${deleted.files} files and ${deleted.directories} directories`, + ) + } else { + toast.warning( + `Deleted ${deleted.files} files and ${deleted.directories} directories; failed to delete ${errors.length} items`, + ) + } + setIsDeleteConfirmationDialogOpen(false) + setTargetItems([]) + }, + }) + + const confirmDelete = () => { + deletePermanently({ + handles: targetItems.map(newFileSystemHandle), + }) + } + + return ( + + + + + Permanently delete {targetItems.length} items? + + + +

+ {targetItems.length} items will be permanently deleted. They + will be IRRECOVERABLE. +

+ + + + + + + +
+
+ ) +}