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,5 +1,5 @@
import { readdir, readFile, writeFile, mkdir, unlink } from 'fs/promises'
import { join } from 'path'
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
import { join, dirname } from 'path'
import type { FileNode, RevisionMeta } from '../renderer/types/editor'
const DRAFT_ROOT =
@@ -215,3 +215,37 @@ export async function deleteRevision(filePath: string, revisionId: string): Prom
const revPath = join(REVISIONS_DIR, slug, `${revisionId}.json`)
await unlink(revPath)
}
// ─── File tree mutations ──────────────────────────────────────────────────────
export async function renameFileOrDir(oldPath: string, newName: string): Promise<string> {
assertInDraftRoot(oldPath)
const isFile = oldPath.endsWith('.md')
const newPath = join(dirname(oldPath), isFile ? `${newName}.md` : newName)
assertInDraftRoot(newPath)
await fsRename(oldPath, newPath)
return newPath
}
export async function deleteFileOrDir(targetPath: string): Promise<void> {
assertInDraftRoot(targetPath)
await rm(targetPath, { recursive: true, force: true })
}
export async function createMarkdownFile(parentPath: string, name: string): Promise<string> {
const dir = parentPath === '__root__' ? DRAFT_ROOT : parentPath
assertInDraftRoot(dir)
const filePath = join(dir, `${name}.md`)
assertInDraftRoot(filePath)
await writeFile(filePath, '', 'utf-8')
return filePath
}
export async function createSubdirectory(parentPath: string, name: string): Promise<string> {
const parent = parentPath === '__root__' ? DRAFT_ROOT : parentPath
assertInDraftRoot(parent)
const newDir = join(parent, name)
assertInDraftRoot(newDir)
await mkdir(newDir, { recursive: true })
return newDir
}

View File

@@ -1,7 +1,7 @@
import { ipcMain, dialog, BrowserWindow } from 'electron'
import { readFileSync } from 'fs'
import { extname, basename } from 'path'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision } from './fileSystem'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory } from './fileSystem'
import { streamMessage } from './aiService'
import type { AIPayload, Attachment } from '../renderer/types/editor'
@@ -50,6 +50,22 @@ export function registerIpcHandlers(): void {
await deleteRevision(filePath, revisionId)
})
ipcMain.handle('fs:rename', async (_event, oldPath: string, newName: string) => {
return await renameFileOrDir(oldPath, newName)
})
ipcMain.handle('fs:delete', async (_event, targetPath: string) => {
await deleteFileOrDir(targetPath)
})
ipcMain.handle('fs:createFile', async (_event, parentPath: string, name: string) => {
return await createMarkdownFile(parentPath, name)
})
ipcMain.handle('fs:createDir', async (_event, parentPath: string, name: string) => {
return await createSubdirectory(parentPath, name)
})
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
const win = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(win!, {

View File

@@ -69,5 +69,17 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.invoke('revisions:load', filePath, revisionId),
deleteRevision: (filePath: string, revisionId: string): Promise<void> =>
ipcRenderer.invoke('revisions:delete', filePath, revisionId)
ipcRenderer.invoke('revisions:delete', filePath, revisionId),
renameNode: (oldPath: string, newName: string): Promise<string> =>
ipcRenderer.invoke('fs:rename', oldPath, newName),
deleteNode: (targetPath: string): Promise<void> =>
ipcRenderer.invoke('fs:delete', targetPath),
createFile: (parentPath: string, name: string): Promise<string> =>
ipcRenderer.invoke('fs:createFile', parentPath, name),
createDir: (parentPath: string, name: string): Promise<string> =>
ipcRenderer.invoke('fs:createDir', parentPath, name)
})

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