✨ revisions
This commit is contained in:
@@ -1,14 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Annotation Tooltip Demo</title>
|
<title>Titlebar Demo</title>
|
||||||
<style>
|
<script type="module" src="./main.tsx"></script>
|
||||||
html, body, #root { height: 100%; margin: 0; padding: 0; overflow: hidden; }
|
</head>
|
||||||
</style>
|
<body>
|
||||||
</head>
|
<div id="root"></div>
|
||||||
<body>
|
</body>
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { useEditorStore } from '@renderer/store/editorStore'
|
import { useEditorStore } from '@renderer/store/editorStore'
|
||||||
import { MarkdownEditor } from '@renderer/components/Editor/MarkdownEditor'
|
import { MarkdownEditor } from '@renderer/components/Editor/MarkdownEditor'
|
||||||
import { ChatPanel } from '@renderer/components/AIChat/ChatPanel'
|
import { ChatPanel } from '@renderer/components/AIChat/ChatPanel'
|
||||||
import '@renderer/styles/global.css'
|
import '@renderer/styles/global.css'
|
||||||
|
import '@renderer/styles/app.css'
|
||||||
|
|
||||||
// Mock window.api so the renderer doesn't crash in a plain browser context
|
// Mock window.api so the renderer doesn't crash in a plain browser context
|
||||||
;(window as unknown as Record<string, unknown>).api = {
|
;(window as unknown as Record<string, unknown>).api = {
|
||||||
@@ -13,7 +14,11 @@ import '@renderer/styles/global.css'
|
|||||||
streamAIMessage: () => Promise.resolve(),
|
streamAIMessage: () => Promise.resolve(),
|
||||||
removeAIListener: () => {},
|
removeAIListener: () => {},
|
||||||
getProjectWordCount: () => Promise.resolve(0),
|
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 =
|
const DEMO_TEXT =
|
||||||
@@ -63,13 +68,36 @@ store.setAnnotations([
|
|||||||
])
|
])
|
||||||
|
|
||||||
function DemoApp(): JSX.Element {
|
function DemoApp(): JSX.Element {
|
||||||
|
const [revActive, setRevActive] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: '100%', background: 'var(--bg)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-base)' }}>
|
||||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
{/* Titlebar – same markup as App.tsx */}
|
||||||
<MarkdownEditor />
|
<div className="app-titlebar">
|
||||||
|
<span className="app-titlebar-title">Hohoff Editor</span>
|
||||||
|
<div className="app-titlebar-right">
|
||||||
|
<div className="app-layout-toggle">
|
||||||
|
<button className="app-layout-toggle-seg active" />
|
||||||
|
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" />
|
||||||
|
<button className="app-layout-toggle-seg active" />
|
||||||
|
</div>
|
||||||
|
<div className="app-titlebar-icon-group">
|
||||||
|
<button
|
||||||
|
className={`app-titlebar-theme-btn app-titlebar-revision-btn${revActive ? ' active' : ''}`}
|
||||||
|
onClick={() => setRevActive(v => !v)}
|
||||||
|
title="Revision history"
|
||||||
|
>⟳</button>
|
||||||
|
<button className="app-titlebar-theme-btn" title="Toggle theme">☀</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: '300px', flexShrink: 0 }}>
|
{/* Editor content below */}
|
||||||
<ChatPanel />
|
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<MarkdownEditor />
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '300px', flexShrink: 0 }}>
|
||||||
|
<ChatPanel />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 { join } from 'path'
|
||||||
import type { FileNode } from '../renderer/types/editor'
|
import type { FileNode, RevisionMeta } 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 ORDER_FILE = join(DRAFT_ROOT, '.order.json')
|
||||||
const SESSION_FILE = join(DRAFT_ROOT, '.session.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']
|
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<number> {
|
|||||||
}
|
}
|
||||||
return total
|
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<void> {
|
||||||
|
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<RevisionMeta[]> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ipcMain } from 'electron'
|
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 { streamMessage } from './aiService'
|
||||||
import type { AIPayload } from '../renderer/types/editor'
|
import type { AIPayload } from '../renderer/types/editor'
|
||||||
|
|
||||||
@@ -32,6 +32,22 @@ export function registerIpcHandlers(): void {
|
|||||||
await writeSession(data)
|
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) => {
|
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||||
try {
|
try {
|
||||||
await streamMessage(payload, (chunk: string) => {
|
await streamMessage(payload, (chunk: string) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
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', {
|
contextBridge.exposeInMainWorld('api', {
|
||||||
listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
|
listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
|
||||||
@@ -54,5 +54,17 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.invoke('session:read'),
|
ipcRenderer.invoke('session:read'),
|
||||||
|
|
||||||
writeSession: (data: Record<string, unknown>): Promise<void> =>
|
writeSession: (data: Record<string, unknown>): Promise<void> =>
|
||||||
ipcRenderer.invoke('session:write', data)
|
ipcRenderer.invoke('session:write', data),
|
||||||
|
|
||||||
|
saveRevision: (filePath: string, content: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('revisions:save', filePath, content),
|
||||||
|
|
||||||
|
listRevisions: (filePath: string): Promise<RevisionMeta[]> =>
|
||||||
|
ipcRenderer.invoke('revisions:list', filePath),
|
||||||
|
|
||||||
|
loadRevision: (filePath: string, revisionId: string): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('revisions:load', filePath, revisionId),
|
||||||
|
|
||||||
|
deleteRevision: (filePath: string, revisionId: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('revisions:delete', filePath, revisionId)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { FileTree } from './components/FileTree/FileTree'
|
|||||||
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
||||||
import { ChatPanel } from './components/AIChat/ChatPanel'
|
import { ChatPanel } from './components/AIChat/ChatPanel'
|
||||||
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
||||||
|
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
||||||
import { useEditorStore } from './store/editorStore'
|
import { useEditorStore } from './store/editorStore'
|
||||||
import './styles/app.css'
|
import './styles/app.css'
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme, loadSession } =
|
const {
|
||||||
useEditorStore()
|
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
||||||
|
loadSession, revisionPanelOpen, toggleRevisionPanel
|
||||||
|
} = useEditorStore()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(
|
const [sidebarOpen, setSidebarOpen] = useState(
|
||||||
() => localStorage.getItem('sidebarOpen') !== 'false'
|
() => localStorage.getItem('sidebarOpen') !== 'false'
|
||||||
)
|
)
|
||||||
@@ -30,6 +33,7 @@ export default function App(): JSX.Element {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (activeFilePath && isDirty) {
|
if (activeFilePath && isDirty) {
|
||||||
await window.api.writeFile(activeFilePath, activeFileContent)
|
await window.api.writeFile(activeFilePath, activeFileContent)
|
||||||
|
await window.api.saveRevision(activeFilePath, activeFileContent)
|
||||||
markSaved()
|
markSaved()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,21 +64,31 @@ export default function App(): JSX.Element {
|
|||||||
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
|
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="app-titlebar-icon-group">
|
||||||
className="app-titlebar-theme-btn"
|
<button
|
||||||
onClick={toggleTheme}
|
className={`app-titlebar-theme-btn app-titlebar-revision-btn${revisionPanelOpen ? ' active' : ''}`}
|
||||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
onClick={toggleRevisionPanel}
|
||||||
>
|
title="Revision history"
|
||||||
{theme === 'dark' ? '☀' : '☾'}
|
>
|
||||||
</button>
|
⟳
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="app-titlebar-theme-btn"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀' : '☾'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<FileTree />
|
<FileTree />
|
||||||
</aside>
|
</aside>
|
||||||
<main className="editor-area">
|
<main className="editor-area" style={{ position: 'relative' }}>
|
||||||
<AnalysisToolbar />
|
<AnalysisToolbar />
|
||||||
<MarkdownEditor />
|
<MarkdownEditor />
|
||||||
|
{revisionPanelOpen && <RevisionPanel />}
|
||||||
</main>
|
</main>
|
||||||
<aside className="chat-area">
|
<aside className="chat-area">
|
||||||
<ChatPanel />
|
<ChatPanel />
|
||||||
|
|||||||
190
src/renderer/components/Revisions/RevisionPanel.css
Normal file
190
src/renderer/components/Revisions/RevisionPanel.css
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/* ─── Revision panel — overlays the editor area ─────────────────────────── */
|
||||||
|
.revision-panel {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--editor-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Header ─────────────────────────────────────────────────────────────── */
|
||||||
|
.revision-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 38px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--toolbar-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-back-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-back-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-panel-title {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Body (two-column layout) ───────────────────────────────────────────── */
|
||||||
|
.revision-panel-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Revision list (left column) ───────────────────────────────────────── */
|
||||||
|
.revision-list {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-empty {
|
||||||
|
padding: 20px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-entry {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-entry:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-entry.selected {
|
||||||
|
background: var(--active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-entry-date {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-entry-words {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-entry-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-entry:hover .revision-entry-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-entry-delete:hover {
|
||||||
|
color: #c05050;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Preview (right column) ─────────────────────────────────────────────── */
|
||||||
|
.revision-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-preview-empty {
|
||||||
|
margin: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-preview-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 32px;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--text-primary);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-preview-content h1,
|
||||||
|
.revision-preview-content h2,
|
||||||
|
.revision-preview-content h3 {
|
||||||
|
color: var(--heading-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 1em 0 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-preview-content p {
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-preview-content em { font-style: italic; }
|
||||||
|
.revision-preview-content strong { font-weight: 700; }
|
||||||
|
|
||||||
|
.revision-preview-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--toolbar-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-restore-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-base);
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revision-restore-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
131
src/renderer/components/Revisions/RevisionPanel.tsx
Normal file
131
src/renderer/components/Revisions/RevisionPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
|
import { currentEditorView } from '../Editor/MarkdownEditor'
|
||||||
|
import type { RevisionMeta } from '../../types/editor'
|
||||||
|
import './RevisionPanel.css'
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
const d = new Date(ts)
|
||||||
|
const now = new Date()
|
||||||
|
const isToday = d.toDateString() === now.toDateString()
|
||||||
|
const isYesterday = d.toDateString() === new Date(now.getTime() - 86400000).toDateString()
|
||||||
|
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
if (isToday) return `Today, ${time}`
|
||||||
|
if (isYesterday) return `Yesterday, ${time}`
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + time
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevisionPanel(): JSX.Element {
|
||||||
|
const { activeFilePath, toggleRevisionPanel, revisions, setRevisions } = useEditorStore()
|
||||||
|
const [selected, setSelected] = useState<RevisionMeta | null>(null)
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||||
|
const [previewRaw, setPreviewRaw] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeFilePath) return
|
||||||
|
window.api.listRevisions(activeFilePath).then(setRevisions)
|
||||||
|
setSelected(null)
|
||||||
|
setPreviewHtml(null)
|
||||||
|
setPreviewRaw(null)
|
||||||
|
}, [activeFilePath])
|
||||||
|
|
||||||
|
const selectRevision = async (rev: RevisionMeta): Promise<void> => {
|
||||||
|
if (selected?.id === rev.id) return
|
||||||
|
setSelected(rev)
|
||||||
|
setPreviewHtml(null)
|
||||||
|
setPreviewRaw(null)
|
||||||
|
if (!activeFilePath) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const content = await window.api.loadRevision(activeFilePath, rev.id)
|
||||||
|
setPreviewRaw(content)
|
||||||
|
const html = await marked.parse(content)
|
||||||
|
setPreviewHtml(DOMPurify.sanitize(html))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restore = (): void => {
|
||||||
|
if (previewRaw === null) return
|
||||||
|
const view = currentEditorView
|
||||||
|
if (view) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: previewRaw }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toggleRevisionPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRev = async (e: React.MouseEvent, rev: RevisionMeta): Promise<void> => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!activeFilePath) return
|
||||||
|
await window.api.deleteRevision(activeFilePath, rev.id)
|
||||||
|
const updated = revisions.filter((r) => r.id !== rev.id)
|
||||||
|
setRevisions(updated)
|
||||||
|
if (selected?.id === rev.id) {
|
||||||
|
setSelected(null)
|
||||||
|
setPreviewHtml(null)
|
||||||
|
setPreviewRaw(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="revision-panel">
|
||||||
|
<div className="revision-panel-header">
|
||||||
|
<button className="revision-back-btn" onClick={toggleRevisionPanel} title="Close">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span className="revision-panel-title">Revision History</span>
|
||||||
|
</div>
|
||||||
|
<div className="revision-panel-body">
|
||||||
|
<div className="revision-list">
|
||||||
|
{revisions.length === 0 ? (
|
||||||
|
<p className="revision-empty">No revisions yet.<br />Save with ⌘S to create one.</p>
|
||||||
|
) : (
|
||||||
|
revisions.map((rev) => (
|
||||||
|
<div
|
||||||
|
key={rev.id}
|
||||||
|
className={`revision-entry${selected?.id === rev.id ? ' selected' : ''}`}
|
||||||
|
onClick={() => selectRevision(rev)}
|
||||||
|
>
|
||||||
|
<div className="revision-entry-date">{formatDate(rev.timestamp)}</div>
|
||||||
|
<div className="revision-entry-words">{rev.wordCount.toLocaleString()} words</div>
|
||||||
|
<button
|
||||||
|
className="revision-entry-delete"
|
||||||
|
onClick={(e) => deleteRev(e, rev)}
|
||||||
|
title="Delete revision"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="revision-preview">
|
||||||
|
{selected === null ? (
|
||||||
|
<p className="revision-preview-empty">Select a revision to preview</p>
|
||||||
|
) : loading ? (
|
||||||
|
<p className="revision-preview-empty">Loading…</p>
|
||||||
|
) : previewHtml !== null ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="revision-preview-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||||
|
/>
|
||||||
|
<div className="revision-preview-footer">
|
||||||
|
<button className="revision-restore-btn" onClick={restore}>
|
||||||
|
Restore this version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode } from '../types/editor'
|
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode, RevisionMeta } from '../types/editor'
|
||||||
|
|
||||||
interface AnnotationFileState {
|
interface AnnotationFileState {
|
||||||
mode: AnalysisMode
|
mode: AnalysisMode
|
||||||
@@ -60,6 +60,12 @@ interface EditorState {
|
|||||||
theme: 'dark' | 'light'
|
theme: 'dark' | 'light'
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
|
|
||||||
|
// Revision panel
|
||||||
|
revisionPanelOpen: boolean
|
||||||
|
toggleRevisionPanel: () => void
|
||||||
|
revisions: RevisionMeta[]
|
||||||
|
setRevisions: (revisions: RevisionMeta[]) => void
|
||||||
|
|
||||||
// Session persistence
|
// Session persistence
|
||||||
loadSession: () => Promise<void>
|
loadSession: () => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -286,6 +292,11 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
projectWordCount: 0,
|
projectWordCount: 0,
|
||||||
setProjectWordCount: (projectWordCount) => set({ projectWordCount }),
|
setProjectWordCount: (projectWordCount) => set({ projectWordCount }),
|
||||||
|
|
||||||
|
revisionPanelOpen: false,
|
||||||
|
toggleRevisionPanel: () => set((s) => ({ revisionPanelOpen: !s.revisionPanelOpen })),
|
||||||
|
revisions: [],
|
||||||
|
setRevisions: (revisions) => set({ revisions }),
|
||||||
|
|
||||||
fontSize: Number(localStorage.getItem('editorFontSize')) || 15,
|
fontSize: Number(localStorage.getItem('editorFontSize')) || 15,
|
||||||
setFontSize: (size) => {
|
setFontSize: (size) => {
|
||||||
const clamped = Math.max(11, Math.min(24, size))
|
const clamped = Math.max(11, Math.min(24, size))
|
||||||
|
|||||||
@@ -100,12 +100,29 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-titlebar-theme-btn:hover {
|
.app-titlebar-theme-btn:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-titlebar-theme-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar-revision-btn {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar-icon-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Sidebar ──────────────────────────────────────────────────── */
|
/* ─── Sidebar ──────────────────────────────────────────────────── */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: var(--sidebar-bg);
|
background: var(--sidebar-bg);
|
||||||
|
|||||||
@@ -34,3 +34,9 @@ export interface AIPayload {
|
|||||||
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
|
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
userMessage: string
|
userMessage: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RevisionMeta {
|
||||||
|
id: string
|
||||||
|
timestamp: number
|
||||||
|
wordCount: number
|
||||||
|
}
|
||||||
|
|||||||
6
src/renderer/types/global.d.ts
vendored
6
src/renderer/types/global.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
import type { FileNode, AIPayload } from './editor'
|
import type { FileNode, AIPayload, RevisionMeta } from './editor'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -13,6 +13,10 @@ declare global {
|
|||||||
removeAIListener: () => void
|
removeAIListener: () => void
|
||||||
getProjectWordCount: () => Promise<number>
|
getProjectWordCount: () => Promise<number>
|
||||||
saveOrder: (order: Record<string, string[]>) => Promise<void>
|
saveOrder: (order: Record<string, string[]>) => Promise<void>
|
||||||
|
saveRevision: (filePath: string, content: string) => Promise<void>
|
||||||
|
listRevisions: (filePath: string) => Promise<RevisionMeta[]>
|
||||||
|
loadRevision: (filePath: string, revisionId: string) => Promise<string>
|
||||||
|
deleteRevision: (filePath: string, revisionId: string) => Promise<void>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user