impl: dir content pagination

This commit is contained in:
2025-12-17 22:59:18 +00:00
parent 5484a08636
commit f2cce889af
12 changed files with 588 additions and 173 deletions

View File

@@ -15,8 +15,45 @@
}, },
"paths": { "paths": {
"/accounts": { "/accounts": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve all accounts for the authenticated user",
"tags": [
"accounts"
],
"summary": "List accounts",
"responses": {
"200": {
"description": "List of accounts for the authenticated user",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/internal_account.Account"
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
}
},
"post": { "post": {
"description": "Create a new user account with email and password. Returns the account, user, and authentication tokens.", "description": "Create a new user account with email and password. Returns the account, user, and authentication tokens. Tokens can be delivered via HTTP-only cookies or in the response body based on the tokenDelivery field.",
"tags": [ "tags": [
"accounts" "accounts"
], ],
@@ -44,7 +81,7 @@
} }
}, },
"400": { "400": {
"description": "Invalid request body", "description": "Invalid request body or token delivery method",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -1616,6 +1653,15 @@
"description": "Password for the new account (min 8 characters)", "description": "Password for the new account (min 8 characters)",
"type": "string", "type": "string",
"example": "securepassword123" "example": "securepassword123"
},
"tokenDelivery": {
"description": "How to deliver tokens: \"cookie\" (set HTTP-only cookies) or \"body\" (include in response)",
"type": "string",
"enum": [
"cookie",
"body"
],
"example": "body"
} }
} }
}, },
@@ -1624,7 +1670,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"accessToken": { "accessToken": {
"description": "JWT access token for immediate authentication", "description": "JWT access token for immediate authentication (only included when tokenDelivery is \"body\")",
"type": "string", "type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature"
}, },
@@ -1637,7 +1683,7 @@
] ]
}, },
"refreshToken": { "refreshToken": {
"description": "Base64 URL encoded refresh token", "description": "Base64 URL encoded refresh token (only included when tokenDelivery is \"body\")",
"type": "string", "type": "string",
"example": "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" "example": "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi"
}, },

View File

