From f9333626aa4c7315333d4a926bbfb181a5305445 Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Thu, 26 Feb 2026 04:10:30 +1000 Subject: [PATCH] :sparkles: revisions --- demo/index.html | 19 +- demo/main.tsx | 42 +++- src/main/fileSystem.ts | 75 ++++++- src/main/ipcHandlers.ts | 18 +- src/preload/index.ts | 16 +- src/renderer/App.tsx | 34 +++- .../components/Revisions/RevisionPanel.css | 190 ++++++++++++++++++ .../components/Revisions/RevisionPanel.tsx | 131 ++++++++++++ src/renderer/store/editorStore.ts | 13 +- src/renderer/styles/app.css | 17 ++ src/renderer/types/editor.ts | 6 + src/renderer/types/global.d.ts | 6 +- tsconfig.node.tsbuildinfo | 2 +- tsconfig.web.tsbuildinfo | 2 +- 14 files changed, 534 insertions(+), 37 deletions(-) create mode 100644 src/renderer/components/Revisions/RevisionPanel.css create mode 100644 src/renderer/components/Revisions/RevisionPanel.tsx diff --git a/demo/index.html b/demo/index.html index 04ac4c0..7a89104 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1,14 +1,11 @@ - - - Annotation Tooltip Demo - - - -
- - + + + Titlebar Demo + + + +
+ diff --git a/demo/main.tsx b/demo/main.tsx index 13697ea..ac72129 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -1,9 +1,10 @@ -import { StrictMode } from 'react' +import { StrictMode, useState } from 'react' import { createRoot } from 'react-dom/client' import { useEditorStore } from '@renderer/store/editorStore' import { MarkdownEditor } from '@renderer/components/Editor/MarkdownEditor' import { ChatPanel } from '@renderer/components/AIChat/ChatPanel' import '@renderer/styles/global.css' +import '@renderer/styles/app.css' // Mock window.api so the renderer doesn't crash in a plain browser context ;(window as unknown as Record).api = { @@ -13,7 +14,11 @@ import '@renderer/styles/global.css' streamAIMessage: () => Promise.resolve(), removeAIListener: () => {}, getProjectWordCount: () => Promise.resolve(0), - saveOrder: () => Promise.resolve() + saveOrder: () => Promise.resolve(), + saveRevision: () => Promise.resolve(), + listRevisions: () => Promise.resolve([]), + loadRevision: () => Promise.resolve(''), + deleteRevision: () => Promise.resolve() } const DEMO_TEXT = @@ -63,13 +68,36 @@ store.setAnnotations([ ]) function DemoApp(): JSX.Element { + const [revActive, setRevActive] = useState(false) return ( -
-
- +
+ {/* 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'} />
- +
+ + +
-
+
+ {revisionPanelOpen && }