From f30684f55d57311cbe0f00cfb41a21bfcd7889a6 Mon Sep 17 00:00:00 2001 From: Jannis Fedoruk-Betschki Date: Fri, 15 May 2026 21:55:33 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8E=A8=20Added=20syntax-highlighted?= =?UTF-8?q?=20diff=20view=20to=20theme=20editor=20review=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the review modal previously showed before/after as two raw
 blocks, leaving readers to spot differences by eye
- swaps the modified-file panel for a CodeMirror merge view (react-codemirror-merge), giving line-level add/remove highlighting and synced scrolling
- lazy-loads the same language extensions the editor uses, so the diff renders with matching syntax colors per file type
- extracts getLanguageExtension / getLanguageLabel into theme-editor-languages.ts so the review modal can reuse them without creating a circular dep between sibling modals
---
 apps/admin-x-settings/package.json            |  1 +
 .../site/theme/theme-code-editor-modal.tsx    | 54 +---------------
 .../site/theme/theme-editor-languages.ts      | 53 ++++++++++++++++
 .../site/theme/theme-review-modal.tsx         | 62 ++++++++++++++++---
 .../test/acceptance/site/theme.test.ts        | 41 ++++++++++++
 pnpm-lock.yaml                                | 45 ++++++++++++++
 pnpm-workspace.yaml                           |  1 +
 7 files changed, 195 insertions(+), 62 deletions(-)
 create mode 100644 apps/admin-x-settings/src/components/settings/site/theme/theme-editor-languages.ts

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..d8aafb8ad25 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,13 +13,13 @@ import {
     cloneThemeFiles,
     createFolderRenameMap,
     extractThemeArchive,
-    getExtension,
     getThemeChanges,
     isDefaultThemeName,
     isEditablePath,
     normaliseRelativePath,
     packThemeArchive
 } from './theme-editor-utils';
+import {getLanguageExtension, getLanguageLabel} from './theme-editor-languages';
 import {getGhostPaths} from '@tryghost/admin-x-framework/helpers';
 import {oneDark} from '@codemirror/theme-one-dark';
 import {search} from '@codemirror/search';
@@ -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..5b42e0ed0b8 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,31 @@ 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;
+        }
+
+        let cancelled = false;
+        getLanguageExtension(diffPath).then((extension) => {
+            if (!cancelled) {
+                setDiffLanguageExtension(extension);
+            }
+        });
+
+        return () => {
+            cancelled = true;
+        };
+    }, [diffPath, diffStatus, diffIsEditable]);
+
     const reviewSummary = formatReviewSummary(reviewItems);
 
     return (
@@ -148,15 +180,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 From 2a6b3fbd04e7688ecb3ad627252ef0d817fc1cb3 Mon Sep 17 00:00:00 2001 From: Jannis Fedoruk-Betschki Date: Sat, 16 May 2026 12:31:23 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20CI=20lint=20failure?= =?UTF-8?q?=20for=20theme=20editor=20diff=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ghost/sort-imports-es6-autofix wants 'single' specifier imports before 'multiple' within the same source group; the new ./theme-editor-languages multi-name import landed between a multi-name local import and a single-name @tryghost import, which broke that ordering - ran the rule's --fix to move the import to its correct slot --- .../components/settings/site/theme/theme-code-editor-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d8aafb8ad25..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 @@ -19,8 +19,8 @@ import { normaliseRelativePath, packThemeArchive } from './theme-editor-utils'; -import {getLanguageExtension, getLanguageLabel} from './theme-editor-languages'; 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'; From 1b915158082a412472a8372d250a59c9a095b168 Mon Sep 17 00:00:00 2001 From: Jannis Fedoruk-Betschki Date: Sat, 16 May 2026 15:51:25 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8E=A8=20Hardened=20diff-view=20langu?= =?UTF-8?q?age=20loader=20against=20failed=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the language-extension loader could leak an unhandled promise rejection when getLanguageExtension's dynamic @codemirror/lang-* import failed, and left the previous file's highlighting in place while a new file's extension loaded - clears the cached extension before the load so switching files never renders against stale highlighting, and adds a catch fallback that drops to plain text on import failure - caught by CodeRabbit review on PR #27921 --- .../settings/site/theme/theme-review-modal.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 5b42e0ed0b8..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 @@ -96,11 +96,22 @@ const ThemeReviewModal: React.FC = ({ 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 () => {