@@ -1,8 +1,11 @@
package catalog package catalog
import ( import (
"encoding/base64"
"errors" "errors"
"fmt"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
@@ -24,7 +27,7 @@ type DirectoryInfo struct {
Kind string `json:"kind" example:"directory"` Kind string `json:"kind" example:"directory"`
// Unique directory identifier // Unique directory identifier
ID string `json:"id" example:"kRp2XYTq9A55"` ID string `json:"id" example:"kRp2XYTq9A55"`
// ParentID is the pbulic ID of the directory this directory is in // ParentID is the public ID of the directory this directory is in
ParentID string `json:"parentId,omitempty" example:"kRp2XYTq9A55"` ParentID string `json:"parentId,omitempty" example:"kRp2XYTq9A55"`
// Full path from root (included when ?include=path) // Full path from root (included when ?include=path)
Path virtualfs.Path `json:"path,omitempty"` Path virtualfs.Path `json:"path,omitempty"`
@@ -54,6 +57,15 @@ type postDirectoryContentRequest struct {
Items []string `json:"items" example:"mElnUNCm8F22,kRp2XYTq9A55"` Items []string `json:"items" example:"mElnUNCm8F22,kRp2XYTq9A55"`
} }
// listDirectoryResponse represents the response to a request to list the contents of a directory
// @Description Response to a request to list the contents of a directory
type listDirectoryResponse struct {
// Items is the list of items in the directory, limited to the limit specified in the request
Items []any `json:"items"`
// NextCursor is the cursor to use to get the next page of results
NextCursor string `json:"nextCursor,omitempty"`
}
// moveItemsToDirectoryResponse represents the response to a request // moveItemsToDirectoryResponse represents the response to a request
// to move items into a directory. // to move items into a directory.
// @Description Response from moving items to a directory with status for each item // @Description Response from moving items to a directory with status for each item
@@ -80,6 +92,12 @@ type moveItemError struct {
Error string `json:"error" example:"permission denied"` Error string `json:"error" example:"permission denied"`
} }
type decodedListChildrenCursor struct {
orderBy virtualfs.ListChildrenOrder
orderDirection virtualfs.ListChildrenDirection
nodeID string
}
func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error { func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
account := account.CurrentAccount(c) account := account.CurrentAccount(c)
if account == nil { if account == nil {
@@ -236,19 +254,79 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
// listDirectory returns directory contents // listDirectory returns directory contents
// @Summary List directory contents // @Summary List directory contents
// @Description Get all files and subdirectories within a directory // @Description Get all files and subdirectories within a directory with optional pagination, sorting, and filtering
// @Tags directories // @Tags directories
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid) // @Param accountID path string true "Account ID" format(uuid)
// @Param directoryID path string true "Directory ID" // @Param directoryID path string true "Directory ID (use 'root' for the root directory)"
// @Success 200 {array} interface{} "Array of FileInfo and DirectoryInfo objects" // @Param orderBy query string false "Sort field: name, createdAt, or updatedAt" Enums(name,createdAt,updatedAt)
// @Param dir query string false "Sort direction: asc or desc" Enums(asc,desc)
// @Param limit query integer false "Maximum number of items to return (default: 100, min: 1)"
// @Param cursor query string false "Cursor for pagination (base64-encoded cursor from previous response)"
// @Success 200 {object} listDirectoryResponse "Paginated list of FileInfo and DirectoryInfo objects"
// @Failure 400 {object} map[string]string "Invalid limit or cursor"
// @Failure 401 {string} string "Not authenticated" // @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found" // @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID}/content [get] // @Router /accounts/{accountID}/directories/{directoryID}/content [get]
func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error { func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c) node := mustCurrentDirectoryNode(c)
children, err := h.vfs.ListChildren(c.Context(), h.db, node)
opts := virtualfs.ListChildrenOptions{}
if by := c.Query("orderBy"); by != "" {
switch by {
case "name":
opts.OrderBy = virtualfs.ListChildrenOrderByName
case "createdAt":
opts.OrderBy = virtualfs.ListChildrenOrderByCreatedAt
case "updatedAt":
opts.OrderBy = virtualfs.ListChildrenOrderByUpdatedAt
}
}
if dir := c.Query("dir"); dir != "" {
switch dir {
case "asc":
opts.OrderDirection = virtualfs.ListChildrenDirectionAsc
case "desc":
opts.OrderDirection = virtualfs.ListChildrenDirectionDesc
}
}
if limit := c.Query("limit"); limit != "" {
limit, err := strconv.Atoi(limit)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid limit"})
}
if limit < 1 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Limit must be at least 1"})
}
opts.Limit = limit
}
if cursor := c.Query("cursor"); cursor != "" {
dc, err := decodeListChildrenCursor(cursor)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
}
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, node.AccountID, dc.nodeID)
if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
}
return httperr.Internal(err)
}
opts.Cursor = &virtualfs.ListChildrenCursor{
Node: n,
OrderBy: dc.orderBy,
OrderDirection: dc.orderDirection,
}
}
children, cursor, err := h.vfs.ListChildren(c.Context(), h.db, node, opts)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
@@ -283,7 +361,10 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
} }
} }
return c.JSON(items) return c.JSON(listDirectoryResponse{
Items: items,
NextCursor: encodeListChildrenCursor(cursor),
})
} }
// patchDirectory updates directory properties // patchDirectory updates directory properties
@@ -562,3 +643,64 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
return c.JSON(res) return c.JSON(res)
} }
func encodeListChildrenCursor(cursor *virtualfs.ListChildrenCursor) string {
var by int
switch cursor.OrderBy {
case virtualfs.ListChildrenOrderByName:
by = 0
case virtualfs.ListChildrenOrderByCreatedAt:
by = 1
case virtualfs.ListChildrenOrderByUpdatedAt:
by = 2
}
var d int
switch cursor.OrderDirection {
case virtualfs.ListChildrenDirectionAsc:
d = 0
case virtualfs.ListChildrenDirectionDesc:
d = 1
}
s := fmt.Sprintf("%d:%d:%s", by, d, cursor.Node.ID)
return base64.URLEncoding.EncodeToString([]byte(s))
}
func decodeListChildrenCursor(s string) (*decodedListChildrenCursor, error) {
bs, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return nil, err
}
parts := strings.Split(string(bs), ":")
if len(parts) != 3 {
return nil, errors.New("invalid cursor")
}
c := new(decodedListChildrenCursor)
switch parts[0] {
case "0":
c.orderBy = virtualfs.ListChildrenOrderByName
case "1":
c.orderBy = virtualfs.ListChildrenOrderByCreatedAt
case "2":
c.orderBy = virtualfs.ListChildrenOrderByUpdatedAt
default:
return nil, errors.New("invalid cursor")
}
switch parts[1] {
case "0":
c.orderDirection = virtualfs.ListChildrenDirectionAsc
case "1":
c.orderDirection = virtualfs.ListChildrenDirectionDesc
default:
return nil, errors.New("invalid cursor")
}
c.nodeID = parts[2]
return c, nil
}

View File

