💄 diff view for revisions

This commit is contained in:
2026-02-28 11:38:51 +10:00
parent 992d3a737e
commit 2dc3bcedcd
6 changed files with 337 additions and 11 deletions

View File

@@ -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>
)