import { useEffect, useState } from 'react' import { diffLines } from 'diff' import type { Change } from 'diff' 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' // ─── Diff view ──────────────────────────────────────────────────────────────── const CONTEXT_LINES = 3 type Segment = | { kind: 'changed'; changes: Change[] } | { kind: 'context'; lines: string[]; segIndex: number } interface DiffViewProps { oldText: string newText: string expandedSegments: Set onExpand: (segIndex: number) => void } function DiffView({ oldText, newText, expandedSegments, onExpand }: DiffViewProps): JSX.Element { const changes = diffLines(oldText, newText) // Build flat list of segments: changed hunks and unchanged context runs const segments: Segment[] = [] let pendingLines: string[] = [] let segCounter = 0 const flushContext = (): void => { if (pendingLines.length > 0) { segments.push({ kind: 'context', lines: pendingLines, segIndex: segCounter++ }) pendingLines = [] } } for (const change of changes) { if (change.added || change.removed) { flushContext() const last = segments[segments.length - 1] if (last?.kind === 'changed') { last.changes.push(change) } else { segments.push({ kind: 'changed', changes: [change] }) } } else { const lines = change.value.split('\n') if (lines[lines.length - 1] === '') lines.pop() pendingLines.push(...lines) } } flushContext() if (segments.length === 0 || !changes.some((c) => c.added || c.removed)) { return

No changes — this revision matches the current content.

} return (
{segments.map((seg, i) => { if (seg.kind === 'changed') { return (
{seg.changes.flatMap((ch, j) => { const prefix = ch.added ? '+' : '-' const cls = ch.added ? 'rdiff-line--added' : 'rdiff-line--removed' const lines = ch.value.split('\n') if (lines[lines.length - 1] === '') lines.pop() return lines.map((line, k) => (
{prefix} {line || '\u00A0'}
)) })}
) } const { lines, segIndex } = seg const total = lines.length const isExpanded = expandedSegments.has(segIndex) if (total <= CONTEXT_LINES * 2 || isExpanded) { return (
{lines.map((line, k) => (
{line || '\u00A0'}
))}
) } const topLines = lines.slice(0, CONTEXT_LINES) const bottomLines = lines.slice(total - CONTEXT_LINES) const hiddenCount = total - CONTEXT_LINES * 2 return (
{topLines.map((line, k) => (
{line || '\u00A0'}
))} {bottomLines.map((line, k) => (
{line || '\u00A0'}
))}
) })}
) } // ─── Helpers ────────────────────────────────────────────────────────────────── 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 } // ─── RevisionPanel ──────────────────────────────────────────────────────────── type ViewMode = 'diff' | 'preview' export function RevisionPanel(): JSX.Element { const { activeFilePath, activeFileContent, toggleRevisionPanel, revisions, setRevisions } = useEditorStore() const [selected, setSelected] = useState(null) const [previewHtml, setPreviewHtml] = useState(null) const [previewRaw, setPreviewRaw] = useState(null) const [loading, setLoading] = useState(false) const [viewMode, setViewMode] = useState('diff') const [expandedSegments, setExpandedSegments] = useState>(new Set()) useEffect(() => { if (!activeFilePath) return window.api.listRevisions(activeFilePath).then(setRevisions) setSelected(null) setPreviewHtml(null) setPreviewRaw(null) }, [activeFilePath]) const selectRevision = async (rev: RevisionMeta): Promise => { if (selected?.id === rev.id) return setSelected(rev) setPreviewHtml(null) setPreviewRaw(null) setViewMode('diff') setExpandedSegments(new Set()) 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 => { 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 (
Revision History
{/* ── Left: revision list ── */}
{revisions.length === 0 ? (

No revisions yet.
Save with ⌘S to create one.

) : ( revisions.map((rev) => (
selectRevision(rev)} >
{formatDate(rev.timestamp)}
{rev.wordCount.toLocaleString()} words
)) )}
{/* ── Right: preview / diff ── */}
{selected === null ? (

Select a revision to preview

) : loading ? (

Loading…

) : previewRaw !== null ? ( <>
{viewMode === 'diff' ? ( setExpandedSegments((prev) => new Set([...prev, idx])) } /> ) : (
)}
) : null}
) }