Add CodeDiffViewer and DiffLabel components for enhanced code comparison

- Introduced CodeDiffViewer component utilizing @codemirror/merge for visual code diffs.
- Added DiffLabel component to display code change statistics and trigger the CodeDiffViewer modal.
- Updated PropertyChanges component to integrate DiffLabel for code property changes.
- Enhanced styling for code diff display with new CSS classes.
- Updated package.json and lock files to include @codemirror/merge dependency.
This commit is contained in:
Tom Butcher 2026-08-19 16:40:04 +01:00
parent d8c13426d1
commit ee038d08c3
8 changed files with 349 additions and 75 deletions

View File

@ -1291,3 +1291,20 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
flex: 0 0 auto;
user-select: none;
}
.code-diff-viewer {
height: 70vh;
border: 1px solid #85858541;
}
.code-diff-viewer .cm-mergeView {
height: 100%;
overflow: auto;
}
.diff-label-square {
width: 7px;
height: 7px;
background-color: #85858541;
border-radius: 2px;
}

View File

@ -23,6 +23,7 @@
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "6.12.1",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "6.39.12",
@ -238,6 +239,8 @@
"@codemirror/lint": ["@codemirror/lint@6.9.3", "", { "dependencies": { "@codemirror/state": "6.7.1", "@codemirror/view": "6.39.12", "crelt": "1.0.6" } }, "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw=="],
"@codemirror/merge": ["@codemirror/merge@6.12.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/highlight": "^1.0.0", "style-mod": "^4.1.0" } }, "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w=="],
"@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "6.7.1", "@codemirror/view": "6.39.12", "crelt": "1.0.7" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="],
"@codemirror/state": ["@codemirror/state@6.7.1", "", { "dependencies": { "@marijn/find-cluster-break": "1.0.3" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="],

View File

@ -27,6 +27,7 @@
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "6.12.1",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "6.39.12",

14
pnpm-lock.yaml generated
View File

@ -70,6 +70,9 @@ importers:
'@codemirror/language':
specifier: 6.12.1
version: 6.12.1
'@codemirror/merge':
specifier: ^6.12.2
version: 6.12.2
'@codemirror/state':
specifier: 6.7.1
version: 6.7.1
@ -611,6 +614,9 @@ packages:
'@codemirror/lint@6.9.3':
resolution: {integrity: sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==}
'@codemirror/merge@6.12.2':
resolution: {integrity: sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==}
'@codemirror/search@6.6.0':
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
@ -7047,6 +7053,14 @@ snapshots:
'@codemirror/view': 6.39.12
crelt: 1.0.6
'@codemirror/merge@6.12.2':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.7.1
'@codemirror/view': 6.39.12
'@lezer/highlight': 1.2.3
style-mod: 4.1.3
'@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.7.1

View File

@ -22,6 +22,64 @@ import { Button, Modal, Typography } from 'antd'
const { Link } = Typography
export function toEditorCode(code, language = 'javascript') {
if (code == null) return ''
if (typeof code === 'object' && language === 'json') {
return JSON.stringify(code, null, 2)
}
return String(code)
}
export function getCodeLanguageExtension(
language = 'javascript',
autoCompleteObject = null
) {
const lang = Array.isArray(language) ? language[0] : language
switch (String(lang || 'javascript').toLowerCase()) {
case 'javascript':
case 'js':
return javascript()
case 'python':
case 'py':
return python()
case 'json':
return json()
case 'xml':
return xml()
case 'fctemplatelang':
case 'ejs':
return fcTemplateLang({ autoCompleteObject })
case 'html':
return html()
case 'css':
return css()
case 'markdown':
case 'md':
return markdown()
case 'sql':
return sql()
case 'java':
return java()
case 'cpp':
case 'c++':
case 'c':
return cpp()
case 'rust':
case 'rs':
return rust()
case 'go':
return go()
case 'php':
return php()
case 'yaml':
case 'yml':
return yaml()
default:
return javascript()
}
}
export default function CodeBlockEditor({
code = '',
language = 'javascript',
@ -37,57 +95,12 @@ export default function CodeBlockEditor({
}) {
const { isDarkMode } = useThemeContext()
const [codeMirrorOpen, setCodeMirrorOpen] = useState(false)
var editorCode = code
const editorCode = toEditorCode(code, language)
if (typeof code == 'object' && language == 'json') {
editorCode = JSON.stringify(code, null, 2)
}
// Map language to CodeMirror extension
const languageExtension = useMemo(() => {
switch (language.toLowerCase()) {
case 'javascript':
case 'js':
return javascript()
case 'python':
case 'py':
return python()
case 'json':
return json()
case 'xml':
return xml()
case 'fctemplatelang':
case 'ejs':
return fcTemplateLang({ autoCompleteObject })
case 'html':
return html()
case 'css':
return css()
case 'markdown':
case 'md':
return markdown()
case 'sql':
return sql()
case 'java':
return java()
case 'cpp':
case 'c++':
case 'c':
return cpp()
case 'rust':
case 'rs':
return rust()
case 'go':
return go()
case 'php':
return php()
case 'yaml':
case 'yml':
return yaml()
default:
return javascript() // Default fallback
}
}, [language, autoCompleteObject])
const languageExtension = useMemo(
() => getCodeLanguageExtension(language, autoCompleteObject),
[language, autoCompleteObject]
)
const handleOnChange = (value) => {
if (typeof code == 'object' && language == 'json') {

View File

@ -0,0 +1,85 @@
import { useLayoutEffect, useMemo, useRef } from 'react'
import PropTypes from 'prop-types'
import { MergeView } from '@codemirror/merge'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { oneDark } from '@codemirror/theme-one-dark'
import { basicSetup } from 'codemirror'
import { useThemeContext } from '../context/ThemeContext'
import {
getCodeLanguageExtension,
toEditorCode
} from './CodeBlockEditor'
export default function CodeDiffViewer({
oldCode = '',
newCode = '',
language = 'javascript'
}) {
const { isDarkMode } = useThemeContext()
const parentRef = useRef(null)
const oldDoc = toEditorCode(oldCode, language)
const newDoc = toEditorCode(newCode, language)
const languageExtension = useMemo(
() => getCodeLanguageExtension(language),
[language]
)
useLayoutEffect(() => {
if (!parentRef.current) return
const editorTheme = EditorView.theme({
'&': {
fontSize: '14px',
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace'
}
})
const sharedExtensions = [
basicSetup,
languageExtension,
editorTheme,
EditorView.editable.of(false),
EditorState.readOnly.of(true),
...(isDarkMode ? [oneDark] : [])
]
const view = new MergeView({
a: {
doc: oldDoc,
extensions: sharedExtensions
},
b: {
doc: newDoc,
extensions: sharedExtensions
},
parent: parentRef.current,
highlightChanges: true,
gutter: true,
collapseUnchanged: { margin: 3, minSize: 6 }
})
return () => {
view.destroy()
}
}, [oldDoc, newDoc, languageExtension, isDarkMode])
return (
<div
ref={parentRef}
className={
isDarkMode ? 'code-diff-viewer is-dark' : 'code-diff-viewer'
}
/>
)
}
CodeDiffViewer.propTypes = {
oldCode: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
newCode: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
language: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string)
])
}

View File

@ -0,0 +1,116 @@
import { useMemo, useState } from 'react'
import PropTypes from 'prop-types'
import { Button, Modal, Typography, theme, Flex } from 'antd'
import { presentableDiff } from '@codemirror/merge'
import CodeDiffViewer from './CodeDiffViewer'
import { toEditorCode } from './CodeBlockEditor'
const { Link, Text } = Typography
const SQUARE_COUNT = 10
const countLines = (str) => {
if (!str) return 0
const parts = str.split('\n')
if (parts[parts.length - 1] === '') parts.pop()
return Math.max(parts.length, 1)
}
export function getCodeDiffStats(oldCode, newCode, language = 'javascript') {
const oldText = toEditorCode(oldCode, language)
const newText = toEditorCode(newCode, language)
const changes = presentableDiff(oldText, newText)
let additions = 0
let deletions = 0
for (const change of changes) {
const deleted = oldText.slice(change.fromA, change.toA)
const added = newText.slice(change.fromB, change.toB)
if (deleted) deletions += countLines(deleted)
if (added) additions += countLines(added)
}
return { additions, deletions }
}
export default function DiffLabel({
oldCode = '',
newCode = '',
language = 'javascript'
}) {
const { token } = theme.useToken()
const [open, setOpen] = useState(false)
const [viewerReady, setViewerReady] = useState(false)
const { additions, deletions } = useMemo(
() => getCodeDiffStats(oldCode, newCode, language),
[oldCode, newCode, language]
)
const greenCount = Math.round(
(additions / (additions + deletions)) * SQUARE_COUNT
)
return (
<>
<Link
className='diff-label'
onClick={() => {
setOpen(true)
}}
>
<Flex gap='small' align='center' style={{ marginTop: 0.5 }}>
<Text style={{ color: token.colorSuccess }}>+{additions}</Text>
<Flex gap='4px' align='center' style={{ marginBottom: 2 }}>
{Array.from({ length: SQUARE_COUNT }).map((_, i) => (
<div
key={i}
className='diff-label-square'
style={{
backgroundColor:
i < greenCount ? token.colorSuccess : token.colorError
}}
/>
))}
</Flex>
<Text style={{ color: token.colorError }}>-{deletions}</Text>
</Flex>
</Link>
<Modal
closeIcon={false}
open={open}
width='90%'
destroyOnClose
afterOpenChange={setViewerReady}
onCancel={() => {
setOpen(false)
}}
footer={
<Button
onClick={() => {
setOpen(false)
}}
>
Close
</Button>
}
>
{viewerReady ? (
<CodeDiffViewer
oldCode={oldCode}
newCode={newCode}
language={language}
/>
) : null}
</Modal>
</>
)
}
DiffLabel.propTypes = {
oldCode: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
newCode: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
language: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string)
])
}

View File

@ -1,7 +1,11 @@
import PropTypes from 'prop-types'
import { Descriptions, Typography, Flex } from 'antd'
import { getModelProperty } from '../../../database/ObjectModels'
import {
getModelProperty,
getPropertyValue
} from '../../../database/ObjectModels'
import ObjectProperty from './ObjectProperty'
import DiffLabel from './DiffLabel'
import ArrowRightIcon from '../../Icons/ArrowRightIcon'
const { Text } = Typography
@ -51,6 +55,17 @@ const PropertyChanges = ({ type, value }) => {
if (!changeProperty) {
return null
}
const oldValue = getPropertyValue(value?.old, key)
const newValue = getPropertyValue(value?.new, key)
const isCodeKey = key === 'code' || changeProperty.type === 'codeBlock'
const isCodeEdit =
isCodeKey &&
oldValue != null &&
oldValue !== '' &&
newValue != null &&
newValue !== ''
return (
<Descriptions.Item key={key} label={changeProperty.label}>
<Flex
@ -58,31 +73,41 @@ const PropertyChanges = ({ type, value }) => {
style={{ minWidth: 0, width: '100%' }}
className='property-changes'
>
{value?.old ? (
<ObjectProperty
{...changeProperty}
longId={false}
minimal={true}
showPreview={false}
objectData={value?.old}
maxWidth='200px'
{isCodeEdit ? (
<DiffLabel
oldCode={oldValue}
newCode={newValue}
language={changeProperty.language}
/>
) : null}
{value?.old && value?.new ? (
<Text type='secondary'>
<ArrowRightIcon />
</Text>
) : null}
{value?.new ? (
<ObjectProperty
{...changeProperty}
longId={false}
minimal={true}
showPreview={false}
objectData={value?.new}
maxWidth='200px'
/>
) : null}
) : (
<>
{value?.old ? (
<ObjectProperty
{...changeProperty}
longId={false}
minimal={true}
showPreview={false}
objectData={value?.old}
maxWidth='200px'
/>
) : null}
{value?.old && value?.new ? (
<Text type='secondary'>
<ArrowRightIcon />
</Text>
) : null}
{value?.new ? (
<ObjectProperty
{...changeProperty}
longId={false}
minimal={true}
showPreview={false}
objectData={value?.new}
maxWidth='200px'
/>
) : null}
</>
)}
</Flex>
</Descriptions.Item>
)