impl: permanent file deletion

implement trash page and permanent file deletion logic

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-10-05 00:41:59 +00:00
parent e806d442b7
commit 19e52feebb
7 changed files with 396 additions and 14 deletions

View File

@@ -8,12 +8,14 @@ import type {
FileHandle, FileHandle,
FileSystemItem, FileSystemItem,
} from "./model/filesystem" } from "./model/filesystem"
import * as FileSystem from "./model/filesystem"
import { import {
type FileSystemHandle, type FileSystemHandle,
FileType, FileType,
VDirectoryHandle, VDirectoryHandle,
VFileSystemHandle, VFileSystemHandle,
} from "./model/filesystem" } from "./model/filesystem"
export const moveItems = authenticatedMutation({ export const moveItems = authenticatedMutation({
args: { args: {
targetDirectory: VDirectoryHandle, targetDirectory: VDirectoryHandle,
@@ -116,3 +118,12 @@ export const fetchDirectoryContent = authenticatedQuery({
return await Directories.fetchContent(ctx, { directoryId, trashed }) 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 })
},
})

View File

@@ -307,3 +307,43 @@ export async function moveToTrashRecursive(
await Promise.all([...filePatches, ...directoryPatches]) 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<Doc<"directories">> =>
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 }
}

View File

@@ -1,4 +1,4 @@
import type { Id } from "../_generated/dataModel" import type { Doc, Id } from "../_generated/dataModel"
import type { AuthenticatedMutationCtx } from "../functions" import type { AuthenticatedMutationCtx } from "../functions"
import * as Err from "./error" import * as Err from "./error"
import type { DirectoryHandle, FileHandle } from "./filesystem" import type { DirectoryHandle, FileHandle } from "./filesystem"
@@ -90,7 +90,10 @@ export async function move(
const results = await Promise.allSettled( const results = await Promise.allSettled(
okFiles.map((handle) => 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 } 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<Doc<"files">> =>
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 }
}

View File

@@ -1,5 +1,8 @@
import { v } from "convex/values" import { v } from "convex/values"
import type { Doc, Id } from "../_generated/dataModel" 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 { export enum FileType {
File = "File", File = "File",
@@ -73,3 +76,84 @@ export const VFileHandle = v.object({
id: v.id("files"), id: v.id("files"),
}) })
export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle) 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 || []),
],
}
}

View File

