diff --git a/apps/drive-web/package.json b/apps/drive-web/package.json index 638e449..8eef969 100644 --- a/apps/drive-web/package.json +++ b/apps/drive-web/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "format": "biome format --write" + "format": "bunx biome format --write", + "lint": "bunx biome lint", + "typecheck": "bunx tsc -p tsconfig.json --noEmit" }, "dependencies": { "@convex-dev/better-auth": "^0.8.9", diff --git a/apps/drive-web/src/directories/directory-page/defaults.ts b/apps/drive-web/src/directories/directory-page/defaults.ts new file mode 100644 index 0000000..95908fc --- /dev/null +++ b/apps/drive-web/src/directories/directory-page/defaults.ts @@ -0,0 +1,9 @@ +import { + DIRECTORY_CONTENT_ORDER_BY, + DIRECTORY_CONTENT_ORDER_DIRECTION, +} from "@/vfs/api" + +export const DEFAULT_DIRECTORY_CONTENT_ORDER_BY = + DIRECTORY_CONTENT_ORDER_BY.name +export const DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION = + DIRECTORY_CONTENT_ORDER_DIRECTION.asc diff --git a/apps/drive-web/src/directories/directory-page/directory-content-table.tsx b/apps/drive-web/src/directories/directory-page/directory-content-table.tsx index ee07bb1..53f382c 100644 --- a/apps/drive-web/src/directories/directory-page/directory-content-table.tsx +++ b/apps/drive-web/src/directories/directory-page/directory-content-table.tsx @@ -1,4 +1,6 @@ -import { useInfiniteQuery } from "@tanstack/react-query" +// TODO: make table sorting work (right now not working probably because different params share same query key) + +import { useInfiniteQuery, useMutation } from "@tanstack/react-query" import { Link, useNavigate } from "@tanstack/react-router" import { type ColumnDef, @@ -10,9 +12,11 @@ import { useReactTable, } from "@tanstack/react-table" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" -import { type PrimitiveAtom, useSetAtom, useStore } from "jotai" +import { type PrimitiveAtom, useAtomValue, useSetAtom, useStore } from "jotai" +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react" import type React from "react" -import { useEffect, useMemo, useRef } from "react" +import { createContext, useContext, useEffect, useMemo, useRef } from "react" +import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { TextFileIcon } from "@/components/icons/text-file-icon" import { Checkbox } from "@/components/ui/checkbox" @@ -31,25 +35,56 @@ import { keyboardModifierAtom, } from "@/lib/keyboard" import { cn } from "@/lib/utils" -import type { DirectoryContentQuery } from "@/vfs/api" +import { + DIRECTORY_CONTENT_ORDER_BY, + DIRECTORY_CONTENT_ORDER_DIRECTION, + type DirectoryContentOrderBy, + type DirectoryContentOrderDirection, + type DirectoryContentQuery, + type MoveDirectoryItemsResult, + moveDirectoryItemsMutationAtom, +} from "@/vfs/api" import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs" +import { + DEFAULT_DIRECTORY_CONTENT_ORDER_BY, + DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION, +} from "./defaults" import { DirectoryContentTableSkeleton } from "./directory-content-table-skeleton" type DirectoryContentTableItemIdFilter = Set +export type DirectoryContentTableSortChangeCallback = ( + orderBy: DirectoryContentOrderBy, + direction: DirectoryContentOrderDirection, +) => void + export type DirectoryContentTableProps = { + query: DirectoryContentQuery directoryUrlFn: (directory: DirectoryInfo) => string fileDragInfoAtom: PrimitiveAtom + loadingComponent?: React.ReactNode + debugBanner?: React.ReactNode + onContextMenu: ( row: Row, table: TableType, ) => void onOpenFile: (file: FileInfo) => void - query: DirectoryContentQuery - loadingComponent?: React.ReactNode - debugBanner?: React.ReactNode + onSortChange: DirectoryContentTableSortChangeCallback } +export type DirectoryContentTableContext = { + orderBy: DirectoryContentOrderBy + direction: DirectoryContentOrderDirection + onSortChange: DirectoryContentTableSortChangeCallback +} +const DirectoryContentTableContext = + createContext({ + orderBy: DEFAULT_DIRECTORY_CONTENT_ORDER_BY, + direction: DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION, + onSortChange: () => {}, + }) + function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B" @@ -92,7 +127,7 @@ function useTableColumns( size: 24, }, { - header: "Name", + header: () => , accessorKey: "doc.name", cell: ({ row }) => { switch (row.original.kind) { @@ -144,15 +179,15 @@ function useTableColumns( ) } -// Shared table component that accepts query options as props export function DirectoryContentTable({ directoryUrlFn, - onContextMenu, fileDragInfoAtom, - onOpenFile, query, loadingComponent, debugBanner, + onOpenFile, + onContextMenu, + onSortChange, }: DirectoryContentTableProps) { const { data: directoryContent, @@ -164,6 +199,33 @@ export function DirectoryContentTable({ const store = useStore() const navigate = useNavigate() + const moveDirectoryItemsMutation = useAtomValue( + moveDirectoryItemsMutationAtom, + ) + + const { mutate: moveDroppedItems } = useMutation({ + ...moveDirectoryItemsMutation, + onSuccess: (data: MoveDirectoryItemsResult) => { + const conflictCount = data.errors.length + if (conflictCount > 0) { + toast.warning( + `${data.moved.length} items moved${conflictCount > 0 ? `, ${conflictCount} conflicts` : ""}`, + ) + } else { + toast.success(`${data.moved.length} items moved!`) + } + }, + }) + + const handleFileDrop = ( + items: import("@/vfs/vfs").DirectoryItem[], + targetDirectory: import("@/vfs/vfs").DirectoryInfo | string, + ) => { + moveDroppedItems({ + targetDirectory, + items, + }) + } const table = useReactTable({ data: useMemo( @@ -292,52 +354,61 @@ export function DirectoryContentTable({ onDoubleClick={() => { handleRowDoubleClick(row) }} + onFileDrop={handleFileDrop} /> ) } return ( -
- {debugBanner} - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef - .header, - header.getContext(), - )} - - ))} - - ))} - - - {rows.length > 0 ? ( - virtualItems.map(renderRow) - ) : ( - - )} - -
-
-
+ +
+ {debugBanner} + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ))} + + ))} + + + {rows.length > 0 ? ( + virtualItems.map(renderRow) + ) : ( + + )} + +
+
+
+
) } @@ -358,6 +429,7 @@ function FileItemRow({ onContextMenu, onDoubleClick, fileDragInfoAtom, + onFileDrop, ...rowProps }: React.ComponentProps & { table: TableType @@ -366,6 +438,10 @@ function FileItemRow({ onContextMenu: (e: React.MouseEvent) => void onDoubleClick: () => void fileDragInfoAtom: PrimitiveAtom + onFileDrop: ( + items: import("@/vfs/vfs").DirectoryItem[], + targetDirectory: import("@/vfs/vfs").DirectoryInfo | string, + ) => void }) { const ref = useRef(null) const setFileDragInfo = useSetAtom(fileDragInfoAtom) @@ -374,6 +450,7 @@ function FileItemRow({ enabled: row.original.kind === "directory", destDir: row.original.kind === "directory" ? row.original : undefined, dragInfoAtom: fileDragInfoAtom, + onDrop: onFileDrop, }) const handleDragStart = (_e: React.DragEvent) => { @@ -433,6 +510,54 @@ function FileItemRow({ ) } +function NameHeaderCell() { + const { orderBy, direction, onSortChange } = useContext( + DirectoryContentTableContext, + ) + + let arrow: React.ReactNode = null + if (orderBy === DIRECTORY_CONTENT_ORDER_BY.name) { + switch (direction) { + case DIRECTORY_CONTENT_ORDER_DIRECTION.asc: + arrow = + break + case DIRECTORY_CONTENT_ORDER_DIRECTION.desc: + arrow = + break + } + } + + let directionLabel: string + switch (direction) { + case DIRECTORY_CONTENT_ORDER_DIRECTION.asc: + directionLabel = "ascending" + break + case DIRECTORY_CONTENT_ORDER_DIRECTION.desc: + directionLabel = "descending" + break + } + + return ( + + ) +} + function DirectoryNameCell({ directory, directoryUrlFn, diff --git a/apps/drive-web/src/files/upload-file-dialog.tsx b/apps/drive-web/src/files/upload-file-dialog.tsx index e8414e2..36b505c 100644 --- a/apps/drive-web/src/files/upload-file-dialog.tsx +++ b/apps/drive-web/src/files/upload-file-dialog.tsx @@ -29,7 +29,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { formatError } from "@/lib/error" -import { directoryContentQueryAtom } from "@/vfs/api" +import { directoryContentQueryKey } from "@/vfs/api" import type { DirectoryInfoWithPath } from "@/vfs/vfs" import { currentAccountAtom } from "../account/account" import { @@ -135,11 +135,16 @@ function useUploadFilesAtom({ toast.success("All files uploaded successfully") } - client.invalidateQueries( - store.get( - directoryContentQueryAtom(targetDirectory.id), - ), - ) + // Invalidate all queries for the target directory (with any params) + const account = store.get(currentAccountAtom) + if (account) { + client.invalidateQueries({ + queryKey: directoryContentQueryKey( + account.id, + targetDirectory.id, + ), + }) + } }, onError: (error) => { console.error(error) diff --git a/apps/drive-web/src/files/use-file-drop.ts b/apps/drive-web/src/files/use-file-drop.ts index 0ffb08b..e8a5403 100644 --- a/apps/drive-web/src/files/use-file-drop.ts +++ b/apps/drive-web/src/files/use-file-drop.ts @@ -1,12 +1,6 @@ -import { useMutation } from "@tanstack/react-query" import type { PrimitiveAtom } from "jotai" -import { useAtomValue, useSetAtom, useStore } from "jotai" +import { useSetAtom, useStore } from "jotai" import { useState } from "react" -import { toast } from "sonner" -import { - type MoveDirectoryItemsResult, - moveDirectoryItemsMutationAtom, -} from "@/vfs/api" import type { DirectoryInfo, DirectoryItem } from "@/vfs/vfs" export interface FileDragInfo { @@ -18,6 +12,10 @@ export interface UseFileDropOptions { destDir?: DirectoryInfo | string dragInfoAtom: PrimitiveAtom enabled?: boolean + onDrop?: ( + items: DirectoryItem[], + targetDirectory: DirectoryInfo | string, + ) => void } export interface UseFileDropReturn { @@ -33,43 +31,22 @@ export function useFileDrop({ destDir, dragInfoAtom, enabled, + onDrop, }: UseFileDropOptions): UseFileDropReturn { const [isDraggedOver, setIsDraggedOver] = useState(false) const setDragInfo = useSetAtom(dragInfoAtom) const store = useStore() - const moveDirectoryItemsMutation = useAtomValue( - moveDirectoryItemsMutationAtom, - ) - - const { mutate: moveDroppedItems } = useMutation({ - ...moveDirectoryItemsMutation, - onSuccess: (data: MoveDirectoryItemsResult, vars, result, ctx) => { - moveDirectoryItemsMutation.onSuccess?.(data, vars, result, ctx) - const conflictCount = data.errors.length - if (conflictCount > 0) { - toast.warning( - `${data.moved.length} items moved${conflictCount > 0 ? `, ${conflictCount} conflicts` : ""}`, - ) - } else { - toast.success(`${data.moved.length} items moved!`) - } - }, - }) - const dirId = typeof destDir === "string" ? destDir : destDir?.id const handleDrop = (_e: React.DragEvent) => { - if (!enabled || !destDir || !dirId) return + if (!enabled || !destDir || !dirId || !onDrop) return const dragInfo = store.get(dragInfoAtom) if (dragInfo) { const items = dragInfo.items.filter((item) => item.id !== dirId) if (items.length > 0) { - moveDroppedItems({ - targetDirectory: destDir, - items, - }) + onDrop(items, destDir) } } setIsDraggedOver(false) diff --git a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index c3bb0aa..87b9e92 100644 --- a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react" import { lazy, Suspense, useCallback, useContext } from "react" import { toast } from "sonner" +import { currentAccountAtom } from "@/account/account" import { DirectoryIcon } from "@/components/icons/directory-icon" import { TextFileIcon } from "@/components/icons/text-file-icon" import { Button } from "@/components/ui/button" @@ -31,6 +32,10 @@ import { import { WithAtom } from "@/components/with-atom" import { backgroundTaskProgressAtom } from "@/dashboard/state" import { DirectoryPageContext } from "@/directories/directory-page/context" +import { + DEFAULT_DIRECTORY_CONTENT_ORDER_BY, + DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION, +} from "@/directories/directory-page/defaults" import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table" import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog" @@ -41,13 +46,22 @@ import { cutItemsAtom, inProgressFileUploadCountAtom } from "@/files/store" import { UploadFileDialog } from "@/files/upload-file-dialog" import type { FileDragInfo } from "@/files/use-file-drop" import { formatError } from "@/lib/error" +import type { + DirectoryContentOrderBy, + DirectoryContentOrderDirection, +} from "@/vfs/api" import { DIRECTORY_CONTENT_ORDER_BY, DIRECTORY_CONTENT_ORDER_DIRECTION, directoryContentQueryAtom, + directoryContentQueryKey, directoryInfoQueryAtom, moveToTrashMutationAtom, } from "@/vfs/api" +import { + optimisticallyRemoveDirectoryItems, + rollbackDirectoryContentOptimisticUpdate, +} from "@/vfs/optimistic" import type { DirectoryInfo, DirectoryInfoWithPath, @@ -70,10 +84,10 @@ const MockDirectoryContentTable = import.meta.env.DEV const DirectoryContentPageParams = type({ orderBy: type .valueOf(DIRECTORY_CONTENT_ORDER_BY) - .default(DIRECTORY_CONTENT_ORDER_BY.name), + .default(DEFAULT_DIRECTORY_CONTENT_ORDER_BY), direction: type .valueOf(DIRECTORY_CONTENT_ORDER_DIRECTION) - .default(DIRECTORY_CONTENT_ORDER_DIRECTION.asc), + .default(DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION), }) export const Route = createFileRoute( @@ -218,6 +232,7 @@ function _DirectoryContentTable() { // Always call the hook - in production the atom always returns false const useMock = useAtomValue(mockTableAtom) const { directory } = useContext(DirectoryPageContext) + const navigate = Route.useNavigate() const search = Route.useSearch() const query = useAtomValue( @@ -257,6 +272,21 @@ function _DirectoryContentTable() { [setContextMenuTargetItems], ) + const applySorting = useCallback( + ( + orderBy: DirectoryContentOrderBy, + direction: DirectoryContentOrderDirection, + ) => { + navigate({ + search: { + orderBy, + direction, + }, + }) + }, + [navigate], + ) + // In production, MockDirectoryContentTable is null, so this always renders real table if (import.meta.env.DEV && useMock && MockDirectoryContentTable) { return ( @@ -278,6 +308,7 @@ function _DirectoryContentTable() { fileDragInfoAtom={fileDragInfoAtom} onContextMenu={handleContextMenuRequest} onOpenFile={onTableOpenFile} + onSortChange={applySorting} /> ) } @@ -320,29 +351,36 @@ function DirectoryContentContextMenu({ const [target, setTarget] = useAtom(contextMenuTargetItemsAtom) const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom) const setCutItems = useSetAtom(cutItemsAtom) + const account = useAtomValue(currentAccountAtom) + const { directory } = useContext(DirectoryPageContext) + const search = Route.useSearch() const moveToTrashMutation = useAtomValue(moveToTrashMutationAtom) const { mutate: moveToTrash } = useMutation({ ...moveToTrashMutation, - onMutate: (vars, ctx) => { + onMutate: (items, { client }) => { setBackgroundTaskProgress({ label: "Moving items to trash…", }) - return ( - moveToTrashMutation.onMutate?.(vars, ctx) ?? { - prevDirContentMap: new Map(), - } - ) + if (!account) { + return null + } + return optimisticallyRemoveDirectoryItems(client, { + queryKey: directoryContentQueryKey(account.id, directory.id, { + orderBy: search.orderBy, + direction: search.direction, + }), + items, + }) }, - onSuccess: (data, vars, result, ctx) => { - moveToTrashMutation.onSuccess?.(data, vars, result, ctx) + onSuccess: (data) => { setBackgroundTaskProgress(null) toast.success(`Moved ${data.length} items to trash`) }, - onError: (err, vars, mutateResult, context) => { - moveToTrashMutation.onError?.(err, vars, mutateResult, context) + onError: (err, _vars, mutateResult, { client }) => { toast.error(formatError(err)) setBackgroundTaskProgress(null) + rollbackDirectoryContentOptimisticUpdate(client, mutateResult) }, }) diff --git a/apps/drive-web/src/vfs/api.ts b/apps/drive-web/src/vfs/api.ts index d7393b0..42cc590 100644 --- a/apps/drive-web/src/vfs/api.ts +++ b/apps/drive-web/src/vfs/api.ts @@ -18,10 +18,11 @@ import { FileInfo, } from "./vfs" -const DirectoryContentResponse = type({ +export const DirectoryContentResponse = type({ items: DirectoryContent, "nextCursor?": "string", }) +export type DirectoryContentResponseType = typeof DirectoryContentResponse.infer /** * This atom derives the file url for a given file. @@ -81,7 +82,7 @@ export const DIRECTORY_CONTENT_ORDER_DIRECTION = { asc: "asc", desc: "desc", } as const -type DirectoryContentOrderDirection = +export type DirectoryContentOrderDirection = (typeof DIRECTORY_CONTENT_ORDER_DIRECTION)[keyof typeof DIRECTORY_CONTENT_ORDER_DIRECTION] type DirectoryContentQueryParams = { @@ -98,22 +99,27 @@ type DirectoryContentPageParam = { cursor: string } -const directoryContentQueryKey = ( +export const directoryContentQueryKey = ( accountId: string | undefined, directoryId: string, -): readonly (string | undefined)[] => [ + params?: { + orderBy?: DirectoryContentOrderBy + direction?: DirectoryContentOrderDirection + }, +): readonly unknown[] => [ "accounts", accountId, "directories", directoryId, "content", + ...(params ? [{ orderBy: params.orderBy, dir: params.direction }] : []), ] export type DirectoryContentQuery = ReturnType< typeof infiniteQueryOptions< typeof DirectoryContentResponse.infer, Error, InfiniteData, - readonly (string | undefined)[], + readonly unknown[], DirectoryContentPageParam > > @@ -122,7 +128,10 @@ export const directoryContentQueryAtom = atomFamily( atom((get) => { const account = get(currentAccountAtom) return infiniteQueryOptions({ - queryKey: directoryContentQueryKey(account?.id, directoryId), + queryKey: directoryContentQueryKey(account?.id, directoryId, { + orderBy, + direction, + }), initialPageParam: { orderBy, direction, @@ -221,53 +230,6 @@ export const moveDirectoryItemsMutationAtom = atom((get) => ) return result }, - onMutate: ({ items }, { client }) => { - const account = get(currentAccountAtom) - if (!account) { - return null - } - - const movedItems = new Map>() - - for (const item of items) { - if (item.parentId) { - const s = movedItems.get(item.parentId) - if (!s) { - movedItems.set(item.parentId, new Set([item.id])) - } else { - s.add(item.id) - } - } - } - - const prevDirContentMap = new Map< - string, - InfiniteData | undefined - >() - - movedItems.forEach((s, parentId) => { - const key = directoryContentQueryKey(account.id, parentId) - const prevDirContent = - client.getQueryData< - InfiniteData - >(key) - client.setQueryData< - InfiniteData - >(key, (prev) => { - if (!prev) return prev - return { - ...prev, - pages: prev.pages.map((page) => ({ - ...page, - items: page.items.filter((it) => !s.has(it.id)), - })), - } - }) - prevDirContentMap.set(parentId, prevDirContent) - }) - - return { prevDirContentMap } - }, onSuccess: (_data, { targetDirectory, items }, _result, { client }) => { const account = get(currentAccountAtom) if (!account) return @@ -276,6 +238,7 @@ export const moveDirectoryItemsMutationAtom = atom((get) => typeof targetDirectory === "string" ? targetDirectory : targetDirectory.id + // Invalidate using base key (without params) to invalidate all queries for these directories client.invalidateQueries({ queryKey: directoryContentQueryKey(account.id, dirId), }) @@ -290,21 +253,6 @@ export const moveDirectoryItemsMutationAtom = atom((get) => } } }, - onError: (_error, _vars, context, { client }) => { - if (context) { - const account = get(currentAccountAtom) - if (account) { - context.prevDirContentMap.forEach( - (prevDirContent, parentId) => { - client.setQueryData( - directoryContentQueryKey(account.id, parentId), - prevDirContent, - ) - }, - ) - } - } - }, }), ) @@ -368,53 +316,10 @@ export const moveToTrashMutationAtom = atom((get) => return [...deletedFiles, ...deletedDirectories] }, - onMutate: (items, { client }) => { - const account = get(currentAccountAtom) - if (!account) { - return null - } - - const trashedItems = new Map>() - for (const item of items) { - if (item.parentId) { - const s = trashedItems.get(item.parentId) - if (!s) { - trashedItems.set(item.parentId, new Set([item.id])) - } else { - s.add(item.id) - } - } - } - - const prevDirContentMap = new Map< - string, - InfiniteData | undefined - >() - trashedItems.forEach((s, parentId) => { - const key = directoryContentQueryKey(account.id, parentId) - const prevDirContent = - client.getQueryData< - InfiniteData - >(key) - client.setQueryData< - InfiniteData - >(key, (prev) => { - if (!prev) return prev - return { - ...prev, - pages: prev.pages.map((page) => ({ - ...page, - items: page.items.filter((it) => !s.has(it.id)), - })), - } - }) - prevDirContentMap.set(parentId, prevDirContent) - }) - return { prevDirContentMap } - }, onSuccess: (_data, items, _result, { client }) => { const account = get(currentAccountAtom) if (account) { + // Invalidate using base key (without params) to invalidate all queries for these directories for (const item of items) { if (item.parentId) { client.invalidateQueries({ @@ -427,21 +332,6 @@ export const moveToTrashMutationAtom = atom((get) => } } }, - onError: (_error, _items, context, { client }) => { - if (context) { - const account = get(currentAccountAtom) - if (account) { - context.prevDirContentMap.forEach( - (prevDirContent, parentId) => { - client.setQueryData( - directoryContentQueryKey(account.id, parentId), - prevDirContent, - ) - }, - ) - } - } - }, }), ) diff --git a/apps/drive-web/src/vfs/optimistic.ts b/apps/drive-web/src/vfs/optimistic.ts new file mode 100644 index 0000000..77ce771 --- /dev/null +++ b/apps/drive-web/src/vfs/optimistic.ts @@ -0,0 +1,51 @@ +import type { InfiniteData, QueryClient } from "@tanstack/react-query" +import type { DirectoryContentResponseType } from "@/vfs/api" +import type { DirectoryItem } from "@/vfs/vfs" + +export type DirectoryContentOptimisticUpdate = { + queryKey: readonly unknown[] + prevDirContent: InfiniteData | undefined +} + +type OptimisticRemovalParams = { + queryKey: readonly unknown[] + items: DirectoryItem[] +} + +export function optimisticallyRemoveDirectoryItems( + client: QueryClient, + { queryKey, items }: OptimisticRemovalParams, +): DirectoryContentOptimisticUpdate { + const prevDirContent = + client.getQueryData>( + queryKey, + ) + const removedItemIds = new Set(items.map((item) => item.id)) + + client.setQueryData>( + queryKey, + (prev) => { + if (!prev) return prev + return { + ...prev, + pages: prev.pages.map((page) => ({ + ...page, + items: page.items.filter( + (item) => !removedItemIds.has(item.id), + ), + })), + } + }, + ) + + return { queryKey, prevDirContent } +} + +export function rollbackDirectoryContentOptimisticUpdate( + client: QueryClient, + update?: DirectoryContentOptimisticUpdate | null, +) { + if (update) { + client.setQueryData(update.queryKey, update.prevDirContent) + } +}