Files
drive/apps/drive-web/src/directories/directory-page/directory-content-table.tsx

625 lines
15 KiB
TypeScript
Raw Normal View History

2025-12-21 01:48:25 +00:00
// 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"
2025-12-18 00:47:59 +00:00
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
2025-12-21 01:48:25 +00:00
import { type PrimitiveAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"
2025-12-18 00:47:59 +00:00
import type React from "react"
2025-12-21 01:48:25 +00:00
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,
2025-12-18 00:47:59 +00:00
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { type FileDragInfo, useFileDrop } from "@/files/use-file-drop"
2025-09-21 15:12:05 +00:00
import {
isControlOrCommandKeyActive,
keyboardModifierAtom,
} from "@/lib/keyboard"
import { cn } from "@/lib/utils"
2025-12-21 01:48:25 +00:00
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"
2025-12-21 01:48:25 +00:00
import {
DEFAULT_DIRECTORY_CONTENT_ORDER_BY,
DEFAULT_DIRECTORY_CONTENT_ORDER_DIRECTION,
} from "./defaults"
2025-12-17 22:59:18 +00:00
import { DirectoryContentTableSkeleton } from "./directory-content-table-skeleton"
type DirectoryContentTableItemIdFilter = Set<string>
2025-12-21 01:48:25 +00:00
export type DirectoryContentTableSortChangeCallback = (
orderBy: DirectoryContentOrderBy,
direction: DirectoryContentOrderDirection,
) => void
export type DirectoryContentTableProps = {
2025-12-21 01:48:25 +00:00
query: DirectoryContentQuery
directoryUrlFn: (directory: DirectoryInfo) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
2025-12-21 01:48:25 +00:00
loadingComponent?: React.ReactNode
debugBanner?: React.ReactNode
2025-12-27 19:27:31 +00:00
readOnly?: boolean
2025-12-21 01:48:25 +00:00
onContextMenu: (
row: Row<DirectoryItem>,
table: TableType<DirectoryItem>,
) => void
onOpenFile: (file: FileInfo) => void
2025-12-21 01:48:25 +00:00
onSortChange: DirectoryContentTableSortChangeCallback
}
2025-12-21 01:48:25 +00:00
export type DirectoryContentTableContext = {
orderBy: DirectoryContentOrderBy
direction: DirectoryContentOrderDirection
onSortChange: DirectoryContentTableSortChangeCallback
}
const DirectoryContentTableContext =
createContext<DirectoryContentTableContext>({
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,
2025-12-27 19:27:31 +00:00
readOnly: boolean,
): ColumnDef<DirectoryItem>[] {
return useMemo(
2025-12-27 19:27:31 +00:00
() => {
const columns: ColumnDef<DirectoryItem>[] = []
if (!readOnly) {
columns.push({
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => {
table.toggleAllPageRowsSelected(!!value)
}}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => {
e.stopPropagation()
}}
onCheckedChange={row.getToggleSelectedHandler()}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 24,
})
}
columns.push(
{
header: () => <NameHeaderCell />,
accessorKey: "doc.name",
cell: ({ row }) => {
switch (row.original.kind) {
case "file":
return (
<FileNameCell
file={row.original}
onOpenFile={onOpenFile}
/>
)
case "directory":
return (
<DirectoryNameCell
directory={row.original}
directoryUrlFn={directoryUrlFn}
/>
)
}
},
size: 1000,
},
2025-12-27 19:27:31 +00:00
{
header: "Size",
accessorKey: "size",
cell: ({ row }) => {
switch (row.original.kind) {
case "file":
return (
<div>
{formatFileSize(row.original.size)}
</div>
)
case "directory":
return <div className="font-mono">-</div>
}
},
},
2025-12-27 19:27:31 +00:00
{
header: "Created At",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<div>
{new Date(
row.original.createdAt,
).toLocaleString()}
</div>
)
},
},
2025-12-27 19:27:31 +00:00
)
return columns
},
[onOpenFile, directoryUrlFn, readOnly],
)
}
export function DirectoryContentTable({
directoryUrlFn,
fileDragInfoAtom,
query,
loadingComponent,
debugBanner,
2025-12-21 01:48:25 +00:00
onOpenFile,
onContextMenu,
onSortChange,
2025-12-27 19:27:31 +00:00
readOnly = false,
}: DirectoryContentTableProps) {
2025-12-18 00:47:59 +00:00
const {
data: directoryContent,
isLoading: isLoadingDirectoryContent,
isFetchingNextPage: isFetchingMoreDirectoryItems,
fetchNextPage: fetchMoreDirectoryItems,
hasNextPage: hasMoreDirectoryItems,
} = useInfiniteQuery(query)
2025-12-17 22:59:18 +00:00
const store = useStore()
const navigate = useNavigate()
2025-12-21 01:48:25 +00:00
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,
) => {
2025-12-27 19:27:31 +00:00
if (readOnly) return
2025-12-21 01:48:25 +00:00
moveDroppedItems({
targetDirectory,
items,
})
}
2025-09-26 22:28:51 +00:00
const table = useReactTable({
2025-12-17 22:59:18 +00:00
data: useMemo(
() =>
directoryContent?.pages.flatMap(
(page) => (page as { items: DirectoryItem[] }).items,
) || [],
2025-12-17 22:59:18 +00:00
[directoryContent],
),
2025-12-27 19:27:31 +00:00
columns: useTableColumns(onOpenFile, directoryUrlFn, readOnly),
2025-09-26 22:28:51 +00:00
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
2025-12-27 19:27:31 +00:00
enableRowSelection: !readOnly,
2025-09-26 22:28:51 +00:00
enableGlobalFilter: true,
globalFilterFn: (
row,
_columnId,
filterValue: DirectoryContentTableItemIdFilter,
_addMeta,
) => !filterValue.has(row.original.id),
getRowId: (row) => row.id,
2025-09-26 22:28:51 +00:00
})
2025-12-18 00:47:59 +00:00
const { rows } = table.getRowModel()
const containerRef = useRef<HTMLDivElement>(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,
])
2025-09-26 22:28:51 +00:00
useEffect(
function escapeToClearSelections() {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
table.setRowSelection({})
}
}
window.addEventListener("keydown", handleEscape)
return () => window.removeEventListener("keydown", handleEscape)
},
[table.setRowSelection],
)
2025-12-17 22:59:18 +00:00
if (isLoadingDirectoryContent) {
return <>{loadingComponent || <DirectoryContentTableSkeleton />}</>
2025-12-17 22:59:18 +00:00
}
const handleRowContextMenu = (
row: Row<DirectoryItem>,
_event: React.MouseEvent,
) => {
2025-12-27 19:27:31 +00:00
if (readOnly) return
if (!row.getIsSelected()) {
selectRow(row)
}
onContextMenu(row, table)
}
const selectRow = (row: Row<DirectoryItem>) => {
2025-12-27 19:27:31 +00:00
if (readOnly) return
2025-09-21 15:12:05 +00:00
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)
2025-09-21 15:12:05 +00:00
} else if (!isRowSelected) {
if (isMultiSelectMode) {
row.toggleSelected(true)
} else {
table.setRowSelection({
[row.id]: true,
})
}
}
}
const handleRowDoubleClick = (row: Row<DirectoryItem>) => {
if (row.original.kind === "directory") {
navigate({
2025-12-27 19:27:31 +00:00
to: directoryUrlFn(row.original),
})
}
}
2025-12-18 00:47:59 +00:00
const renderRow = (virtualRow: VirtualItem, i: number) => {
const row = rows[virtualRow.index]
if (!row) return null
2025-12-18 00:47:59 +00:00
return (
<FileItemRow
style={{
height: virtualRow.size,
transform: `translateY(${
virtualRow.start - i * virtualRow.size
}px)`,
}}
key={row.id}
table={table}
row={row}
onClick={() => selectRow(row)}
2025-12-27 19:27:31 +00:00
readOnly={readOnly}
2025-12-18 00:47:59 +00:00
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={(e) => handleRowContextMenu(row, e)}
onDoubleClick={() => {
handleRowDoubleClick(row)
}}
2025-12-21 01:48:25 +00:00
onFileDrop={handleFileDrop}
2025-12-18 00:47:59 +00:00
/>
)
}
return (
2025-12-21 01:48:25 +00:00
<DirectoryContentTableContext
value={{
orderBy: query.initialPageParam.orderBy,
direction: query.initialPageParam.direction,
onSortChange,
}}
>
<div className="h-full flex flex-col">
{debugBanner}
<TableContainer
className={debugBanner ? "flex-1" : "h-full"}
ref={containerRef}
>
<Table className="h-full min-h-0">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
className="px-4 border-b-0!"
key={headerGroup.id}
>
{headerGroup.headers.map((header) => (
<TableHead
className="first:pl-4 last:pr-4 sticky top-0 bg-background z-1 inset-shadow-[0_-1px_0_0_var(--border)]"
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody className="overflow-auto">
{rows.length > 0 ? (
virtualItems.map(renderRow)
) : (
2025-12-27 19:27:31 +00:00
<NoResultsRow
colSpan={table.getAllLeafColumns().length}
/>
2025-12-21 01:48:25 +00:00
)}
</TableBody>
</Table>
</TableContainer>
</div>
</DirectoryContentTableContext>
)
}
2025-12-27 19:27:31 +00:00
function NoResultsRow({ colSpan }: { colSpan: number }) {
return (
2025-10-05 00:46:29 +00:00
<TableRow className="hover:bg-transparent">
2025-12-27 19:27:31 +00:00
<TableCell colSpan={colSpan} className="text-center">
No results.
</TableCell>
</TableRow>
)
}
2025-09-20 22:25:01 +00:00
function FileItemRow({
table,
2025-09-20 22:25:01 +00:00
row,
onClick,
onContextMenu,
onDoubleClick,
2025-12-27 19:27:31 +00:00
readOnly,
fileDragInfoAtom,
2025-12-21 01:48:25 +00:00
onFileDrop,
2025-12-18 00:47:59 +00:00
...rowProps
}: React.ComponentProps<typeof TableRow> & {
table: TableType<DirectoryItem>
row: Row<DirectoryItem>
2025-09-20 22:25:01 +00:00
onClick: () => void
onContextMenu: (e: React.MouseEvent) => void
onDoubleClick: () => void
2025-12-27 19:27:31 +00:00
readOnly: boolean
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
2025-12-21 01:48:25 +00:00
onFileDrop: (
items: import("@/vfs/vfs").DirectoryItem[],
targetDirectory: import("@/vfs/vfs").DirectoryInfo | string,
) => void
2025-09-20 22:25:01 +00:00
}) {
const ref = useRef<HTMLTableRowElement>(null)
const setFileDragInfo = useSetAtom(fileDragInfoAtom)
2025-09-20 22:25:01 +00:00
const { isDraggedOver, dropHandlers } = useFileDrop({
2025-12-27 19:27:31 +00:00
enabled: !readOnly && row.original.kind === "directory",
destDir: row.original.kind === "directory" ? row.original : undefined,
dragInfoAtom: fileDragInfoAtom,
2025-12-21 01:48:25 +00:00
onDrop: onFileDrop,
2025-09-20 22:25:01 +00:00
})
2025-09-28 15:45:49 +00:00
const handleDragStart = (_e: React.DragEvent) => {
2025-12-27 19:27:31 +00:00
if (readOnly) return
let draggedItems: DirectoryItem[]
2025-09-26 22:20:30 +00:00
// 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)
}
2025-09-26 22:20:30 +00:00
} else {
draggedItems = [row.original]
2025-09-26 22:20:30 +00:00
}
setFileDragInfo({
source: row.original,
items: draggedItems,
})
2025-09-20 22:25:01 +00:00
}
const handleDragEnd = () => {
2025-12-27 19:27:31 +00:00
if (readOnly) return
setFileDragInfo(null)
2025-09-20 22:25:01 +00:00
}
return (
<TableRow
2025-12-27 19:27:31 +00:00
draggable={!readOnly}
2025-09-20 22:25:01 +00:00
ref={ref}
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={onClick}
onDoubleClick={onDoubleClick}
2025-09-20 22:25:01 +00:00
onContextMenu={onContextMenu}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
{...dropHandlers}
2025-09-20 22:25:01 +00:00
className={cn({ "bg-muted": isDraggedOver })}
2025-12-18 00:47:59 +00:00
{...rowProps}
2025-09-20 22:25:01 +00:00
>
{row.getVisibleCells().map((cell) => (
<TableCell
className="first:pl-4 last:pr-4"
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
2025-12-21 01:48:25 +00:00
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 = <ArrowUpIcon className="size-4" />
break
case DIRECTORY_CONTENT_ORDER_DIRECTION.desc:
arrow = <ArrowDownIcon className="size-4" />
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 (
<button
type="button"
className="hover:underline cursor-pointer flex items-center gap-2 w-full"
onClick={() => {
onSortChange(
DIRECTORY_CONTENT_ORDER_BY.name,
direction === DIRECTORY_CONTENT_ORDER_DIRECTION.asc
? DIRECTORY_CONTENT_ORDER_DIRECTION.desc
: DIRECTORY_CONTENT_ORDER_DIRECTION.asc,
)
}}
>
<span className="sr-only">Sort by</span>
<span>Name</span>
<span className="sr-only">in {directionLabel} order</span>
{arrow}
</button>
)
}
function DirectoryNameCell({
directory,
directoryUrlFn,
}: {
directory: DirectoryInfo
directoryUrlFn: (directory: DirectoryInfo) => string
}) {
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
<Link className="hover:underline" to={directoryUrlFn(directory)}>
{directory.name}
</Link>
</div>
)
}
function FileNameCell({
file,
onOpenFile,
}: {
file: FileInfo
onOpenFile: (file: FileInfo) => void
}) {
return (
<div className="flex w-full items-center gap-2">
<TextFileIcon className="size-4" />
2025-09-20 19:55:20 +00:00
<button
type="button"
className="hover:underline cursor-pointer"
onClick={() => {
onOpenFile(file)
2025-09-20 19:55:20 +00:00
}}
>
{file.name}
</button>
</div>
)
}