Download src/components/Sidebar/TreeView.tsx from algorembrant/rem-notepad: direct link, hf CLI and curl.
- Browser
- Download file 14.8 kB
-
https://huggingface.co/algorembrant/rem-notepad/resolve/main/src/components/Sidebar/TreeView.tsx
- Command line
-
hf download hf://algorembrant/rem-notepad/src/components/Sidebar/TreeView.tsx
-
curl -L -o TreeView.tsx https://huggingface.co/algorembrant/rem-notepad/resolve/main/src/components/Sidebar/TreeView.tsx
14.8 kB
| import { useFileStore } from '../../stores/fileStore'; | |
| import { FileNode } from '../../lib/db'; | |
| import { useState, useMemo, useEffect, useRef } from 'react'; | |
| import { | |
| Folder01Icon, | |
| File01Icon, | |
| MoreVerticalIcon, | |
| FolderAddIcon, | |
| FileAddIcon, | |
| ArrowDown01Icon, | |
| ArrowRight01Icon, | |
| DragDropVerticalIcon, | |
| Copy01Icon | |
| } from 'hugeicons-react'; | |
| import * as HugeIcons from 'hugeicons-react'; | |
| import IconPicker from './IconPicker'; | |
| import { | |
| DndContext, | |
| closestCenter, | |
| KeyboardSensor, | |
| PointerSensor, | |
| useSensor, | |
| useSensors, | |
| DragEndEvent, | |
| } from '@dnd-kit/core'; | |
| import { | |
| SortableContext, | |
| sortableKeyboardCoordinates, | |
| verticalListSortingStrategy, | |
| useSortable, | |
| } from '@dnd-kit/sortable'; | |
| import { CSS } from '@dnd-kit/utilities'; | |
| export default function TreeView() { | |
| const { files, loadFiles, createNode, reorderNodes, moveNode, copySelectedContents } = useFileStore(); | |
| const sensors = useSensors( | |
| useSensor(PointerSensor), | |
| useSensor(KeyboardSensor, { | |
| coordinateGetter: sortableKeyboardCoordinates, | |
| }) | |
| ); | |
| // Pre-calculate tree structure for O(N) rendering instead of O(N^2) | |
| const rootNodes = useMemo(() => files.filter(f => !f.parentId).sort((a,b) => a.orderIndex - b.orderIndex), [files]); | |
| const childrenMap = useMemo(() => { | |
| const map: Record<string, FileNode[]> = {}; | |
| files.forEach(f => { | |
| if (f.parentId) { | |
| if (!map[f.parentId]) map[f.parentId] = []; | |
| map[f.parentId].push(f); | |
| } | |
| }); | |
| // Sort each child group | |
| Object.keys(map).forEach(key => { | |
| map[key].sort((a,b) => a.orderIndex - b.orderIndex); | |
| }); | |
| return map; | |
| }, [files]); | |
| const handleDragEnd = async (event: DragEndEvent) => { | |
| const { active, over } = event; | |
| if (!over || active.id === over.id) return; | |
| if (over.id.toString().startsWith('empty-')) { | |
| const targetId = over.id.toString().replace('empty-', ''); | |
| let currentId: string | null = targetId; | |
| while (currentId) { | |
| if (currentId === active.id) return; | |
| const p = files.find(f => f.id === currentId); | |
| currentId = p?.parentId || null; | |
| } | |
| await moveNode(active.id.toString(), targetId); | |
| await loadFiles(); | |
| return; | |
| } | |
| const overNode = files.find(f => f.id === over.id); | |
| if (!overNode) return; | |
| let targetParentId = overNode.parentId || null; | |
| let currentId = targetParentId; | |
| while (currentId) { | |
| if (currentId === active.id) return; | |
| const p = files.find(f => f.id === currentId); | |
| currentId = p?.parentId || null; | |
| } | |
| await reorderNodes(active.id.toString(), over.id.toString(), targetParentId); | |
| await loadFiles(); | |
| }; | |
| const handleCopyAll = () => { | |
| const text = copySelectedContents(); | |
| if (text) { | |
| navigator.clipboard.writeText(text); | |
| } | |
| }; | |
| const handleAdd = async (type: 'file' | 'folder') => { | |
| await createNode(type === 'file' ? 'New file' : 'New folder', type, null); | |
| await loadFiles(); | |
| }; | |
| return ( | |
| <div className="tree-view" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> | |
| <div className="tree-actions" style={{ | |
| padding: '12px 16px', | |
| display: 'flex', | |
| justifyContent: 'space-between', | |
| alignItems: 'center', | |
| borderBottom: '1px solid rgba(0,0,0,0.03)' | |
| }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> | |
| <span style={{ fontSize: '10px', fontWeight: 700, opacity: 0.3, textTransform: 'uppercase' }}>Structure</span> | |
| <button | |
| className="btn-icon-sm" | |
| onClick={handleCopyAll} | |
| title="Copy all selected contents" | |
| style={{ opacity: 0.5, cursor: 'pointer', border: 'none', background: 'transparent', padding: 0 }} | |
| > | |
| <Copy01Icon size={12} /> | |
| </button> | |
| </div> | |
| <div style={{ display: 'flex', gap: '4px' }}> | |
| <button className="btn-icon-sm" onClick={() => handleAdd('file')} title="Add File"> | |
| <FileAddIcon size={14} /> | |
| </button> | |
| <button className="btn-icon-sm" onClick={() => handleAdd('folder')} title="Add Folder"> | |
| <FolderAddIcon size={14} /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="tree-scrollable" style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}> | |
| <DndContext | |
| sensors={sensors} | |
| collisionDetection={closestCenter} | |
| onDragEnd={handleDragEnd} | |
| > | |
| <SortableContext | |
| items={rootNodes.map(n => n.id)} | |
| strategy={verticalListSortingStrategy} | |
| > | |
| {rootNodes.map(node => ( | |
| <SidebarItem | |
| key={node.id} | |
| node={node} | |
| level={0} | |
| childrenMap={childrenMap} | |
| /> | |
| ))} | |
| </SortableContext> | |
| </DndContext> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function SidebarItem({ | |
| node, | |
| level, | |
| childrenMap | |
| }: { | |
| node: FileNode; | |
| level: number; | |
| childrenMap: Record<string, FileNode[]> | |
| }) { | |
| const { | |
| activeFileId, selectedIds, | |
| toggleSelect, renameNode, deleteNode, duplicateNode, createNode, loadFiles, setNodeIcon, setNodeColor, copyNodeContents | |
| } = useFileStore(); | |
| const [isExpanded, setIsExpanded] = useState(true); | |
| const [isEditing, setIsEditing] = useState(false); | |
| const [editName, setEditName] = useState(node.name); | |
| const [showMenu, setShowMenu] = useState(false); | |
| const [showIconPicker, setShowIconPicker] = useState(false); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const { | |
| attributes, | |
| listeners, | |
| setNodeRef, | |
| transform, | |
| transition, | |
| isDragging | |
| } = useSortable({ id: node.id }); | |
| const style = { | |
| transform: CSS.Transform.toString(transform), | |
| transition, | |
| opacity: isDragging ? 0.5 : 1, | |
| }; | |
| useEffect(() => { | |
| if (!showMenu && !showIconPicker) return; | |
| const handleEvents = (e: any) => { | |
| // Close on Escape | |
| if (e.key === 'Escape') { | |
| setShowMenu(false); | |
| setShowIconPicker(false); | |
| } | |
| // Close on Outside Click | |
| if (e.type === 'mousedown' && containerRef.current && !containerRef.current.contains(e.target as Node)) { | |
| setShowMenu(false); | |
| setShowIconPicker(false); | |
| } | |
| }; | |
| document.addEventListener('keydown', handleEvents); | |
| document.addEventListener('mousedown', handleEvents); | |
| return () => { | |
| document.removeEventListener('keydown', handleEvents); | |
| document.removeEventListener('mousedown', handleEvents); | |
| }; | |
| }, [showMenu, showIconPicker]); | |
| const children = childrenMap[node.id] || []; | |
| const isSelected = selectedIds.includes(node.id); | |
| const isActive = activeFileId === node.id; | |
| const handleClick = (e: any) => { | |
| e.stopPropagation(); | |
| if (node.type === 'folder') { | |
| setIsExpanded(!isExpanded); | |
| } | |
| toggleSelect(node.id, e.ctrlKey || e.metaKey || e.shiftKey); | |
| }; | |
| const handleRename = async () => { | |
| if (editName.trim() !== '' && editName !== node.name) { | |
| await renameNode(node.id, editName); | |
| } | |
| setIsEditing(false); | |
| }; | |
| const handleMenuAction = async (action: string, e: any) => { | |
| e.stopPropagation(); | |
| setShowMenu(false); | |
| switch (action) { | |
| case 'addFile': | |
| setIsExpanded(true); | |
| await createNode('New file', 'file', node.id); | |
| break; | |
| case 'addFolder': | |
| setIsExpanded(true); | |
| await createNode('New folder', 'folder', node.id); | |
| break; | |
| case 'rename': | |
| setIsEditing(true); | |
| break; | |
| case 'duplicate': | |
| await duplicateNode(node.id); | |
| break; | |
| case 'delete': | |
| await deleteNode(node.id); | |
| break; | |
| case 'changeIcon': | |
| setShowIconPicker(true); | |
| break; | |
| case 'copyContents': | |
| const text = copyNodeContents(node.id); | |
| navigator.clipboard.writeText(text); | |
| break; | |
| } | |
| await loadFiles(); | |
| }; | |
| return ( | |
| <div | |
| ref={(nodeEl) => { | |
| setNodeRef(nodeEl); | |
| (containerRef as any).current = nodeEl; | |
| }} | |
| className="tree-item-wrapper" | |
| style={{ ...style, display: 'flex', flexDirection: 'column' }} | |
| {...attributes} | |
| > | |
| <div | |
| className={`tree-item ${isSelected ? 'selected' : ''} ${isActive ? 'active' : ''}`} | |
| style={{ | |
| paddingLeft: `${16 + level * 12}px`, | |
| display: 'flex', | |
| alignItems: 'center', | |
| paddingTop: '4px', | |
| paddingBottom: '4px', | |
| paddingRight: '12px', | |
| cursor: 'pointer', | |
| position: 'relative', | |
| transition: 'all 0.2s ease', | |
| backgroundColor: isActive ? 'rgba(0,0,0,0.04)' : isSelected ? 'rgba(0,0,0,0.02)' : 'transparent', | |
| borderLeft: isActive ? '2px solid var(--accent-color)' : '2px solid transparent', | |
| }} | |
| onClick={handleClick} | |
| > | |
| <div | |
| className="drag-handle" | |
| {...listeners} | |
| style={{ cursor: 'grab', opacity: 0.2, marginRight: 4, display: 'flex' }} | |
| > | |
| <DragDropVerticalIcon size={14} /> | |
| </div> | |
| {node.type === 'folder' && ( | |
| <div style={{ marginRight: 4, display: 'flex', opacity: 0.4 }}> | |
| {isExpanded ? <ArrowDown01Icon size={12} /> : <ArrowRight01Icon size={12} />} | |
| </div> | |
| )} | |
| <div style={{ marginRight: 8, display: 'flex', color: node.color ? node.color : (isActive ? 'var(--text-primary)' : 'var(--text-secondary)') }}> | |
| {node.icon && (HugeIcons as any)[node.icon] ? ( | |
| (() => { | |
| const CustomIcon = (HugeIcons as any)[node.icon]; | |
| return <CustomIcon size={16} />; | |
| })() | |
| ) : ( | |
| node.type === 'folder' ? <Folder01Icon size={16} /> : <File01Icon size={16} /> | |
| )} | |
| </div> | |
| {isEditing ? ( | |
| <input | |
| autoFocus | |
| value={editName} | |
| onChange={e => setEditName(e.target.value)} | |
| onBlur={handleRename} | |
| onKeyDown={e => { if (e.key === 'Enter') handleRename(); }} | |
| style={{ | |
| border: 'none', | |
| outline: 'none', | |
| background: 'rgba(0,0,0,0.03)', | |
| flex: 1, | |
| fontFamily: 'inherit', | |
| fontSize: '13px', | |
| padding: '2px 4px', | |
| borderRadius: '2px' | |
| }} | |
| /> | |
| ) : ( | |
| <span style={{ | |
| flex: 1, | |
| fontSize: '13px', | |
| userSelect: 'none', | |
| whiteSpace: 'nowrap', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| opacity: isActive ? 1 : 0.8 | |
| }}> | |
| {node.name} | |
| </span> | |
| )} | |
| <div className="item-actions" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> | |
| <button | |
| className="more-btn" | |
| onClick={(e: any) => { e.stopPropagation(); setShowMenu(!showMenu); }} | |
| style={{ padding: '4px', opacity: 0, transition: 'opacity 0.2s ease' }} | |
| > | |
| <MoreVerticalIcon size={14} /> | |
| </button> | |
| </div> | |
| {showMenu && ( | |
| <div className="context-menu" style={{ | |
| position: 'absolute', right: 8, top: 28, zIndex: 100, | |
| background: 'var(--bg-panel)', border: '1px solid var(--border-color)', | |
| borderRadius: '6px', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', | |
| display: 'flex', flexDirection: 'column', minWidth: 140, padding: 4 | |
| }}> | |
| {node.type === 'folder' && <MenuButton onClick={(e: any) => handleMenuAction('addFile', e)}>New File</MenuButton>} | |
| {node.type === 'folder' && <MenuButton onClick={(e: any) => handleMenuAction('addFolder', e)}>New Folder</MenuButton>} | |
| <MenuButton onClick={(e: any) => handleMenuAction('rename', e)}>Rename</MenuButton> | |
| <MenuButton onClick={(e: any) => handleMenuAction('changeIcon', e)}>Change Icon</MenuButton> | |
| <MenuButton onClick={(e: any) => handleMenuAction('duplicate', e)}>Duplicate</MenuButton> | |
| <MenuButton onClick={(e: any) => handleMenuAction('copyContents', e)}>Copy Contents</MenuButton> | |
| <MenuButton onClick={(e: any) => handleMenuAction('delete', e)} danger>Delete</MenuButton> | |
| </div> | |
| )} | |
| </div> | |
| {node.type === 'folder' && isExpanded && children.length > 0 && ( | |
| <div className="tree-children" style={{ borderLeft: '1px solid rgba(0,0,0,0.03)', marginLeft: `${18 + level * 12}px` }}> | |
| <SortableContext | |
| items={children.map(c => c.id)} | |
| strategy={verticalListSortingStrategy} | |
| > | |
| {children.map(child => ( | |
| <SidebarItem | |
| key={child.id} | |
| node={child} | |
| level={level + 1} | |
| childrenMap={childrenMap} | |
| /> | |
| ))} | |
| </SortableContext> | |
| </div> | |
| )} | |
| {node.type === 'folder' && isExpanded && children.length === 0 && ( | |
| <div className="tree-children" style={{ borderLeft: '1px solid rgba(0,0,0,0.03)', marginLeft: `${18 + level * 12}px` }}> | |
| <SortableContext | |
| items={[`empty-${node.id}`]} | |
| strategy={verticalListSortingStrategy} | |
| > | |
| <EmptyDropZone id={`empty-${node.id}`} /> | |
| </SortableContext> | |
| </div> | |
| )} | |
| {showIconPicker && ( | |
| <IconPicker | |
| onSelect={async (iconName, color) => { | |
| await setNodeIcon(node.id, iconName); | |
| if (color) { | |
| await setNodeColor(node.id, color); | |
| } | |
| }} | |
| onClose={() => setShowIconPicker(false)} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function EmptyDropZone({ id }: { id: string }) { | |
| const { setNodeRef } = useSortable({ id }); | |
| return ( | |
| <div | |
| ref={setNodeRef} | |
| style={{ padding: '4px 16px', fontSize: '11px', opacity: 0.4, fontStyle: 'italic', display: 'flex', alignItems: 'center', height: '24px' }} | |
| > | |
| Drop files here... | |
| </div> | |
| ); | |
| } | |
| function MenuButton({ children, onClick, danger }: { children: any, onClick: any, danger?: boolean }) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| style={{ | |
| padding: '6px 10px', textAlign: 'left', fontSize: '12px', | |
| borderRadius: '4px', color: danger ? '#ff4d4f' : 'inherit', | |
| transition: 'background 0.2s ease', | |
| width: '100%' | |
| }} | |
| onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.03)'} | |
| onMouseLeave={e => e.currentTarget.style.background = 'transparent'} | |
| > | |
| {children} | |
| </button> | |
| ); | |
| } | |