"use client"; import { ArrowDown, ArrowUp, Bookmark, FileText, Folder, FolderInput, FolderOpen, type LucideIcon, MoreHorizontal, Pencil, Undo2, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type DragEvent, type KeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger, } from "@/components/motion/popover-morph"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { useTouchCapable } from "@/lib/hooks/use-touch-capable"; import { cn } from "@/lib/utils"; export type SidebarResourceKind = | "folder" | "project" | "file" | "bookmark"; export interface SidebarResource { id: string; label: string; kind: SidebarResourceKind; children?: SidebarResource[]; disabled?: boolean; } export type SidebarResourceDropPosition = "before" | "inside" | "after"; export interface SidebarResourceMove { itemId: string; targetId: string | null; position: SidebarResourceDropPosition; } /** * The moves this row can make right now, the same four the keyboard offers on * `Alt+Shift+Arrow`. A pointer drag is the fast path for them; a finger has no * drag to give, so the row menu carries them too. Absent keys are moves this * row cannot make from where it sits. */ export interface SidebarResourceMoveCommands { up?: () => void; down?: () => void; into?: { label: string; run: () => void }; out?: () => void; } export interface SidebarResourceMenuControls { close: () => void; rename: () => void; moves: SidebarResourceMoveCommands; } export interface AISidebarProps { items?: SidebarResource[]; defaultItems?: SidebarResource[]; onItemsChange?: (items: SidebarResource[]) => void; /** Reject the promise to roll the optimistic move back. */ onMove?: (move: SidebarResourceMove) => void | Promise; onMoveError?: (error: unknown, move: SidebarResourceMove) => void; onRename?: (item: SidebarResource, label: string) => void | Promise; activeId?: string | null; defaultActiveId?: string | null; onActiveChange?: (id: string) => void; defaultExpandedIds?: string[]; renderIcon?: (item: SidebarResource) => ReactNode; renderMenu?: ( item: SidebarResource, controls: SidebarResourceMenuControls, ) => ReactNode; ariaLabel?: string; className?: string; } interface FlatResource { item: SidebarResource; depth: number; parentId: string | null; } interface DropTarget { id: string | null; position: SidebarResourceDropPosition; } const ROW_REVEAL = { duration: 0.16, ease: EASE_OUT, } as const; function canContain(item: SidebarResource) { return item.kind === "folder" || item.kind === "project"; } function flattenResources( items: SidebarResource[], expanded: Set, depth = 0, parentId: string | null = null, ): FlatResource[] { return items.flatMap((item) => { const row = { item, depth, parentId }; if (!item.children?.length || !expanded.has(item.id)) return [row]; return [ row, ...flattenResources(item.children, expanded, depth + 1, item.id), ]; }); } function findResource( items: SidebarResource[], id: string, ): SidebarResource | undefined { for (const item of items) { if (item.id === id) return item; const child = item.children ? findResource(item.children, id) : undefined; if (child) return child; } } function containsResource(item: SidebarResource, id: string): boolean { return ( item.id === id || item.children?.some((child) => containsResource(child, id)) === true ); } function removeResource( items: SidebarResource[], id: string, ): { items: SidebarResource[]; removed?: SidebarResource } { let removed: SidebarResource | undefined; const next: SidebarResource[] = []; for (const item of items) { if (item.id === id) { removed = item; continue; } if (item.children?.length) { const childResult = removeResource(item.children, id); if (childResult.removed) { removed = childResult.removed; next.push({ ...item, children: childResult.items }); continue; } } next.push(item); } return { items: next, removed }; } function insertResource( items: SidebarResource[], resource: SidebarResource, targetId: string | null, position: SidebarResourceDropPosition, ): SidebarResource[] { if (targetId === null) return [...items, resource]; const next: SidebarResource[] = []; for (const item of items) { if (item.id === targetId) { if (position === "before") next.push(resource, item); else if (position === "after") next.push(item, resource); else next.push({ ...item, children: [...(item.children ?? []), resource] }); continue; } if (item.children?.length) { next.push({ ...item, children: insertResource(item.children, resource, targetId, position), }); } else { next.push(item); } } return next; } function moveResource( items: SidebarResource[], move: SidebarResourceMove, ): SidebarResource[] | null { const source = findResource(items, move.itemId); if (!source || source.disabled) return null; if (move.targetId && containsResource(source, move.targetId)) return null; const target = move.targetId ? findResource(items, move.targetId) : undefined; if ( move.position === "inside" && (!target || target.disabled || !canContain(target)) ) return null; const removed = removeResource(items, move.itemId); if (!removed.removed) return null; return insertResource( removed.items, removed.removed, move.targetId, move.position, ); } function renameResource( items: SidebarResource[], id: string, label: string, ): SidebarResource[] { return items.map((item) => ({ ...item, label: item.id === id ? label : item.label, children: item.children ? renameResource(item.children, id, label) : undefined, })); } function defaultIcon(item: SidebarResource, expanded: boolean) { const Icon = item.kind === "folder" || item.kind === "project" ? expanded ? FolderOpen : Folder : item.kind === "bookmark" ? Bookmark : FileText; return ; } function MarqueeLabel({ active, children }: { active: boolean; children: string }) { const reduce = useReducedMotion() ?? false; const viewportRef = useRef(null); const labelRef = useRef(null); const [distance, setDistance] = useState(0); useEffect(() => { const measure = () => { const viewport = viewportRef.current; const label = labelRef.current; if (!viewport || !label) return; setDistance(label.scrollWidth > viewport.clientWidth ? label.scrollWidth + 24 : 0); }; measure(); const observer = new ResizeObserver(measure); if (viewportRef.current) observer.observe(viewportRef.current); if (labelRef.current) observer.observe(labelRef.current); return () => observer.disconnect(); }, []); const running = active && distance > 0 && !reduce; return ( {children} {running ? : null} ); } function ResourceMenuAction({ icon: Icon, onSelect, children, }: { icon: LucideIcon; onSelect: () => void; children: ReactNode; }) { return ( ); } interface ResourceRowProps { row: FlatResource; active: boolean; expanded: boolean; focused: boolean; draggingId: string | null; dropTarget: DropTarget | null; menuOpen: boolean; moves: SidebarResourceMoveCommands; renaming: boolean; onDragEnd: () => void; onDragOver: (event: DragEvent, row: FlatResource) => void; onDragStart: (event: DragEvent, id: string) => void; onDrop: (event: DragEvent) => void; onFocus: () => void; onKeyDown: (event: KeyboardEvent) => void; onMenuOpenChange: (open: boolean) => void; onRenameCancel: () => void; onRenameCommit: (label: string) => void; onRenameStart: () => void; onSelect: () => void; onToggle: () => void; renderIcon?: (item: SidebarResource) => ReactNode; renderMenu?: AISidebarProps["renderMenu"]; setRef: (node: HTMLDivElement | null) => void; } function ResourceRow({ row, active, expanded, focused, draggingId, dropTarget, menuOpen, moves, renaming, onDragEnd, onDragOver, onDragStart, onDrop, onFocus, onKeyDown, onMenuOpenChange, onRenameCancel, onRenameCommit, onRenameStart, onSelect, onToggle, renderIcon, renderMenu, setRef, }: ResourceRowProps) { const reduce = useReducedMotion() ?? false; const canTouch = useTouchCapable(); const [hovered, setHovered] = useState(false); const inputRef = useRef(null); const skipRenameBlurRef = useRef(false); const draggedRef = useRef(false); const [draft, setDraft] = useState(row.item.label); const acceptsChildren = canContain(row.item); const isDragging = draggingId === row.item.id; const dropPosition = dropTarget?.id === row.item.id ? dropTarget.position : null; useEffect(() => { if (!renaming) return; skipRenameBlurRef.current = false; setDraft(row.item.label); requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); }); }, [renaming, row.item.label]); const runFromMenu = (action: () => void) => () => { onMenuOpenChange(false); action(); }; const menu = renderMenu?.(row.item, { close: () => onMenuOpenChange(false), rename: () => { onMenuOpenChange(false); onRenameStart(); }, moves, }) ?? ( <> Rename {moves.up || moves.down || moves.into || moves.out ? (