💄 diff view for revisions
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
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'
|
||||
@@ -6,6 +8,126 @@ 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<number>
|
||||
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 <p className="revision-preview-empty">No changes — this revision matches the current content.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rdiff-root">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.kind === 'changed') {
|
||||
return (
|
||||
<div key={i} className="rdiff-hunk">
|
||||
{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) => (
|
||||
<div key={`${j}-${k}`} className={`rdiff-line ${cls}`}>
|
||||
<span className="rdiff-gutter">{prefix}</span>
|
||||
<span className="rdiff-text">{line || '\u00A0'}</span>
|
||||
</div>
|
||||
))
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { lines, segIndex } = seg
|
||||
const total = lines.length
|
||||
const isExpanded = expandedSegments.has(segIndex)
|
||||
|
||||
if (total <= CONTEXT_LINES * 2 || isExpanded) {
|
||||
return (
|
||||
<div key={i} className="rdiff-context">
|
||||
{lines.map((line, k) => (
|
||||
<div key={k} className="rdiff-line rdiff-line--context">
|
||||
<span className="rdiff-gutter"> </span>
|
||||
<span className="rdiff-text">{line || '\u00A0'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const topLines = lines.slice(0, CONTEXT_LINES)
|
||||
const bottomLines = lines.slice(total - CONTEXT_LINES)
|
||||
const hiddenCount = total - CONTEXT_LINES * 2
|
||||
|
||||
return (
|
||||
<div key={i} className="rdiff-context">
|
||||
{topLines.map((line, k) => (
|
||||
<div key={`t${k}`} className="rdiff-line rdiff-line--context">
|
||||
<span className="rdiff-gutter"> </span>
|
||||
<span className="rdiff-text">{line || '\u00A0'}</span>
|
||||
</div>
|
||||
))}
|
||||
<button className="rdiff-collapse-btn" onClick={() => onExpand(segIndex)}>
|
||||
··· {hiddenCount} unchanged line{hiddenCount !== 1 ? 's' : ''} ···
|
||||
</button>
|
||||
{bottomLines.map((line, k) => (
|
||||
<div key={`b${k}`} className="rdiff-line rdiff-line--context">
|
||||
<span className="rdiff-gutter"> </span>
|
||||
<span className="rdiff-text">{line || '\u00A0'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
@@ -17,12 +139,19 @@ function formatDate(ts: number): string {
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + time
|
||||
}
|
||||
|
||||
// ─── RevisionPanel ────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'diff' | 'preview'
|
||||
|
||||
export function RevisionPanel(): JSX.Element {
|
||||
const { activeFilePath, toggleRevisionPanel, revisions, setRevisions } = useEditorStore()
|
||||
const { activeFilePath, activeFileContent, 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)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('diff')
|
||||
const [expandedSegments, setExpandedSegments] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeFilePath) return
|
||||
@@ -37,6 +166,8 @@ export function RevisionPanel(): JSX.Element {
|
||||
setSelected(rev)
|
||||
setPreviewHtml(null)
|
||||
setPreviewRaw(null)
|
||||
setViewMode('diff')
|
||||
setExpandedSegments(new Set())
|
||||
if (!activeFilePath) return
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -82,6 +213,8 @@ export function RevisionPanel(): JSX.Element {
|
||||
<span className="revision-panel-title">Revision History</span>
|
||||
</div>
|
||||
<div className="revision-panel-body">
|
||||
|
||||
{/* ── Left: revision list ── */}
|
||||
<div className="revision-list">
|
||||
{revisions.length === 0 ? (
|
||||
<p className="revision-empty">No revisions yet.<br />Save with ⌘S to create one.</p>
|
||||
@@ -106,17 +239,47 @@ export function RevisionPanel(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right: preview / diff ── */}
|
||||
<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 ? (
|
||||
) : previewRaw !== null ? (
|
||||
<>
|
||||
<div
|
||||
className="revision-preview-content"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
<div className="revision-view-toggle">
|
||||
<button
|
||||
className={`revision-view-btn${viewMode === 'diff' ? ' active' : ''}`}
|
||||
onClick={() => setViewMode('diff')}
|
||||
>
|
||||
Diff
|
||||
</button>
|
||||
<button
|
||||
className={`revision-view-btn${viewMode === 'preview' ? ' active' : ''}`}
|
||||
onClick={() => setViewMode('preview')}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="revision-preview-scroll">
|
||||
{viewMode === 'diff' ? (
|
||||
<DiffView
|
||||
oldText={previewRaw}
|
||||
newText={activeFileContent}
|
||||
expandedSegments={expandedSegments}
|
||||
onExpand={(idx) =>
|
||||
setExpandedSegments((prev) => new Set([...prev, idx]))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="revision-preview-content"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml ?? '' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="revision-preview-footer">
|
||||
<button className="revision-restore-btn" onClick={restore}>
|
||||
Restore this version
|
||||
@@ -125,6 +288,7 @@ export function RevisionPanel(): JSX.Element {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user