import { createContext, useContext, useEffect, useCallback, useRef, useState, } from 'react'; import type { FileSystemTreeNode } from '../filesystem/types'; import type { ContextMenuItem } from '../platform/contextMenu'; /** * Shared drag-and-drop state for the tree. While a file is dragged, exactly one * directory is the drop target (the deepest folder under the cursor); it * highlights its whole subtree. */ type TreeDnd = { targetDirId: string | null; setTargetDirId: (id: string | null) => void; }; const TreeDndContext = createContext(null); export type InlineCreateState = { parentId?: string; type: 'file' | 'folder'; name: string; error: string | null; }; export type FileSystemTreeProps = { nodes: FileSystemTreeNode[]; /** Id of the vault root, so files can be dropped at the top level. */ rootId?: string; onSelect?: (node: FileSystemTreeNode) => void; onContextMenu?: (node: FileSystemTreeNode, items: ContextMenuItem[]) => void; /** Move `entryId` into the directory `targetDirId` (file drag-and-drop). */ onMove?: (entryId: string, targetDirId: string) => void; selectedId?: string; renameNodeId?: string; renameValue?: string; renameExt?: string; renameError?: string | null; onRenameValueChange?: (value: string) => void; onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void; inlineCreate?: InlineCreateState; onInlineCreateChange?: (name: string) => void; onInlineCreateSubmit?: (name: string) => void; onInlineCreateCancel?: () => void; }; /** Shared row style for the active (highlighted) item in the tree. */ const ACTIVE_ROW = 'bg-accent/10 text-foreground'; const INACTIVE_ROW = 'text-foreground/70 hover:bg-border/35'; /** Tint applied to a directory's whole subtree while it is the drop target. */ const DROP_TARGET_REGION = 'rounded-sm bg-accent/15'; /** Drag payload for moving a file: carries the entry id. */ const FILE_DND_MIME = 'application/x-textile-file'; /** Build an independent, off-screen drag image labelled with the file name. */ function makeFileDragImage(name: string): HTMLElement { const ghost = document.createElement('div'); ghost.textContent = name; Object.assign(ghost.style, { position: 'fixed', top: '-1000px', left: '0', padding: '2px 8px', fontSize: '12px', borderRadius: '6px', background: 'var(--color-background)', color: 'var(--color-foreground)', border: '1px solid var(--color-border)', boxShadow: '0 2px 8px rgb(0 0 0 / 0.25)', whiteSpace: 'nowrap', pointerEvents: 'none', }); document.body.appendChild(ghost); return ghost; } function TriangleRight({ className }: { className?: string }) { return ( ); } function TriangleDown({ className }: { className?: string }) { return ( ); } function InlineCreateInput({ depth, type, name, error, onChange, onSubmit, onCancel, }: { depth: number; type: 'file' | 'folder'; name: string; error: string | null; onChange: (value: string) => void; onSubmit: (value: string) => void; onCancel: () => void; }) { const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); inputRef.current?.select(); }, []); return (
onChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onSubmit(e.currentTarget.value.trim()); } else if (e.key === 'Escape') { onCancel(); } }} onBlur={() => onCancel()} className="text-foreground w-full min-w-0 border-0 bg-transparent p-0 text-xs outline-none" /> {error && {error}}
); } function TreeNode({ node, depth, parentDirId, selectedId, onSelect, onContextMenu, renameNodeId, renameValue, renameExt, renameError, onRenameValueChange, onRenameSubmit, onRenameCancel, inlineCreate, onInlineCreateChange, onInlineCreateSubmit, onInlineCreateCancel, }: { node: FileSystemTreeNode; depth: number; /** Id of the directory containing this node (undefined at the vault root). */ parentDirId?: string; selectedId?: string; onSelect?: (node: FileSystemTreeNode) => void; onContextMenu?: (node: FileSystemTreeNode, items: ContextMenuItem[]) => void; renameNodeId?: string; renameValue?: string; renameExt?: string; renameError?: string | null; onRenameValueChange?: (value: string) => void; onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void; inlineCreate?: InlineCreateState; onInlineCreateChange?: (name: string) => void; onInlineCreateSubmit?: (name: string) => void; onInlineCreateCancel?: () => void; }) { const [expanded, setExpanded] = useState(depth < 2); const dnd = useContext(TreeDndContext); const isActive = node.id === renameNodeId || (node.id === selectedId && !inlineCreate); const hasChildren = node.children && node.children.length > 0; const isRenaming = node.id === renameNodeId; const isCreatingHere = inlineCreate?.parentId === node.id; const inputRef = useRef(null); useEffect(() => { if (isRenaming) { inputRef.current?.focus(); inputRef.current?.select(); } }, [isRenaming]); useEffect(() => { if (isCreatingHere) { setExpanded(true); } }, [isCreatingHere]); const handleClick = useCallback(() => { if (node.type === 'folder') { setExpanded((e) => !e); } onSelect?.(node); }, [node, onSelect]); const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!onContextMenu) return; const items: ContextMenuItem[] = node.type === 'folder' ? [ { id: 'new-note', label: 'New Note' }, { id: 'new-folder', label: 'New Folder' }, { id: 'copy', label: 'Copy' }, { id: 'copy-path', label: 'Copy Path' }, { id: 'rename', label: 'Rename' }, { id: 'delete', label: 'Delete' }, ] : [ { id: 'open-right', label: 'Open in new tile to the right' }, { id: 'open-below', label: 'Open in new tile below' }, { id: 'open-sep', label: '', type: 'separator' }, { id: 'copy', label: 'Copy' }, { id: 'rename', label: 'Rename' }, { id: 'delete', label: 'Delete' }, ]; onContextMenu(node, items); }, [node, onContextMenu], ); const isFile = node.type === 'file'; const isFolder = node.type === 'folder'; // The directory a drop here lands in: this folder itself, or — for a file — // the folder that contains it. Undefined for items at the vault root. const enclosingDirId = isFolder ? node.id : parentDirId; const isDropTarget = isFolder && dnd?.targetDirId === node.id; const handleDragStart = (e: React.DragEvent) => { e.dataTransfer.setData(FILE_DND_MIME, node.id); e.dataTransfer.effectAllowed = 'move'; const ghost = makeFileDragImage(node.name); e.dataTransfer.setDragImage(ghost, 8, ghost.offsetHeight / 2); window.setTimeout(() => ghost.remove(), 0); }; return (
{node.type === 'folder' && ( )} {node.type === 'file' && } {isRenaming ? (
onRenameValueChange?.(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onRenameSubmit?.(e.currentTarget.value.trim()); } else if (e.key === 'Escape') { onRenameCancel?.(); } }} onBlur={() => onRenameCancel?.()} className="text-foreground min-w-0 border-0 bg-transparent p-0 text-xs outline-none" /> {renameExt && ( {renameExt} )}
{renameError && ( {renameError} )}
) : ( )}
{hasChildren && expanded && (
{isCreatingHere && ( )} {node.children!.map((child) => ( ))}
)} {node.type === 'folder' && !hasChildren && isCreatingHere && (
)}
); } export function FileSystemTree({ nodes, rootId, onSelect, onContextMenu, onMove, selectedId, renameNodeId, renameValue, renameExt, renameError, onRenameValueChange, onRenameSubmit, onRenameCancel, inlineCreate, onInlineCreateChange, onInlineCreateSubmit, onInlineCreateCancel, }: FileSystemTreeProps) { const isCreatingAtRoot = inlineCreate && inlineCreate.parentId === undefined; const [targetDirId, setTargetDirId] = useState(null); // Clear the drop highlight whenever a drag ends anywhere (drop or cancel). useEffect(() => { if (targetDirId === null) return; const clear = () => setTargetDirId(null); window.addEventListener('dragend', clear); return () => window.removeEventListener('dragend', clear); }, [targetDirId]); // Resolve the directory the cursor is over from the nearest row carrying a // `data-drop-dir` (a folder, or a file's enclosing folder). null over a // root-level file or empty space. const dirUnderCursor = (e: React.DragEvent): string | null => (e.target as HTMLElement) .closest('[data-drop-dir]') ?.getAttribute('data-drop-dir') ?? null; return (
{ if (!onMove || !e.dataTransfer.types.includes(FILE_DND_MIME)) return; const dirId = dirUnderCursor(e); setTargetDirId(dirId); // Only allow a drop (and show the move cursor) over a directory. if (dirId) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} onDrop={(e) => { if (!onMove || !e.dataTransfer.types.includes(FILE_DND_MIME)) return; const dirId = dirUnderCursor(e); setTargetDirId(null); if (!dirId) return; e.preventDefault(); const entryId = e.dataTransfer.getData(FILE_DND_MIME); if (entryId) onMove(entryId, dirId); }} onDragLeave={(e) => { // Clear when the drag leaves the tree entirely (not when moving // between rows inside it). if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setTargetDirId(null); } }} > {isCreatingAtRoot && ( )} {nodes.map((node) => ( ))}
); }