mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 14:51:18 +00:00
refactor: unify mock/real dir content table
This commit is contained in:
@@ -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<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
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router"
|
import { Link, useNavigate } from "@tanstack/react-router"
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
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 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 { DirectoryIcon } from "@/components/icons/directory-icon"
|
||||||
import { TextFileIcon } from "@/components/icons/text-file-icon"
|
import { TextFileIcon } from "@/components/icons/text-file-icon"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@@ -31,14 +31,13 @@ import {
|
|||||||
keyboardModifierAtom,
|
keyboardModifierAtom,
|
||||||
} from "@/lib/keyboard"
|
} from "@/lib/keyboard"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { DirectoryContentQuery } from "@/vfs/api"
|
||||||
import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs"
|
import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs"
|
||||||
import { directoryContentQueryAtom } from "../../vfs/api"
|
|
||||||
import { DirectoryPageContext } from "./context"
|
|
||||||
import { DirectoryContentTableSkeleton } from "./directory-content-table-skeleton"
|
import { DirectoryContentTableSkeleton } from "./directory-content-table-skeleton"
|
||||||
|
|
||||||
type DirectoryContentTableItemIdFilter = Set<string>
|
type DirectoryContentTableItemIdFilter = Set<string>
|
||||||
|
|
||||||
type DirectoryContentTableProps = {
|
export type DirectoryContentTableProps = {
|
||||||
directoryUrlFn: (directory: DirectoryInfo) => string
|
directoryUrlFn: (directory: DirectoryInfo) => string
|
||||||
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
|
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
|
||||||
onContextMenu: (
|
onContextMenu: (
|
||||||
@@ -46,6 +45,9 @@ type DirectoryContentTableProps = {
|
|||||||
table: TableType<DirectoryItem>,
|
table: TableType<DirectoryItem>,
|
||||||
) => void
|
) => void
|
||||||
onOpenFile: (file: FileInfo) => void
|
onOpenFile: (file: FileInfo) => void
|
||||||
|
query: DirectoryContentQuery
|
||||||
|
loadingComponent?: React.ReactNode
|
||||||
|
debugBanner?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -142,39 +144,33 @@ function useTableColumns(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared table component that accepts query options as props
|
||||||
export function DirectoryContentTable({
|
export function DirectoryContentTable({
|
||||||
directoryUrlFn,
|
directoryUrlFn,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
fileDragInfoAtom,
|
fileDragInfoAtom,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
query,
|
||||||
|
loadingComponent,
|
||||||
|
debugBanner,
|
||||||
}: DirectoryContentTableProps) {
|
}: 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 {
|
const {
|
||||||
data: directoryContent,
|
data: directoryContent,
|
||||||
isLoading: isLoadingDirectoryContent,
|
isLoading: isLoadingDirectoryContent,
|
||||||
isFetchingNextPage: isFetchingMoreDirectoryItems,
|
isFetchingNextPage: isFetchingMoreDirectoryItems,
|
||||||
fetchNextPage: fetchMoreDirectoryItems,
|
fetchNextPage: fetchMoreDirectoryItems,
|
||||||
hasNextPage: hasMoreDirectoryItems,
|
hasNextPage: hasMoreDirectoryItems,
|
||||||
} = useInfiniteQuery(directoryContentQuery)
|
} = useInfiniteQuery(query)
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: useMemo(
|
data: useMemo(
|
||||||
() => directoryContent?.pages.flatMap((page) => page.items) || [],
|
() =>
|
||||||
|
directoryContent?.pages.flatMap(
|
||||||
|
(page) => (page as { items: DirectoryItem[] }).items,
|
||||||
|
) || [],
|
||||||
[directoryContent],
|
[directoryContent],
|
||||||
),
|
),
|
||||||
columns: useTableColumns(onOpenFile, directoryUrlFn),
|
columns: useTableColumns(onOpenFile, directoryUrlFn),
|
||||||
@@ -233,7 +229,7 @@ export function DirectoryContentTable({
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (isLoadingDirectoryContent) {
|
if (isLoadingDirectoryContent) {
|
||||||
return <DirectoryContentTableSkeleton />
|
return <>{loadingComponent || <DirectoryContentTableSkeleton />}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRowContextMenu = (
|
const handleRowContextMenu = (
|
||||||
@@ -277,7 +273,8 @@ export function DirectoryContentTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderRow = (virtualRow: VirtualItem, i: number) => {
|
const renderRow = (virtualRow: VirtualItem, i: number) => {
|
||||||
const row = rows[virtualRow.index]!
|
const row = rows[virtualRow.index]
|
||||||
|
if (!row) return null
|
||||||
return (
|
return (
|
||||||
<FileItemRow
|
<FileItemRow
|
||||||
style={{
|
style={{
|
||||||
@@ -300,7 +297,12 @@ export function DirectoryContentTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer className="h-full" ref={containerRef}>
|
<div className="h-full flex flex-col">
|
||||||
|
{debugBanner}
|
||||||
|
<TableContainer
|
||||||
|
className={debugBanner ? "flex-1" : "h-full"}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
<Table className="h-full min-h-0">
|
<Table className="h-full min-h-0">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
@@ -317,7 +319,8 @@ export function DirectoryContentTable({
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef
|
||||||
|
.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -334,6 +337,7 @@ export function DirectoryContentTable({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,40 +7,16 @@
|
|||||||
* 3. The component will automatically use mock data
|
* 3. The component will automatically use mock data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query"
|
import { type InfiniteData, infiniteQueryOptions } from "@tanstack/react-query"
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
import { atom } from "jotai"
|
||||||
import {
|
import { useMemo } from "react"
|
||||||
type ColumnDef,
|
import type { FileDragInfo } from "@/files/use-file-drop"
|
||||||
flexRender,
|
import type { DirectoryContentOrderBy } from "@/vfs/api"
|
||||||
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"
|
import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs"
|
||||||
|
import {
|
||||||
|
DirectoryContentTable,
|
||||||
|
type DirectoryContentTableProps,
|
||||||
|
} from "./directory-content-table"
|
||||||
|
|
||||||
// Configuration - adjust these to test different scenarios
|
// Configuration - adjust these to test different scenarios
|
||||||
const TOTAL_ITEMS = 10_000 // Total number of items to simulate
|
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(
|
function generateMockDirectoryItems(
|
||||||
totalItems: number,
|
totalItems: number,
|
||||||
itemsPerPage: number = 100,
|
_mixFilesAndDirs: boolean = true,
|
||||||
mixFilesAndDirs: boolean = true,
|
|
||||||
): DirectoryItem[] {
|
): DirectoryItem[] {
|
||||||
const items: DirectoryItem[] = []
|
const items: DirectoryItem[] = []
|
||||||
const now = new Date().toISOString()
|
const now = new Date()
|
||||||
|
|
||||||
for (let i = 0; i < totalItems; i++) {
|
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 id = `mock-${i}`
|
||||||
const name = isFile
|
const name = isFile
|
||||||
? `file-${i.toString().padStart(4, "0")}.txt`
|
? `file-${i.toString().padStart(4, "0")}.txt`
|
||||||
@@ -106,21 +81,36 @@ function createMockDirectoryContentQuery(
|
|||||||
// 1. Directories first (kind='directory' < kind='file' alphabetically)
|
// 1. Directories first (kind='directory' < kind='file' alphabetically)
|
||||||
// 2. Then by the sort field (name, createdAt, etc.)
|
// 2. Then by the sort field (name, createdAt, etc.)
|
||||||
// 3. Then by id as tiebreaker
|
// 3. Then by id as tiebreaker
|
||||||
const allItems = generateMockDirectoryItems(totalItems, itemsPerPage).sort(
|
const allItems = generateMockDirectoryItems(totalItems).sort((a, b) => {
|
||||||
(a, b) => {
|
|
||||||
// First sort by kind (directories before files)
|
// First sort by kind (directories before files)
|
||||||
if (a.kind !== b.kind) {
|
if (a.kind !== b.kind) {
|
||||||
return a.kind.localeCompare(b.kind) // 'directory' < 'file'
|
return a.kind.localeCompare(b.kind) // 'directory' < 'file'
|
||||||
}
|
}
|
||||||
// Then by name (default sort)
|
// Then by name (default sort)
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
return infiniteQueryOptions({
|
type MockPageData = { items: DirectoryItem[]; nextCursor?: string }
|
||||||
queryKey: ["mock", "directories", "content", totalItems],
|
type MockPageParam = {
|
||||||
|
orderBy: DirectoryContentOrderBy
|
||||||
|
direction: "asc" | "desc"
|
||||||
|
limit: number
|
||||||
|
cursor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
return infiniteQueryOptions<
|
||||||
|
MockPageData,
|
||||||
|
Error,
|
||||||
|
InfiniteData<MockPageData, MockPageParam>,
|
||||||
|
readonly (string | undefined)[],
|
||||||
|
MockPageParam
|
||||||
|
>({
|
||||||
|
queryKey: ["mock", "directories", "content", totalItems] as readonly (
|
||||||
|
| string
|
||||||
|
| undefined
|
||||||
|
)[],
|
||||||
initialPageParam: {
|
initialPageParam: {
|
||||||
orderBy: "name" as const,
|
orderBy: "name" as DirectoryContentOrderBy,
|
||||||
direction: "asc" as const,
|
direction: "asc" as const,
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
cursor: "",
|
cursor: "",
|
||||||
@@ -133,16 +123,20 @@ function createMockDirectoryContentQuery(
|
|||||||
|
|
||||||
const cursor = pageParam.cursor || ""
|
const cursor = pageParam.cursor || ""
|
||||||
const startIndex = cursor ? parseInt(cursor, 10) : 0
|
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)
|
const pageItems = allItems.slice(startIndex, endIndex)
|
||||||
|
|
||||||
// Items are already sorted globally, but we may need to re-sort per page
|
// 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)
|
// 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
|
// The global sort already handles directories first, so we just return the slice
|
||||||
|
const nextCursor =
|
||||||
|
endIndex < allItems.length ? endIndex.toString() : undefined
|
||||||
return {
|
return {
|
||||||
items: pageItems,
|
items: pageItems,
|
||||||
nextCursor:
|
...(nextCursor !== undefined && { nextCursor }),
|
||||||
endIndex < allItems.length ? endIndex.toString() : undefined,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage, _pages, lastPageParam) =>
|
getNextPageParam: (lastPage, _pages, lastPageParam) =>
|
||||||
@@ -155,144 +149,18 @@ function createMockDirectoryContentQuery(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type MockDirectoryContentTableProps = Omit<
|
||||||
* Helper to log virtualization metrics for debugging.
|
DirectoryContentTableProps,
|
||||||
*/
|
"query" | "loadingComponent" | "debugBanner" | "fileDragInfoAtom"
|
||||||
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({
|
export function MockDirectoryContentTable({
|
||||||
directoryUrlFn,
|
directoryUrlFn,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
fileDragInfoAtom,
|
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: MockDirectoryContentTableProps) {
|
}: MockDirectoryContentTableProps) {
|
||||||
// Use mock query instead of real API
|
// Use mock query options instead of real API
|
||||||
const mockQuery = useMemo(
|
const query = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createMockDirectoryContentQuery(
|
createMockDirectoryContentQuery(
|
||||||
TOTAL_ITEMS,
|
TOTAL_ITEMS,
|
||||||
@@ -302,327 +170,34 @@ export function MockDirectoryContentTable({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const fileDragInfoAtom = useMemo(() => atom<FileDragInfo | null>(null), [])
|
||||||
data: directoryContent,
|
|
||||||
isLoading: isLoadingDirectoryContent,
|
|
||||||
isFetchingNextPage: isFetchingMoreDirectoryItems,
|
|
||||||
fetchNextPage: fetchMoreDirectoryItems,
|
|
||||||
hasNextPage: hasMoreDirectoryItems,
|
|
||||||
} = useInfiniteQuery(mockQuery)
|
|
||||||
|
|
||||||
const store = useStore()
|
// Create debug banner (will be updated after query runs)
|
||||||
const navigate = useNavigate()
|
const debugBanner = (
|
||||||
|
<div className="bg-muted px-4 py-2 text-sm border-b">
|
||||||
const table = useReactTable({
|
<span className="font-semibold">Test Mode:</span> Simulating{" "}
|
||||||
data: useMemo(
|
{TOTAL_ITEMS.toLocaleString()} items | Check console for
|
||||||
() => directoryContent?.pages.flatMap((page) => page.items) || [],
|
virtualization stats
|
||||||
[directoryContent],
|
</div>
|
||||||
),
|
|
||||||
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) {
|
const loadingComponent = (
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
Loading {TOTAL_ITEMS.toLocaleString()} mock items...
|
Loading {TOTAL_ITEMS.toLocaleString()} mock items...
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<FileItemRow
|
<DirectoryContentTable
|
||||||
style={{
|
directoryUrlFn={directoryUrlFn}
|
||||||
height: virtualRow.size,
|
onContextMenu={onContextMenu}
|
||||||
transform: `translateY(${
|
|
||||||
virtualRow.start - i * virtualRow.size
|
|
||||||
}px)`,
|
|
||||||
}}
|
|
||||||
key={row.id}
|
|
||||||
table={table}
|
|
||||||
row={row}
|
|
||||||
onClick={() => selectRow(row)}
|
|
||||||
fileDragInfoAtom={fileDragInfoAtom}
|
fileDragInfoAtom={fileDragInfoAtom}
|
||||||
onContextMenu={(e) => handleRowContextMenu(row, e)}
|
onOpenFile={onOpenFile}
|
||||||
onDoubleClick={() => {
|
query={query}
|
||||||
handleRowDoubleClick(row)
|
loadingComponent={loadingComponent}
|
||||||
}}
|
debugBanner={debugBanner}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"
|
|||||||
import type { Row, Table } from "@tanstack/react-table"
|
import type { Row, Table } from "@tanstack/react-table"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
|
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
|
||||||
|
import { atomWithStorage } from "jotai/utils"
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
TextCursorInputIcon,
|
TextCursorInputIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useCallback, useContext } from "react"
|
import { lazy, Suspense, useCallback, useContext } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { DirectoryIcon } from "@/components/icons/directory-icon"
|
import { DirectoryIcon } from "@/components/icons/directory-icon"
|
||||||
import { TextFileIcon } from "@/components/icons/text-file-icon"
|
import { TextFileIcon } from "@/components/icons/text-file-icon"
|
||||||
@@ -30,10 +31,7 @@ import {
|
|||||||
import { WithAtom } from "@/components/with-atom"
|
import { WithAtom } from "@/components/with-atom"
|
||||||
import { backgroundTaskProgressAtom } from "@/dashboard/state"
|
import { backgroundTaskProgressAtom } from "@/dashboard/state"
|
||||||
import { DirectoryPageContext } from "@/directories/directory-page/context"
|
import { DirectoryPageContext } from "@/directories/directory-page/context"
|
||||||
import {
|
import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table"
|
||||||
DirectoryContentTableWrapper,
|
|
||||||
mockTableAtom,
|
|
||||||
} from "@/directories/directory-page/directory-content-table-wrapper"
|
|
||||||
import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
|
import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
|
||||||
import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog"
|
import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog"
|
||||||
import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog"
|
import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog"
|
||||||
@@ -46,6 +44,7 @@ import { formatError } from "@/lib/error"
|
|||||||
import {
|
import {
|
||||||
DIRECTORY_CONTENT_ORDER_BY,
|
DIRECTORY_CONTENT_ORDER_BY,
|
||||||
DIRECTORY_CONTENT_ORDER_DIRECTION,
|
DIRECTORY_CONTENT_ORDER_DIRECTION,
|
||||||
|
directoryContentQueryAtom,
|
||||||
directoryInfoQueryAtom,
|
directoryInfoQueryAtom,
|
||||||
moveToTrashMutationAtom,
|
moveToTrashMutationAtom,
|
||||||
} from "@/vfs/api"
|
} from "@/vfs/api"
|
||||||
@@ -56,6 +55,18 @@ import type {
|
|||||||
FileInfo,
|
FileInfo,
|
||||||
} from "@/vfs/vfs"
|
} 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({
|
const DirectoryContentPageParams = type({
|
||||||
orderBy: type
|
orderBy: type
|
||||||
.valueOf(DIRECTORY_CONTENT_ORDER_BY)
|
.valueOf(DIRECTORY_CONTENT_ORDER_BY)
|
||||||
@@ -98,6 +109,14 @@ const itemBeingRenamedAtom = atom<{
|
|||||||
name: string
|
name: string
|
||||||
} | null>(null)
|
} | 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
|
// MARK: page entry
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { directoryId } = Route.useParams()
|
const { directoryId } = Route.useParams()
|
||||||
@@ -105,39 +124,11 @@ function RouteComponent() {
|
|||||||
useAtomValue(directoryInfoQueryAtom(directoryId)),
|
useAtomValue(directoryInfoQueryAtom(directoryId)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const setOpenedFile = useSetAtom(openedFileAtom)
|
|
||||||
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
|
|
||||||
|
|
||||||
const directoryUrlById = useCallback(
|
const directoryUrlById = useCallback(
|
||||||
(directoryId: string) => `/directories/${directoryId}`,
|
(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<DirectoryItem>, table: Table<DirectoryItem>) => {
|
|
||||||
if (row.getIsSelected()) {
|
|
||||||
setContextMenuTargetItems(
|
|
||||||
table.getSelectedRowModel().rows.map((row) => row.original),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setContextMenuTargetItems([row.original])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setContextMenuTargetItems],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoadingDirectoryInfo) {
|
if (isLoadingDirectoryInfo) {
|
||||||
return <DirectoryPageSkeleton />
|
return <DirectoryPageSkeleton />
|
||||||
}
|
}
|
||||||
@@ -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 */}
|
{/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */}
|
||||||
<DirectoryContentContextMenu>
|
<DirectoryContentContextMenu>
|
||||||
<div className="w-full min-h-0">
|
<div className="w-full min-h-0">
|
||||||
<DirectoryContentTableWrapper
|
<_DirectoryContentTable />
|
||||||
directoryUrlFn={directoryUrlFn}
|
|
||||||
fileDragInfoAtom={fileDragInfoAtom}
|
|
||||||
onContextMenu={handleContextMenuRequest}
|
|
||||||
onOpenFile={onTableOpenFile}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</DirectoryContentContextMenu>
|
</DirectoryContentContextMenu>
|
||||||
|
|
||||||
@@ -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<DirectoryItem>, table: Table<DirectoryItem>) => {
|
||||||
|
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 (
|
||||||
|
<Suspense fallback={<div>Loading mock table...</div>}>
|
||||||
|
<MockDirectoryContentTable
|
||||||
|
directoryUrlFn={directoryUrlFn}
|
||||||
|
onContextMenu={handleContextMenuRequest}
|
||||||
|
onOpenFile={onTableOpenFile}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DirectoryContentTable
|
||||||
|
query={query}
|
||||||
|
directoryUrlFn={directoryUrlFn}
|
||||||
|
fileDragInfoAtom={fileDragInfoAtom}
|
||||||
|
onContextMenu={handleContextMenuRequest}
|
||||||
|
onOpenFile={onTableOpenFile}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================================
|
// ==================================
|
||||||
// MARK: ctx menu
|
// MARK: ctx menu
|
||||||
|
|
||||||
@@ -411,28 +490,3 @@ function NewDirectoryItemDropdown() {
|
|||||||
</DropdownMenu>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -91,10 +91,32 @@ type DirectoryContentQueryParams = {
|
|||||||
limit: number
|
limit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DirectoryContentPageParam = {
|
||||||
|
orderBy: DirectoryContentOrderBy
|
||||||
|
direction: DirectoryContentOrderDirection
|
||||||
|
limit: number
|
||||||
|
cursor: string
|
||||||
|
}
|
||||||
|
|
||||||
const directoryContentQueryKey = (
|
const directoryContentQueryKey = (
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
directoryId: string,
|
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<typeof DirectoryContentResponse.infer>,
|
||||||
|
readonly (string | undefined)[],
|
||||||
|
DirectoryContentPageParam
|
||||||
|
>
|
||||||
|
>
|
||||||
export const directoryContentQueryAtom = atomFamily(
|
export const directoryContentQueryAtom = atomFamily(
|
||||||
({ directoryId, orderBy, direction, limit }: DirectoryContentQueryParams) =>
|
({ directoryId, orderBy, direction, limit }: DirectoryContentQueryParams) =>
|
||||||
atom((get) => {
|
atom((get) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user