From ee038d08c3cc17d3ef816d335a1155eed853fb2e Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Wed, 19 Aug 2026 16:40:04 +0100 Subject: [PATCH] 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. --- assets/stylesheets/App.css | 17 +++ bun.lock | 3 + package.json | 1 + pnpm-lock.yaml | 14 +++ .../Dashboard/common/CodeBlockEditor.jsx | 113 +++++++++-------- .../Dashboard/common/CodeDiffViewer.jsx | 85 +++++++++++++ src/components/Dashboard/common/DiffLabel.jsx | 116 ++++++++++++++++++ .../Dashboard/common/PropertyChanges.jsx | 75 +++++++---- 8 files changed, 349 insertions(+), 75 deletions(-) create mode 100644 src/components/Dashboard/common/CodeDiffViewer.jsx create mode 100644 src/components/Dashboard/common/DiffLabel.jsx diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index d895f450..a5d8c78a 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -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; +} diff --git a/bun.lock b/bun.lock index 167e1f29..f62e7df7 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 7a09ce82..6113b489 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b744cb7e..b4848732 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/Dashboard/common/CodeBlockEditor.jsx b/src/components/Dashboard/common/CodeBlockEditor.jsx index b8ac8e28..01706fbb 100644 --- a/src/components/Dashboard/common/CodeBlockEditor.jsx +++ b/src/components/Dashboard/common/CodeBlockEditor.jsx @@ -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') { diff --git a/src/components/Dashboard/common/CodeDiffViewer.jsx b/src/components/Dashboard/common/CodeDiffViewer.jsx new file mode 100644 index 00000000..801c7abf --- /dev/null +++ b/src/components/Dashboard/common/CodeDiffViewer.jsx @@ -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 ( +
+ ) +} + +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) + ]) +} diff --git a/src/components/Dashboard/common/DiffLabel.jsx b/src/components/Dashboard/common/DiffLabel.jsx new file mode 100644 index 00000000..2625c706 --- /dev/null +++ b/src/components/Dashboard/common/DiffLabel.jsx @@ -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 ( + <> + { + setOpen(true) + }} + > + + +{additions} + + {Array.from({ length: SQUARE_COUNT }).map((_, i) => ( +
+ ))} + + -{deletions} + + + { + setOpen(false) + }} + footer={ + + } + > + {viewerReady ? ( + + ) : null} + + + ) +} + +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) + ]) +} diff --git a/src/components/Dashboard/common/PropertyChanges.jsx b/src/components/Dashboard/common/PropertyChanges.jsx index eed74f81..2d5548b9 100644 --- a/src/components/Dashboard/common/PropertyChanges.jsx +++ b/src/components/Dashboard/common/PropertyChanges.jsx @@ -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 ( { style={{ minWidth: 0, width: '100%' }} className='property-changes' > - {value?.old ? ( - - ) : null} - {value?.old && value?.new ? ( - - - - ) : null} - {value?.new ? ( - - ) : null} + ) : ( + <> + {value?.old ? ( + + ) : null} + {value?.old && value?.new ? ( + + + + ) : null} + {value?.new ? ( + + ) : null} + + )} )