chapter re-ordering

This commit is contained in:
2026-02-21 10:42:01 +10:00
parent 740faae8c8
commit ca49a407da
8 changed files with 162 additions and 9 deletions

View File

@@ -5,6 +5,8 @@ import type { FileNode } from '../renderer/types/editor'
const DRAFT_ROOT = const DRAFT_ROOT =
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft' 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'] 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 { function sortDraftNodes(a: FileNode, b: FileNode): number {
@@ -16,8 +18,30 @@ function sortDraftNodes(a: FileNode, b: FileNode): number {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
} }
async function readOrderFile(): Promise<Record<string, string[]>> {
try {
return JSON.parse(await readFile(ORDER_FILE, 'utf-8'))
} catch {
return {}
}
}
export async function saveOrderFile(order: Record<string, string[]>): Promise<void> {
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<FileNode[]> { export async function listDraftFiles(): Promise<FileNode[]> {
const entries = await readdir(DRAFT_ROOT, { withFileTypes: true }) const [entries, order] = await Promise.all([
readdir(DRAFT_ROOT, { withFileTypes: true }),
readOrderFile()
])
const nodes: FileNode[] = [] const nodes: FileNode[] = []
for (const entry of entries) { for (const entry of entries) {
@@ -26,14 +50,19 @@ export async function listDraftFiles(): Promise<FileNode[]> {
if (entry.isDirectory()) { if (entry.isDirectory()) {
const children = await readdir(fullPath, { withFileTypes: true }) const children = await readdir(fullPath, { withFileTypes: true })
const childNodes: FileNode[] = children let childNodes: FileNode[] = children
.filter((c) => c.name.endsWith('.md') && !c.name.startsWith('.')) .filter((c) => c.name.endsWith('.md') && !c.name.startsWith('.'))
.map((c) => ({ .map((c) => ({
name: c.name.replace(/\.md$/, ''), name: c.name.replace(/\.md$/, ''),
path: join(fullPath, c.name), path: join(fullPath, c.name),
type: 'file' as const 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({ nodes.push({
name: entry.name, name: entry.name,
@@ -50,6 +79,9 @@ export async function listDraftFiles(): Promise<FileNode[]> {
} }
} }
if (order['__root__']) {
return applyOrder(nodes, order['__root__'])
}
return nodes.sort(sortDraftNodes) return nodes.sort(sortDraftNodes)
} }

View File

@@ -1,5 +1,5 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount } from './fileSystem' import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile } from './fileSystem'
import { streamMessage } from './aiService' import { streamMessage } from './aiService'
import type { AIPayload } from '../renderer/types/editor' import type { AIPayload } from '../renderer/types/editor'
@@ -20,6 +20,10 @@ export function registerIpcHandlers(): void {
return await getProjectWordCount() return await getProjectWordCount()
}) })
ipcMain.handle('fs:saveOrder', async (_event, order: Record<string, string[]>) => {
await saveOrderFile(order)
})
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try { try {
await streamMessage(payload, (chunk: string) => { await streamMessage(payload, (chunk: string) => {

View File

@@ -45,5 +45,8 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.removeAllListeners('ai:error') ipcRenderer.removeAllListeners('ai:error')
}, },
getProjectWordCount: (): Promise<number> => ipcRenderer.invoke('fs:projectWordCount') getProjectWordCount: (): Promise<number> => ipcRenderer.invoke('fs:projectWordCount'),
saveOrder: (order: Record<string, string[]>): Promise<void> =>
ipcRenderer.invoke('fs:saveOrder', order)
}) })

View File

@@ -78,3 +78,17 @@
color: var(--accent); color: var(--accent);
background: var(--active-bg); 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);
}

View File

@@ -10,7 +10,13 @@ export function FileTree(): JSX.Element {
<div className="file-tree-header">HOHOFF</div> <div className="file-tree-header">HOHOFF</div>
<div className="file-tree-list"> <div className="file-tree-list">
{fileTree.map((node) => ( {fileTree.map((node) => (
<FileTreeNode key={node.path} node={node} depth={0} /> <FileTreeNode
key={node.path}
node={node}
depth={0}
dirPath="__root__"
siblings={fileTree}
/>
))} ))}
</div> </div>
</nav> </nav>

View File

@@ -2,14 +2,21 @@ import { useState } from 'react'
import { useEditorStore } from '../../store/editorStore' import { useEditorStore } from '../../store/editorStore'
import type { FileNode } from '../../types/editor' import type { FileNode } from '../../types/editor'
interface DndPayload {
name: string
dirPath: string
}
interface Props { interface Props {
node: FileNode node: FileNode
depth: number 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 [expanded, setExpanded] = useState(true)
const { activeFilePath, setActiveFile } = useEditorStore() const { activeFilePath, setActiveFile, moveNode } = useEditorStore()
const openFile = async (): Promise<void> => { const openFile = async (): Promise<void> => {
if (node.type === 'file') { if (node.type === 'file') {
@@ -18,6 +25,40 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element {
} }
} }
function handleDragStart(e: React.DragEvent<HTMLElement>): 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<HTMLElement>): void {
e.currentTarget.classList.remove('dragging')
}
function handleDragOver(e: React.DragEvent<HTMLElement>): void {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
e.currentTarget.classList.add('drag-over')
}
function handleDragLeave(e: React.DragEvent<HTMLElement>): void {
e.currentTarget.classList.remove('drag-over')
}
function handleDrop(e: React.DragEvent<HTMLElement>): 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') { if (node.type === 'directory') {
return ( return (
<div className="tree-dir"> <div className="tree-dir">
@@ -25,12 +66,24 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element {
className="tree-dir-header" className="tree-dir-header"
style={{ paddingLeft: `${depth * 12 + 8}px` }} style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
> >
<span className="tree-arrow">{expanded ? '▾' : '▸'}</span> <span className="tree-arrow">{expanded ? '▾' : '▸'}</span>
{node.name} {node.name}
</button> </button>
{expanded && node.children?.map((child) => ( {expanded && node.children?.map((child) => (
<FileTreeNode key={child.path} node={child} depth={depth + 1} /> <FileTreeNode
key={child.path}
node={child}
depth={depth + 1}
dirPath={node.path}
siblings={node.children!}
/>
))} ))}
</div> </div>
) )
@@ -44,6 +97,12 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element {
style={{ paddingLeft: `${depth * 12 + 20}px` }} style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={openFile} onClick={openFile}
title={node.name} title={node.name}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
> >
{node.name} {node.name}
</button> </button>

View File

@@ -35,6 +35,9 @@ interface EditorState {
analysisMode: AnalysisMode analysisMode: AnalysisMode
setAnalysisMode: (mode: AnalysisMode) => void setAnalysisMode: (mode: AnalysisMode) => void
// File tree reordering
moveNode: (dirPath: string, fromIdx: number, toIdx: number) => void
// Word counts // Word counts
projectWordCount: number projectWordCount: number
setProjectWordCount: (count: number) => void setProjectWordCount: (count: number) => void
@@ -52,6 +55,37 @@ export const useEditorStore = create<EditorState>((set, get) => ({
fileTree: [], fileTree: [],
setFileTree: (fileTree) => set({ 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<string, string[]> = {}
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, activeFilePath: null,
activeFileContent: '', activeFileContent: '',
isDirty: false, isDirty: false,

View File

@@ -12,6 +12,7 @@ declare global {
) => Promise<void> ) => Promise<void>
removeAIListener: () => void removeAIListener: () => void
getProjectWordCount: () => Promise<number> getProjectWordCount: () => Promise<number>
saveOrder: (order: Record<string, string[]>) => Promise<void>
} }
} }
} }