@@ -18,8 +18,8 @@ type FileInfo struct {
Kind string `json:"kind" example:"file"` Kind string `json:"kind" example:"file"`
// Unique file identifier // Unique file identifier
ID string `json:"id" example:"mElnUNCm8F22"` ID string `json:"id" example:"mElnUNCm8F22"`
// ParentID is the pbulic ID of the directory this file is in // ParentID is the public ID of the directory this file is in
ParentID string `json:"parentId,omitempty" exmaple:"kRp2XYTq9A55"` ParentID string `json:"parentId,omitempty" example:"kRp2XYTq9A55"`
// File name // File name
Name string `json:"name" example:"document.pdf"` Name string `json:"name" example:"document.pdf"`
// File size in bytes // File size in bytes
@@ -297,7 +297,6 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
} }
return c.JSON(res) return c.JSON(res)
} else { } else {
err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes) err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes)
if err != nil { if err != nil {

View File

@@ -6,4 +6,6 @@ var (
ErrNodeNotFound = errors.New("node not found") ErrNodeNotFound = errors.New("node not found")
ErrNodeConflict = errors.New("node conflict") ErrNodeConflict = errors.New("node conflict")
ErrUnsupportedOperation = errors.New("unsupported operation") ErrUnsupportedOperation = errors.New("unsupported operation")
ErrCursorMismatchedOrderField = errors.New("cursor mismatched order field")
ErrCursorMismatchedDirection = errors.New("cursor mismatched direction")
) )

View File

@@ -7,6 +7,7 @@ import (
"database/sql" "database/sql"
"encoding/binary" "encoding/binary"
"errors" "errors"
"fmt"
"io" "io"
"time" "time"
@@ -19,6 +20,23 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ListChildrenOrder string
const (
ListChildrenOrderByName ListChildrenOrder = "name"
ListChildrenOrderByCreatedAt ListChildrenOrder = "created_at"
ListChildrenOrderByUpdatedAt ListChildrenOrder = "updated_at"
)
type ListChildrenDirection int
const (
ListChildrenDirectionAsc ListChildrenDirection = iota
ListChildrenDirectionDesc
)
const listChildrenDefaultLimit = 50
type VirtualFS struct { type VirtualFS struct {
blobStore blob.Store blobStore blob.Store
keyResolver BlobKeyResolver keyResolver BlobKeyResolver
@@ -48,6 +66,19 @@ type MoveFilesResult struct {
Errors []MoveFileError Errors []MoveFileError
} }
type ListChildrenOptions struct {
Limit int
OrderBy ListChildrenOrder
OrderDirection ListChildrenDirection
Cursor *ListChildrenCursor
}
type ListChildrenCursor struct {
Node *Node
OrderBy ListChildrenOrder
OrderDirection ListChildrenDirection
}
const RootDirectoryName = "root" const RootDirectoryName = "root"
func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) { func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) {
@@ -128,26 +159,108 @@ func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, account
return root, nil return root, nil
} }
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node) ([]*Node, error) { // ListChildren returns the children of a directory node with optional sorting and cursor-based pagination.
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions) ([]*Node, *ListChildrenCursor, error) {
if !node.IsAccessible() { if !node.IsAccessible() {
return nil, ErrNodeNotFound return nil, nil, ErrNodeNotFound
} }
var nodes []*Node var nodes []*Node
err := db.NewSelect().Model(&nodes). q := db.NewSelect().Model(&nodes).
Where("account_id = ?", node.AccountID). Where("account_id = ?", node.AccountID).
Where("parent_id = ?", node.ID). Where("parent_id = ?", node.ID).
Where("status = ?", NodeStatusReady). Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL"). Where("deleted_at IS NULL")
Scan(ctx)
if err != nil { var dir string
if errors.Is(err, sql.ErrNoRows) { if opts.OrderBy != "" {
return make([]*Node, 0), nil switch opts.OrderDirection {
default:
dir = "ASC"
case ListChildrenDirectionAsc:
dir = "ASC"
case ListChildrenDirectionDesc:
dir = "DESC"
} }
return nil, err
} }
return nodes, nil // Apply sorting with directories always first, then ID as tiebreaker.
//
// Cursor-based pagination implementation notes:
// - The cursor contains the last node from the previous page along with the sort configuration
// - The WHERE clause uses tuple comparison (kind, field, id) to filter results after the cursor position
// - Directories are always ordered before files (kind ASC puts 'directory' before 'file' alphabetically)
// - ID is always sorted ASC as a tiebreaker, regardless of the main sort direction
//
// Why ID is always ASC:
// - Ensures deterministic ordering when multiple items have the same sort field value
// - Maintains consistent tiebreaker behavior across different sort directions
// - Prevents pagination inconsistencies where items with the same name/date appear in different orders
// depending on whether sorting ASC or DESC
// - The tuple comparison in the WHERE clause correctly handles the direction for the main field,
// while ID provides a stable secondary sort
switch opts.OrderBy {
case ListChildrenOrderByName:
q = q.Order("kind ASC", "name "+dir, "id ASC")
case ListChildrenOrderByCreatedAt:
q = q.Order("kind ASC", "created_at "+dir, "id ASC")
case ListChildrenOrderByUpdatedAt:
q = q.Order("kind ASC", "updated_at "+dir, "id ASC")
}
// Apply cursor filter for pagination.
// The cursor contains the last node from the previous page. We use tuple comparison
// (kind, field, id) to find all rows that come after the cursor position in the sorted order.
// Kind is included to handle pagination across the directory/file boundary correctly.
// For ASC: use > to get rows after cursor
// For DESC: use < to get rows after cursor (because "after" in descending order means lesser values)
if opts.Cursor != nil {
if opts.Cursor.OrderBy != opts.OrderBy {
return nil, nil, ErrCursorMismatchedOrderField
}
if opts.Cursor.OrderDirection != opts.OrderDirection {
return nil, nil, ErrCursorMismatchedDirection
}
var op string
switch opts.Cursor.OrderDirection {
case ListChildrenDirectionAsc:
op = ">"
case ListChildrenDirectionDesc:
op = "<"
}
// Include kind in tuple comparison to handle pagination across directory/file boundary
switch opts.Cursor.OrderBy {
case ListChildrenOrderByName:
q = q.Where("(kind, name, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.Name, opts.Cursor.Node.ID)
case ListChildrenOrderByCreatedAt:
q = q.Where("(kind, created_at, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.CreatedAt, opts.Cursor.Node.ID)
case ListChildrenOrderByUpdatedAt:
q = q.Where("(kind, updated_at, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.UpdatedAt, opts.Cursor.Node.ID)
}
}
if opts.Limit > 0 {
q = q.Limit(opts.Limit)
} else {
q = q.Limit(listChildrenDefaultLimit)
}
if err := q.Scan(ctx); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return make([]*Node, 0), nil, nil
}
return nil, nil, err
}
c := &ListChildrenCursor{
Node: nodes[len(nodes)-1],
OrderBy: opts.OrderBy,
OrderDirection: opts.OrderDirection,
}
return nodes, c, nil
} }
func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateFileOptions) (*Node, error) { func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateFileOptions) (*Node, error) {
@@ -178,7 +291,7 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
} }
} }
_, err = db.NewInsert().Model(&node).Returning("*").Exec(ctx) _, err = db.NewInsert().Model(&node).On("CONFLICT DO NOTHING").Returning("*").Exec(ctx)
if err != nil { if err != nil {
if database.IsUniqueViolation(err) { if database.IsUniqueViolation(err) {
return nil, ErrNodeConflict return nil, ErrNodeConflict
@@ -355,7 +468,7 @@ func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*
} }
} }
_, err := db.NewUpdate().Model(deletableNodes). _, err := db.NewUpdate().Model(&deletableNodes).
Where("id IN (?)", bun.In(nodeIDs)). Where("id IN (?)", bun.In(nodeIDs)).
Where("status = ?", NodeStatusReady). Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL"). Where("deleted_at IS NULL").
@@ -363,7 +476,7 @@ func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*
Returning("deleted_at"). Returning("deleted_at").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to soft delete nodes: %w", err)
} }
return deletableNodes, nil return deletableNodes, nil

