impl: dir content table virtualization

This commit is contained in:
2025-12-18 00:47:59 +00:00
parent 1024f36a9f
commit ba540918dc
10 changed files with 944 additions and 43 deletions

View File

@@ -24,6 +24,7 @@
"@tanstack/react-query": "^5.87.4",
"@tanstack/react-router": "^1.131.41",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.131.42",
"arktype": "^2.1.28",
"better-auth": "1.3.8",

View File

@@ -4,16 +4,21 @@ import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableContainer({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="table-container"
className={cn("relative w-full overflow-x-auto", className)}
{...props}
/>
)
}
@@ -104,6 +109,7 @@ function TableCaption({
export {
Table,
TableContainer,
TableHeader,
TableBody,
TableFooter,

View File

@@ -0,0 +1,65 @@
/**
* Wrapper component that conditionally loads real or mock table.
* Only available in dev mode - mock table is never included in production builds.
*/
import type { Row, Table as TableType } from "@tanstack/react-table"
import type { PrimitiveAtom } from "jotai"
import { atom, useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { lazy, Suspense } from "react"
import type { FileDragInfo } from "@/files/use-file-drop"
import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs"
import { DirectoryContentTable } from "./directory-content-table"
// Atom to control mock table usage
// In dev mode: uses atomWithStorage to persist in localStorage
// In production: uses regular atom (always false, tree-shaken)
// This ensures hooks are always called in the same order
const useMockTableAtom = import.meta.env.DEV
? atomWithStorage<boolean>("drexa:use-mock-directory-table", false)
: atom<boolean>(false)
// Conditional lazy import - Vite will tree-shake this entire import in production
// because import.meta.env.DEV is evaluated at build time
const MockDirectoryContentTable = import.meta.env.DEV
? lazy(() =>
import("./mock-directory-content-table").then((mod) => ({
default: mod.MockDirectoryContentTable,
})),
)
: null
type DirectoryContentTableWrapperProps = {
directoryUrlFn: (directory: DirectoryInfo) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: (
row: Row<DirectoryItem>,
table: TableType<DirectoryItem>,
) => void
onOpenFile: (file: FileInfo) => void
}
export function DirectoryContentTableWrapper(
props: DirectoryContentTableWrapperProps,
) {
// Always call the hook - in production the atom always returns false
const useMock = useAtomValue(useMockTableAtom)
// In production, MockDirectoryContentTable is null, so this always renders real table
if (import.meta.env.DEV && useMock && MockDirectoryContentTable) {
return (
<Suspense fallback={<div>Loading mock table...</div>}>
<MockDirectoryContentTable {...props} />
</Suspense>
)
}
return <DirectoryContentTable {...props} />
}
/**
* Dev-only: Export the atom for use in toggle components.
* This is only available in dev mode and will be tree-shaken in production.
*/
export const mockTableAtom = useMockTableAtom

View File

@@ -9,7 +9,9 @@ import {
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 type React from "react"
import { useContext, useEffect, useMemo, useRef } from "react"
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { TextFileIcon } from "@/components/icons/text-file-icon"
@@ -18,6 +20,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@@ -158,8 +161,13 @@ export function DirectoryContentTable({
limit: 100,
}),
)
const { data: directoryContent, isLoading: isLoadingDirectoryContent } =
useInfiniteQuery(directoryContentQuery)
const {
data: directoryContent,
isLoading: isLoadingDirectoryContent,
isFetchingNextPage: isFetchingMoreDirectoryItems,
fetchNextPage: fetchMoreDirectoryItems,
hasNextPage: hasMoreDirectoryItems,
} = useInfiniteQuery(directoryContentQuery)
const store = useStore()
const navigate = useNavigate()
@@ -182,6 +190,34 @@ export function DirectoryContentTable({
) => !filterValue.has(row.original.id),
getRowId: (row) => row.id,
})
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,
])
useEffect(
function escapeToClearSelections() {
@@ -240,15 +276,41 @@ export function DirectoryContentTable({
}
}
const renderRow = (virtualRow: VirtualItem, i: number) => {
const row = rows[virtualRow.index]!
return (
<div className="overflow-hidden">
<Table>
<FileItemRow
style={{
height: virtualRow.size,
transform: `translateY(${
virtualRow.start - i * virtualRow.size
}px)`,
}}
key={row.id}
table={table}
row={row}
onClick={() => selectRow(row)}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={(e) => handleRowContextMenu(row, e)}
onDoubleClick={() => {
handleRowDoubleClick(row)
}}
/>
)
}
return (
<TableContainer className="h-full" ref={containerRef}>
<Table className="h-full min-h-0">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow className="px-4" key={headerGroup.id}>
<TableRow
className="px-4 border-b-0!"
key={headerGroup.id}
>
{headerGroup.headers.map((header) => (
<TableHead
className="first:pl-4 last:pr-4"
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() }}
>
@@ -263,29 +325,15 @@ export function DirectoryContentTable({
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<FileItemRow
key={row.id}
table={table}
row={row}
onClick={() => selectRow(row)}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={(e) =>
handleRowContextMenu(row, e)
}
onDoubleClick={() => {
handleRowDoubleClick(row)
}}
/>
))
<TableBody className="overflow-auto">
{rows.length > 0 ? (
virtualItems.map(renderRow)
) : (
<NoResultsRow />
)}
</TableBody>
</Table>
</div>
</TableContainer>
)
}
@@ -306,7 +354,8 @@ function FileItemRow({
onContextMenu,
onDoubleClick,
fileDragInfoAtom,
}: {
...rowProps
}: React.ComponentProps<typeof TableRow> & {
table: TableType<DirectoryItem>
row: Row<DirectoryItem>
onClick: () => void
@@ -365,6 +414,7 @@ function FileItemRow({
onDragEnd={handleDragEnd}
{...dropHandlers}
className={cn({ "bg-muted": isDraggedOver })}
{...rowProps}
>
{row.getVisibleCells().map((cell) => (
<TableCell

View File

@@ -0,0 +1,628 @@
/**
* Mock component for testing virtualization with large datasets.
*
* Usage:
* 1. Import this component instead of DirectoryContentTable
* 2. Adjust TOTAL_ITEMS and ITEMS_PER_PAGE constants as needed
* 3. The component will automatically use mock data
*/
import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query"
import { 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, useSetAtom, useStore } from "jotai"
import type React from "react"
import { useEffect, useMemo, useRef } from "react"
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 type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs"
// Configuration - adjust these to test different scenarios
const TOTAL_ITEMS = 10_000 // Total number of items to simulate
const ITEMS_PER_PAGE = 100 // Items per page (for pagination)
const NETWORK_DELAY_MS = 50 // Simulated network delay in milliseconds
/**
* Generates mock directory items for testing virtualization.
*/
function generateMockDirectoryItems(
totalItems: number,
itemsPerPage: number = 100,
mixFilesAndDirs: boolean = true,
): DirectoryItem[] {
const items: DirectoryItem[] = []
const now = new Date().toISOString()
for (let i = 0; i < totalItems; i++) {
const isFile = mixFilesAndDirs ? i % 2 === 0 : false
const id = `mock-${i}`
const name = isFile
? `file-${i.toString().padStart(4, "0")}.txt`
: `directory-${i.toString().padStart(4, "0")}`
if (isFile) {
const file: FileInfo = {
kind: "file",
id,
parentId: "mock-parent",
name,
size: Math.floor(Math.random() * 10_000_000), // Random size up to 10MB
mimeType: "text/plain",
createdAt: now,
updatedAt: now,
}
items.push(file)
} else {
const directory: DirectoryInfo = {
kind: "directory",
id,
parentId: "mock-parent",
name,
createdAt: now,
updatedAt: now,
}
items.push(directory)
}
}
return items
}
/**
* Creates a mock infinite query options that simulates paginated directory content.
* Matches the real API behavior: directories are always sorted first, then files.
*/
function createMockDirectoryContentQuery(
totalItems: number = 10_000,
itemsPerPage: number = 100,
delayMs: number = 0,
) {
// Generate all items and sort them to match API behavior:
// 1. Directories first (kind='directory' < kind='file' alphabetically)
// 2. Then by the sort field (name, createdAt, etc.)
// 3. Then by id as tiebreaker
const allItems = generateMockDirectoryItems(totalItems, itemsPerPage).sort(
(a, b) => {
// First sort by kind (directories before files)
if (a.kind !== b.kind) {
return a.kind.localeCompare(b.kind) // 'directory' < 'file'
}
// Then by name (default sort)
return a.name.localeCompare(b.name)
},
)
return infiniteQueryOptions({
queryKey: ["mock", "directories", "content", totalItems],
initialPageParam: {
orderBy: "name" as const,
direction: "asc" as const,
limit: itemsPerPage,
cursor: "",
},
queryFn: async ({ pageParam }) => {
// Simulate network delay if specified
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
const cursor = pageParam.cursor || ""
const startIndex = cursor ? parseInt(cursor, 10) : 0
const endIndex = Math.min(startIndex + itemsPerPage, allItems.length)
const pageItems = allItems.slice(startIndex, endIndex)
// Items are already sorted globally, but we may need to re-sort per page
// based on the orderBy parameter (for now, we only support name sorting)
// The global sort already handles directories first, so we just return the slice
return {
items: pageItems,
nextCursor:
endIndex < allItems.length ? endIndex.toString() : undefined,
}
},
getNextPageParam: (lastPage, _pages, lastPageParam) =>
lastPage.nextCursor
? {
...lastPageParam,
cursor: lastPage.nextCursor,
}
: null,
})
}
/**
* Helper to log virtualization metrics for debugging.
*/
function logVirtualizationStats(
virtualItems: Array<{ index: number; start: number; size: number }>,
totalRows: number,
) {
if (virtualItems.length === 0) {
console.log("No virtual items rendered")
return
}
const firstIndex = virtualItems[0]?.index ?? 0
const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? 0
const renderedCount = virtualItems.length
console.log("Virtualization Stats:", {
totalRows,
renderedRows: renderedCount,
firstRenderedIndex: firstIndex,
lastRenderedIndex: lastIndex,
coverage: `${((renderedCount / totalRows) * 100).toFixed(2)}%`,
viewportRange: `${firstIndex} - ${lastIndex}`,
})
}
type MockDirectoryContentTableProps = {
directoryUrlFn: (directory: DirectoryInfo) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: (
row: Row<DirectoryItem>,
table: TableType<DirectoryItem>,
) => void
onOpenFile: (file: FileInfo) => void
}
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<DirectoryItem>[] {
return useMemo(
() => [
{
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,
},
{
header: "Name",
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,
},
{
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>
}
},
},
{
header: "Created At",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<div>
{new Date(row.original.createdAt).toLocaleString()}
</div>
)
},
},
],
[onOpenFile, directoryUrlFn],
)
}
export function MockDirectoryContentTable({
directoryUrlFn,
onContextMenu,
fileDragInfoAtom,
onOpenFile,
}: MockDirectoryContentTableProps) {
// Use mock query instead of real API
const mockQuery = useMemo(
() =>
createMockDirectoryContentQuery(
TOTAL_ITEMS,
ITEMS_PER_PAGE,
NETWORK_DELAY_MS,
),
[],
)
const {
data: directoryContent,
isLoading: isLoadingDirectoryContent,
isFetchingNextPage: isFetchingMoreDirectoryItems,
fetchNextPage: fetchMoreDirectoryItems,
hasNextPage: hasMoreDirectoryItems,
} = useInfiniteQuery(mockQuery)
const store = useStore()
const navigate = useNavigate()
const table = useReactTable({
data: useMemo(
() => directoryContent?.pages.flatMap((page) => page.items) || [],
[directoryContent],
),
columns: useTableColumns(onOpenFile, directoryUrlFn),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
enableGlobalFilter: true,
globalFilterFn: (row, _columnId, filterValue: Set<string>, _addMeta) =>
!filterValue.has(row.original.id),
getRowId: (row) => row.id,
})
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()
// Log virtualization stats for debugging
useEffect(() => {
if (rows.length > 0 && virtualItems.length > 0) {
logVirtualizationStats(virtualItems, rows.length)
}
}, [virtualItems, rows.length])
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 (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">
Loading {TOTAL_ITEMS.toLocaleString()} mock items...
</div>
</div>
)
}
const handleRowContextMenu = (
row: Row<DirectoryItem>,
_event: React.MouseEvent,
) => {
if (!row.getIsSelected()) {
selectRow(row)
}
onContextMenu(row, table)
}
const selectRow = (row: Row<DirectoryItem>) => {
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<DirectoryItem>) => {
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 (
<FileItemRow
style={{
height: virtualRow.size,
transform: `translateY(${
virtualRow.start - i * virtualRow.size
}px)`,
}}
key={row.id}
table={table}
row={row}
onClick={() => selectRow(row)}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={(e) => handleRowContextMenu(row, e)}
onDoubleClick={() => {
handleRowDoubleClick(row)
}}
/>
)
}
return (
<div className="h-full flex flex-col">
{/* Debug info banner */}
<div className="bg-muted px-4 py-2 text-sm border-b">
<span className="font-semibold">Test Mode:</span> Showing{" "}
{rows.length.toLocaleString()} of {TOTAL_ITEMS.toLocaleString()}{" "}
items | Rendered: {virtualItems.length} rows | Check console for
virtualization stats
</div>
<TableContainer className="flex-1" 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)
) : (
<NoResultsRow />
)}
</TableBody>
</Table>
</TableContainer>
</div>
)
}
function NoResultsRow() {
return (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="text-center">
No results.
</TableCell>
</TableRow>
)
}
function FileItemRow({
table,
row,
onClick,
onContextMenu,
onDoubleClick,
fileDragInfoAtom,
...rowProps
}: React.ComponentProps<typeof TableRow> & {
table: TableType<DirectoryItem>
row: Row<DirectoryItem>
onClick: () => void
onContextMenu: (e: React.MouseEvent) => void
onDoubleClick: () => void
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
}) {
const ref = useRef<HTMLTableRowElement>(null)
const setFileDragInfo = useSetAtom(fileDragInfoAtom)
const { isDraggedOver, dropHandlers } = useFileDrop({
enabled: row.original.kind === "directory",
destDir: row.original.kind === "directory" ? row.original : undefined,
dragInfoAtom: fileDragInfoAtom,
})
const handleDragStart = (_e: React.DragEvent) => {
let draggedItems: DirectoryItem[]
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 (
<TableRow
draggable
ref={ref}
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
{...dropHandlers}
className={cn({ "bg-muted": isDraggedOver })}
{...rowProps}
>
{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>
)
}
function DirectoryNameCell({
directory,
}: {
directory: DirectoryInfo
directoryUrlFn: (directory: DirectoryInfo) => string
}) {
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
<span className="hover:underline">{directory.name}</span>
</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" />
<button
type="button"
className="hover:underline cursor-pointer"
onClick={() => {
onOpenFile(file)
}}
>
{file.name}
</button>
</div>
)
}

View File

@@ -11,7 +11,7 @@ function RouteComponent() {
<SidebarProvider>
<div className="flex h-screen w-full">
<DashboardSidebar />
<SidebarInset>
<SidebarInset className="overflow-hidden">
<Outlet />
</SidebarInset>
</div>

View File

@@ -30,7 +30,10 @@ import {
import { WithAtom } from "@/components/with-atom"
import { backgroundTaskProgressAtom } from "@/dashboard/state"
import { DirectoryPageContext } from "@/directories/directory-page/context"
import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table"
import {
DirectoryContentTableWrapper,
mockTableAtom,
} from "@/directories/directory-page/directory-content-table-wrapper"
import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog"
import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog"
@@ -155,6 +158,7 @@ function RouteComponent() {
fileDragInfoAtom={fileDragInfoAtom}
/>
<div className="ml-auto flex flex-row gap-2">
{import.meta.env.DEV && <MockTableToggle />}
<NewDirectoryItemDropdown />
<UploadFileButton />
</div>
@@ -162,8 +166,8 @@ function RouteComponent() {
{/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */}
<DirectoryContentContextMenu>
<div className="w-full">
<DirectoryContentTable
<div className="w-full min-h-0">
<DirectoryContentTableWrapper
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
@@ -408,3 +412,28 @@ function NewDirectoryItemDropdown() {
</DropdownMenu>
)
}
function MockTableToggle() {
// Always call the hook - mockTableAtom is always defined
// (it's a regular atom in production, but we only render in dev mode)
const [isEnabled, setIsEnabled] = useAtom(mockTableAtom)
if (!import.meta.env.DEV) {
return null
}
const handleToggle = () => {
setIsEnabled((prev) => !prev)
}
return (
<Button
variant={isEnabled ? "default" : "outline"}
size="sm"
onClick={handleToggle}
title="Toggle mock directory table (dev only)"
>
{isEnabled ? "Mock: ON" : "Mock: OFF"}
</Button>
)
}

View File

@@ -115,10 +115,13 @@ export const directoryContentQueryAtom = atomFamily(
{ returns: DirectoryContentResponse },
).then(([_, result]) => result)
: Promise.reject(new Error("No account selected")),
getNextPageParam: (lastPage, _pages, lastPageParam) => ({
getNextPageParam: (lastPage, _pages, lastPageParam) =>
lastPage.nextCursor
? {
...lastPageParam,
cursor: lastPage.nextCursor ?? "",
}),
cursor: lastPage.nextCursor,
}
: null,
})
}),
(paramsA, paramsB) =>

View File

@@ -49,6 +49,7 @@
"@tanstack/react-query": "^5.87.4",
"@tanstack/react-router": "^1.131.41",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.131.42",
"arktype": "^2.1.28",
"better-auth": "1.3.8",
@@ -499,6 +500,8 @@
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.13", "", { "dependencies": { "@tanstack/virtual-core": "3.13.13" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg=="],
"@tanstack/router-cli": ["@tanstack/router-cli@1.133.12", "", { "dependencies": { "@tanstack/router-generator": "1.133.12", "chokidar": "^3.6.0", "yargs": "^17.7.2" }, "bin": { "tsr": "bin/tsr.cjs" } }, "sha512-5rBpY1yixbxtuLarXSTXK6mD2Wrluyqy9/LRS1k9o61dLiBi9L4HlYkkXkKtpvOXb4VhxlqgmSg2JwASYCi2ng=="],
"@tanstack/router-core": ["@tanstack/router-core@1.133.13", "", { "dependencies": { "@tanstack/history": "1.133.3", "@tanstack/store": "^0.7.0", "cookie-es": "^2.0.0", "seroval": "^1.3.2", "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-zZptdlS/wSkqozb07Y3zX5gas2OapJdjEG6/Id0e/twNefVdR4EY2TK/mgvyhHtKIpCxIcnZz/3opypgeQi9bg=="],
@@ -517,6 +520,8 @@
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.13", "", {}, "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.133.3", "", {}, "sha512-6d2AP9hAjEi8mcIew2RkxBX+wClH1xedhfaYhs8fUiX+V2Cedk7RBD9E9ww2z6BGUYD8Es4fS0OIrzXZWHKGhw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -859,7 +864,7 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@drexa/auth/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
"@drexa/auth/@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@fileone/web/arktype": ["arktype@2.1.28", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.4" } }, "sha512-LVZqXl2zWRpNFnbITrtFmqeqNkPPo+KemuzbGSY6jvJwCb4v8NsDzrWOLHnQgWl26TkJeWWcUNUeBpq2Mst1/Q=="],
@@ -897,7 +902,7 @@
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"@drexa/auth/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"@drexa/auth/@types/bun/bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"@fileone/web/arktype/@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],

View File

@@ -0,0 +1,114 @@
# Virtualization Testing Guide
This guide explains how to test the virtualization of the `DirectoryContentTable` component with large datasets.
## Quick Start
The app includes a dev-mode-only toggle to switch between the real and mock table. No code changes needed!
1. **Enable Dev Mode**: Make sure you're running in development mode (`bun dev` or `npm run dev`)
2. **Toggle Mock Table**:
- Look for the "Mock: OFF/ON" button in the directory page header
- Click it to toggle between real and mock data
- The toggle state persists in localStorage
3. **Adjust Test Parameters** (optional): Edit `apps/drive-web/src/directories/directory-page/mock-directory-content-table.tsx`:
```tsx
const TOTAL_ITEMS = 10_000 // Total number of items to simulate
const ITEMS_PER_PAGE = 100 // Items per page (for pagination)
const NETWORK_DELAY_MS = 50 // Simulated network delay in milliseconds
```
## How It Works
- The `DirectoryContentTableWrapper` component automatically handles switching between real and mock tables
- Uses Jotai's `atomWithStorage` to persist the toggle state in localStorage
- The mock table code is completely tree-shaken in production builds
- No page reload needed - switching is instant
## Test Configuration
You can adjust these parameters in `mock-directory-content-table.tsx`:
- **TOTAL_ITEMS**: Total number of items to simulate (default: 10,000)
- Try: 1,000 for quick tests, 50,000+ for stress testing
- **ITEMS_PER_PAGE**: Number of items per page (default: 100)
- This simulates pagination behavior
- **NETWORK_DELAY_MS**: Simulated network delay (default: 50ms)
- Set to 0 for instant loading, higher values to test loading states
## What to Test
1. **Scroll Performance**: Scroll through the list and verify smooth scrolling
2. **Infinite Loading**: Scroll to the bottom and verify more items load automatically
3. **Selection**: Select multiple items and verify performance
4. **Virtualization Stats**: Check the browser console for virtualization metrics
5. **Memory Usage**: Monitor browser DevTools to ensure memory stays reasonable
## Debugging
The mock table component includes:
- A debug banner showing current stats (total items, rendered rows)
- Console logging of virtualization metrics (check browser console)
- Visual indicators for rendered vs total items
## Implementation Details
### Files
- `apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx`
- Wrapper component that conditionally loads real or mock table
- Uses Jotai atoms for state management
- Exports `mockTableAtom` for toggle components
- `apps/drive-web/src/directories/directory-page/mock-directory-content-table.tsx`
- Mock table component with all utilities inlined
- Generates mock data matching API behavior (directories first, then files)
- Includes virtualization stats logging
### Utilities
The `mock-directory-content-table.tsx` file includes all utilities inlined:
- `generateMockDirectoryItems()`: Generate mock data
- `createMockDirectoryContentQuery()`: Create a mock infinite query
- `logVirtualizationStats()`: Log virtualization metrics
All utilities are defined within the same file for convenience.
## Example Test Scenarios
### Small Dataset (1,000 items)
```tsx
const TOTAL_ITEMS = 1_000
const ITEMS_PER_PAGE = 100
```
### Medium Dataset (10,000 items)
```tsx
const TOTAL_ITEMS = 10_000
const ITEMS_PER_PAGE = 100
```
### Large Dataset (100,000 items)
```tsx
const TOTAL_ITEMS = 100_000
const ITEMS_PER_PAGE = 200
```
### Stress Test (1,000,000 items)
```tsx
const TOTAL_ITEMS = 1_000_000
const ITEMS_PER_PAGE = 500
```
## Production Safety
- The mock table code is completely excluded from production builds
- Vite's static analysis removes all dev-mode code paths
- The toggle button only appears in development mode
- No performance impact on production bundles