💄 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

@@ -3,6 +3,7 @@ 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 { RevisionPanel } from '@renderer/components/Revisions/RevisionPanel'
import '@renderer/styles/global.css' import '@renderer/styles/global.css'
import '@renderer/styles/app.css' import '@renderer/styles/app.css'
@@ -69,7 +70,7 @@ store.setAnnotations([
]) ])
function DemoApp(): JSX.Element { function DemoApp(): JSX.Element {
const [revActive, setRevActive] = useState(false) const { revisionPanelOpen, toggleRevisionPanel } = useEditorStore()
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-base)' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-base)' }}>
{/* Titlebar same markup as App.tsx */} {/* Titlebar same markup as App.tsx */}
@@ -83,8 +84,8 @@ function DemoApp(): JSX.Element {
</div> </div>
<div className="app-titlebar-icon-group"> <div className="app-titlebar-icon-group">
<button <button
className={`app-titlebar-theme-btn app-titlebar-revision-btn${revActive ? ' active' : ''}`} className={`app-titlebar-theme-btn app-titlebar-revision-btn${revisionPanelOpen ? ' active' : ''}`}
onClick={() => setRevActive(v => !v)} onClick={toggleRevisionPanel}
title="Revision history" title="Revision history"
></button> ></button>
<button className="app-titlebar-theme-btn" title="Toggle theme"></button> <button className="app-titlebar-theme-btn" title="Toggle theme"></button>
@@ -93,8 +94,9 @@ function DemoApp(): JSX.Element {
</div> </div>
{/* Editor content below */} {/* Editor content below */}
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}> <div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', position: 'relative' }}>
<MarkdownEditor /> <MarkdownEditor />
{revisionPanelOpen && <RevisionPanel />}
</div> </div>
<div style={{ width: '300px', flexShrink: 0 }}> <div style={{ width: '300px', flexShrink: 0 }}>
<ChatPanel /> <ChatPanel />

17
package-lock.json generated
View File

@@ -17,8 +17,10 @@
"@codemirror/view": "^6.35.0", "@codemirror/view": "^6.35.0",
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@types/diff": "^7.0.2",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"diff": "^8.0.3",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"marked": "^17.0.3", "marked": "^17.0.3",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
@@ -2175,6 +2177,12 @@
"@types/ms": "*" "@types/ms": "*"
} }
}, },
"node_modules/@types/diff": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz",
"integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
"license": "MIT"
},
"node_modules/@types/dompurify": { "node_modules/@types/dompurify": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
@@ -3452,6 +3460,15 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/diff": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dir-compare": { "node_modules/dir-compare": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz",

View File

@@ -32,8 +32,10 @@
"@codemirror/view": "^6.35.0", "@codemirror/view": "^6.35.0",
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@types/diff": "^7.0.2",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"diff": "^8.0.3",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"marked": "^17.0.3", "marked": "^17.0.3",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",

View File

@@ -188,3 +188,144 @@
.revision-restore-btn:hover { .revision-restore-btn:hover {
background: var(--accent-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);
}

View File

@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { diffLines } from 'diff'
import type { Change } from 'diff'
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { useEditorStore } from '../../store/editorStore' import { useEditorStore } from '../../store/editorStore'
@@ -6,6 +8,126 @@ import { currentEditorView } from '../Editor/MarkdownEditor'
import type { RevisionMeta } from '../../types/editor' import type { RevisionMeta } from '../../types/editor'
import './RevisionPanel.css' 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 { function formatDate(ts: number): string {
const d = new Date(ts) const d = new Date(ts)
const now = new Date() const now = new Date()
@@ -17,12 +139,19 @@ function formatDate(ts: number): string {
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + time return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + time
} }
// ─── RevisionPanel ────────────────────────────────────────────────────────────
type ViewMode = 'diff' | 'preview'
export function RevisionPanel(): JSX.Element { 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 [selected, setSelected] = useState<RevisionMeta | null>(null)
const [previewHtml, setPreviewHtml] = useState<string | null>(null) const [previewHtml, setPreviewHtml] = useState<string | null>(null)
const [previewRaw, setPreviewRaw] = useState<string | null>(null) const [previewRaw, setPreviewRaw] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('diff')
const [expandedSegments, setExpandedSegments] = useState<Set<number>>(new Set())
useEffect(() => { useEffect(() => {
if (!activeFilePath) return if (!activeFilePath) return
@@ -37,6 +166,8 @@ export function RevisionPanel(): JSX.Element {
setSelected(rev) setSelected(rev)
setPreviewHtml(null) setPreviewHtml(null)
setPreviewRaw(null) setPreviewRaw(null)
setViewMode('diff')
setExpandedSegments(new Set())
if (!activeFilePath) return if (!activeFilePath) return
setLoading(true) setLoading(true)
try { try {
@@ -82,6 +213,8 @@ export function RevisionPanel(): JSX.Element {
<span className="revision-panel-title">Revision History</span> <span className="revision-panel-title">Revision History</span>
</div> </div>
<div className="revision-panel-body"> <div className="revision-panel-body">
{/* ── Left: revision list ── */}
<div className="revision-list"> <div className="revision-list">
{revisions.length === 0 ? ( {revisions.length === 0 ? (
<p className="revision-empty">No revisions yet.<br />Save with S to create one.</p> <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> </div>
{/* ── Right: preview / diff ── */}
<div className="revision-preview"> <div className="revision-preview">
{selected === null ? ( {selected === null ? (
<p className="revision-preview-empty">Select a revision to preview</p> <p className="revision-preview-empty">Select a revision to preview</p>
) : loading ? ( ) : loading ? (
<p className="revision-preview-empty">Loading</p> <p className="revision-preview-empty">Loading</p>
) : previewHtml !== null ? ( ) : previewRaw !== null ? (
<> <>
<div <div className="revision-view-toggle">
className="revision-preview-content" <button
dangerouslySetInnerHTML={{ __html: previewHtml }} 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"> <div className="revision-preview-footer">
<button className="revision-restore-btn" onClick={restore}> <button className="revision-restore-btn" onClick={restore}>
Restore this version Restore this version
@@ -125,6 +288,7 @@ export function RevisionPanel(): JSX.Element {
</> </>
) : null} ) : null}
</div> </div>
</div> </div>
</div> </div>
) )

File diff suppressed because one or more lines are too long