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

@@ -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
)
}

View File

@@ -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;
}

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)}
/>
)}
</>
)
}

View File

@@ -48,6 +48,9 @@ interface EditorState {
// File tree reordering
moveNode: (dirPath: string, fromIdx: number, toIdx: number) => void
// Clear active file (e.g. after deletion)
clearActiveFile: () => void
// Word counts
projectWordCount: number
setProjectWordCount: (count: number) => void
@@ -115,6 +118,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
})
},
clearActiveFile: () => set({ activeFilePath: null, activeFileContent: '', isDirty: false }),
activeFilePath: null,
activeFileContent: '',
isDirty: false,

View File

@@ -18,6 +18,10 @@ declare global {
listRevisions: (filePath: string) => Promise<RevisionMeta[]>
loadRevision: (filePath: string, revisionId: string) => Promise<string>
deleteRevision: (filePath: string, revisionId: string) => Promise<void>
renameNode: (oldPath: string, newName: string) => Promise<string>
deleteNode: (targetPath: string) => Promise<void>
createFile: (parentPath: string, name: string) => Promise<string>
createDir: (parentPath: string, name: string) => Promise<string>
}
}
}