@@ -42,6 +42,7 @@ import { DirectoryPageContext } from "./context"
type DirectoryContentTableProps = { type DirectoryContentTableProps = {
filterFn: (item: FileSystemItem) => boolean filterFn: (item: FileSystemItem) => boolean
directoryUrlFn: (directory: Doc<"directories">) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null> fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: ( onContextMenu: (
row: Row<FileSystemItem>, row: Row<FileSystemItem>,
@@ -62,6 +63,7 @@ function formatFileSize(bytes: number): string {
function useTableColumns( function useTableColumns(
onOpenFile: (file: Doc<"files">) => void, onOpenFile: (file: Doc<"files">) => void,
directoryUrlFn: (directory: Doc<"directories">) => string,
): ColumnDef<FileSystemItem>[] { ): ColumnDef<FileSystemItem>[] {
return useMemo( return useMemo(
() => [ () => [
@@ -106,6 +108,7 @@ function useTableColumns(
return ( return (
<DirectoryNameCell <DirectoryNameCell
directory={row.original.doc} directory={row.original.doc}
directoryUrlFn={directoryUrlFn}
/> />
) )
} }
@@ -142,12 +145,13 @@ function useTableColumns(
}, },
}, },
], ],
[onOpenFile], [onOpenFile, directoryUrlFn],
) )
} }
export function DirectoryContentTable({ export function DirectoryContentTable({
filterFn, filterFn,
directoryUrlFn,
onContextMenu, onContextMenu,
fileDragInfoAtom, fileDragInfoAtom,
onOpenFile, onOpenFile,
@@ -158,7 +162,7 @@ export function DirectoryContentTable({
const table = useReactTable({ const table = useReactTable({
data: directoryContent || [], data: directoryContent || [],
columns: useTableColumns(onOpenFile), columns: useTableColumns(onOpenFile, directoryUrlFn),
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
enableRowSelection: true, enableRowSelection: true,
enableGlobalFilter: 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 ( return (
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" /> <DirectoryIcon className="size-4" />
<Link <Link className="hover:underline" to={directoryUrlFn(directory)}>
className="hover:underline"
to={`/directories/${directory._id}`}
>
{directory.name} {directory.name}
</Link> </Link>
</div> </div>

View File

@@ -99,6 +99,11 @@ function RouteComponent() {
[setOpenedFile], [setOpenedFile],
) )
const directoryUrlFn = useCallback(
(directory: Doc<"directories">) => `/directories/${directory._id}`,
[],
)
const handleContextMenuRequest = ( const handleContextMenuRequest = (
row: Row<FileSystemItem>, row: Row<FileSystemItem>,
table: Table<FileSystemItem>, table: Table<FileSystemItem>,
@@ -133,6 +138,7 @@ function RouteComponent() {
<div className="w-full"> <div className="w-full">
<DirectoryContentTable <DirectoryContentTable
filterFn={tableFilter} filterFn={tableFilter}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom} fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest} onContextMenu={handleContextMenuRequest}
onOpenFile={openFile} onOpenFile={openFile}

View File

@@ -1,11 +1,40 @@
import { api } from "@fileone/convex/_generated/api" import { api } from "@fileone/convex/_generated/api"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import {
type FileSystemItem,
newFileSystemHandle,
} from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { useQuery as useConvexQuery } from "convex/react" import type { Row, Table } from "@tanstack/react-table"
import { TrashIcon } from "lucide-react" import {
useMutation as useConvexMutation,
useQuery as useConvexQuery,
} from "convex/react"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react"
import { useCallback } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { DirectoryPageContext } from "@/directories/directory-page/context" import { DirectoryPageContext } from "@/directories/directory-page/context"
import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table"
import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb" import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb"
import type { FileDragInfo } from "@/files/use-file-drop"
export const Route = createFileRoute( export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/trash/directories/$directoryId", "/_authenticated/_sidebar-layout/trash/directories/$directoryId",
@@ -13,6 +42,13 @@ export const Route = createFileRoute(
component: RouteComponent, component: RouteComponent,
}) })
const contextMenuTargetItemsAtom = atom<FileSystemItem[]>([])
const fileDragInfoAtom = atom<FileDragInfo | null>(null)
const isDeleteConfirmationDialogOpenAtom = atom(false)
const optimisticRemovedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
function RouteComponent() { function RouteComponent() {
const { directoryId } = Route.useParams() const { directoryId } = Route.useParams()
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory) const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
@@ -26,11 +62,31 @@ function RouteComponent() {
trashed: true, trashed: true,
}, },
) )
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
const directoryUrlFn = useCallback(
(directory: Doc<"directories">) =>
`/trash/directories/${directory._id}`,
[],
)
if (!directory || !directoryContent || !rootDirectory) { if (!directory || !directoryContent || !rootDirectory) {
return <DirectoryPageSkeleton /> return <DirectoryPageSkeleton />
} }
const handleContextMenuRequest = (
row: Row<FileSystemItem>,
table: Table<FileSystemItem>,
) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
}
return ( return (
<DirectoryPageContext <DirectoryPageContext
value={{ rootDirectory, directory, directoryContent }} value={{ rootDirectory, directory, directoryContent }}
@@ -42,16 +98,148 @@ function RouteComponent() {
</div> </div>
</header> </header>
{/* <DirectoryPage /> */} <TableContextMenu>
<div className="w-full">
<DirectoryContentTable
filterFn={() => true}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={() => {}}
/>
</div>
</TableContextMenu>
<DeleteConfirmationDialog />
</DirectoryPageContext> </DirectoryPageContext>
) )
} }
function EmptyTrashButton() { function TableContextMenu({ children }: React.PropsWithChildren) {
const setIsDeleteConfirmationDialogOpen = useSetAtom(
isDeleteConfirmationDialogOpenAtom,
)
return ( return (
<Button size="sm" type="button" variant="outline"> <ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<UndoIcon />
Restore
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={() => {
setIsDeleteConfirmationDialogOpen(true)
}}
>
<ShredderIcon />
Delete permanently
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
function EmptyTrashButton() {
const setIsDeleteConfirmationDialogOpen = useSetAtom(
isDeleteConfirmationDialogOpenAtom,
)
return (
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
setIsDeleteConfirmationDialogOpen(true)
}}
>
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
Empty Trash Empty Trash
</Button> </Button>
) )
} }
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 (
<Dialog
open={isDeleteConfirmationDialogOpen}
onOpenChange={setIsDeleteConfirmationDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Permanently delete {targetItems.length} items?
</DialogTitle>
</DialogHeader>
<p>
{targetItems.length} items will be permanently deleted. They
will be IRRECOVERABLE.
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={isDeleting}>
Go back
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={isDeleting}
loading={isDeleting}
>
Yes, delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}