Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/admin-x-settings/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import {
cloneThemeFiles,
createFolderRenameMap,
extractThemeArchive,
getExtension,
getThemeChanges,
isDefaultThemeName,
isEditablePath,
normaliseRelativePath,
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';
Expand All @@ -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<string, ThemeEditorFile>): SelectedNode => {
if (files['package.json']?.editable) {
return {type: 'file', path: 'package.json'};
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
};
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getLanguageExtension>>;

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]';
Expand Down Expand Up @@ -60,6 +66,7 @@ const ThemeReviewModal: React.FC<ThemeReviewModalProps> = ({
onRevert
}) => {
const [selectedReviewPath, setSelectedReviewPath] = useState<string | null>(null);
const [diffLanguageExtension, setDiffLanguageExtension] = useState<LanguageExtension | null>(null);
const selectedReviewItem = reviewItems.find(item => item.path === selectedReviewPath) || reviewItems[0] || null;

// Keep selectedReviewPath valid as reviewItems change. If the currently
Expand All @@ -76,6 +83,42 @@ const ThemeReviewModal: React.FC<ThemeReviewModalProps> = ({
}
}, [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);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return () => {
cancelled = true;
};
}, [diffPath, diffStatus, diffIsEditable]);

const reviewSummary = formatReviewSummary(reviewItems);

return (
Expand Down Expand Up @@ -148,15 +191,27 @@ const ThemeReviewModal: React.FC<ThemeReviewModalProps> = ({
<pre className={previewBlockClass}>{selectedReviewItem.before ?? ''}</pre>
</div>
) : (
<div className='grid min-h-0 flex-1 grid-cols-2 gap-4 p-4'>
<div className='min-h-0 overflow-auto'>
<div className={previewSectionLabelClass}>Before</div>
<pre className={`h-full ${previewBlockClass}`}>{selectedReviewItem.before ?? ''}</pre>
</div>
<div className='min-h-0 overflow-auto'>
<div className={previewSectionLabelClass}>After</div>
<pre className={`h-full ${previewBlockClass}`}>{selectedReviewItem.after ?? ''}</pre>
</div>
<div className='gte-merge min-h-0 flex-1 overflow-auto' data-testid='theme-review-diff'>
<CodeMirrorMerge
// Re-mounting on path keeps the merge view's internal diff
// computation aligned when the user switches files — CodeMirror
// merge does not always recompute chunks when both panes' values
// change in the same render.
key={selectedReviewItem.path}
orientation='a-b'
theme={oneDark}
>
<CodeMirrorMerge.Original
extensions={diffLanguageExtension ? [diffLanguageExtension, EditorView.lineWrapping] : [EditorView.lineWrapping]}
readOnly={true}
value={selectedReviewItem.before ?? ''}
/>
<CodeMirrorMerge.Modified
extensions={diffLanguageExtension ? [diffLanguageExtension, EditorView.lineWrapping] : [EditorView.lineWrapping]}
readOnly={true}
value={selectedReviewItem.after ?? ''}
/>
</CodeMirrorMerge>
</div>
)}
</>
Expand Down
41 changes: 41 additions & 0 deletions apps/admin-x-settings/test/acceptance/site/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading