import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdownMenu"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scrollArea"; import { Folder, Prompt } from "@/lib/types/prompts"; import { cn } from "@/lib/utils"; import { DragDropProvider, useDraggable, useDroppable } from "@dnd-kit/react"; import { ChevronDown, ChevronRight, FileText, Folder as FolderIcon, FolderOpen, MoreHorizontal, Pencil, Plus, PlusIcon, Search, Trash2, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { usePromptContext } from "../context"; /** * Renders the prompt-manager sidebar including search, folder hierarchy, root prompts, and drag-and-drop reorganization. * * The sidebar supports creating, renaming, and deleting folders and prompts (when permitted), selecting prompts, auto-expanding the folder that contains the selected prompt, filtering by search query, and dragging prompts between folders or to the root. Visual drag-over feedback and permission gating for create/update/delete actions are applied. * * @returns The sidebar React element containing the search input, folder list, root prompt drop zone, and drag-and-drop provider. */ export function PromptSidebar() { const { folders, prompts, selectedPromptId, handleSelectPrompt: onSelectPrompt, setFolderSheet, setDeleteFolderDialog, setPromptSheet, setDeletePromptDialog, handleMovePrompt: onMovePrompt, canCreate, canUpdate, canDelete, } = usePromptContext(); const onCreateFolder = useCallback(() => setFolderSheet({ open: true }), [setFolderSheet]); const onEditFolder = useCallback((folder: Folder) => setFolderSheet({ open: true, folder }), [setFolderSheet]); const onDeleteFolder = useCallback((folder: Folder) => setDeleteFolderDialog({ open: true, folder }), [setDeleteFolderDialog]); const onCreatePrompt = useCallback((folderId?: string) => setPromptSheet({ open: true, folderId }), [setPromptSheet]); const onEditPrompt = useCallback((prompt: Prompt) => setPromptSheet({ open: true, prompt }), [setPromptSheet]); const onDeletePrompt = useCallback((prompt: Prompt) => setDeletePromptDialog({ open: true, prompt }), [setDeletePromptDialog]); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(""); const [dragOverTarget, setDragOverTarget] = useState(null); // Auto-expand the folder containing the selected prompt useEffect(() => { if (!selectedPromptId) return; const prompt = prompts.find((p) => p.id === selectedPromptId); if (prompt?.folder_id) { setExpandedFolders((prev) => { if (prev.has(prompt.folder_id!)) return prev; const next = new Set(prev); next.add(prompt.folder_id!); return next; }); } }, [selectedPromptId, prompts]); const toggleFolder = useCallback((folderId: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(folderId)) { next.delete(folderId); } else { next.add(folderId); } return next; }); }, []); // Group prompts by folder, root prompts have no folder_id const { promptsByFolder, rootPrompts } = useMemo(() => { const map = new Map(); const root: Prompt[] = []; for (const prompt of prompts) { if (!prompt.folder_id) { root.push(prompt); } else { const list = map.get(prompt.folder_id) || []; list.push(prompt); map.set(prompt.folder_id, list); } } return { promptsByFolder: map, rootPrompts: root }; }, [prompts]); // Filter folders and prompts based on search const filteredData = useMemo(() => { if (!searchQuery.trim()) { return { folders, promptsByFolder, rootPrompts }; } const query = searchQuery.toLowerCase(); const matchedFolderIds = new Set(); const filteredPromptsByFolder = new Map(); const filteredRootPrompts: Prompt[] = []; for (const prompt of prompts) { if (prompt.name.toLowerCase().includes(query)) { if (!prompt.folder_id) { filteredRootPrompts.push(prompt); } else { matchedFolderIds.add(prompt.folder_id); const list = filteredPromptsByFolder.get(prompt.folder_id) || []; list.push(prompt); filteredPromptsByFolder.set(prompt.folder_id, list); } } } const filteredFolders = folders.filter((folder) => folder.name.toLowerCase().includes(query) || matchedFolderIds.has(folder.id)); return { folders: filteredFolders, promptsByFolder: filteredPromptsByFolder, rootPrompts: filteredRootPrompts, }; }, [folders, prompts, promptsByFolder, rootPrompts, searchQuery]); // Prompt lookup for drag events const promptMap = useMemo(() => { const map = new Map(); for (const p of prompts) map.set(p.id, p); return map; }, [prompts]); return ( { if (!canUpdate) return; const targetId = event.operation.target?.id as string | undefined; setDragOverTarget(targetId ?? null); }} onDragEnd={(event) => { setDragOverTarget(null); if (!canUpdate) return; if (event.canceled || !onMovePrompt) return; const sourceId = event.operation.source?.id as string | undefined; const targetId = event.operation.target?.id as string | undefined; if (!sourceId || !targetId) return; const promptId = sourceId.startsWith("prompt-") ? sourceId.slice(7) : null; if (!promptId) return; const prompt = promptMap.get(promptId); if (!prompt) return; let targetFolderId: string | null = null; if (targetId === "root-drop-zone") { targetFolderId = null; } else if (targetId.startsWith("folder-")) { targetFolderId = targetId.slice(7); } else { return; } if ((prompt.folder_id ?? null) === targetFolderId) return; onMovePrompt(promptId, targetFolderId); }} >
{/* Search */}
setSearchQuery(e.target.value)} data-testid="sidebar-search" className="h-8 pl-8" />
{canCreate && ( { e.stopPropagation(); onCreatePrompt(); }} > New Prompt { e.stopPropagation(); onCreateFolder(); }} > New Folder )}
{filteredData.folders.length === 0 && filteredData.rootPrompts.length === 0 ? (
{searchQuery ? "No results found" : "No prompts yet"}
) : ( <> {filteredData.folders.map((folder) => ( toggleFolder(folder.id)} onSelectPrompt={onSelectPrompt} onEdit={() => onEditFolder(folder)} onDelete={() => onDeleteFolder(folder)} onCreatePrompt={() => onCreatePrompt(folder.id)} onEditPrompt={onEditPrompt} onDeletePrompt={onDeletePrompt} canCreate={canCreate} canUpdate={canUpdate} canDelete={canDelete} /> ))} )}
); } interface RootDropZoneProps { isDragOver: boolean; rootPrompts: Prompt[]; selectedPromptId?: string | null; onSelectPrompt: (promptId: string) => void; onEditPrompt: (prompt: Prompt) => void; onDeletePrompt: (prompt: Prompt) => void; canUpdate: boolean; canDelete: boolean; } /** * Renders the droppable root area that lists and hosts draggable root-level prompts. * * @param isDragOver - Whether a draggable item is currently over the root drop zone (applies drag-over styling). * @param rootPrompts - Array of prompts that belong at the root (no folder). * @param selectedPromptId - ID of the currently selected prompt, used to mark its item as selected. * @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected. * @param onEditPrompt - Callback invoked with a prompt when the prompt's edit action is triggered. * @param onDeletePrompt - Callback invoked with a prompt when the prompt's delete action is triggered. * @param canUpdate - Whether prompts are movable/editable (enables dragging). * @param canDelete - Whether prompts may be deleted (controls delete action visibility). * @returns The JSX element for the root drop zone containing draggable prompt items. */ function RootDropZone({ isDragOver, rootPrompts, selectedPromptId, onSelectPrompt, onEditPrompt, onDeletePrompt, canUpdate, canDelete, }: RootDropZoneProps) { const { ref } = useDroppable({ id: "root-drop-zone" }); return (
{rootPrompts.map((prompt) => ( onSelectPrompt(prompt.id)} onEdit={() => onEditPrompt(prompt)} onDelete={() => onDeletePrompt(prompt)} canUpdate={canUpdate} canDelete={canDelete} /> ))}
); } interface DroppableFolderProps { folder: Folder; prompts: Prompt[]; isExpanded: boolean; isDragOver: boolean; selectedPromptId?: string | null; onToggle: () => void; onSelectPrompt: (promptId: string) => void; onEdit: () => void; onDelete: () => void; onCreatePrompt: () => void; onEditPrompt: (prompt: Prompt) => void; onDeletePrompt: (prompt: Prompt) => void; canCreate: boolean; canUpdate: boolean; canDelete: boolean; } /** * Renders a droppable folder header with optional action menu and its list of prompts. * * @param folder - Folder metadata (id, name, etc.) * @param prompts - Prompts that belong to this folder * @param isExpanded - Whether the folder is expanded to show its prompts * @param isDragOver - Whether a draggable item is currently over this folder (affects visual state) * @param selectedPromptId - ID of the currently selected prompt, used to highlight an item * @param onToggle - Callback invoked to toggle the folder's expanded state * @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected * @param onEdit - Callback invoked to start editing the folder * @param onDelete - Callback invoked to start deleting the folder * @param onCreatePrompt - Callback invoked to create a new prompt inside this folder * @param onEditPrompt - Callback invoked with a prompt to start editing that prompt * @param onDeletePrompt - Callback invoked with a prompt to start deleting that prompt * @param canCreate - Whether the current user may create prompts in this folder * @param canUpdate - Whether the current user may move/rename prompts or edit the folder * @param canDelete - Whether the current user may delete prompts or the folder * @returns A JSX element containing the folder row and, when expanded, its nested prompt items */ function DroppableFolder({ folder, prompts, isExpanded, isDragOver, selectedPromptId, onToggle, onSelectPrompt, onEdit, onDelete, onCreatePrompt, onEditPrompt, onDeletePrompt, canCreate, canUpdate, canDelete, }: DroppableFolderProps) { const { ref } = useDroppable({ id: `folder-${folder.id}` }); const showActions = canCreate || canUpdate || canDelete; return (
{isExpanded ? ( ) : ( )} {folder.name} {prompts.length} {showActions && ( e.stopPropagation()} className="bg-card absolute top-1/2 right-2 -translate-y-1/2"> {canCreate && ( { e.stopPropagation(); onCreatePrompt(); }} > New Prompt )} {canCreate && (canUpdate || canDelete) && } {canUpdate && ( { e.stopPropagation(); onEdit(); }} > Edit Folder )} {canDelete && ( { e.stopPropagation(); onDelete(); }} > Delete Folder )} )}
{isExpanded && (
{prompts.length === 0 ? (
{isDragOver ? "Drop here" : "No prompts"}
) : ( prompts.map((prompt) => ( onSelectPrompt(prompt.id)} onEdit={() => onEditPrompt(prompt)} onDelete={() => onDeletePrompt(prompt)} canUpdate={canUpdate} canDelete={canDelete} /> )) )}
)}
); } interface DraggablePromptItemProps { prompt: Prompt; isSelected: boolean; onSelect: () => void; onEdit: () => void; onDelete: () => void; canUpdate: boolean; canDelete: boolean; } /** * Renders a draggable prompt list item that shows the prompt name, selection/drag states, and an actions menu when permitted. * * Displays a file icon and truncated prompt name, applies visual styles for selection and dragging, prevents selection while dragging, and exposes rename/delete actions via a dropdown when `canUpdate` or `canDelete` are true. * * @param prompt - The prompt object to render. * @param isSelected - Whether this prompt is currently selected; used for styling. * @param onSelect - Callback invoked when the item is clicked (not invoked if the item is being dragged). * @param onEdit - Callback invoked to start editing/renaming the prompt. * @param onDelete - Callback invoked to delete the prompt. * @param canUpdate - When true, enables dragging and shows the rename action. * @param canDelete - When true, shows the delete action. * @returns The rendered prompt item JSX element. */ function DraggablePromptItem({ prompt, isSelected, onSelect, onEdit, onDelete, canUpdate, canDelete }: DraggablePromptItemProps) { const { ref, isDragging } = useDraggable({ id: `prompt-${prompt.id}`, disabled: !canUpdate, }); const showActions = canUpdate || canDelete; return (
{ // Don't navigate if this was a drag if (isDragging) return; onSelect(); }} > {prompt.name} {showActions && ( e.stopPropagation()}> {canUpdate && ( { e.stopPropagation(); onEdit(); }} > Rename )} {canDelete && ( { e.stopPropagation(); onDelete(); }} > Delete )} )}
); }