View File

@@ -3,7 +3,6 @@ import type { DirectoryContent, DirectoryInfoWithPath } from "@/vfs/vfs"
type DirectoryPageContextType = { type DirectoryPageContextType = {
directory: DirectoryInfoWithPath directory: DirectoryInfoWithPath
directoryContent: DirectoryContent
} }
export const DirectoryPageContext = createContext<DirectoryPageContextType>( export const DirectoryPageContext = createContext<DirectoryPageContextType>(

View File

@@ -1,4 +1,5 @@
import { Link, useNavigate } from "@tanstack/react-router" import { useInfiniteQuery } from "@tanstack/react-query"
import { Link, useNavigate, useSearch } from "@tanstack/react-router"
import { import {
type ColumnDef, type ColumnDef,
flexRender, flexRender,
@@ -8,7 +9,7 @@ import {
type Table as TableType, type Table as TableType,
useReactTable, useReactTable,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { type PrimitiveAtom, useSetAtom, useStore } from "jotai" import { type PrimitiveAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { useContext, useEffect, useMemo, useRef } from "react" import { useContext, 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"
@@ -28,12 +29,13 @@ import {
} from "@/lib/keyboard" } from "@/lib/keyboard"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
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 { DirectoryPageContext } from "./context"
import { DirectoryContentTableSkeleton } from "./directory-content-table-skeleton"
type DirectoryContentTableItemIdFilter = Set<string> type DirectoryContentTableItemIdFilter = Set<string>
type DirectoryContentTableProps = { type DirectoryContentTableProps = {
hiddenItems: DirectoryContentTableItemIdFilter
directoryUrlFn: (directory: DirectoryInfo) => string directoryUrlFn: (directory: DirectoryInfo) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null> fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: ( onContextMenu: (
@@ -138,26 +140,40 @@ function useTableColumns(
} }
export function DirectoryContentTable({ export function DirectoryContentTable({
hiddenItems,
directoryUrlFn, directoryUrlFn,
onContextMenu, onContextMenu,
fileDragInfoAtom, fileDragInfoAtom,
onOpenFile, onOpenFile,
}: DirectoryContentTableProps) { }: DirectoryContentTableProps) {
const { directoryContent } = useContext(DirectoryPageContext) 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 } =
useInfiniteQuery(directoryContentQuery)
const store = useStore() const store = useStore()
const navigate = useNavigate() const navigate = useNavigate()
const table = useReactTable({ const table = useReactTable({
data: directoryContent || [], data: useMemo(
() => directoryContent?.pages.flatMap((page) => page.items) || [],
[directoryContent],
),
columns: useTableColumns(onOpenFile, directoryUrlFn), columns: useTableColumns(onOpenFile, directoryUrlFn),
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true, enableRowSelection: true,
enableGlobalFilter: true, enableGlobalFilter: true,
state: {
globalFilter: hiddenItems,
},
globalFilterFn: ( globalFilterFn: (
row, row,
_columnId, _columnId,
@@ -180,6 +196,10 @@ export function DirectoryContentTable({
[table.setRowSelection], [table.setRowSelection],
) )
if (isLoadingDirectoryContent) {
return <DirectoryContentTableSkeleton />
}
const handleRowContextMenu = ( const handleRowContextMenu = (
row: Row<DirectoryItem>, row: Row<DirectoryItem>,
_event: React.MouseEvent, _event: React.MouseEvent,

View File

@@ -32,3 +32,9 @@ if (import.meta.hot) {
// The hot module reloading API is not available in production. // The hot module reloading API is not available in production.
createRoot(elem).render(app) createRoot(elem).render(app)
} }
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query" import { useMutation, useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router" 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 { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { import {
ChevronDownIcon, ChevronDownIcon,
@@ -38,8 +39,10 @@ import { FilePreviewDialog } from "@/files/file-preview-dialog"
import { cutItemsAtom, inProgressFileUploadCountAtom } from "@/files/store" import { cutItemsAtom, inProgressFileUploadCountAtom } from "@/files/store"
import { UploadFileDialog } from "@/files/upload-file-dialog" import { UploadFileDialog } from "@/files/upload-file-dialog"
import type { FileDragInfo } from "@/files/use-file-drop" import type { FileDragInfo } from "@/files/use-file-drop"
import { formatError } from "@/lib/error"
import { import {
directoryContentQueryAtom, DIRECTORY_CONTENT_ORDER_BY,
DIRECTORY_CONTENT_ORDER_DIRECTION,
directoryInfoQueryAtom, directoryInfoQueryAtom,
moveToTrashMutationAtom, moveToTrashMutationAtom,
} from "@/vfs/api" } from "@/vfs/api"
@@ -49,11 +52,20 @@ import type {
DirectoryItem, DirectoryItem,
FileInfo, FileInfo,
} from "@/vfs/vfs" } from "@/vfs/vfs"
import { formatError } from "../../../lib/error"
const DirectoryContentPageParams = type({
orderBy: type
.valueOf(DIRECTORY_CONTENT_ORDER_BY)
.default(DIRECTORY_CONTENT_ORDER_BY.name),
direction: type
.valueOf(DIRECTORY_CONTENT_ORDER_DIRECTION)
.default(DIRECTORY_CONTENT_ORDER_DIRECTION.asc),
})
export const Route = createFileRoute( export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/directories/$directoryId", "/_authenticated/_sidebar-layout/directories/$directoryId",
)({ )({
validateSearch: DirectoryContentPageParams,
component: RouteComponent, component: RouteComponent,
}) })
@@ -87,37 +99,54 @@ const itemBeingRenamedAtom = atom<{
// MARK: page entry // MARK: page entry
function RouteComponent() { function RouteComponent() {
const { directoryId } = Route.useParams() const { directoryId } = Route.useParams()
const { const { data: directoryInfo, isLoading: isLoadingDirectoryInfo } = useQuery(
data: directoryInfo, useAtomValue(directoryInfoQueryAtom(directoryId)),
isLoading: isLoadingDirectoryInfo, )
error: directoryInfoError,
} = useQuery(useAtomValue(directoryInfoQueryAtom(directoryId))) const setOpenedFile = useSetAtom(openedFileAtom)
const { const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
data: directoryContent,
isLoading: isLoadingDirectoryContent,
error: directoryContentError,
} = useQuery(useAtomValue(directoryContentQueryAtom(directoryId)))
const directoryUrlById = useCallback( const directoryUrlById = useCallback(
(directoryId: string) => `/directories/${directoryId}`, (directoryId: string) => `/directories/${directoryId}`,
[], [],
) )
console.log({ directoryInfoError, directoryContentError }) const onTableOpenFile = useCallback(
(file: FileInfo) => {
setOpenedFile(file)
},
[setOpenedFile],
)
if (isLoadingDirectoryInfo || isLoadingDirectoryContent) { 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) {
return <DirectoryPageSkeleton /> return <DirectoryPageSkeleton />
} }
if (!directoryInfo || !directoryContent) { if (!directoryInfo) {
// TODO: handle empty state/error // TODO: handle empty state/error
return null return null
} }
return ( return (
<DirectoryPageContext <DirectoryPageContext value={{ directory: directoryInfo }}>
value={{ directory: directoryInfo, directoryContent }}
>
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full"> <header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<DirectoryPathBreadcrumb <DirectoryPathBreadcrumb
directory={directoryInfo} directory={directoryInfo}
@@ -134,7 +163,12 @@ 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"> <div className="w-full">
<_DirectoryContentTable /> <DirectoryContentTable
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={onTableOpenFile}
/>
</div> </div>
</DirectoryContentContextMenu> </DirectoryContentContextMenu>
@@ -191,46 +225,6 @@ function RouteComponent() {
) )
} }
// MARK: directory table
function _DirectoryContentTable() {
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
const setOpenedFile = useSetAtom(openedFileAtom)
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
const onTableOpenFile = (file: FileInfo) => {
setOpenedFile(file)
}
const directoryUrlFn = useCallback(
(directory: DirectoryInfo) => `/directories/${directory.id}`,
[],
)
const handleContextMenuRequest = (
row: Row<DirectoryItem>,
table: Table<DirectoryItem>,
) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
}
return (
<DirectoryContentTable
hiddenItems={optimisticDeletedItems}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={onTableOpenFile}
/>
)
}
// ================================== // ==================================
// MARK: ctx menu // MARK: ctx menu

View File

@@ -1,6 +1,5 @@
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router" import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useSetAtom } from "jotai"
import { GalleryVerticalEnd } from "lucide-react" import { GalleryVerticalEnd } from "lucide-react"
import { loginMutation } from "@/auth/api" import { loginMutation } from "@/auth/api"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -20,7 +19,6 @@ import {
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { currentAccountAtom } from "../account/account"
export const Route = createFileRoute("/login")({ export const Route = createFileRoute("/login")({
component: RouteComponent, component: RouteComponent,

View File

@@ -1,4 +1,10 @@
import { mutationOptions, queryOptions, skipToken } from "@tanstack/react-query" import {
type InfiniteData,
infiniteQueryOptions,
mutationOptions,
queryOptions,
skipToken,
} from "@tanstack/react-query"
import { type } from "arktype" import { type } from "arktype"
import { atom } from "jotai" import { atom } from "jotai"
import { atomFamily } from "jotai/utils" import { atomFamily } from "jotai/utils"
@@ -12,6 +18,11 @@ import {
FileInfo, FileInfo,
} from "./vfs" } from "./vfs"
const DirectoryContentResponse = type({
items: DirectoryContent,
"nextCursor?": "string",
})
/** /**
* This atom derives the file url for a given file. * This atom derives the file url for a given file.
* It is recommended to use {@link useFileUrl} instead of using this atom directly. * It is recommended to use {@link useFileUrl} instead of using this atom directly.
@@ -58,27 +69,63 @@ export const directoryInfoQueryAtom = atomFamily((directoryId: string) =>
}), }),
) )
export const directoryContentQueryAtom = atomFamily((directoryId: string) => export const DIRECTORY_CONTENT_ORDER_BY = {
name: "name",
createdAt: "createdAt",
updatedAt: "updatedAt",
} as const
export type DirectoryContentOrderBy =
(typeof DIRECTORY_CONTENT_ORDER_BY)[keyof typeof DIRECTORY_CONTENT_ORDER_BY]
export const DIRECTORY_CONTENT_ORDER_DIRECTION = {
asc: "asc",
desc: "desc",
} as const
type DirectoryContentOrderDirection =
(typeof DIRECTORY_CONTENT_ORDER_DIRECTION)[keyof typeof DIRECTORY_CONTENT_ORDER_DIRECTION]
type DirectoryContentQueryParams = {
directoryId: string
orderBy: DirectoryContentOrderBy
direction: DirectoryContentOrderDirection
limit: number
}
const directoryContentQueryKey = (
accountId: string | undefined,
directoryId: string,
) => ["accounts", accountId, "directories", directoryId, "content"]
export const directoryContentQueryAtom = atomFamily(
({ directoryId, orderBy, direction, limit }: DirectoryContentQueryParams) =>
atom((get) => { atom((get) => {
const account = get(currentAccountAtom) const account = get(currentAccountAtom)
return queryOptions({ return infiniteQueryOptions({
queryKey: [ queryKey: directoryContentQueryKey(account?.id, directoryId),
"accounts", initialPageParam: {
account?.id, orderBy,
"directories", direction,
directoryId, limit,
"content", cursor: "",
], },
queryFn: account queryFn: ({ pageParam }) =>
? () => account
fetchApi( ? fetchApi(
"GET", "GET",
`/accounts/${account.id}/directories/${directoryId}/content`, `/accounts/${account.id}/directories/${directoryId}/content?orderBy=${pageParam.orderBy}&dir=${pageParam.direction}&limit=${pageParam.limit}${pageParam.cursor ? `&cursor=${pageParam.cursor}` : ""}`,
{ returns: DirectoryContent }, { returns: DirectoryContentResponse },
).then(([_, result]) => result) ).then(([_, result]) => result)
: skipToken, : Promise.reject(new Error("No account selected")),
getNextPageParam: (lastPage, _pages, lastPageParam) => ({
...lastPageParam,
cursor: lastPage.nextCursor ?? "",
}),
}) })
}), }),
(paramsA, paramsB) =>
paramsA.directoryId === paramsB.directoryId &&
paramsA.orderBy === paramsB.orderBy &&
paramsA.direction === paramsB.direction &&
paramsA.limit === paramsB.limit,
) )
export const createDirectoryMutationAtom = atom((get) => { export const createDirectoryMutationAtom = atom((get) => {
@@ -103,13 +150,6 @@ export const createDirectoryMutationAtom = atom((get) => {
get(directoryInfoQueryAtom(data.id)).queryKey, get(directoryInfoQueryAtom(data.id)).queryKey,
data, data,
) )
const parent = data.path.at(-2)
if (parent) {
client.setQueryData(
get(directoryContentQueryAtom(parent.id)).queryKey,
(prev) => (prev ? [...prev, data] : [data]),
)
}
}, },
}) })
}) })
@@ -157,13 +197,18 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
return result return result
}, },
onMutate: ({ items }, { client }) => { onMutate: ({ items }, { client }) => {
const account = get(currentAccountAtom)
if (!account) {
return null
}
const movedItems = new Map<string, Set<string>>() const movedItems = new Map<string, Set<string>>()
for (const item of items) { for (const item of items) {
if (item.parentId) { if (item.parentId) {
const s = movedItems.get(item.parentId) const s = movedItems.get(item.parentId)
if (!s) { if (!s) {
movedItems.set(item.parentId, new Set(s)) movedItems.set(item.parentId, new Set([item.id]))
} else { } else {
s.add(item.id) s.add(item.id)
} }
@@ -172,46 +217,68 @@ export const moveDirectoryItemsMutationAtom = atom((get) =>
const prevDirContentMap = new Map< const prevDirContentMap = new Map<
string, string,
DirectoryItem[] | undefined InfiniteData<typeof DirectoryContentResponse.infer> | undefined
>() >()
movedItems.forEach((s, parentId) => { movedItems.forEach((s, parentId) => {
const query = get(directoryContentQueryAtom(parentId)) const key = directoryContentQueryKey(account.id, parentId)
const prevDirContent = client.getQueryData(query.queryKey) const prevDirContent =
client.setQueryData( client.getQueryData<
query.queryKey, InfiniteData<typeof DirectoryContentResponse.infer>
(prev) => prev?.filter((it) => !s.has(it.id)) ?? prev, >(key)
) client.setQueryData<
InfiniteData<typeof DirectoryContentResponse.infer>
>(key, (prev) => {
if (!prev) return prev
return {
...prev,
pages: prev.pages.map((page) => ({
...page,
items: page.items.filter((it) => !s.has(it.id)),
})),
}
})
prevDirContentMap.set(parentId, prevDirContent) prevDirContentMap.set(parentId, prevDirContent)
}) })
return { prevDirContentMap } return { prevDirContentMap }
}, },
onSuccess: (_data, { targetDirectory, items }, _result, { client }) => { onSuccess: (_data, { targetDirectory, items }, _result, { client }) => {
const account = get(currentAccountAtom)
if (!account) return
const dirId = const dirId =
typeof targetDirectory === "string" typeof targetDirectory === "string"
? targetDirectory ? targetDirectory
: targetDirectory.id : targetDirectory.id
client.invalidateQueries(get(directoryContentQueryAtom(dirId))) client.invalidateQueries({
queryKey: directoryContentQueryKey(account.id, dirId),
})
for (const item of items) { for (const item of items) {
if (item.parentId) { if (item.parentId) {
client.invalidateQueries( client.invalidateQueries({
get(directoryContentQueryAtom(item.parentId)), queryKey: directoryContentQueryKey(
) account.id,
item.parentId,
),
})
} }
} }
}, },
onError: (_error, _vars, context, { client }) => { onError: (_error, _vars, context, { client }) => {
if (context) { if (context) {
const account = get(currentAccountAtom)
if (account) {
context.prevDirContentMap.forEach( context.prevDirContentMap.forEach(
(prevDirContent, parentId) => { (prevDirContent, parentId) => {
client.setQueryData( client.setQueryData(
get(directoryContentQueryAtom(parentId)).queryKey, directoryContentQueryKey(account.id, parentId),
prevDirContent, prevDirContent,
) )
}, },
) )
} }
}
}, },
}), }),
) )
@@ -277,12 +344,17 @@ export const moveToTrashMutationAtom = atom((get) =>
return [...deletedFiles, ...deletedDirectories] return [...deletedFiles, ...deletedDirectories]
}, },
onMutate: (items, { client }) => { onMutate: (items, { client }) => {
const account = get(currentAccountAtom)
if (!account) {
return null
}
const trashedItems = new Map<string, Set<string>>() const trashedItems = new Map<string, Set<string>>()
for (const item of items) { for (const item of items) {
if (item.parentId) { if (item.parentId) {
const s = trashedItems.get(item.parentId) const s = trashedItems.get(item.parentId)
if (!s) { if (!s) {
trashedItems.set(item.parentId, new Set(s)) trashedItems.set(item.parentId, new Set([item.id]))
} else { } else {
s.add(item.id) s.add(item.id)
} }
@@ -291,39 +363,59 @@ export const moveToTrashMutationAtom = atom((get) =>
const prevDirContentMap = new Map< const prevDirContentMap = new Map<
string, string,
DirectoryItem[] | undefined InfiniteData<typeof DirectoryContentResponse.infer> | undefined
>() >()
trashedItems.forEach((s, parentId) => { trashedItems.forEach((s, parentId) => {
const query = get(directoryContentQueryAtom(parentId)) const key = directoryContentQueryKey(account.id, parentId)
const prevDirContent = client.getQueryData(query.queryKey) const prevDirContent =
client.setQueryData( client.getQueryData<
query.queryKey, InfiniteData<typeof DirectoryContentResponse.infer>
(prev) => prev?.filter((it) => !s.has(it.id)) ?? prev, >(key)
) client.setQueryData<
InfiniteData<typeof DirectoryContentResponse.infer>
>(key, (prev) => {
if (!prev) return prev
return {
...prev,
pages: prev.pages.map((page) => ({
...page,
items: page.items.filter((it) => !s.has(it.id)),
})),
}
})
prevDirContentMap.set(parentId, prevDirContent) prevDirContentMap.set(parentId, prevDirContent)
}) })
return { prevDirContentMap } return { prevDirContentMap }
}, },
onSuccess: (_data, items, _result, { client }) => { onSuccess: (_data, items, _result, { client }) => {
const account = get(currentAccountAtom)
if (account) {
for (const item of items) { for (const item of items) {
if (item.parentId) { if (item.parentId) {
client.invalidateQueries( client.invalidateQueries({
get(directoryContentQueryAtom(item.parentId)), queryKey: directoryContentQueryKey(
) account.id,
item.parentId,
),
})
}
} }
} }
}, },
onError: (_error, items, context, { client }) => { onError: (_error, _items, context, { client }) => {
if (context) { if (context) {
const account = get(currentAccountAtom)
if (account) {
context.prevDirContentMap.forEach( context.prevDirContentMap.forEach(
(prevDirContent, parentId) => { (prevDirContent, parentId) => {
client.setQueryData( client.setQueryData(
get(directoryContentQueryAtom(parentId)).queryKey, directoryContentQueryKey(account.id, parentId),
prevDirContent, prevDirContent,
) )
}, },
) )
} }
}
}, },
}), }),
) )

View File

@@ -1,11 +1,15 @@
import path from "node:path" import path from "node:path"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite" import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import { defineConfig } from "vite" import { defineConfig } from "vite"
export default defineConfig({ export default defineConfig({
plugins: [TanStackRouterVite(), react(), tailwindcss()], plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
react(),
tailwindcss(),
],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),