From c6d346394ccc33093a62d801cb4b7a76ced1b57f Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 28 Sep 2025 15:45:49 +0000 Subject: [PATCH] feat: impl multi file deletion support --- packages/convex/files.ts | 41 +----- packages/convex/filesystem.ts | 64 +++++++--- packages/convex/model/directories.ts | 37 +++--- packages/convex/model/filesystem.ts | 60 ++++++--- .../src/directories/directory-page/context.ts | 8 +- .../directory-content-table.tsx | 119 ++++++++++-------- .../src/directories/directory-page/state.ts | 12 +- 7 files changed, 182 insertions(+), 159 deletions(-) diff --git a/packages/convex/files.ts b/packages/convex/files.ts index 05afdf4..dfc9917 100644 --- a/packages/convex/files.ts +++ b/packages/convex/files.ts @@ -1,10 +1,9 @@ -import type { Id } from "@fileone/convex/_generated/dataModel" import { v } from "convex/values" +import type { Id } from "./_generated/dataModel" import { authenticatedMutation, authenticatedQuery } from "./functions" -import type { DirectoryItem } from "./model/directories" import * as Directories from "./model/directories" -import * as Err from "./model/error" import * as Files from "./model/files" +import type { FileSystemItem } from "./model/filesystem" export const generateUploadUrl = authenticatedMutation({ handler: async (ctx) => { @@ -55,7 +54,7 @@ export const fetchDirectoryContent = authenticatedQuery({ args: { directoryId: v.optional(v.id("directories")), }, - handler: async (ctx, { directoryId }): Promise => { + handler: async (ctx, { directoryId }): Promise => { return await Directories.fetchContent(ctx, { directoryId }) }, }) @@ -107,37 +106,3 @@ export const renameFile = authenticatedMutation({ await Files.renameFile(ctx, { directoryId, itemId, newName }) }, }) - -export const moveToTrash = authenticatedMutation({ - args: { - kind: v.union(v.literal("file"), v.literal("directory")), - itemId: v.union(v.id("files"), v.id("directories")), - }, - handler: async (ctx, { itemId, kind }) => { - switch (kind) { - case "file": { - const file = await ctx.db.get(itemId as Id<"files">) - if (!file || file.userId !== ctx.user._id) { - throw new Error("File not found or access denied") - } - await ctx.db.patch(itemId, { - deletedAt: new Date().toISOString(), - }) - break - } - case "directory": { - const directory = await ctx.db.get(itemId as Id<"directories">) - if (!directory || directory.userId !== ctx.user._id) { - throw new Error("Directory not found or access denied") - } - await Directories.moveToTrashRecursive( - ctx, - itemId as Id<"directories">, - ) - break - } - } - - return itemId - }, -}) diff --git a/packages/convex/filesystem.ts b/packages/convex/filesystem.ts index 9d6d297..5365f7f 100644 --- a/packages/convex/filesystem.ts +++ b/packages/convex/filesystem.ts @@ -4,18 +4,12 @@ import * as Directories from "./model/directories" import * as Err from "./model/error" import * as Files from "./model/files" import type { DirectoryHandle, FileHandle } from "./model/filesystem" - -const VDirectoryHandle = v.object({ - kind: v.literal("directory"), - id: v.id("directories"), -}) - -const VFileHandle = v.object({ - kind: v.literal("file"), - id: v.id("files"), -}) - -const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle) +import { + type FileSystemHandle, + FileType, + VDirectoryHandle, + VFileSystemHandle, +} from "./model/filesystem" export const moveItems = authenticatedMutation({ args: { @@ -38,10 +32,10 @@ export const moveItems = authenticatedMutation({ const fileHandles: FileHandle[] = [] for (const item of items) { switch (item.kind) { - case "directory": + case FileType.Directory: directoryHandles.push(item) break - case "file": + case FileType.File: fileHandles.push(item) break } @@ -64,3 +58,45 @@ export const moveItems = authenticatedMutation({ } }, }) + +export const moveToTrash = authenticatedMutation({ + args: { + handles: v.array(VFileSystemHandle), + }, + handler: async (ctx, { handles }) => { + // biome-ignore lint/suspicious/useIterableCallbackReturn: switch statement is exhaustive + const promises = handles.map((handle) => { + switch (handle.kind) { + case FileType.File: + return ctx.db + .patch(handle.id, { + deletedAt: new Date().toISOString(), + }) + .then(() => handle) + case FileType.Directory: + return Directories.moveToTrashRecursive(ctx, handle).then( + () => handle, + ) + } + }) + + const results = await Promise.allSettled(promises) + const errors: Err.ApplicationErrorData[] = [] + const okHandles: FileSystemHandle[] = [] + for (const result of results) { + switch (result.status) { + case "fulfilled": + okHandles.push(result.value) + break + case "rejected": + errors.push(Err.createJson(Err.Code.Internal)) + break + } + } + + return { + deleted: okHandles, + errors, + } + }, +}) diff --git a/packages/convex/model/directories.ts b/packages/convex/model/directories.ts index 147fad8..695d0d0 100644 --- a/packages/convex/model/directories.ts +++ b/packages/convex/model/directories.ts @@ -4,21 +4,14 @@ import type { AuthenticatedQueryCtx, } from "../functions" import * as Err from "./error" -import type { DirectoryHandle, FilePath, ReverseFilePath } from "./filesystem" -import { newDirectoryHandle } from "./filesystem" - -type Directory = { - kind: "directory" - doc: Doc<"directories"> -} - -type File = { - kind: "file" - doc: Doc<"files"> -} - -export type DirectoryItem = Directory | File -export type DirectoryItemKind = DirectoryItem["kind"] +import { + type DirectoryHandle, + type FilePath, + type FileSystemItem, + FileType, + newDirectoryHandle, + type ReverseFilePath, +} from "./filesystem" export type DirectoryInfo = Doc<"directories"> & { path: FilePath } @@ -83,7 +76,7 @@ export async function fetch( export async function fetchContent( ctx: AuthenticatedQueryCtx, { directoryId }: { directoryId?: Id<"directories"> } = {}, -): Promise { +): Promise { let dirId: Id<"directories"> | undefined if (directoryId) { dirId = directoryId @@ -110,12 +103,12 @@ export async function fetchContent( .collect(), ]) - const items: DirectoryItem[] = [] + const items: FileSystemItem[] = [] for (const directory of directories) { - items.push({ kind: "directory", doc: directory }) + items.push({ kind: FileType.Directory, doc: directory }) } for (const file of files) { - items.push({ kind: "file", doc: file }) + items.push({ kind: FileType.File, doc: file }) } return items @@ -206,7 +199,7 @@ export async function move( ), ) } else { - okDirectories.push(sourceDirectories[i]) + okDirectories.push(sourceDirectories[i]!) } } else if (result.status === "rejected") { errors.push(Err.createJson(Err.Code.Internal)) @@ -244,14 +237,14 @@ export async function move( export async function moveToTrashRecursive( ctx: AuthenticatedMutationCtx, - directoryId: Id<"directories">, + handle: DirectoryHandle, ): Promise { const now = new Date().toISOString() const filesToDelete: Id<"files">[] = [] const directoriesToDelete: Id<"directories">[] = [] - const directoryQueue: Id<"directories">[] = [directoryId] + const directoryQueue: Id<"directories">[] = [handle.id] while (directoryQueue.length > 0) { const currentDirectoryId = directoryQueue.shift()! diff --git a/packages/convex/model/filesystem.ts b/packages/convex/model/filesystem.ts index b965019..4a8ae05 100644 --- a/packages/convex/model/filesystem.ts +++ b/packages/convex/model/filesystem.ts @@ -1,10 +1,21 @@ -import type { Id } from "../_generated/dataModel" +import { v } from "convex/values" +import type { Doc, Id } from "../_generated/dataModel" export enum FileType { File = "File", Directory = "Directory", } +export type Directory = { + kind: FileType.Directory + doc: Doc<"directories"> +} +export type File = { + kind: FileType.File + doc: Doc<"files"> +} +export type FileSystemItem = Directory | File + export type DirectoryPathComponent = { handle: DirectoryHandle name: string @@ -14,31 +25,28 @@ export type FilePathComponent = { handle: FileHandle name: string } - export type PathComponent = FilePathComponent | DirectoryPathComponent - export type FilePath = [...DirectoryPathComponent[], PathComponent] - export type ReverseFilePath = [PathComponent, ...DirectoryPathComponent[]] -export type FileHandle = { - kind: "file" - id: Id<"files"> -} - export type DirectoryHandle = { - kind: "directory" + kind: FileType.Directory id: Id<"directories"> } - +export type FileHandle = { + kind: FileType.File + id: Id<"files"> +} export type FileSystemHandle = DirectoryHandle | FileHandle -export function newDirectoryHandle(id: Id<"directories">): DirectoryHandle { - return { kind: "directory", id } -} - -export function newFileHandle(id: Id<"files">): FileHandle { - return { kind: "file", id } +export function newFileSystemHandle(item: FileSystemItem): FileSystemHandle { + console.log("item", item) + switch (item.kind) { + case FileType.File: + return { kind: item.kind, id: item.doc._id } + case FileType.Directory: + return { kind: item.kind, id: item.doc._id } + } } export function isSameHandle( @@ -47,3 +55,21 @@ export function isSameHandle( ): boolean { return handle1.kind === handle2.kind && handle1.id === handle2.id } + +export function newDirectoryHandle(id: Id<"directories">): DirectoryHandle { + return { kind: FileType.Directory, id } +} + +export function newFileHandle(id: Id<"files">): FileHandle { + return { kind: FileType.File, id } +} + +export const VDirectoryHandle = v.object({ + kind: v.literal(FileType.Directory), + id: v.id("directories"), +}) +export const VFileHandle = v.object({ + kind: v.literal(FileType.File), + id: v.id("files"), +}) +export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle) diff --git a/packages/web/src/directories/directory-page/context.ts b/packages/web/src/directories/directory-page/context.ts index e6bca7e..3b0a965 100644 --- a/packages/web/src/directories/directory-page/context.ts +++ b/packages/web/src/directories/directory-page/context.ts @@ -1,14 +1,12 @@ import type { Doc } from "@fileone/convex/_generated/dataModel" -import type { - DirectoryInfo, - DirectoryItem, -} from "@fileone/convex/model/directories" +import type { DirectoryInfo } from "@fileone/convex/model/directories" +import type { FileSystemItem } from "@fileone/convex/model/filesystem" import { createContext } from "react" type DirectoryPageContextType = { rootDirectory: Doc<"directories"> directory: DirectoryInfo - directoryContent: DirectoryItem[] + directoryContent: FileSystemItem[] } export const DirectoryPageContext = createContext( 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 332dc79..6ff0fe2 100644 --- a/packages/web/src/directories/directory-page/directory-content-table.tsx +++ b/packages/web/src/directories/directory-page/directory-content-table.tsx @@ -1,11 +1,15 @@ import { api } from "@fileone/convex/_generated/api" import type { Doc } from "@fileone/convex/_generated/dataModel" -import type { DirectoryItem } from "@fileone/convex/model/directories" import { + type DirectoryHandle, + type FileHandle, type FileSystemHandle, + type FileSystemItem, + FileType, isSameHandle, newDirectoryHandle, newFileHandle, + newFileSystemHandle, } from "@fileone/convex/model/filesystem" import { useMutation } from "@tanstack/react-query" import { Link, useNavigate } from "@tanstack/react-router" @@ -19,8 +23,8 @@ import { } from "@tanstack/react-table" import { useMutation as useContextMutation } from "convex/react" import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai" -import { CheckIcon, TextCursorInputIcon, TrashIcon, XIcon } from "lucide-react" -import { useContext, useEffect, useId, useRef } from "react" +import { TextCursorInputIcon, TrashIcon } from "lucide-react" +import { useContext, useEffect, useRef } from "react" import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { Checkbox } from "@/components/ui/checkbox" @@ -43,17 +47,13 @@ import { keyboardModifierAtom, } from "@/lib/keyboard" import { TextFileIcon } from "../../components/icons/text-file-icon" -import { Button } from "../../components/ui/button" -import { LoadingSpinner } from "../../components/ui/loading-spinner" import { useFileDrop } from "../../files/use-file-drop" -import { withDefaultOnError } from "../../lib/error" import { cn } from "../../lib/utils" import { DirectoryPageContext } from "./context" import { - contextMenuTargeItemAtom, + contextMenuTargeItemsAtom, dragInfoAtom, itemBeingRenamedAtom, - newFileTypeAtom, openedFileAtom, optimisticDeletedItemsAtom, } from "./state" @@ -68,7 +68,7 @@ function formatFileSize(bytes: number): string { return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}` } -const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { id: "select", header: ({ table }) => ( @@ -99,9 +99,9 @@ const columns: ColumnDef[] = [ accessorKey: "doc.name", cell: ({ row }) => { switch (row.original.kind) { - case "file": + case FileType.File: return - case "directory": + case FileType.Directory: return } }, @@ -112,9 +112,9 @@ const columns: ColumnDef[] = [ accessorKey: "size", cell: ({ row }) => { switch (row.original.kind) { - case "file": + case FileType.File: return
{formatFileSize(row.original.doc.size)}
- case "directory": + case FileType.Directory: return
-
} }, @@ -148,31 +148,44 @@ export function DirectoryContentTableContextMenu({ children: React.ReactNode }) { const store = useStore() - const target = useAtomValue(contextMenuTargeItemAtom) + const [target, setTarget] = useAtom(contextMenuTargeItemsAtom) const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom) - const moveToTrashMutation = useContextMutation(api.files.moveToTrash) + const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash) const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom) - const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom) const { mutate: moveToTrash } = useMutation({ mutationFn: moveToTrashMutation, - onMutate: ({ itemId }) => { - setOptimisticDeletedItems((prev) => new Set([...prev, itemId])) + onMutate: ({ handles }) => { + setOptimisticDeletedItems( + (prev) => + new Set([...prev, ...handles.map((handle) => handle.id)]), + ) }, - onSuccess: (itemId) => { + onSuccess: ({ deleted, errors }, { handles }) => { setOptimisticDeletedItems((prev) => { const newSet = new Set(prev) - newSet.delete(itemId) + for (const handle of handles) { + newSet.delete(handle.id) + } return newSet }) - toast.success("Moved to trash") + if (errors.length === 0 && deleted.length === handles.length) { + toast.success(`Moved ${handles.length} items to trash`) + } else if (errors.length === handles.length) { + toast.error("Failed to move to trash") + } else { + toast.info( + `Moved ${deleted.length} items to trash; failed to move ${errors.length} items`, + ) + } }, }) const handleRename = () => { - const selectedItem = store.get(contextMenuTargeItemAtom) - if (selectedItem) { + const selectedItems = store.get(contextMenuTargeItemsAtom) + if (selectedItems.length === 1) { + // biome-ignore lint/style/noNonNullAssertion: length is checked + const selectedItem = selectedItems[0]! setItemBeingRenamed({ - kind: selectedItem.kind, originalItem: selectedItem, name: selectedItem.doc.name, }) @@ -180,11 +193,10 @@ export function DirectoryContentTableContextMenu({ } const handleDelete = () => { - const selectedItem = store.get(contextMenuTargeItemAtom) - if (selectedItem) { + const selectedItems = store.get(contextMenuTargeItemsAtom) + if (selectedItems.length > 0) { moveToTrash({ - kind: selectedItem.kind, - itemId: selectedItem.doc._id, + handles: selectedItems.map(newFileSystemHandle), }) } } @@ -193,7 +205,7 @@ export function DirectoryContentTableContextMenu({ { if (!open) { - setContextMenuTargetItem(null) + setTarget([]) } }} > @@ -217,7 +229,7 @@ export function DirectoryContentTableContextMenu({ export function DirectoryContentTableContent() { const { directoryContent } = useContext(DirectoryPageContext) const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom) - const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom) + const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemsAtom) const store = useStore() const navigate = useNavigate() @@ -247,23 +259,26 @@ export function DirectoryContentTableContent() { ) const handleRowContextMenu = ( - row: Row, + row: Row, _event: React.MouseEvent, ) => { - const target = store.get(contextMenuTargeItemAtom) - if (target === row.original) { - setContextMenuTargetItem(null) + const target = store.get(contextMenuTargeItemsAtom) + if (target.length > 0) { + setContextMenuTargetItem([]) + } else if (row.getIsSelected()) { + setContextMenuTargetItem( + table.getSelectedRowModel().rows.map((row) => row.original), + ) } else { selectRow(row) - setContextMenuTargetItem(row.original) + setContextMenuTargetItem([row.original]) } } - const selectRow = (row: Row) => { + const selectRow = (row: Row) => { const keyboardModifiers = store.get(keyboardModifierAtom) const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers) const isRowSelected = row.getIsSelected() - console.log({ isMultiSelectMode, isRowSelected }) if (isRowSelected && isMultiSelectMode) { row.toggleSelected(false) } else if (isRowSelected && !isMultiSelectMode) { @@ -282,8 +297,8 @@ export function DirectoryContentTableContent() { } } - const handleRowDoubleClick = (row: Row) => { - if (row.original.kind === "directory") { + const handleRowDoubleClick = (row: Row) => { + if (row.original.kind === FileType.Directory) { navigate({ to: `/directories/${row.original.doc._id}`, }) @@ -355,8 +370,8 @@ function FileItemRow({ onContextMenu, onDoubleClick, }: { - table: TableType - row: Row + table: TableType + row: Row onClick: () => void onContextMenu: (e: React.MouseEvent) => void onDoubleClick: () => void @@ -366,19 +381,19 @@ function FileItemRow({ const { isDraggedOver, dropHandlers } = useFileDrop({ destItem: - row.original.kind === "directory" + row.original.kind === FileType.Directory ? newDirectoryHandle(row.original.doc._id) : null, dragInfoAtom, }) - const handleDragStart = (e: React.DragEvent) => { - let source: FileSystemHandle + const handleDragStart = (_e: React.DragEvent) => { + let source: DirectoryHandle | FileHandle switch (row.original.kind) { - case "file": + case FileType.File: source = newFileHandle(row.original.doc._id) break - case "directory": + case FileType.Directory: source = newDirectoryHandle(row.original.doc._id) break } @@ -386,15 +401,9 @@ function FileItemRow({ let draggedItems: FileSystemHandle[] // drag all selections, but only if the currently dragged row is also selected if (row.getIsSelected()) { - // biome-ignore lint/suspicious/useIterableCallbackReturn: the switch statement is exhaustive - draggedItems = table.getSelectedRowModel().rows.map((row) => { - switch (row.original.kind) { - case "file": - return newFileHandle(row.original.doc._id) - case "directory": - return newDirectoryHandle(row.original.doc._id) - } - }) + draggedItems = table + .getSelectedRowModel() + .rows.map((row) => newFileSystemHandle(row.original)) if (!draggedItems.some((item) => isSameHandle(item, source))) { draggedItems.push(source) } diff --git a/packages/web/src/directories/directory-page/state.ts b/packages/web/src/directories/directory-page/state.ts index 0178b47..03377f4 100644 --- a/packages/web/src/directories/directory-page/state.ts +++ b/packages/web/src/directories/directory-page/state.ts @@ -1,14 +1,11 @@ import type { Doc, Id } from "@fileone/convex/_generated/dataModel" -import type { - DirectoryItem, - DirectoryItemKind, -} from "@fileone/convex/model/directories" -import type { FileType } from "@fileone/convex/model/filesystem" +import type { DirectoryItemKind } from "@fileone/convex/model/directories" +import type { FileSystemItem, FileType } from "@fileone/convex/model/filesystem" import type { RowSelectionState } from "@tanstack/react-table" import { atom } from "jotai" import type { FileDragInfo } from "../../files/use-file-drop" -export const contextMenuTargeItemAtom = atom(null) +export const contextMenuTargeItemsAtom = atom([]) export const optimisticDeletedItemsAtom = atom( new Set | Id<"directories">>(), ) @@ -18,8 +15,7 @@ export const selectedFileRowsAtom = atom({}) export const newFileTypeAtom = atom(null) export const itemBeingRenamedAtom = atom<{ - kind: DirectoryItemKind - originalItem: DirectoryItem + originalItem: FileSystemItem name: string } | null>(null)