From 13f5386420bd7d330267d506b544b2f6f9b6581e Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Thu, 26 Feb 2026 10:40:38 +1000 Subject: [PATCH] :sparkles: context menu for file tree --- src/main/fileSystem.ts | 38 ++- src/main/ipcHandlers.ts | 18 +- src/preload/index.ts | 14 +- .../components/FileTree/ContextMenu.tsx | 58 ++++ src/renderer/components/FileTree/FileTree.css | 64 +++++ .../components/FileTree/FileTreeNode.tsx | 250 +++++++++++++++--- src/renderer/store/editorStore.ts | 5 + src/renderer/types/global.d.ts | 4 + 8 files changed, 410 insertions(+), 41 deletions(-) create mode 100644 src/renderer/components/FileTree/ContextMenu.tsx diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts index c5aeb72..01012aa 100644 --- a/src/main/fileSystem.ts +++ b/src/main/fileSystem.ts @@ -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 { + 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 { + assertInDraftRoot(targetPath) + await rm(targetPath, { recursive: true, force: true }) +} + +export async function createMarkdownFile(parentPath: string, name: string): Promise { + 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 { + const parent = parentPath === '__root__' ? DRAFT_ROOT : parentPath + assertInDraftRoot(parent) + const newDir = join(parent, name) + assertInDraftRoot(newDir) + await mkdir(newDir, { recursive: true }) + return newDir +} diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index ccdeab9..a0ce8d6 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -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 => { const win = BrowserWindow.fromWebContents(event.sender) const result = await dialog.showOpenDialog(win!, { diff --git a/src/preload/index.ts b/src/preload/index.ts index 624670b..fb8b966 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -69,5 +69,17 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.invoke('revisions:load', filePath, revisionId), deleteRevision: (filePath: string, revisionId: string): Promise => - ipcRenderer.invoke('revisions:delete', filePath, revisionId) + ipcRenderer.invoke('revisions:delete', filePath, revisionId), + + renameNode: (oldPath: string, newName: string): Promise => + ipcRenderer.invoke('fs:rename', oldPath, newName), + + deleteNode: (targetPath: string): Promise => + ipcRenderer.invoke('fs:delete', targetPath), + + createFile: (parentPath: string, name: string): Promise => + ipcRenderer.invoke('fs:createFile', parentPath, name), + + createDir: (parentPath: string, name: string): Promise => + ipcRenderer.invoke('fs:createDir', parentPath, name) }) diff --git a/src/renderer/components/FileTree/ContextMenu.tsx b/src/renderer/components/FileTree/ContextMenu.tsx new file mode 100644 index 0000000..47f9158 --- /dev/null +++ b/src/renderer/components/FileTree/ContextMenu.tsx @@ -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(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( +
+ {items.map((item, i) => + item === 'separator' ? ( +
+ ) : ( + + ) + )} +
, + document.body + ) +} diff --git a/src/renderer/components/FileTree/FileTree.css b/src/renderer/components/FileTree/FileTree.css index d8698e0..56206bd 100644 --- a/src/renderer/components/FileTree/FileTree.css +++ b/src/renderer/components/FileTree/FileTree.css @@ -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; +} diff --git a/src/renderer/components/FileTree/FileTreeNode.tsx b/src/renderer/components/FileTree/FileTreeNode.tsx index 5465bf1..392e436 100644 --- a/src/renderer/components/FileTree/FileTreeNode.tsx +++ b/src/renderer/components/FileTree/FileTreeNode.tsx @@ -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 => { 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 { + const tree = await window.api.listFiles() + setFileTree(tree) + } + + function startRename(): void { + setRenameValue(node.name) + setRenaming(true) + } + + async function submitRename(): Promise { + 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 { + 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 { + 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 (
- - {expanded && node.children?.map((child) => ( - setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitRename() + if (e.key === 'Escape') setRenaming(false) + }} + onBlur={submitRename} + autoFocus /> - ))} + ) : ( + + )} + {expanded && ( + <> + {creatingChild && ( + 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) => ( + + ))} + + )} + {menuPos && ( + setMenuPos(null)} + /> + )}
) } const isActive = activeFilePath === node.path + if (renaming) { + return ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitRename() + if (e.key === 'Escape') setRenaming(false) + }} + onBlur={submitRename} + autoFocus + /> + ) + } + return ( - + <> + + {menuPos && ( + setMenuPos(null)} + /> + )} + ) } diff --git a/src/renderer/store/editorStore.ts b/src/renderer/store/editorStore.ts index 329b7ce..eefe27b 100644 --- a/src/renderer/store/editorStore.ts +++ b/src/renderer/store/editorStore.ts @@ -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((set, get) => ({ }) }, + clearActiveFile: () => set({ activeFilePath: null, activeFileContent: '', isDirty: false }), + activeFilePath: null, activeFileContent: '', isDirty: false, diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 3b050d6..138df44 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -18,6 +18,10 @@ declare global { listRevisions: (filePath: string) => Promise loadRevision: (filePath: string, revisionId: string) => Promise deleteRevision: (filePath: string, revisionId: string) => Promise + renameNode: (oldPath: string, newName: string) => Promise + deleteNode: (targetPath: string) => Promise + createFile: (parentPath: string, name: string) => Promise + createDir: (parentPath: string, name: string) => Promise } } }