// 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, flexRender, getCoreRowModel, getFilteredRowModel, type Row, type Table as TableType, useReactTable, } from "@tanstack/react-table" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" import { type PrimitiveAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { ArrowDownIcon, ArrowUpIcon } from "lucide-react" import type React 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" import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { type FileDragInfo, useFileDrop } from "@/files/use-file-drop" import { isControlOrCommandKeyActive, keyboardModifierAtom, } from "@/lib/keyboard" import { cn } from "@/lib/utils" 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 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" const k = 1024 const sizes = ["B", "KB", "MB", "GB", "TB", "PB"] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}` } function useTableColumns( onOpenFile: (file: FileInfo) => void, directoryUrlFn: (directory: DirectoryInfo) => string, ): ColumnDef[] { return useMemo( () => [ { id: "select", header: ({ table }) => ( { table.toggleAllPageRowsSelected(!!value) }} aria-label="Select all" /> ), cell: ({ row }) => ( { e.stopPropagation() }} onCheckedChange={row.getToggleSelectedHandler()} aria-label="Select row" /> ), enableSorting: false, enableHiding: false, size: 24, }, { header: () => , accessorKey: "doc.name", cell: ({ row }) => { switch (row.original.kind) { case "file": return ( ) case "directory": return ( ) } }, size: 1000, }, { header: "Size", accessorKey: "size", cell: ({ row }) => { switch (row.original.kind) { case "file": return (
{formatFileSize(row.original.size)}
) case "directory": return
-
} }, }, { header: "Created At", accessorKey: "createdAt", cell: ({ row }) => { return (
{new Date(row.original.createdAt).toLocaleString()}
) }, }, ], [onOpenFile, directoryUrlFn], ) } export function DirectoryContentTable({ directoryUrlFn, fileDragInfoAtom, query, loadingComponent, debugBanner, onOpenFile, onContextMenu, onSortChange, }: DirectoryContentTableProps) { const { data: directoryContent, isLoading: isLoadingDirectoryContent, isFetchingNextPage: isFetchingMoreDirectoryItems, fetchNextPage: fetchMoreDirectoryItems, hasNextPage: hasMoreDirectoryItems, } = useInfiniteQuery(query) 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( () => directoryContent?.pages.flatMap( (page) => (page as { items: DirectoryItem[] }).items, ) || [], [directoryContent], ), columns: useTableColumns(onOpenFile, directoryUrlFn), getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), enableRowSelection: true, enableGlobalFilter: true, globalFilterFn: ( row, _columnId, filterValue: DirectoryContentTableItemIdFilter, _addMeta, ) => !filterValue.has(row.original.id), getRowId: (row) => row.id, }) const { rows } = table.getRowModel() const containerRef = useRef(null) const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => containerRef.current, estimateSize: () => 36, overscan: 20, }) const virtualItems = virtualizer.getVirtualItems() useEffect(() => { const lastVirtualItem = virtualItems.at(-1) if ( lastVirtualItem && lastVirtualItem.index >= rows.length - 1 && hasMoreDirectoryItems && !isFetchingMoreDirectoryItems ) { fetchMoreDirectoryItems() } }, [ virtualItems, rows.length, hasMoreDirectoryItems, isFetchingMoreDirectoryItems, fetchMoreDirectoryItems, ]) useEffect( function escapeToClearSelections() { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { table.setRowSelection({}) } } window.addEventListener("keydown", handleEscape) return () => window.removeEventListener("keydown", handleEscape) }, [table.setRowSelection], ) if (isLoadingDirectoryContent) { return <>{loadingComponent || } } const handleRowContextMenu = ( row: Row, _event: React.MouseEvent, ) => { if (!row.getIsSelected()) { selectRow(row) } onContextMenu(row, table) } const selectRow = (row: Row) => { const keyboardModifiers = store.get(keyboardModifierAtom) const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers) const isRowSelected = row.getIsSelected() if (isRowSelected && isMultiSelectMode) { row.toggleSelected(false) } else if (isRowSelected && !isMultiSelectMode) { table.setRowSelection({ [row.id]: true, }) row.toggleSelected(true) } else if (!isRowSelected) { if (isMultiSelectMode) { row.toggleSelected(true) } else { table.setRowSelection({ [row.id]: true, }) } } } const handleRowDoubleClick = (row: Row) => { if (row.original.kind === "directory") { navigate({ to: `/directories/${row.original.id}`, }) } } const renderRow = (virtualRow: VirtualItem, i: number) => { const row = rows[virtualRow.index] if (!row) return null return ( selectRow(row)} fileDragInfoAtom={fileDragInfoAtom} onContextMenu={(e) => handleRowContextMenu(row, e)} 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) ) : ( )}
) } function NoResultsRow() { return ( No results. ) } function FileItemRow({ table, row, onClick, onContextMenu, onDoubleClick, fileDragInfoAtom, onFileDrop, ...rowProps }: React.ComponentProps & { table: TableType row: Row onClick: () => void 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) const { isDraggedOver, dropHandlers } = useFileDrop({ enabled: row.original.kind === "directory", destDir: row.original.kind === "directory" ? row.original : undefined, dragInfoAtom: fileDragInfoAtom, onDrop: onFileDrop, }) const handleDragStart = (_e: React.DragEvent) => { let draggedItems: DirectoryItem[] // drag all selections, but only if the currently dragged row is also selected if (row.getIsSelected()) { draggedItems = [] let currentRowFound = false for (const { original: item } of table.getSelectedRowModel().rows) { draggedItems.push(item) if (item.id === row.original.id) { currentRowFound = true } } if (!currentRowFound) { draggedItems.push(row.original) } } else { draggedItems = [row.original] } setFileDragInfo({ source: row.original, items: draggedItems, }) } const handleDragEnd = () => { setFileDragInfo(null) } return ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) } 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, }: { directory: DirectoryInfo directoryUrlFn: (directory: DirectoryInfo) => string }) { return (
{directory.name}
) } function FileNameCell({ file, onOpenFile, }: { file: FileInfo onOpenFile: (file: FileInfo) => void }) { return (
) }