From ca49a407da0857a3bcd23eb520e7df1475af1c7a Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Sat, 21 Feb 2026 10:42:01 +1000 Subject: [PATCH] :sparkles: chapter re-ordering --- src/main/fileSystem.ts | 38 ++++++++++- src/main/ipcHandlers.ts | 6 +- src/preload/index.ts | 5 +- src/renderer/components/FileTree/FileTree.css | 14 ++++ src/renderer/components/FileTree/FileTree.tsx | 8 ++- .../components/FileTree/FileTreeNode.tsx | 65 ++++++++++++++++++- src/renderer/store/editorStore.ts | 34 ++++++++++ src/renderer/types/global.d.ts | 1 + 8 files changed, 162 insertions(+), 9 deletions(-) diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts index 8fb1464..72922f5 100644 --- a/src/main/fileSystem.ts +++ b/src/main/fileSystem.ts @@ -5,6 +5,8 @@ import type { FileNode } from '../renderer/types/editor' const DRAFT_ROOT = process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft' +const ORDER_FILE = join(DRAFT_ROOT, '.order.json') + const PART_ORDER = ['Prologue', 'Content Warning', 'Part I', 'Part II', 'Part III', 'Part IV', 'Epilogue', 'The first time'] function sortDraftNodes(a: FileNode, b: FileNode): number { @@ -16,8 +18,30 @@ function sortDraftNodes(a: FileNode, b: FileNode): number { return a.name.localeCompare(b.name) } +async function readOrderFile(): Promise> { + try { + return JSON.parse(await readFile(ORDER_FILE, 'utf-8')) + } catch { + return {} + } +} + +export async function saveOrderFile(order: Record): Promise { + await writeFile(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8') +} + +function applyOrder(nodes: FileNode[], savedNames: string[]): FileNode[] { + const map = new Map(nodes.map((n) => [n.name, n])) + const ordered = savedNames.filter((n) => map.has(n)).map((n) => map.get(n)!) + const rest = nodes.filter((n) => !savedNames.includes(n.name)) + return [...ordered, ...rest] +} + export async function listDraftFiles(): Promise { - const entries = await readdir(DRAFT_ROOT, { withFileTypes: true }) + const [entries, order] = await Promise.all([ + readdir(DRAFT_ROOT, { withFileTypes: true }), + readOrderFile() + ]) const nodes: FileNode[] = [] for (const entry of entries) { @@ -26,14 +50,19 @@ export async function listDraftFiles(): Promise { if (entry.isDirectory()) { const children = await readdir(fullPath, { withFileTypes: true }) - const childNodes: FileNode[] = children + let childNodes: FileNode[] = children .filter((c) => c.name.endsWith('.md') && !c.name.startsWith('.')) .map((c) => ({ name: c.name.replace(/\.md$/, ''), path: join(fullPath, c.name), type: 'file' as const })) - .sort((a, b) => a.name.localeCompare(b.name)) + + if (order[fullPath]) { + childNodes = applyOrder(childNodes, order[fullPath]) + } else { + childNodes = childNodes.sort((a, b) => a.name.localeCompare(b.name)) + } nodes.push({ name: entry.name, @@ -50,6 +79,9 @@ export async function listDraftFiles(): Promise { } } + if (order['__root__']) { + return applyOrder(nodes, order['__root__']) + } return nodes.sort(sortDraftNodes) } diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index e104e84..765dc16 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount } from './fileSystem' +import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile } from './fileSystem' import { streamMessage } from './aiService' import type { AIPayload } from '../renderer/types/editor' @@ -20,6 +20,10 @@ export function registerIpcHandlers(): void { return await getProjectWordCount() }) + ipcMain.handle('fs:saveOrder', async (_event, order: Record) => { + await saveOrderFile(order) + }) + ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { try { await streamMessage(payload, (chunk: string) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 81faac6..e95c5d0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -45,5 +45,8 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.removeAllListeners('ai:error') }, - getProjectWordCount: (): Promise => ipcRenderer.invoke('fs:projectWordCount') + getProjectWordCount: (): Promise => ipcRenderer.invoke('fs:projectWordCount'), + + saveOrder: (order: Record): Promise => + ipcRenderer.invoke('fs:saveOrder', order) }) diff --git a/src/renderer/components/FileTree/FileTree.css b/src/renderer/components/FileTree/FileTree.css index 47e6614..d8698e0 100644 --- a/src/renderer/components/FileTree/FileTree.css +++ b/src/renderer/components/FileTree/FileTree.css @@ -78,3 +78,17 @@ color: var(--accent); background: var(--active-bg); } + +.tree-file.dragging, +.tree-dir-header.dragging { + opacity: 0.35; + cursor: grabbing; +} + +.tree-file.drag-over { + box-shadow: inset 0 -2px 0 var(--accent); +} + +.tree-dir-header.drag-over { + box-shadow: inset 0 -2px 0 var(--accent); +} diff --git a/src/renderer/components/FileTree/FileTree.tsx b/src/renderer/components/FileTree/FileTree.tsx index ca092cc..b476fdf 100644 --- a/src/renderer/components/FileTree/FileTree.tsx +++ b/src/renderer/components/FileTree/FileTree.tsx @@ -10,7 +10,13 @@ export function FileTree(): JSX.Element {
HOHOFF
{fileTree.map((node) => ( - + ))}
diff --git a/src/renderer/components/FileTree/FileTreeNode.tsx b/src/renderer/components/FileTree/FileTreeNode.tsx index 34158d7..5465bf1 100644 --- a/src/renderer/components/FileTree/FileTreeNode.tsx +++ b/src/renderer/components/FileTree/FileTreeNode.tsx @@ -2,14 +2,21 @@ import { useState } from 'react' import { useEditorStore } from '../../store/editorStore' import type { FileNode } from '../../types/editor' +interface DndPayload { + name: string + dirPath: string +} + interface Props { node: FileNode depth: number + dirPath: string + siblings: FileNode[] } -export function FileTreeNode({ node, depth }: Props): JSX.Element { +export function FileTreeNode({ node, depth, dirPath, siblings }: Props): JSX.Element { const [expanded, setExpanded] = useState(true) - const { activeFilePath, setActiveFile } = useEditorStore() + const { activeFilePath, setActiveFile, moveNode } = useEditorStore() const openFile = async (): Promise => { if (node.type === 'file') { @@ -18,6 +25,40 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element { } } + function handleDragStart(e: React.DragEvent): void { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('dnd', JSON.stringify({ name: node.name, dirPath } satisfies DndPayload)) + e.currentTarget.classList.add('dragging') + } + + function handleDragEnd(e: React.DragEvent): void { + e.currentTarget.classList.remove('dragging') + } + + function handleDragOver(e: React.DragEvent): void { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + e.currentTarget.classList.add('drag-over') + } + + function handleDragLeave(e: React.DragEvent): void { + e.currentTarget.classList.remove('drag-over') + } + + function handleDrop(e: React.DragEvent): void { + e.preventDefault() + e.currentTarget.classList.remove('drag-over') + const raw = e.dataTransfer.getData('dnd') + if (!raw) return + const payload: DndPayload = JSON.parse(raw) + if (payload.dirPath !== dirPath) return + const fromIdx = siblings.findIndex((n) => n.name === payload.name) + const toIdx = siblings.findIndex((n) => n.name === node.name) + if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) { + moveNode(dirPath, fromIdx, toIdx) + } + } + if (node.type === 'directory') { return (
@@ -25,12 +66,24 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element { 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} > {expanded ? '▾' : '▸'} {node.name} {expanded && node.children?.map((child) => ( - + ))}
) @@ -44,6 +97,12 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element { 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} diff --git a/src/renderer/store/editorStore.ts b/src/renderer/store/editorStore.ts index de45f0d..c874a57 100644 --- a/src/renderer/store/editorStore.ts +++ b/src/renderer/store/editorStore.ts @@ -35,6 +35,9 @@ interface EditorState { analysisMode: AnalysisMode setAnalysisMode: (mode: AnalysisMode) => void + // File tree reordering + moveNode: (dirPath: string, fromIdx: number, toIdx: number) => void + // Word counts projectWordCount: number setProjectWordCount: (count: number) => void @@ -52,6 +55,37 @@ export const useEditorStore = create((set, get) => ({ fileTree: [], setFileTree: (fileTree) => set({ fileTree }), + moveNode: (dirPath, fromIdx, toIdx) => { + set((s) => { + let newTree: typeof s.fileTree + + if (dirPath === '__root__') { + newTree = [...s.fileTree] + const [moved] = newTree.splice(fromIdx, 1) + newTree.splice(toIdx, 0, moved) + } else { + newTree = s.fileTree.map((node) => { + if (node.path !== dirPath || !node.children) return node + const children = [...node.children] + const [moved] = children.splice(fromIdx, 1) + children.splice(toIdx, 0, moved) + return { ...node, children } + }) + } + + const orderMap: Record = {} + orderMap['__root__'] = newTree.map((n) => n.name) + for (const node of newTree) { + if (node.type === 'directory' && node.children) { + orderMap[node.path] = node.children.map((c) => c.name) + } + } + window.api.saveOrder(orderMap).catch(console.error) + + return { fileTree: newTree } + }) + }, + activeFilePath: null, activeFileContent: '', isDirty: false, diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 956f470..a859b35 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -12,6 +12,7 @@ declare global { ) => Promise removeAIListener: () => void getProjectWordCount: () => Promise + saveOrder: (order: Record) => Promise } } }