"use client"; import { useState, useCallback, useRef, useEffect } from "react"; import { DndContext, DragOverlay, useDraggable, useDroppable, closestCenter, type DragStartEvent, type DragEndEvent, type DragOverEvent, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { ContextMenu, type ContextMenuAction, type ContextMenuTarget } from "./context-menu"; import { InlineRename, RENAME_SHAKE_STYLE } from "./inline-rename"; // --- Types --- export type TreeNode = { name: string; path: string; type: "object" | "document" | "folder" | "file" | "database" | "report"; icon?: string; defaultView?: "table" | "kanban"; children?: TreeNode[]; /** When true, the node represents a virtual folder/file outside the real workspace (e.g. Skills, Memories). CRUD ops are disabled. */ virtual?: boolean; /** True when the entry is a symbolic link / shortcut. */ symlink?: boolean; }; /** Folder names reserved for virtual sections -- cannot be created/renamed to. */ const RESERVED_FOLDER_NAMES = new Set(["Chats", "Skills", "Memories"]); /** Check if a node (or any of its ancestors) is virtual. Paths starting with ~ are always virtual. */ function isVirtualNode(node: TreeNode): boolean { return !!node.virtual || node.path.startsWith("~"); } type FileManagerTreeProps = { tree: TreeNode[]; activePath: string | null; onSelect: (node: TreeNode) => void; onRefresh: () => void; compact?: boolean; /** Parent directory path for ".." navigation. Null when at filesystem root or in workspace mode without browsing. */ parentDir?: string | null; /** Callback when user clicks ".." to navigate up. */ onNavigateUp?: () => void; /** Current browse directory (absolute path), or null when in workspace mode. */ browseDir?: string | null; /** Absolute path of the workspace root. Nodes matching this path are rendered as a special non-collapsible workspace entry point. */ workspaceRoot?: string | null; /** Called when a node is dragged and dropped outside the tree onto an external drop target (e.g. chat input). */ onExternalDrop?: (node: TreeNode) => void; }; // --- System file detection (client-side mirror) --- /** Always protected regardless of depth. */ const ALWAYS_SYSTEM_PATTERNS = [ /^\.object\.yaml$/, /\.wal$/, /\.tmp$/, ]; /** Only protected at the workspace root (no "/" in the relative path). */ const ROOT_ONLY_SYSTEM_PATTERNS = [ /^workspace\.duckdb/, /^workspace_context\.yaml$/, ]; function isSystemFile(path: string): boolean { const base = path.split("/").pop() ?? ""; if (ALWAYS_SYSTEM_PATTERNS.some((p) => p.test(base))) {return true;} const isRoot = !path.includes("/"); return isRoot && ROOT_ONLY_SYSTEM_PATTERNS.some((p) => p.test(base)); } // --- Icons (inline SVG, zero-dep) --- function FolderIcon({ open }: { open?: boolean }) { return ( ); } function TableIcon() { return ( ); } function KanbanIcon() { return ( ); } function DocumentIcon() { return ( ); } function FileIcon() { return ( ); } function DatabaseIcon() { return ( ); } function ReportIcon() { return ( ); } function ChatBubbleIcon() { return ( ); } function LockBadge() { return ( ); } function SymlinkBadge() { return ( ); } function ChevronIcon({ open }: { open: boolean }) { return ( ); } function WorkspaceGridIcon() { return ( ); } function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) { // Chat items use the chat bubble icon if (node.path.startsWith("~chats/") || node.path === "~chats") { return ; } switch (node.type) { case "object": return node.defaultView === "kanban" ? : ; case "document": return ; case "folder": return ; case "database": return ; case "report": return ; default: return ; } } function typeColor(node: TreeNode): string { switch (node.type) { case "object": return "var(--color-accent)"; case "document": return "#60a5fa"; case "database": return "#c084fc"; case "report": return "#22c55e"; default: return "var(--color-text-muted)"; } } // --- API helpers --- async function apiRename(path: string, newName: string) { const res = await fetch("/api/workspace/rename", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, newName }), }); return res.json(); } async function apiDelete(path: string) { const res = await fetch("/api/workspace/file", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); return res.json(); } async function apiMove(sourcePath: string, destinationDir: string) { const res = await fetch("/api/workspace/move", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sourcePath, destinationDir }), }); return res.json(); } async function apiDuplicate(path: string) { const res = await fetch("/api/workspace/copy", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); return res.json(); } async function apiMkdir(path: string) { const res = await fetch("/api/workspace/mkdir", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); return res.json(); } async function apiCreateFile(path: string, content: string = "") { const res = await fetch("/api/workspace/file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, content }), }); return res.json(); } // --- Confirm dialog --- function ConfirmDialog({ message, onConfirm, onCancel, }: { message: string; onConfirm: () => void; onCancel: () => void; }) { useEffect(() => { function handleKey(e: KeyboardEvent) { if (e.key === "Escape") {onCancel();} if (e.key === "Enter") {onConfirm();} } document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [onConfirm, onCancel]); return (

{message}

); } // --- New item prompt --- function NewItemPrompt({ kind, parentPath, onSubmit, onCancel, }: { kind: "file" | "folder"; parentPath: string; onSubmit: (name: string) => void; onCancel: () => void; }) { const [value, setValue] = useState(kind === "file" ? "untitled.md" : "new-folder"); const inputRef = useRef(null); useEffect(() => { const el = inputRef.current; if (!el) {return;} el.focus(); if (kind === "file") { const dot = value.lastIndexOf("."); el.setSelectionRange(0, dot > 0 ? dot : value.length); } else { el.select(); } }, []); return (

