From 68f9b84da3e182ffe6548361630ccfc2f458e625 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Thu, 18 Dec 2025 01:24:35 +0000 Subject: [PATCH] refactor: unify mock/real dir content table --- .../directory-content-table-wrapper.tsx | 65 -- .../directory-content-table.tsx | 118 ++-- .../mock-directory-content-table.tsx | 573 +++--------------- .../directories.$directoryId.tsx | 182 ++++-- apps/drive-web/src/vfs/api.ts | 24 +- 5 files changed, 276 insertions(+), 686 deletions(-) delete mode 100644 apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx diff --git a/apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx b/apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx deleted file mode 100644 index 4d9dbed..0000000 --- a/apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 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("drexa:use-mock-directory-table", false) - : atom(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 - onContextMenu: ( - row: Row, - table: TableType, - ) => 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 ( - Loading mock table...}> - - - ) - } - - return -} - -/** - * 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 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 3e42eaa..ee07bb1 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,5 +1,5 @@ import { useInfiniteQuery } from "@tanstack/react-query" -import { Link, useNavigate, useSearch } from "@tanstack/react-router" +import { Link, useNavigate } from "@tanstack/react-router" import { type ColumnDef, flexRender, @@ -10,9 +10,9 @@ import { useReactTable, } from "@tanstack/react-table" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" -import { type PrimitiveAtom, useAtomValue, useSetAtom, useStore } from "jotai" +import { type PrimitiveAtom, useSetAtom, useStore } from "jotai" import type React from "react" -import { useContext, useEffect, useMemo, useRef } 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" @@ -31,14 +31,13 @@ import { keyboardModifierAtom, } from "@/lib/keyboard" import { cn } from "@/lib/utils" +import type { DirectoryContentQuery } from "@/vfs/api" import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs" -import { directoryContentQueryAtom } from "../../vfs/api" -import { DirectoryPageContext } from "./context" import { DirectoryContentTableSkeleton } from "./directory-content-table-skeleton" type DirectoryContentTableItemIdFilter = Set -type DirectoryContentTableProps = { +export type DirectoryContentTableProps = { directoryUrlFn: (directory: DirectoryInfo) => string fileDragInfoAtom: PrimitiveAtom onContextMenu: ( @@ -46,6 +45,9 @@ type DirectoryContentTableProps = { table: TableType, ) => void onOpenFile: (file: FileInfo) => void + query: DirectoryContentQuery + loadingComponent?: React.ReactNode + debugBanner?: React.ReactNode } function formatFileSize(bytes: number): string { @@ -142,39 +144,33 @@ function useTableColumns( ) } +// Shared table component that accepts query options as props export function DirectoryContentTable({ directoryUrlFn, onContextMenu, fileDragInfoAtom, onOpenFile, + query, + loadingComponent, + debugBanner, }: DirectoryContentTableProps) { - const { directory } = useContext(DirectoryPageContext) - const search = useSearch({ - from: "/_authenticated/_sidebar-layout/directories/$directoryId", - }) - - const directoryContentQuery = useAtomValue( - directoryContentQueryAtom({ - directoryId: directory.id, - orderBy: search.orderBy, - direction: search.direction, - limit: 100, - }), - ) const { data: directoryContent, isLoading: isLoadingDirectoryContent, isFetchingNextPage: isFetchingMoreDirectoryItems, fetchNextPage: fetchMoreDirectoryItems, hasNextPage: hasMoreDirectoryItems, - } = useInfiniteQuery(directoryContentQuery) + } = useInfiniteQuery(query) const store = useStore() const navigate = useNavigate() const table = useReactTable({ data: useMemo( - () => directoryContent?.pages.flatMap((page) => page.items) || [], + () => + directoryContent?.pages.flatMap( + (page) => (page as { items: DirectoryItem[] }).items, + ) || [], [directoryContent], ), columns: useTableColumns(onOpenFile, directoryUrlFn), @@ -233,7 +229,7 @@ export function DirectoryContentTable({ ) if (isLoadingDirectoryContent) { - return + return <>{loadingComponent || } } const handleRowContextMenu = ( @@ -277,7 +273,8 @@ export function DirectoryContentTable({ } const renderRow = (virtualRow: VirtualItem, i: number) => { - const row = rows[virtualRow.index]! + const row = rows[virtualRow.index] + if (!row) return null return ( - - - {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) + ) : ( + + )} + +
+
+
) } diff --git a/apps/drive-web/src/directories/directory-page/mock-directory-content-table.tsx b/apps/drive-web/src/directories/directory-page/mock-directory-content-table.tsx index feb071c..a73a5c5 100644 --- a/apps/drive-web/src/directories/directory-page/mock-directory-content-table.tsx +++ b/apps/drive-web/src/directories/directory-page/mock-directory-content-table.tsx @@ -7,40 +7,16 @@ * 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 InfiniteData, infiniteQueryOptions } from "@tanstack/react-query" +import { atom } from "jotai" +import { useMemo } from "react" +import type { FileDragInfo } from "@/files/use-file-drop" +import type { DirectoryContentOrderBy } from "@/vfs/api" import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs" +import { + DirectoryContentTable, + type DirectoryContentTableProps, +} from "./directory-content-table" // Configuration - adjust these to test different scenarios const TOTAL_ITEMS = 10_000 // Total number of items to simulate @@ -52,14 +28,13 @@ const NETWORK_DELAY_MS = 50 // Simulated network delay in milliseconds */ function generateMockDirectoryItems( totalItems: number, - itemsPerPage: number = 100, - mixFilesAndDirs: boolean = true, + _mixFilesAndDirs: boolean = true, ): DirectoryItem[] { const items: DirectoryItem[] = [] - const now = new Date().toISOString() + const now = new Date() for (let i = 0; i < totalItems; i++) { - const isFile = mixFilesAndDirs ? i % 2 === 0 : false + const isFile = _mixFilesAndDirs ? i % 2 === 0 : false const id = `mock-${i}` const name = isFile ? `file-${i.toString().padStart(4, "0")}.txt` @@ -106,21 +81,36 @@ function createMockDirectoryContentQuery( // 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) - }, - ) + const allItems = generateMockDirectoryItems(totalItems).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], + type MockPageData = { items: DirectoryItem[]; nextCursor?: string } + type MockPageParam = { + orderBy: DirectoryContentOrderBy + direction: "asc" | "desc" + limit: number + cursor: string + } + + return infiniteQueryOptions< + MockPageData, + Error, + InfiniteData, + readonly (string | undefined)[], + MockPageParam + >({ + queryKey: ["mock", "directories", "content", totalItems] as readonly ( + | string + | undefined + )[], initialPageParam: { - orderBy: "name" as const, + orderBy: "name" as DirectoryContentOrderBy, direction: "asc" as const, limit: itemsPerPage, cursor: "", @@ -133,16 +123,20 @@ function createMockDirectoryContentQuery( const cursor = pageParam.cursor || "" const startIndex = cursor ? parseInt(cursor, 10) : 0 - const endIndex = Math.min(startIndex + itemsPerPage, allItems.length) + 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 + const nextCursor = + endIndex < allItems.length ? endIndex.toString() : undefined return { items: pageItems, - nextCursor: - endIndex < allItems.length ? endIndex.toString() : undefined, + ...(nextCursor !== undefined && { nextCursor }), } }, getNextPageParam: (lastPage, _pages, lastPageParam) => @@ -155,144 +149,18 @@ function createMockDirectoryContentQuery( }) } -/** - * 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 - onContextMenu: ( - row: Row, - table: TableType, - ) => 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[] { - 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: "Name", - 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 type MockDirectoryContentTableProps = Omit< + DirectoryContentTableProps, + "query" | "loadingComponent" | "debugBanner" | "fileDragInfoAtom" +> export function MockDirectoryContentTable({ directoryUrlFn, onContextMenu, - fileDragInfoAtom, onOpenFile, }: MockDirectoryContentTableProps) { - // Use mock query instead of real API - const mockQuery = useMemo( + // Use mock query options instead of real API + const query = useMemo( () => createMockDirectoryContentQuery( TOTAL_ITEMS, @@ -302,327 +170,34 @@ export function MockDirectoryContentTable({ [], ) - const { - data: directoryContent, - isLoading: isLoadingDirectoryContent, - isFetchingNextPage: isFetchingMoreDirectoryItems, - fetchNextPage: fetchMoreDirectoryItems, - hasNextPage: hasMoreDirectoryItems, - } = useInfiniteQuery(mockQuery) + const fileDragInfoAtom = useMemo(() => atom(null), []) - 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, _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() - - // 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 ( -
-
- Loading {TOTAL_ITEMS.toLocaleString()} mock items... -
-
- ) - } - - 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) - }} - /> - ) - } - - return ( -
- {/* Debug info banner */} -
- Test Mode: Showing{" "} - {rows.length.toLocaleString()} of {TOTAL_ITEMS.toLocaleString()}{" "} - items | Rendered: {virtualItems.length} rows | Check console for - virtualization stats -
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef - .header, - header.getContext(), - )} - - ))} - - ))} - - - {rows.length > 0 ? ( - virtualItems.map(renderRow) - ) : ( - - )} - -
-
+ // Create debug banner (will be updated after query runs) + const debugBanner = ( +
+ Test Mode: Simulating{" "} + {TOTAL_ITEMS.toLocaleString()} items | Check console for + virtualization stats
) -} -function NoResultsRow() { - return ( - - - No results. - - + const loadingComponent = ( +
+
+ Loading {TOTAL_ITEMS.toLocaleString()} mock items... +
+
) -} - -function FileItemRow({ - table, - row, - onClick, - onContextMenu, - onDoubleClick, - fileDragInfoAtom, - ...rowProps -}: React.ComponentProps & { - table: TableType - row: Row - onClick: () => void - onContextMenu: (e: React.MouseEvent) => void - onDoubleClick: () => void - fileDragInfoAtom: PrimitiveAtom -}) { - 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, - }) - - 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 ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - + fileDragInfoAtom={fileDragInfoAtom} + onOpenFile={onOpenFile} + query={query} + loadingComponent={loadingComponent} + debugBanner={debugBanner} + /> ) } - -function DirectoryNameCell({ - directory, -}: { - directory: DirectoryInfo - directoryUrlFn: (directory: DirectoryInfo) => string -}) { - return ( -
- - {directory.name} -
- ) -} - -function FileNameCell({ - file, - onOpenFile, -}: { - file: FileInfo - onOpenFile: (file: FileInfo) => void -}) { - return ( -
- - -
- ) -} - 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 c707cd2..c3bb0aa 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 @@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router" import type { Row, Table } from "@tanstack/react-table" import { type } from "arktype" import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" +import { atomWithStorage } from "jotai/utils" import { ChevronDownIcon, PlusIcon, @@ -10,7 +11,7 @@ import { TextCursorInputIcon, TrashIcon, } from "lucide-react" -import { useCallback, useContext } from "react" +import { lazy, Suspense, useCallback, useContext } from "react" import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { TextFileIcon } from "@/components/icons/text-file-icon" @@ -30,10 +31,7 @@ import { import { WithAtom } from "@/components/with-atom" import { backgroundTaskProgressAtom } from "@/dashboard/state" import { DirectoryPageContext } from "@/directories/directory-page/context" -import { - DirectoryContentTableWrapper, - mockTableAtom, -} from "@/directories/directory-page/directory-content-table-wrapper" +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" import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog" @@ -46,6 +44,7 @@ import { formatError } from "@/lib/error" import { DIRECTORY_CONTENT_ORDER_BY, DIRECTORY_CONTENT_ORDER_DIRECTION, + directoryContentQueryAtom, directoryInfoQueryAtom, moveToTrashMutationAtom, } from "@/vfs/api" @@ -56,6 +55,18 @@ import type { FileInfo, } from "@/vfs/vfs" +// 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( + "@/directories/directory-page/mock-directory-content-table" + ).then((mod) => ({ + default: mod.MockDirectoryContentTable, + })), + ) + : null + const DirectoryContentPageParams = type({ orderBy: type .valueOf(DIRECTORY_CONTENT_ORDER_BY) @@ -98,6 +109,14 @@ const itemBeingRenamedAtom = atom<{ name: string } | null>(null) +// 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 mockTableAtom = import.meta.env.DEV + ? atomWithStorage("drexa:use-mock-directory-table", false) + : atom(false) + // MARK: page entry function RouteComponent() { const { directoryId } = Route.useParams() @@ -105,39 +124,11 @@ function RouteComponent() { useAtomValue(directoryInfoQueryAtom(directoryId)), ) - const setOpenedFile = useSetAtom(openedFileAtom) - const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) - const directoryUrlById = useCallback( (directoryId: string) => `/directories/${directoryId}`, [], ) - const onTableOpenFile = useCallback( - (file: FileInfo) => { - setOpenedFile(file) - }, - [setOpenedFile], - ) - - const directoryUrlFn = useCallback( - (directory: DirectoryInfo) => `/directories/${directory.id}`, - [], - ) - - const handleContextMenuRequest = useCallback( - (row: Row, table: Table) => { - if (row.getIsSelected()) { - setContextMenuTargetItems( - table.getSelectedRowModel().rows.map((row) => row.original), - ) - } else { - setContextMenuTargetItems([row.original]) - } - }, - [setContextMenuTargetItems], - ) - if (isLoadingDirectoryInfo) { return } @@ -166,12 +157,7 @@ function RouteComponent() { {/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */}
- + <_DirectoryContentTable />
@@ -228,6 +214,99 @@ function RouteComponent() { ) } +function _DirectoryContentTable() { + // Always call the hook - in production the atom always returns false + const useMock = useAtomValue(mockTableAtom) + const { directory } = useContext(DirectoryPageContext) + + const search = Route.useSearch() + const query = useAtomValue( + directoryContentQueryAtom({ + directoryId: directory.id, + orderBy: search.orderBy, + direction: search.direction, + limit: 100, + }), + ) + + const setOpenedFile = useSetAtom(openedFileAtom) + const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) + + const onTableOpenFile = useCallback( + (file: FileInfo) => { + setOpenedFile(file) + }, + [setOpenedFile], + ) + + const directoryUrlFn = useCallback( + (directory: DirectoryInfo) => `/directories/${directory.id}`, + [], + ) + + const handleContextMenuRequest = useCallback( + (row: Row, table: Table) => { + if (row.getIsSelected()) { + setContextMenuTargetItems( + table.getSelectedRowModel().rows.map((row) => row.original), + ) + } else { + setContextMenuTargetItems([row.original]) + } + }, + [setContextMenuTargetItems], + ) + + // In production, MockDirectoryContentTable is null, so this always renders real table + if (import.meta.env.DEV && useMock && MockDirectoryContentTable) { + return ( + Loading mock table...
}> + + + ) + } + + return ( + + ) +} + +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 ( + + ) +} + // ================================== // MARK: ctx menu @@ -411,28 +490,3 @@ function NewDirectoryItemDropdown() { ) } - -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 ( - - ) -} diff --git a/apps/drive-web/src/vfs/api.ts b/apps/drive-web/src/vfs/api.ts index e1b17fa..d7393b0 100644 --- a/apps/drive-web/src/vfs/api.ts +++ b/apps/drive-web/src/vfs/api.ts @@ -91,10 +91,32 @@ type DirectoryContentQueryParams = { limit: number } +type DirectoryContentPageParam = { + orderBy: DirectoryContentOrderBy + direction: DirectoryContentOrderDirection + limit: number + cursor: string +} + const directoryContentQueryKey = ( accountId: string | undefined, directoryId: string, -) => ["accounts", accountId, "directories", directoryId, "content"] +): readonly (string | undefined)[] => [ + "accounts", + accountId, + "directories", + directoryId, + "content", +] +export type DirectoryContentQuery = ReturnType< + typeof infiniteQueryOptions< + typeof DirectoryContentResponse.infer, + Error, + InfiniteData, + readonly (string | undefined)[], + DirectoryContentPageParam + > +> export const directoryContentQueryAtom = atomFamily( ({ directoryId, orderBy, direction, limit }: DirectoryContentQueryParams) => atom((get) => {