context menu for file tree

This commit is contained in:
2026-02-26 10:40:38 +10:00
parent ee09163a7d
commit 13f5386420
8 changed files with 410 additions and 41 deletions

View File

@@ -1,6 +1,8 @@
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
@@ -16,7 +18,13 @@ interface Props {
export function FileTreeNode({ node, depth, dirPath, siblings }: Props): JSX.Element {
const [expanded, setExpanded] = useState(true)
const { activeFilePath, setActiveFile, moveNode } = useEditorStore()
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') {
@@ -59,52 +67,220 @@ export function FileTreeNode({ node, depth, dirPath, siblings }: Props): JSX.Ele
}
}
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">
<button
className="tree-dir-header"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => setExpanded(!expanded)}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<span className="tree-arrow">{expanded ? '▾' : '▸'}</span>
{node.name}
</button>
{expanded && node.children?.map((child) => (
<FileTreeNode
key={child.path}
node={child}
depth={depth + 1}
dirPath={node.path}
siblings={node.children!}
{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}
title={node.name}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{node.name}
</button>
<>
<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)}
/>
)}
</>
)
}