New {kind} in {parentPath || "/"}

setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") {onSubmit(value.trim());} if (e.key === "Escape") {onCancel();} }} className="w-full px-3 py-2 rounded-md text-sm outline-none border" style={{ background: "var(--color-bg)", color: "var(--color-text)", borderColor: "var(--color-border)" }} />
); } // --- Draggable + Droppable Node --- function DraggableNode({ node, depth, activePath, selectedPath, onSelect, onNodeSelect, expandedPaths, onToggleExpand, renamingPath, onStartRename, onCommitRename, onCancelRename, onContextMenu, compact, dragOverPath, workspaceRoot, }: { node: TreeNode; depth: number; activePath: string | null; selectedPath: string | null; onSelect: (node: TreeNode) => void; onNodeSelect: (path: string) => void; expandedPaths: Set; onToggleExpand: (path: string) => void; renamingPath: string | null; onStartRename: (path: string) => void; onCommitRename: (newName: string) => void; onCancelRename: () => void; onContextMenu: (e: React.MouseEvent, node: TreeNode) => void; compact?: boolean; dragOverPath: string | null; workspaceRoot?: string | null; }) { // Workspace root in browse mode: non-expandable entry point back to workspace const isWorkspaceRoot = !!workspaceRoot && node.path === workspaceRoot; const hasChildren = node.children && node.children.length > 0; const isExpandable = isWorkspaceRoot ? false : (hasChildren || node.type === "folder" || node.type === "object"); const isExpanded = isWorkspaceRoot ? false : expandedPaths.has(node.path); const isActive = activePath === node.path; const isSelected = selectedPath === node.path; const isRenaming = renamingPath === node.path; const isSysFile = isSystemFile(node.path); const isVirtual = isVirtualNode(node); const isProtected = isSysFile || isVirtual || isWorkspaceRoot; const isDragOver = dragOverPath === node.path && isExpandable; const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ id: `drag-${node.path}`, data: { node }, disabled: isProtected, }); const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `drop-${node.path}`, data: { node }, disabled: !isExpandable || isVirtual, }); const handleClick = useCallback(() => { onNodeSelect(node.path); onSelect(node); if (isExpandable) { onToggleExpand(node.path); } }, [node, isExpandable, onSelect, onNodeSelect, onToggleExpand]); const handleDoubleClick = useCallback(() => { if (!isProtected) { onStartRename(node.path); } }, [node.path, isProtected, onStartRename]); const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onNodeSelect(node.path); onContextMenu(e, node); }, [node, onNodeSelect, onContextMenu], ); // Merge drag + drop refs const mergedRef = useCallback( (el: HTMLElement | null) => { setDragRef(el); setDropRef(el); }, [setDragRef, setDropRef], ); const showDropHighlight = (isOver || isDragOver) && isExpandable; return (
{ // Native HTML5 drag for cross-component drops (e.g. into chat editor). // Coexists with @dnd-kit which uses pointer events for intra-tree reordering. e.dataTransfer.setData( "application/x-file-mention", JSON.stringify({ name: node.name, path: node.path }), ); e.dataTransfer.setData("text/plain", node.path); e.dataTransfer.effectAllowed = "copy"; }} onClick={handleClick} onDoubleClick={handleDoubleClick} onContextMenu={handleContextMenu} className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer select-none" style={{ paddingLeft: `${depth * 16 + 8}px`, background: isWorkspaceRoot ? "var(--color-accent-light)" : showDropHighlight ? "var(--color-accent-light)" : isSelected ? "var(--color-surface-hover)" : isActive ? "var(--color-surface-hover)" : "transparent", color: isWorkspaceRoot ? "var(--color-accent)" : isActive || isSelected ? "var(--color-text)" : "var(--color-text-muted)", outline: isWorkspaceRoot ? "1.5px solid var(--color-accent)" : showDropHighlight ? "1px dashed var(--color-accent)" : "none", outlineOffset: "-1px", borderRadius: isWorkspaceRoot ? "8px" : "6px", marginTop: isWorkspaceRoot ? "2px" : undefined, marginBottom: isWorkspaceRoot ? "2px" : undefined, }} onMouseEnter={(e) => { if (isWorkspaceRoot) { (e.currentTarget as HTMLElement).style.opacity = "0.8"; } else if (!isActive && !isSelected && !showDropHighlight) { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; } }} onMouseLeave={(e) => { if (isWorkspaceRoot) { (e.currentTarget as HTMLElement).style.opacity = "1"; } else if (!isActive && !isSelected && !showDropHighlight) { (e.currentTarget as HTMLElement).style.background = "transparent"; } }} > {/* Expand/collapse chevron – intercept click so it only toggles without navigating */} { e.stopPropagation(); onToggleExpand(node.path); } : undefined} > {isExpandable && } {/* Icon */} {isWorkspaceRoot ? : } {/* Label or rename input */} {isRenaming ? ( ) : ( {node.name} )} {/* Workspace badge for the workspace root entry point */} {isWorkspaceRoot && ( workspace )} {/* Lock badge for system/virtual files (skip for workspace root -- it has its own badge) */} {isProtected && !isWorkspaceRoot && !compact && ( )} {/* Symlink indicator */} {node.symlink && !compact && ( )} {/* Type badge for objects */} {node.type === "object" && ( {node.defaultView === "kanban" ? "board" : "table"} )}
{/* Children */} {isExpanded && hasChildren && (
0 ? "1px solid var(--color-border)" : "none", marginLeft: `${depth * 16 + 16}px`, }}> {node.children!.map((child) => ( ))}
)}
); } // --- Root drop zone (allows dropping items back to the top level) --- function RootDropZone({ isDragging }: { isDragging: boolean }) { const { setNodeRef, isOver } = useDroppable({ id: "drop-__root__", data: { rootDrop: true }, }); const showHighlight = isOver && isDragging; return (
{isDragging && ( Drop here to move to root )}
); } // --- Drag Overlay --- function DragOverlayContent({ node }: { node: TreeNode }) { return (
{node.name}
); } // --- Helper: find node by path --- function findNode(tree: TreeNode[], path: string): TreeNode | null { for (const n of tree) { if (n.path === path) {return n;} if (n.children) { const found = findNode(n.children, path); if (found) {return found;} } } return null; } // --- Helper: get parent path --- function parentPath(path: string): string { const parts = path.split("/"); parts.pop(); return parts.join("/") || "."; } // --- Flatten tree for keyboard navigation --- function flattenVisible(tree: TreeNode[], expanded: Set): TreeNode[] { const result: TreeNode[] = []; function walk(nodes: TreeNode[]) { for (const n of nodes) { result.push(n); if (n.children && expanded.has(n.path)) { walk(n.children); } } } walk(tree); return result; } // --- Main Exported Component --- export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir: _browseDir, workspaceRoot, onExternalDrop }: FileManagerTreeProps) { const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); const [selectedPath, setSelectedPath] = useState(null); const [renamingPath, setRenamingPath] = useState(null); const [dragOverPath, setDragOverPath] = useState(null); const [activeNode, setActiveNode] = useState(null); // Track pointer position during @dnd-kit drags for cross-component drops. // Installed synchronously in handleDragStart (not useEffect) to avoid // missing early pointer moves. Capture-phase on window fires before // @dnd-kit's own document-level listener. const pointerPosRef = useRef({ x: 0, y: 0 }); const pointerListenerRef = useRef<((e: PointerEvent) => void) | null>(null); const installPointerTracker = useCallback(() => { const handler = (e: PointerEvent) => { pointerPosRef.current = { x: e.clientX, y: e.clientY }; // Toggle visual drop indicator on external chat drop target const el = document.elementFromPoint(e.clientX, e.clientY); const target = el?.closest("[data-chat-drop-target]") as HTMLElement | null; const prev = document.querySelector("[data-drag-hover]"); if (target && !target.hasAttribute("data-drag-hover")) { target.setAttribute("data-drag-hover", ""); } if (prev && prev !== target) { prev.removeAttribute("data-drag-hover"); } }; pointerListenerRef.current = handler; window.addEventListener("pointermove", handler, true); }, []); const removePointerTracker = useCallback(() => { if (pointerListenerRef.current) { window.removeEventListener("pointermove", pointerListenerRef.current, true); pointerListenerRef.current = null; } document.querySelector("[data-drag-hover]")?.removeAttribute("data-drag-hover"); }, []); // Clean up on unmount useEffect(() => removePointerTracker, [removePointerTracker]); // Context menu state const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; target: ContextMenuTarget } | null>(null); // Confirm dialog const [confirmDelete, setConfirmDelete] = useState(null); // New item prompt const [newItemPrompt, setNewItemPrompt] = useState<{ kind: "file" | "folder"; parentPath: string } | null>(null); const containerRef = useRef(null); // Auto-expand first level on mount. // Keep ~skills and ~memories collapsed by default; always expand ~chats. const collapsedByDefault = new Set(["~skills", "~memories"]); useEffect(() => { if (tree.length > 0 && expandedPaths.size === 0) { const initial = new Set(); for (const node of tree) { if (collapsedByDefault.has(node.path)) {continue;} if (node.children && node.children.length > 0) { initial.add(node.path); } } setExpandedPaths(initial); } }, [tree]); const handleToggleExpand = useCallback((path: string) => { setExpandedPaths((prev) => { const next = new Set(prev); if (next.has(path)) {next.delete(path);} else {next.add(path);} return next; }); }, []); // DnD sensors -- require 8px movement before dragging starts (prevents accidental drags on click) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), ); const handleDragStart = useCallback((event: DragStartEvent) => { const data = event.active.data.current as { node: TreeNode } | undefined; if (data?.node) { setActiveNode(data.node); installPointerTracker(); } }, [installPointerTracker]); const handleDragOver = useCallback((event: DragOverEvent) => { const overData = event.over?.data.current as { node?: TreeNode; rootDrop?: boolean } | undefined; if (overData?.rootDrop) { setDragOverPath("__root__"); } else if (overData?.node) { setDragOverPath(overData.node.path); // Auto-expand folders on drag hover (300ms delay) const path = overData.node.path; if (overData.node.type === "folder" || overData.node.type === "object") { setTimeout(() => { setExpandedPaths((prev) => { if (prev.has(path)) {return prev;} const next = new Set(prev); next.add(path); return next; }); }, 300); } } else { setDragOverPath(null); } }, []); const handleDragEnd = useCallback( async (event: DragEndEvent) => { setActiveNode(null); setDragOverPath(null); removePointerTracker(); const activeData = event.active.data.current as { node: TreeNode } | undefined; if (!activeData?.node) {return;} const source = activeData.node; // Check for external drop targets FIRST (e.g. chat input). // closestCenter always returns a droppable even when the pointer is // far outside the tree, so we can't rely on `event.over === null`. if (onExternalDrop) { const { x, y } = pointerPosRef.current; const el = document.elementFromPoint(x, y); if (el?.closest("[data-chat-drop-target]")) { onExternalDrop(source); return; } } const overData = event.over?.data.current as { node?: TreeNode; rootDrop?: boolean } | undefined; // Drop onto root level if (overData?.rootDrop) { // Already at root? No-op if (parentPath(source.path) === ".") {return;} const result = await apiMove(source.path, "."); if (result.ok) { onRefresh(); } return; } if (!overData?.node) {return;} const target = overData.node; // Only drop onto expandable targets (folders/objects) if (target.type !== "folder" && target.type !== "object") {return;} // Prevent dropping into self or children if (target.path === source.path || target.path.startsWith(source.path + "/")) {return;} // Prevent no-op moves (already in same parent) if (parentPath(source.path) === target.path) {return;} const result = await apiMove(source.path, target.path); if (result.ok) { onRefresh(); } }, [onRefresh, onExternalDrop, removePointerTracker], ); const handleDragCancel = useCallback(() => { setActiveNode(null); setDragOverPath(null); removePointerTracker(); }, [removePointerTracker]); // Context menu handlers const handleContextMenu = useCallback((e: React.MouseEvent, node: TreeNode) => { const isSys = isSystemFile(node.path) || isVirtualNode(node); const isFolder = node.type === "folder" || node.type === "object"; setCtxMenu({ x: e.clientX, y: e.clientY, target: { kind: isFolder ? "folder" : "file", path: node.path, name: node.name, isSystem: isSys, }, }); }, []); const handleEmptyContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, target: { kind: "empty" }, }); }, []); const handleContextMenuAction = useCallback( async (action: ContextMenuAction) => { const target = ctxMenu?.target; if (!target) {return;} switch (action) { case "open": { if (target.kind !== "empty") { const node = findNode(tree, target.path); if (node) {onSelect(node);} } break; } case "rename": { if (target.kind !== "empty") { setRenamingPath(target.path); } break; } case "duplicate": { if (target.kind !== "empty") { await apiDuplicate(target.path); onRefresh(); } break; } case "copy": { if (target.kind !== "empty") { await navigator.clipboard.writeText(target.path); } break; } case "delete": { if (target.kind !== "empty") { setConfirmDelete(target.path); } break; } case "newFile": { const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : ""; setNewItemPrompt({ kind: "file", parentPath: parent }); break; } case "newFolder": { const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : ""; setNewItemPrompt({ kind: "folder", parentPath: parent }); break; } case "getInfo": { // Future: show info panel. For now, copy path. if (target.kind !== "empty") { await navigator.clipboard.writeText(target.path); } break; } } }, [ctxMenu, tree, onSelect, onRefresh], ); // Rename handlers const handleCommitRename = useCallback( async (newName: string) => { if (!renamingPath) {return;} // Block reserved folder names if (RESERVED_FOLDER_NAMES.has(newName)) { alert(`"${newName}" is a reserved name and cannot be used.`); setRenamingPath(null); return; } const result = await apiRename(renamingPath, newName); setRenamingPath(null); if (result.ok) {onRefresh();} }, [renamingPath, onRefresh], ); const handleCancelRename = useCallback(() => { setRenamingPath(null); }, []); // Delete confirm const handleConfirmDelete = useCallback(async () => { if (!confirmDelete) {return;} const result = await apiDelete(confirmDelete); setConfirmDelete(null); if (result.ok) {onRefresh();} }, [confirmDelete, onRefresh]); // New item submit const handleNewItemSubmit = useCallback( async (name: string) => { if (!newItemPrompt || !name) {return;} // Block reserved folder names if (RESERVED_FOLDER_NAMES.has(name)) { alert(`"${name}" is a reserved name and cannot be used.`); return; } const fullPath = newItemPrompt.parentPath ? `${newItemPrompt.parentPath}/${name}` : name; if (newItemPrompt.kind === "folder") { await apiMkdir(fullPath); } else { await apiCreateFile(fullPath, ""); } setNewItemPrompt(null); onRefresh(); // Auto-expand parent setExpandedPaths((prev) => { const next = new Set(prev); next.add(newItemPrompt.parentPath); return next; }); }, [newItemPrompt, onRefresh], ); // Keyboard shortcuts const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { // Don't capture keyboard events when renaming if (renamingPath) {return;} const flat = flattenVisible(tree, expandedPaths); const curIdx = flat.findIndex((n) => n.path === selectedPath); const curNode = curIdx >= 0 ? flat[curIdx] : null; switch (e.key) { case "ArrowDown": { e.preventDefault(); const next = curIdx < flat.length - 1 ? flat[curIdx + 1] : flat[0]; if (next) {setSelectedPath(next.path);} break; } case "ArrowUp": { e.preventDefault(); const prev = curIdx > 0 ? flat[curIdx - 1] : flat[flat.length - 1]; if (prev) {setSelectedPath(prev.path);} break; } case "ArrowRight": { e.preventDefault(); if (curNode && (curNode.type === "folder" || curNode.type === "object")) { setExpandedPaths((p) => new Set([...p, curNode.path])); } break; } case "ArrowLeft": { e.preventDefault(); if (curNode && expandedPaths.has(curNode.path)) { setExpandedPaths((p) => { const n = new Set(p); n.delete(curNode.path); return n; }); } break; } case "Enter": { e.preventDefault(); if (curNode) { const curProtected = isSystemFile(curNode.path) || isVirtualNode(curNode); if (e.shiftKey || curProtected) { onSelect(curNode); } else { setRenamingPath(curNode.path); } } break; } case "F2": { e.preventDefault(); if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) { setRenamingPath(curNode.path); } break; } case "Backspace": case "Delete": { if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) { e.preventDefault(); setConfirmDelete(curNode.path); } break; } default: { // Cmd+key shortcuts if (e.metaKey || e.ctrlKey) { if (e.key === "c" && curNode) { e.preventDefault(); void navigator.clipboard.writeText(curNode.path); } else if (e.key === "d" && curNode && !isSystemFile(curNode.path)) { e.preventDefault(); void apiDuplicate(curNode.path).then(() => onRefresh()); } else if (e.key === "n") { e.preventDefault(); const parent = curNode ? curNode.type === "folder" || curNode.type === "object" ? curNode.path : parentPath(curNode.path) : ""; if (e.shiftKey) { setNewItemPrompt({ kind: "folder", parentPath: parent }); } else { setNewItemPrompt({ kind: "file", parentPath: parent }); } } } break; } } }, [tree, expandedPaths, selectedPath, renamingPath, onSelect, onRefresh], ); if (tree.length === 0) { return (
No files in workspace {ctxMenu && ( setCtxMenu(null)} /> )} {newItemPrompt && ( setNewItemPrompt(null)} /> )}
); } return (
{/* ".." navigation entry for browsing up */} {parentDir != null && onNavigateUp && (
{ (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} > ..
)} {tree.map((node) => ( ))} {/* Root-level drop zone: fills remaining space so items can be moved to root */}
{/* Drag overlay (ghost) — pointer-events:none so elementFromPoint sees through it */} {activeNode ? : null} {/* Context menu */} {ctxMenu && ( setCtxMenu(null)} /> )} {/* Delete confirmation dialog */} {confirmDelete && ( setConfirmDelete(null)} /> )} {/* New file/folder prompt */} {newItemPrompt && ( setNewItemPrompt(null)} /> )} {/* Inject animation styles */}
); }