diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index 2485bad8766..c6e1bb0b196 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -64,6 +64,7 @@ "lucide-react": "0.577.0", "mingo": "2.5.3", "react": "18.3.1", + "react-codemirror-merge": "catalog:", "react-dom": "18.3.1", "react-hot-toast": "2.6.0", "react-select": "5.10.2", diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx index 47e4c28863a..590b3f04419 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx @@ -13,7 +13,6 @@ import { cloneThemeFiles, createFolderRenameMap, extractThemeArchive, - getExtension, getThemeChanges, isDefaultThemeName, isEditablePath, @@ -21,6 +20,7 @@ import { packThemeArchive } from './theme-editor-utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; +import {getLanguageExtension, getLanguageLabel} from './theme-editor-languages'; import {oneDark} from '@codemirror/theme-one-dark'; import {search} from '@codemirror/search'; import {showToast} from '@tryghost/admin-x-design-system'; @@ -34,58 +34,6 @@ import type {ThemeEditorFile} from './theme-editor-utils'; import type {ThemeEditorInputModalProps} from './theme-editor-input-modal'; import type {ThemesInstallResponseType} from '@tryghost/admin-x-framework/api/themes'; -const getLanguageExtension = (path: string) => { - const extension = getExtension(path); - - switch (extension) { - case 'css': - case 'scss': - case 'sass': - case 'less': - return import('@codemirror/lang-css').then(module => module.css()); - case 'js': - case 'cjs': - case 'mjs': - return import('@codemirror/lang-javascript').then(module => module.javascript()); - case 'json': - return import('@codemirror/lang-json').then(module => module.json()); - case 'md': - case 'markdown': - return import('@codemirror/lang-markdown').then(module => module.markdown()); - case 'yaml': - case 'yml': - return import('@codemirror/lang-yaml').then(module => module.yaml()); - case 'hbs': - case 'handlebars': - case 'html': - case 'htm': - case 'svg': - case 'xml': - return import('@codemirror/lang-html').then(module => module.html()); - default: - return import('@codemirror/lang-html').then(module => module.html()); - } -}; - -const getLanguageLabel = (path: string) => { - const extension = getExtension(path); - - if (!extension) { - return 'text'; - } - - switch (extension) { - case 'hbs': - return 'handlebars'; - case 'htm': - return 'html'; - case 'yml': - return 'yaml'; - default: - return extension; - } -}; - const getDefaultSelection = (files: Record): SelectedNode => { if (files['package.json']?.editable) { return {type: 'file', path: 'package.json'}; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-languages.ts b/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-languages.ts new file mode 100644 index 00000000000..8c0ebe8d634 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-languages.ts @@ -0,0 +1,53 @@ +import {getExtension} from './theme-editor-utils'; + +export const getLanguageExtension = (path: string) => { + const extension = getExtension(path); + + switch (extension) { + case 'css': + case 'scss': + case 'sass': + case 'less': + return import('@codemirror/lang-css').then(module => module.css()); + case 'js': + case 'cjs': + case 'mjs': + return import('@codemirror/lang-javascript').then(module => module.javascript()); + case 'json': + return import('@codemirror/lang-json').then(module => module.json()); + case 'md': + case 'markdown': + return import('@codemirror/lang-markdown').then(module => module.markdown()); + case 'yaml': + case 'yml': + return import('@codemirror/lang-yaml').then(module => module.yaml()); + case 'hbs': + case 'handlebars': + case 'html': + case 'htm': + case 'svg': + case 'xml': + return import('@codemirror/lang-html').then(module => module.html()); + default: + return import('@codemirror/lang-html').then(module => module.html()); + } +}; + +export const getLanguageLabel = (path: string) => { + const extension = getExtension(path); + + if (!extension) { + return 'text'; + } + + switch (extension) { + case 'hbs': + return 'handlebars'; + case 'htm': + return 'html'; + case 'yml': + return 'yaml'; + default: + return extension; + } +}; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-review-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-review-modal.tsx index 897f01d6412..72a4588a26b 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-review-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-review-modal.tsx @@ -1,8 +1,14 @@ +import CodeMirrorMerge from 'react-codemirror-merge'; import React, {useEffect, useState} from 'react'; import {CircleDot, Undo2, X} from 'lucide-react'; +import {EditorView} from '@uiw/react-codemirror'; +import {getLanguageExtension} from './theme-editor-languages'; import {ghostButtonClass, iconButtonClass} from './theme-editor-styles'; +import {oneDark} from '@codemirror/theme-one-dark'; import type {ThemeChange, ThemeEditorFile} from './theme-editor-utils'; +type LanguageExtension = Awaited>; + const previewBlockClass = 'overflow-auto rounded-md border border-[#23262c] bg-[#15171a] p-4 text-[12px] leading-5 text-[#d4d8de]'; const previewSectionLabelClass = 'mb-2 text-[11px] font-semibold tracking-[0.08em] text-[#8a8f98] uppercase'; const previewEmptyStateClass = 'flex flex-1 items-center justify-center p-8 text-center text-[13px] text-[#6a6f78]'; @@ -60,6 +66,7 @@ const ThemeReviewModal: React.FC = ({ onRevert }) => { const [selectedReviewPath, setSelectedReviewPath] = useState(null); + const [diffLanguageExtension, setDiffLanguageExtension] = useState(null); const selectedReviewItem = reviewItems.find(item => item.path === selectedReviewPath) || reviewItems[0] || null; // Keep selectedReviewPath valid as reviewItems change. If the currently @@ -76,6 +83,42 @@ const ThemeReviewModal: React.FC = ({ } }, [reviewItems, selectedReviewItem, selectedReviewPath]); + // Lazy-load the language extension for the merge view so it matches the + // syntax highlighting users see in the editor. Tracking the path/status + // separately keeps the effect from re-firing on identity-only changes to + // selectedReviewItem (it's rebuilt on every render). + const diffPath = selectedReviewItem?.path ?? null; + const diffStatus = selectedReviewItem?.status ?? null; + const diffIsEditable = selectedReviewItem?.editable ?? false; + useEffect(() => { + if (!diffPath || diffStatus !== 'modified' || !diffIsEditable) { + setDiffLanguageExtension(null); + return; + } + + // Clear before loading so switching files doesn't briefly render the + // previous file's highlighting against the new content. + setDiffLanguageExtension(null); + + let cancelled = false; + getLanguageExtension(diffPath).then((extension) => { + if (!cancelled) { + setDiffLanguageExtension(extension); + } + }).catch(() => { + // Dynamic language imports can fail (network, missing chunk). + // Fall back to plain text — the merge view still renders without + // syntax highlighting. + if (!cancelled) { + setDiffLanguageExtension(null); + } + }); + + return () => { + cancelled = true; + }; + }, [diffPath, diffStatus, diffIsEditable]); + const reviewSummary = formatReviewSummary(reviewItems); return ( @@ -148,15 +191,27 @@ const ThemeReviewModal: React.FC = ({
{selectedReviewItem.before ?? ''}
) : ( -
-
-
Before
-
{selectedReviewItem.before ?? ''}
-
-
-
After
-
{selectedReviewItem.after ?? ''}
-
+
+ + + +
)} diff --git a/apps/admin-x-settings/test/acceptance/site/theme.test.ts b/apps/admin-x-settings/test/acceptance/site/theme.test.ts index 29c292cac51..8c5af40a6b9 100644 --- a/apps/admin-x-settings/test/acceptance/site/theme.test.ts +++ b/apps/admin-x-settings/test/acceptance/site/theme.test.ts @@ -401,6 +401,47 @@ test.describe('Theme settings', async () => { await expect(editorModal).toContainText(/1 file modified/); }); + test('Renders a CodeMirror merge view when reviewing a modified file', async ({page}) => { + // The shared theme.zip fixture has __MACOSX entries that suppress + // rootPrefix detection and complicate tree navigation. Build a clean + // archive so the test is focused on the diff view itself. + const themeZip = await createArchiveBuffer((zip) => { + zip.file('package.json', '{"name":"edition","version":"1.0.0"}\n'); + }); + + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: { + method: 'GET', + path: '/themes/edition/download/', + response: '', + rawResponse: themeZip, + responseHeaders: {'content-type': 'application/zip'} + } + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + + const codeEditor = editorModal.locator('.cm-content'); + await expect(codeEditor).toBeVisible(); + await codeEditor.click(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.insertText('{"name":"edition","version":"99.0.0"}\n'); + await expect(editorModal).toContainText(/1 file modified/); + + // Open the review modal via the "files modified" badge. + await editorModal.getByRole('button', {name: /files? modified/}).click(); + await expect(page.getByRole('heading', {name: 'All changes'})).toBeVisible(); + + // The merge view should render two CodeMirror panes side-by-side. If + // either pane fails to mount (e.g. language extension import error or + // a missing peer dep), only the wrapper renders and this count drops. + const diffView = page.getByTestId('theme-review-diff'); + await expect(diffView).toBeVisible(); + await expect(diffView.locator('.cm-editor')).toHaveCount(2); + }); + test('Saves built-in themes as a new theme name', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0ae207bd96..80b017cba4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ catalogs: postcss: specifier: 8.5.6 version: 8.5.6 + react-codemirror-merge: + specifier: 4.25.2 + version: 4.25.2 react-router: specifier: 7.14.0 version: 7.14.0 @@ -822,6 +825,9 @@ importers: react: specifier: 18.3.1 version: 18.3.1 + react-codemirror-merge: + specifier: 'catalog:' + version: 4.25.2(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) @@ -3745,6 +3751,9 @@ packages: '@codemirror/lint@6.9.5': resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} + '@codemirror/merge@6.12.1': + resolution: {integrity: sha512-GA8hBq2T+IFM0sb5fk8CunTrqOulA3zurJmHtzcU15EMnL8aYpVINfJ5bkfd53M4ikwoew4Y1ydtSaAlk6+B1w==} + '@codemirror/search@6.7.0': resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} @@ -19006,6 +19015,17 @@ packages: resolution: {integrity: sha512-aJHwaTbbLjYcbMmzD/6xQ785vbEs8TTwoUg/Y/+faSk0TxPcsUxFk9bwsYRyDJID/krm1ZATBn3U+JSP1Vv2Bg==} engines: {node: '>=8'} + react-codemirror-merge@4.25.2: + resolution: {integrity: sha512-+AXsrV/uXocJvZajjbvXkuys18XFqb09a8BbvZT5DQ3CdMVkf9qISrpEJfX1eJumFdJ3EHtd9jm4Th9icENspg==} + peerDependencies: + '@babel/runtime': ^7.26.10 + '@codemirror/state': '>=6.0.0' + '@codemirror/theme-one-dark': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + codemirror: '>=6.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-colorful@5.6.1: resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} peerDependencies: @@ -23411,6 +23431,14 @@ snapshots: '@codemirror/view': 6.40.0 crelt: 1.0.6 + '@codemirror/merge@6.12.1': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/highlight': 1.2.3 + style-mod: 4.1.3 + '@codemirror/search@6.7.0': dependencies: '@codemirror/state': 6.6.0 @@ -43618,6 +43646,23 @@ snapshots: got: 11.8.6 p-reflect: 2.1.0 + react-codemirror-merge@4.25.2(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.2 + '@codemirror/merge': 6.12.1 + '@codemirror/state': 6.6.0 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.40.0 + '@uiw/react-codemirror': 4.25.2(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + codemirror: 5.65.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@codemirror/autocomplete' + - '@codemirror/language' + - '@codemirror/lint' + - '@codemirror/search' + react-colorful@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d20aded063..077a89f71af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,6 +52,7 @@ catalog: mocha: 11.7.5 msw: 2.12.14 postcss: 8.5.6 + react-codemirror-merge: 4.25.2 react-router: 7.14.0 sonner: 2.0.7 storybook: 10.3.5