/** * Etheris-style library sidebar — nav + folders (documents2-aligned UI). */ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MutableRefObject, type ReactNode, } from 'react' import type { RecordData } from 'deepspace' import { FileText, Folder, FolderPlus, LayoutGrid, PanelLeftClose, Pencil, Star, Trash2, Users, } from 'lucide-react' import type { DocFolderFields, LibraryNavSelection } from './documents-library-types' const SIDEBAR_COLLAPSED_KEY = 'deepspace-documents-library-sidebar-collapsed' export function readSidebarCollapsed(): boolean { if (typeof window === 'undefined') return false try { return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { return false } } export function writeSidebarCollapsed(collapsed: boolean): void { try { window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? '1' : '0') } catch { /* ignore */ } } function NavRow({ active, icon: Icon, label, onClick, testId, trailing, collapsed, }: { active: boolean icon: typeof FileText label: string onClick: () => void testId?: string trailing?: ReactNode collapsed?: boolean }) { return (
{!collapsed && trailing}
) } const FOLDER_RENAME_BLUR_DEFER_MS = 200 const FOLDER_RENAME_BLUR_GRACE_MS = 520 function SidebarFolderRenameField({ folderId, renameFolderValue, setRenameFolderValue, onCommitFolderRenameRef, onCancelRenameFolder, blurTimerRef, sessionStartRef, testId, }: { folderId: string renameFolderValue: string setRenameFolderValue: (v: string) => void onCommitFolderRenameRef: MutableRefObject<() => void | Promise> onCancelRenameFolder: () => void blurTimerRef: MutableRefObject | null> sessionStartRef: MutableRefObject testId: string }) { const inputRef = useRef(null) useLayoutEffect(() => { const t = typeof performance !== 'undefined' ? performance.now() : Date.now() sessionStartRef.current = t const el = inputRef.current if (!el) return const placeCaretAtEnd = () => { const len = el.value.length el.setSelectionRange(len, len) } el.focus({ preventScroll: true }) placeCaretAtEnd() const raf = requestAnimationFrame(() => { if (document.activeElement !== el) { el.focus({ preventScroll: true }) placeCaretAtEnd() } }) return () => cancelAnimationFrame(raf) // sessionStartRef is a stable ref (only written here); listing it is a no-op // but satisfies the rule without changing re-run behavior. }, [folderId, sessionStartRef]) return ( setRenameFolderValue(e.target.value)} onFocus={() => { if (blurTimerRef.current) { clearTimeout(blurTimerRef.current) blurTimerRef.current = null } }} onBlur={() => { if (blurTimerRef.current) clearTimeout(blurTimerRef.current) blurTimerRef.current = setTimeout(() => { blurTimerRef.current = null const t0 = sessionStartRef.current const now = typeof performance !== 'undefined' ? performance.now() : Date.now() if (now - t0 < FOLDER_RENAME_BLUR_GRACE_MS) return void onCommitFolderRenameRef.current() }, FOLDER_RENAME_BLUR_DEFER_MS) }} onKeyDown={(e) => { if (e.key === 'Enter') { if (blurTimerRef.current) { clearTimeout(blurTimerRef.current) blurTimerRef.current = null } void onCommitFolderRenameRef.current() } if (e.key === 'Escape') { if (blurTimerRef.current) { clearTimeout(blurTimerRef.current) blurTimerRef.current = null } onCancelRenameFolder() } }} data-testid={testId} className="min-w-0 flex-1 rounded-md border px-2 py-1.5 text-[13px] outline-none focus-visible:ring-2 focus-visible:ring-ring/30" style={{ borderColor: 'var(--documents-el-line)', backgroundColor: 'var(--documents-el-bg)', color: 'var(--documents-el-text)', }} /> ) } export interface LibrarySidebarProps { selection: LibraryNavSelection onSelect: (s: LibraryNavSelection) => void folders: RecordData[] canManageFolders: boolean collapsed: boolean onToggleCollapsed: () => void onCreateFolder: (name: string) => void | Promise onDeleteFolder: (folderId: string) => void | Promise onStartRenameFolder: (folder: RecordData) => void renamingFolderId: string | null renameFolderValue: string setRenameFolderValue: (v: string) => void onCommitRenameFolder: () => void | Promise onCancelRenameFolder: () => void } export function LibrarySidebar({ selection, onSelect, folders, canManageFolders, collapsed, onToggleCollapsed, onCreateFolder, onDeleteFolder, onStartRenameFolder, renamingFolderId, renameFolderValue, setRenameFolderValue, onCommitRenameFolder, onCancelRenameFolder, }: LibrarySidebarProps) { const [addingFolder, setAddingFolder] = useState(false) const [newFolderName, setNewFolderName] = useState('') const folderRenameBlurTimerRef = useRef | null>(null) const folderRenameSessionStartRef = useRef(0) const onCommitFolderRenameRef = useRef(onCommitRenameFolder) onCommitFolderRenameRef.current = onCommitRenameFolder const clearPendingFolderRename = useCallback(() => { if (folderRenameBlurTimerRef.current) clearTimeout(folderRenameBlurTimerRef.current) }, []) useEffect(() => { return clearPendingFolderRename }, [clearPendingFolderRename]) const submitNewFolder = useCallback(async () => { const name = newFolderName.trim() if (!name) { setAddingFolder(false) setNewFolderName('') return } await onCreateFolder(name) setNewFolderName('') setAddingFolder(false) }, [newFolderName, onCreateFolder]) const sortedFolders = [...folders].sort((a, b) => (a.data.name ?? '').localeCompare(b.data.name ?? ''), ) return ( ) }