import { memo, useState, useCallback, useMemo, useRef, useEffect } from "react"; import { PencilSimple, Copy, Trash, FilePlus, FolderSimplePlus, FolderSimple, } from "@phosphor-icons/react"; import { ChevronDown, ChevronRight } from "../../icons/SystemIcons"; import { FileIcon, buildTree as _buildTree, sortChildren, isActiveInSubtree, type TreeNode, type ContextMenuState, type InlineInputState, } from "./FileTreeIcons"; export type { ContextMenuState, InlineInputState }; export { buildTree, sortChildren, isActiveInSubtree } from "./FileTreeIcons"; const SZ_ICON = 14; // ── Context Menu Component ── export function ContextMenu({ state, onClose, onNewFile, onNewFolder, onRename, onDuplicate, onDelete, }: { state: ContextMenuState; onClose: () => void; onNewFile: (parentPath: string) => void; onNewFolder: (parentPath: string) => void; onRename: (path: string) => void; onDuplicate: (path: string) => void; onDelete: (path: string) => void; }) { const menuRef = useRef(null); // eslint-disable-next-line no-restricted-syntax useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose(); } }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [onClose]); const adjustedX = Math.min(state.x, window.innerWidth - 180); const adjustedY = Math.min(state.y, window.innerHeight - 200); const parentPath = state.targetIsFolder ? state.targetPath : state.targetPath.includes("/") ? state.targetPath.slice(0, state.targetPath.lastIndexOf("/")) : ""; return (
{state.targetIsFolder && ( <>
)} {!state.targetIsFolder && ( <>
)} {!state.targetIsFolder && ( )}
); } // ── Inline Input (for new file/folder/rename) ── export function InlineInput({ defaultValue, depth, isFolder, onCommit, onCancel, }: { defaultValue: string; depth: number; isFolder: boolean; onCommit: (value: string) => void; onCancel: () => void; }) { const inputRef = useRef(null); const committedRef = useRef(false); const [value, setValue] = useState(defaultValue); // eslint-disable-next-line no-restricted-syntax useEffect(() => { const el = inputRef.current; if (!el) return; el.focus(); if (defaultValue && defaultValue.includes(".")) { const dotIdx = defaultValue.lastIndexOf("."); el.setSelectionRange(0, dotIdx); } else { el.select(); } }, [defaultValue]); const commit = (name: string) => { if (committedRef.current) return; committedRef.current = true; onCommit(name); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); const trimmed = value.trim(); if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed); else onCancel(); } else if (e.key === "Escape") { e.preventDefault(); onCancel(); } }; const handleBlur = () => { const trimmed = value.trim(); if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed); else onCancel(); }; return (
{isFolder ? ( ) : ( )} setValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleBlur} className="flex-1 min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-[#3CE6AC]" spellCheck={false} />
); } // ── Delete Confirmation ── export function DeleteConfirm({ name, onConfirm, onCancel, }: { name: string; onConfirm: () => void; onCancel: () => void; }) { const ref = useRef(null); // eslint-disable-next-line no-restricted-syntax useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; const handleClickOutside = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onCancel(); }; document.addEventListener("keydown", handleEscape); document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("keydown", handleEscape); document.removeEventListener("mousedown", handleClickOutside); }; }, [onCancel]); return (

Delete {name}?

); } // ── TreeFile ── export const TreeFile = memo(function TreeFile({ node, depth, activeFile, onSelectFile, onContextMenu, inlineInput, onDragStart, lintInfo, }: { node: TreeNode; depth: number; activeFile: string | null; onSelectFile: (path: string) => void; onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void; inlineInput: InlineInputState | null; onDragStart: (e: React.DragEvent, path: string) => void; lintInfo?: { count: number; messages: string[] }; }) { const isActive = node.fullPath === activeFile; const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath; if (isRenaming) { return ( { inlineInput?.onCommit?.(name); }} onCancel={() => { inlineInput?.onCancel?.(); }} /> ); } return ( ); }); // ── TreeFolder ── export const TreeFolder = memo(function TreeFolder({ node, depth, activeFile, onSelectFile, defaultOpen, onContextMenu, inlineInput, onDragStart, onDragOver, onDrop, onDragLeave, dragOverFolder, lintFindingsByFile, }: { node: TreeNode; depth: number; activeFile: string | null; onSelectFile: (path: string) => void; defaultOpen: boolean; onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void; inlineInput: InlineInputState | null; onDragStart: (e: React.DragEvent, path: string) => void; onDragOver: (e: React.DragEvent, folderPath: string) => void; onDrop: (e: React.DragEvent, folderPath: string) => void; onDragLeave: () => void; dragOverFolder: string | null; lintFindingsByFile?: Map; }) { const [isOpen, setIsOpen] = useState(defaultOpen); const toggle = useCallback(() => setIsOpen((v) => !v), []); const children = useMemo(() => sortChildren(node.children), [node.children]); const Chevron = isOpen ? ChevronDown : ChevronRight; const isDragOver = dragOverFolder === node.fullPath; const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath; if (isRenaming) { return ( { inlineInput?.onCommit?.(name); }} onCancel={() => { inlineInput?.onCancel?.(); }} /> ); } return ( <> {isOpen && ( <> {inlineInput && (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") && inlineInput.parentPath === node.fullPath && ( { inlineInput?.onCommit?.(name); }} onCancel={() => { inlineInput?.onCancel?.(); }} /> )} {children.map((child) => child.isFile && child.children.size === 0 ? ( ) : child.children.size > 0 ? ( ) : ( ), )} )} ); });