import { useState } from 'react' import { useEditorStore } from '../../store/editorStore' import type { FileNode } from '../../types/editor' import { ContextMenu } from './ContextMenu' import type { MenuItem } from './ContextMenu' interface DndPayload { name: string dirPath: string sourcePath: string nodeType: 'file' | 'directory' } interface Props { node: FileNode depth: number dirPath: string siblings: FileNode[] } export function FileTreeNode({ node, depth, dirPath, siblings }: Props): JSX.Element { const [expanded, setExpanded] = useState(true) const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) const [renaming, setRenaming] = useState(false) const [renameValue, setRenameValue] = useState('') const [creatingChild, setCreatingChild] = useState<'file' | 'dir' | null>(null) const [createValue, setCreateValue] = useState('') const { activeFilePath, setActiveFile, moveNode, setFileTree, clearActiveFile } = useEditorStore() const openFile = async (): Promise => { if (node.type === 'file') { if (node.path === activeFilePath) return const content = await window.api.readFile(node.path) setActiveFile(node.path, content) } } function handleDragStart(e: React.DragEvent): void { e.stopPropagation() e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData( 'dnd', JSON.stringify({ name: node.name, dirPath, sourcePath: node.path, nodeType: node.type } satisfies DndPayload) ) e.currentTarget.classList.add('dragging') } function handleDragEnd(e: React.DragEvent): void { e.currentTarget.classList.remove('dragging') } function handleDragOver(e: React.DragEvent): void { e.preventDefault() e.dataTransfer.dropEffect = 'move' e.currentTarget.classList.add('drag-over') } function handleDragLeave(e: React.DragEvent): void { e.currentTarget.classList.remove('drag-over') } async function handleMoveIntoDir(sourcePath: string, targetDirPath: string): Promise { try { const newPath = await window.api.moveFile(sourcePath, targetDirPath) if (activeFilePath === sourcePath) { const content = await window.api.readFile(newPath) setActiveFile(newPath, content) } await refreshTree() } catch (err) { console.error('Move failed:', err) } } function handleDrop(e: React.DragEvent): void { e.preventDefault() e.stopPropagation() e.currentTarget.classList.remove('drag-over') const raw = e.dataTransfer.getData('dnd') if (!raw) return const payload: DndPayload = JSON.parse(raw) if (node.type === 'directory') { // Drop ON a directory = move INTO it if (payload.sourcePath === node.path) return if (payload.dirPath === node.path) return if (payload.nodeType === 'directory' && node.path.startsWith(payload.sourcePath + '/')) return void handleMoveIntoDir(payload.sourcePath, node.path) } else { if (payload.dirPath === dirPath) { // Same parent — reorder const fromIdx = siblings.findIndex((n) => n.name === payload.name) const toIdx = siblings.findIndex((n) => n.name === node.name) if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) { moveNode(dirPath, fromIdx, toIdx) } } else { // Different parent — move to this file's parent directory void handleMoveIntoDir(payload.sourcePath, dirPath) } } } function handleContextMenu(e: React.MouseEvent): void { e.preventDefault() e.stopPropagation() setMenuPos({ x: e.clientX, y: e.clientY }) } async function refreshTree(): Promise { const tree = await window.api.listFiles() setFileTree(tree) } function startRename(): void { setRenameValue(node.name) setRenaming(true) } async function submitRename(): Promise { const trimmed = renameValue.trim() setRenaming(false) if (!trimmed || trimmed === node.name) return try { const newPath = await window.api.renameNode(node.path, trimmed) if (activeFilePath === node.path) { const content = await window.api.readFile(newPath) setActiveFile(newPath, content) } await refreshTree() } catch (err) { console.error('Rename failed:', err) } } async function submitDelete(): Promise { const label = node.type === 'directory' ? `folder "${node.name}"` : `"${node.name}"` if (!confirm(`Delete ${label}? This cannot be undone.`)) return try { if (activeFilePath && (activeFilePath === node.path || activeFilePath.startsWith(node.path + '/'))) { clearActiveFile() } await window.api.deleteNode(node.path) await refreshTree() } catch (err) { console.error('Delete failed:', err) } } async function submitCreateChild(): Promise { const trimmed = createValue.trim() setCreatingChild(null) setCreateValue('') if (!trimmed) return try { if (creatingChild === 'file') { await window.api.createFile(node.path, trimmed) } else { await window.api.createDir(node.path, trimmed) } await refreshTree() } catch (err) { console.error('Create failed:', err) } } const menuItems: (MenuItem | 'separator')[] = node.type === 'directory' ? [ { label: 'New File', action: () => { setExpanded(true) setCreatingChild('file') setCreateValue('') } }, { label: 'New Folder', action: () => { setExpanded(true) setCreatingChild('dir') setCreateValue('') } }, 'separator', { label: 'Rename', action: startRename }, { label: 'Delete', action: submitDelete, danger: true } ] : [ { label: 'Rename', action: startRename }, { label: 'Delete', action: submitDelete, danger: true } ] if (node.type === 'directory') { return (
{renaming ? ( setRenameValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submitRename() if (e.key === 'Escape') setRenaming(false) }} onBlur={submitRename} autoFocus /> ) : ( )} {expanded && ( <> {creatingChild && ( setCreateValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submitCreateChild() if (e.key === 'Escape') { setCreatingChild(null) setCreateValue('') } }} onBlur={() => { setCreatingChild(null) setCreateValue('') }} placeholder={creatingChild === 'file' ? 'file name' : 'folder name'} autoFocus /> )} {node.children?.map((child) => ( ))} )} {menuPos && ( setMenuPos(null)} /> )}
) } const isActive = activeFilePath === node.path if (renaming) { return ( setRenameValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submitRename() if (e.key === 'Escape') setRenaming(false) }} onBlur={submitRename} autoFocus /> ) } return ( <> {menuPos && ( setMenuPos(null)} /> )} ) }