/**
* 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 (
)
}