✨ context menu for file tree
This commit is contained in:
58
src/renderer/components/FileTree/ContextMenu.tsx
Normal file
58
src/renderer/components/FileTree/ContextMenu.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
action: () => void
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
x: number
|
||||
y: number
|
||||
items: (MenuItem | 'separator')[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(e: MouseEvent): void {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return createPortal(
|
||||
<div className="context-menu" style={{ left: x, top: y }} ref={ref}>
|
||||
{items.map((item, i) =>
|
||||
item === 'separator' ? (
|
||||
<div key={i} className="context-menu-separator" />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
className={`context-menu-item${item.danger ? ' danger' : ''}`}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
item.action()
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -92,3 +92,67 @@
|
||||
.tree-dir-header.drag-over {
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
/* ── Inline rename / create input ─────────────────────────────────────── */
|
||||
|
||||
.tree-rename-input {
|
||||
display: block;
|
||||
width: calc(100% - 8px);
|
||||
background: var(--bg-secondary, #1e1e1e);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
margin: 1px 4px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Context menu ──────────────────────────────────────────────────────── */
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
min-width: 140px;
|
||||
background: var(--bg-secondary, #1e1e1e);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 0;
|
||||
z-index: 9999;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: #e06c6c;
|
||||
}
|
||||
|
||||
.context-menu-item.danger:hover {
|
||||
background: rgba(224, 108, 108, 0.12);
|
||||
color: #e06c6c;
|
||||
}
|
||||
|
||||
.context-menu-separator {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user