Files
hohoff/src/renderer/components/FileTree/FileTreeNode.tsx
2026-03-06 20:23:51 +10:00

322 lines
9.6 KiB
TypeScript

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<void> => {
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<HTMLElement>): 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<HTMLElement>): void {
e.currentTarget.classList.remove('dragging')
}
function handleDragOver(e: React.DragEvent<HTMLElement>): void {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
e.currentTarget.classList.add('drag-over')
}
function handleDragLeave(e: React.DragEvent<HTMLElement>): void {
e.currentTarget.classList.remove('drag-over')
}
async function handleMoveIntoDir(sourcePath: string, targetDirPath: string): Promise<void> {
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<HTMLElement>): 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<void> {
const tree = await window.api.listFiles()
setFileTree(tree)
}
function startRename(): void {
setRenameValue(node.name)
setRenaming(true)
}
async function submitRename(): Promise<void> {
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<void> {
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<void> {
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 (
<div className="tree-dir">
{renaming ? (
<input
className="tree-rename-input"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setRenaming(false)
}}
onBlur={submitRename}
autoFocus
/>
) : (
<button
className="tree-dir-header"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => setExpanded(!expanded)}
onContextMenu={handleContextMenu}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<span className="tree-arrow">{expanded ? '▾' : '▸'}</span>
{node.name}
</button>
)}
{expanded && (
<>
{creatingChild && (
<input
className="tree-rename-input"
style={{ paddingLeft: `${(depth + 1) * 12 + 20}px` }}
value={createValue}
onChange={(e) => 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) => (
<FileTreeNode
key={child.path}
node={child}
depth={depth + 1}
dirPath={node.path}
siblings={node.children!}
/>
))}
</>
)}
{menuPos && (
<ContextMenu
x={menuPos.x}
y={menuPos.y}
items={menuItems}
onClose={() => setMenuPos(null)}
/>
)}
</div>
)
}
const isActive = activeFilePath === node.path
if (renaming) {
return (
<input
className="tree-rename-input"
style={{ paddingLeft: `${depth * 12 + 20}px` }}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setRenaming(false)
}}
onBlur={submitRename}
autoFocus
/>
)
}
return (
<>
<button
className={`tree-file${isActive ? ' active' : ''}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={openFile}
onContextMenu={handleContextMenu}
title={node.name}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{node.name}
</button>
{menuPos && (
<ContextMenu
x={menuPos.x}
y={menuPos.y}
items={menuItems}
onClose={() => setMenuPos(null)}
/>
)}
</>
)
}