+ {/* Titlebar – same markup as App.tsx */}
+
+
Hohoff Editor
+
+
+
+
+
+
+
-
-
+ {/* Editor content below */}
+
)
diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts
index ecdd229..c5aeb72 100644
--- a/src/main/fileSystem.ts
+++ b/src/main/fileSystem.ts
@@ -1,12 +1,15 @@
-import { readdir, readFile, writeFile } from 'fs/promises'
+import { readdir, readFile, writeFile, mkdir, unlink } from 'fs/promises'
import { join } from 'path'
-import type { FileNode } from '../renderer/types/editor'
+import type { FileNode, RevisionMeta } 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 SESSION_FILE = join(DRAFT_ROOT, '.session.json')
+const REVISIONS_DIR = join(DRAFT_ROOT, '.revisions')
+
+const MAX_REVISIONS = 50
const PART_ORDER = ['Prologue', 'Content Warning', 'Part I', 'Part II', 'Part III', 'Part IV', 'Epilogue', 'The first time']
@@ -144,3 +147,71 @@ export async function getProjectWordCount(): Promise
{
}
return total
}
+
+// ─── Revision system ─────────────────────────────────────────────────────────
+
+function revisionSlug(filePath: string): string {
+ const prefix = DRAFT_ROOT + '/'
+ const rel = filePath.startsWith(prefix) ? filePath.slice(prefix.length) : filePath
+ return rel.replace(/\.md$/, '').replace(/\//g, '__')
+}
+
+function shortId(): string {
+ return Math.random().toString(36).slice(2, 7)
+}
+
+export async function saveRevision(filePath: string, content: string): Promise {
+ assertInDraftRoot(filePath)
+ const slug = revisionSlug(filePath)
+ const dir = join(REVISIONS_DIR, slug)
+ await mkdir(dir, { recursive: true })
+ const timestamp = Date.now()
+ const id = `${timestamp}_${shortId()}`
+ const revision = { id, timestamp, wordCount: countWords(content), content }
+ await writeFile(join(dir, `${id}.json`), JSON.stringify(revision), 'utf-8')
+ // Prune oldest revisions beyond the limit
+ const entries = (await readdir(dir)).filter((e) => e.endsWith('.json')).sort()
+ if (entries.length > MAX_REVISIONS) {
+ await Promise.all(
+ entries.slice(0, entries.length - MAX_REVISIONS).map((f) => unlink(join(dir, f)))
+ )
+ }
+}
+
+export async function listRevisions(filePath: string): Promise {
+ assertInDraftRoot(filePath)
+ const slug = revisionSlug(filePath)
+ const dir = join(REVISIONS_DIR, slug)
+ try {
+ const entries = (await readdir(dir)).filter((e) => e.endsWith('.json')).sort().reverse()
+ return await Promise.all(
+ entries.map(async (f) => {
+ const raw = JSON.parse(await readFile(join(dir, f), 'utf-8')) as {
+ id: string
+ timestamp: number
+ wordCount: number
+ }
+ return { id: raw.id, timestamp: raw.timestamp, wordCount: raw.wordCount }
+ })
+ )
+ } catch {
+ return []
+ }
+}
+
+export async function loadRevision(filePath: string, revisionId: string): Promise {
+ assertInDraftRoot(filePath)
+ if (!/^[\w-]+$/.test(revisionId)) throw new Error('Invalid revision ID')
+ const slug = revisionSlug(filePath)
+ const revPath = join(REVISIONS_DIR, slug, `${revisionId}.json`)
+ const raw = JSON.parse(await readFile(revPath, 'utf-8')) as { content: string }
+ return raw.content
+}
+
+export async function deleteRevision(filePath: string, revisionId: string): Promise {
+ assertInDraftRoot(filePath)
+ if (!/^[\w-]+$/.test(revisionId)) throw new Error('Invalid revision ID')
+ const slug = revisionSlug(filePath)
+ const revPath = join(REVISIONS_DIR, slug, `${revisionId}.json`)
+ await unlink(revPath)
+}
diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts
index 8defe33..4f458b6 100644
--- a/src/main/ipcHandlers.ts
+++ b/src/main/ipcHandlers.ts
@@ -1,5 +1,5 @@
import { ipcMain } from 'electron'
-import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession } from './fileSystem'
+import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision } from './fileSystem'
import { streamMessage } from './aiService'
import type { AIPayload } from '../renderer/types/editor'
@@ -32,6 +32,22 @@ export function registerIpcHandlers(): void {
await writeSession(data)
})
+ ipcMain.handle('revisions:save', async (_event, filePath: string, content: string) => {
+ await saveRevision(filePath, content)
+ })
+
+ ipcMain.handle('revisions:list', async (_event, filePath: string) => {
+ return await listRevisions(filePath)
+ })
+
+ ipcMain.handle('revisions:load', async (_event, filePath: string, revisionId: string) => {
+ return await loadRevision(filePath, revisionId)
+ })
+
+ ipcMain.handle('revisions:delete', async (_event, filePath: string, revisionId: string) => {
+ await deleteRevision(filePath, revisionId)
+ })
+
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 f6aa7b3..8de049d 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
-import type { FileNode, AIPayload } from '../renderer/types/editor'
+import type { FileNode, AIPayload, RevisionMeta } from '../renderer/types/editor'
contextBridge.exposeInMainWorld('api', {
listFiles: (): Promise => ipcRenderer.invoke('fs:listFiles'),
@@ -54,5 +54,17 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.invoke('session:read'),
writeSession: (data: Record): Promise =>
- ipcRenderer.invoke('session:write', data)
+ ipcRenderer.invoke('session:write', data),
+
+ saveRevision: (filePath: string, content: string): Promise =>
+ ipcRenderer.invoke('revisions:save', filePath, content),
+
+ listRevisions: (filePath: string): Promise =>
+ ipcRenderer.invoke('revisions:list', filePath),
+
+ loadRevision: (filePath: string, revisionId: string): Promise =>
+ ipcRenderer.invoke('revisions:load', filePath, revisionId),
+
+ deleteRevision: (filePath: string, revisionId: string): Promise =>
+ ipcRenderer.invoke('revisions:delete', filePath, revisionId)
})
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index d3e4e72..00f714e 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -3,12 +3,15 @@ import { FileTree } from './components/FileTree/FileTree'
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
import { ChatPanel } from './components/AIChat/ChatPanel'
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
+import { RevisionPanel } from './components/Revisions/RevisionPanel'
import { useEditorStore } from './store/editorStore'
import './styles/app.css'
export default function App(): JSX.Element {
- const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme, loadSession } =
- useEditorStore()
+ const {
+ setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
+ loadSession, revisionPanelOpen, toggleRevisionPanel
+ } = useEditorStore()
const [sidebarOpen, setSidebarOpen] = useState(
() => localStorage.getItem('sidebarOpen') !== 'false'
)
@@ -30,6 +33,7 @@ export default function App(): JSX.Element {
e.preventDefault()
if (activeFilePath && isDirty) {
await window.api.writeFile(activeFilePath, activeFileContent)
+ await window.api.saveRevision(activeFilePath, activeFileContent)
markSaved()
}
}
@@ -60,21 +64,31 @@ export default function App(): JSX.Element {
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
/>