💄 diff view for revisions
This commit is contained in:
@@ -188,3 +188,144 @@
|
||||
.revision-restore-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* ─── View mode toggle (Diff | Preview) ─────────────────────────────────── */
|
||||
.revision-view-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--toolbar-bg);
|
||||
padding: 0 16px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.revision-view-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 8px 10px 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.revision-view-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.revision-view-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── Shared scroll container ────────────────────────────────────────────── */
|
||||
.revision-preview-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Diff renderer ──────────────────────────────────────────────────────── */
|
||||
.rdiff-root {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.rdiff-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
min-height: 1.75em;
|
||||
padding-right: 16px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rdiff-gutter {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.rdiff-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Added lines (in current, not in revision) */
|
||||
.rdiff-line--added {
|
||||
background: rgba(80, 160, 80, 0.13);
|
||||
}
|
||||
|
||||
.rdiff-line--added .rdiff-gutter {
|
||||
color: #6abf6a;
|
||||
}
|
||||
|
||||
.rdiff-line--added .rdiff-text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Removed lines (in revision, not in current) */
|
||||
.rdiff-line--removed {
|
||||
background: rgba(180, 60, 60, 0.12);
|
||||
}
|
||||
|
||||
.rdiff-line--removed .rdiff-gutter {
|
||||
color: #c07070;
|
||||
}
|
||||
|
||||
.rdiff-line--removed .rdiff-text {
|
||||
color: var(--text-muted);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(180, 60, 60, 0.45);
|
||||
}
|
||||
|
||||
/* Unchanged context lines */
|
||||
.rdiff-line--context .rdiff-gutter {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.rdiff-line--context .rdiff-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Changed hunk group */
|
||||
.rdiff-hunk {
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
/* Expand collapsed context */
|
||||
.rdiff-collapse-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px dashed var(--border);
|
||||
border-bottom: 1px dashed var(--border);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 5px 0;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.rdiff-collapse-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
@@ -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