"use client"; import { ChevronDown, ChevronRight, Eye, EyeOff, Folder, FolderOpen, FolderPlus, MoreHorizontal, Move, PenLine, RefreshCw, Trash2, } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { FolderSelectionState } from "./FolderTreeView"; export const DND_TYPES = { FOLDER: "FOLDER", DOCUMENT: "DOCUMENT", } as const; type DropZone = "top" | "middle" | "bottom"; export interface FolderDisplay { id: number; name: string; position: string; parentId: number | null; searchSpaceId: number; } interface FolderNodeProps { folder: FolderDisplay; depth: number; isExpanded: boolean; isRenaming: boolean; childCount: number; selectionState: FolderSelectionState; onToggleSelect: (folderId: number, selectAll: boolean) => void; onToggleExpand: (folderId: number) => void; onRename: (folder: FolderDisplay, newName: string) => void; onStartRename: (folderId: number) => void; onCancelRename: () => void; onDelete: (folder: FolderDisplay) => void; onMove: (folder: FolderDisplay) => void; onCreateSubfolder: (parentId: number) => void; onDropIntoFolder?: ( itemType: "folder" | "document", itemId: number, targetFolderId: number ) => void; onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void; siblingPositions?: { before: string | null; after: string | null }; disabledDropIds?: Set; contextMenuOpen?: boolean; onContextMenuOpenChange?: (open: boolean) => void; isWatched?: boolean; onRescan?: (folder: FolderDisplay) => void; onStopWatching?: (folder: FolderDisplay) => void; onViewMetadata?: (folder: FolderDisplay) => void; } function getDropZone( monitor: { getClientOffset: () => { y: number } | null }, element: HTMLElement ): DropZone { const offset = monitor.getClientOffset(); if (!offset) return "middle"; const rect = element.getBoundingClientRect(); const y = offset.y - rect.top; const pct = y / rect.height; if (pct < 0.25) return "top"; if (pct > 0.75) return "bottom"; return "middle"; } export const FolderNode = React.memo(function FolderNode({ folder, depth, isExpanded, isRenaming, childCount, selectionState, onToggleSelect, onToggleExpand, onRename, onStartRename, onCancelRename, onDelete, onMove, onCreateSubfolder, onDropIntoFolder, onReorderFolder, siblingPositions, disabledDropIds, contextMenuOpen, onContextMenuOpenChange, isWatched, onRescan, onStopWatching, onViewMetadata, }: FolderNodeProps) { const [renameValue, setRenameValue] = useState(folder.name); const inputRef = useRef(null); const rowRef = useRef(null); const [dropZone, setDropZone] = useState(null); const [{ isDragging }, drag] = useDrag( () => ({ type: DND_TYPES.FOLDER, item: { id: folder.id, position: folder.position, parentId: folder.parentId }, collect: (monitor) => ({ isDragging: monitor.isDragging() }), }), [folder.id, folder.position, folder.parentId] ); const [{ isOver, canDrop }, drop] = useDrop( () => ({ accept: [DND_TYPES.FOLDER, DND_TYPES.DOCUMENT], canDrop: (item: { id: number }) => { if (item.id === folder.id) return false; if (disabledDropIds?.has(item.id)) return false; return true; }, hover: (_item, monitor) => { if (!rowRef.current || !monitor.isOver({ shallow: true })) { setDropZone(null); return; } setDropZone(getDropZone(monitor, rowRef.current)); }, drop: (item: { id: number }, monitor) => { if (!rowRef.current) return; const zone = getDropZone(monitor, rowRef.current); const type = monitor.getItemType(); if (zone === "middle") { if (type === DND_TYPES.FOLDER) { onDropIntoFolder?.("folder", item.id, folder.id); } else { onDropIntoFolder?.("document", item.id, folder.id); } } else if (type === DND_TYPES.FOLDER && onReorderFolder && siblingPositions) { if (zone === "top") { onReorderFolder(item.id, siblingPositions.before, folder.position); } else { onReorderFolder(item.id, folder.position, siblingPositions.after); } } setDropZone(null); }, collect: (monitor) => ({ isOver: monitor.isOver({ shallow: true }), canDrop: monitor.canDrop(), }), }), [ folder.id, folder.position, disabledDropIds, onDropIntoFolder, onReorderFolder, siblingPositions, ] ); useEffect(() => { if (!isOver) setDropZone(null); }, [isOver]); const attachRef = useCallback( (node: HTMLDivElement | null) => { rowRef.current = node; drag(drop(node)); }, [drag, drop] ); useEffect(() => { if (isRenaming && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [isRenaming]); const handleRenameSubmit = useCallback(() => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== folder.name) { onRename(folder, trimmed); } onCancelRename(); }, [renameValue, folder, onRename, onCancelRename]); const handleRenameKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleRenameSubmit(); } else if (e.key === "Escape") { e.preventDefault(); setRenameValue(folder.name); onCancelRename(); } }, [handleRenameSubmit, folder.name, onCancelRename] ); const startRename = useCallback(() => { setRenameValue(folder.name); onStartRename(folder.id); }, [folder, onStartRename]); const handleCheckChange = useCallback(() => { onToggleSelect(folder.id, selectionState !== "all"); }, [folder.id, selectionState, onToggleSelect]); const FolderIcon = isExpanded ? FolderOpen : Folder; return ( {/* biome-ignore lint/a11y/useSemanticElements: div required for drag/drop refs */}
{ if ((e.ctrlKey || e.metaKey) && onViewMetadata) { e.preventDefault(); e.stopPropagation(); onViewMetadata(folder); return; } onToggleExpand(folder.id); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggleExpand(folder.id); } }} onDoubleClick={(e) => { e.stopPropagation(); startRename(); }} > {isExpanded ? ( ) : ( )} e.stopPropagation()} className="h-3.5 w-3.5 shrink-0" /> {isRenaming ? ( setRenameValue(e.target.value)} onBlur={handleRenameSubmit} onKeyDown={handleRenameKeyDown} onClick={(e) => e.stopPropagation()} placeholder="Enter folder name" className="flex-1 min-w-0 bg-transparent px-1 py-0.5 text-sm outline-none caret-primary placeholder:text-muted-foreground/50" /> ) : ( {folder.name} )} {!isRenaming && childCount > 0 && ( {childCount} )} {!isRenaming && ( {isWatched && onRescan && ( { e.stopPropagation(); onRescan(folder); }} > Re-scan )} {isWatched && onStopWatching && ( { e.stopPropagation(); onStopWatching(folder); }} > Stop watching )} { e.stopPropagation(); onCreateSubfolder(folder.id); }} > New subfolder { e.stopPropagation(); startRename(); }} > Rename { e.stopPropagation(); onMove(folder); }} > Move to... { e.stopPropagation(); onDelete(folder); }} > Delete )}
{!isRenaming && contextMenuOpen && ( {isWatched && onRescan && ( onRescan(folder)}> Re-scan )} {isWatched && onStopWatching && ( onStopWatching(folder)}> Stop watching )} onCreateSubfolder(folder.id)}> New subfolder startRename()}> Rename onMove(folder)}> Move to... onDelete(folder)} > Delete )}
); });