refactor(CodeEditor): decouple CodeEditor and global settings (#10163)

* refactor(CodeEditor): decouple CodeEditor and global settings

* refactor: improve language extension fallbacks

* refactor: make a copy of CodeEditor in the ui package

* refactor: update ui CodeEditor and language list

* refactor: use CodeEditor from the ui package

* feat: add a story for CodeEditor
This commit is contained in:
one 2025-09-16 10:11:36 +08:00 committed by GitHub
parent de44938d9b
commit 8981d0a09d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 5099 additions and 334 deletions

View File

@ -15,7 +15,8 @@
"lint": "eslint src --ext .ts,.tsx --fix",
"type-check": "tsc --noEmit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"update:languages": "tsx scripts/update-languages.ts"
},
"keywords": [
"ui",
@ -53,17 +54,27 @@
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/styled-components": "^5.1.34",
"@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.25.1",
"@uiw/react-codemirror": "^4.25.1",
"antd": "^5.22.5",
"eslint-plugin-storybook": "9.1.6",
"framer-motion": "^12.23.12",
"linguist-languages": "^9.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"storybook": "^9.1.6",
"styled-components": "^6.1.15",
"tsdown": "^0.12.9",
"tsx": "^4.20.5",
"typescript": "^5.6.2",
"vitest": "^3.2.4"
},
"resolutions": {
"@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1"
},
"sideEffects": false,
"engines": {
"node": ">=18.0.0"

View File

@ -0,0 +1,135 @@
import { exec } from 'child_process'
import * as fs from 'fs/promises'
import * as linguistLanguages from 'linguist-languages'
import * as path from 'path'
import { promisify } from 'util'
const execAsync = promisify(exec)
type LanguageData = {
type: string
aliases?: string[]
extensions?: string[]
}
const LANGUAGES_FILE_PATH = path.join(__dirname, '../src/config/languages.ts')
/**
* Extracts and filters necessary language data from the linguist-languages package.
* @returns A record of language data.
*/
function extractAllLanguageData(): Record<string, LanguageData> {
console.log('🔍 Extracting language data from linguist-languages...')
const languages = Object.entries(linguistLanguages).reduce(
(acc, [name, langData]) => {
const { type, extensions, aliases } = langData as any
// Only include languages with extensions or aliases
if ((extensions && extensions.length > 0) || (aliases && aliases.length > 0)) {
acc[name] = {
type: type || 'programming',
...(extensions && { extensions }),
...(aliases && { aliases })
}
}
return acc
},
{} as Record<string, LanguageData>
)
console.log(`✅ Extracted ${Object.keys(languages).length} languages.`)
return languages
}
/**
* Generates the content for the languages.ts file.
* @param languages The language data to include in the file.
* @returns The generated file content as a string.
*/
function generateLanguagesFileContent(languages: Record<string, LanguageData>): string {
console.log('📝 Generating languages.ts file content...')
const sortedLanguages = Object.fromEntries(Object.entries(languages).sort(([a], [b]) => a.localeCompare(b)))
const languagesObjectString = JSON.stringify(sortedLanguages, null, 2)
const content = `/**
* Code language list.
* Data source: linguist-languages
*
*
* THIS FILE IS AUTOMATICALLY GENERATED BY A SCRIPT. DO NOT EDIT IT MANUALLY!
* Run \`yarn update:languages\` to update this file.
*
*
*/
type LanguageData = {
type: string;
aliases?: string[];
extensions?: string[];
};
export const languages: Record<string, LanguageData> = ${languagesObjectString};
`
console.log('✅ File content generated.')
return content
}
/**
* Formats a file using Prettier.
* @param filePath The path to the file to format.
*/
async function formatWithPrettier(filePath: string): Promise<void> {
console.log('🎨 Formatting file with Prettier...')
try {
await execAsync(`yarn prettier --write ${filePath}`)
console.log('✅ Prettier formatting complete.')
} catch (e: any) {
console.error('❌ Prettier formatting failed:', e.stdout || e.stderr)
throw new Error('Prettier formatting failed.')
}
}
/**
* Checks a file with TypeScript compiler.
* @param filePath The path to the file to check.
*/
async function checkTypeScript(filePath: string): Promise<void> {
console.log('🧐 Checking file with TypeScript compiler...')
try {
await execAsync(`yarn tsc --noEmit --skipLibCheck ${filePath}`)
console.log('✅ TypeScript check passed.')
} catch (e: any) {
console.error('❌ TypeScript check failed:', e.stdout || e.stderr)
throw new Error('TypeScript check failed.')
}
}
/**
* Main function to update the languages.ts file.
*/
async function updateLanguagesFile(): Promise<void> {
console.log('🚀 Starting to update languages.ts...')
try {
const extractedLanguages = extractAllLanguageData()
const fileContent = generateLanguagesFileContent(extractedLanguages)
await fs.writeFile(LANGUAGES_FILE_PATH, fileContent, 'utf-8')
console.log(`✅ Successfully wrote to ${LANGUAGES_FILE_PATH}`)
await formatWithPrettier(LANGUAGES_FILE_PATH)
await checkTypeScript(LANGUAGES_FILE_PATH)
console.log('🎉 Successfully updated languages.ts file!')
console.log(`📊 Contains ${Object.keys(extractedLanguages).length} languages.`)
} catch (error) {
console.error('❌ An error occurred during the update process:', (error as Error).message)
// No need to restore backup as we write only at the end of successful generation.
process.exit(1)
}
}
if (require.main === module) {
updateLanguagesFile()
}
export { updateLanguagesFile }

View File

@ -41,6 +41,14 @@ export { default as WebSearchIcon } from './icons/WebSearchIcon'
export { default as WrapIcon } from './icons/WrapIcon'
// Interactive Components
export {
default as CodeEditor,
type CodeEditorHandles,
type CodeEditorProps,
type CodeMirrorTheme,
getCmThemeByName,
getCmThemeNames
} from './interactive/CodeEditor'
export { default as CollapsibleSearchBar } from './interactive/CollapsibleSearchBar'
export { DraggableList, useDraggableReorder } from './interactive/DraggableList'
export type { EditableNumberProps } from './interactive/EditableNumber'

View File

@ -0,0 +1,135 @@
import CodeMirror, { Annotation, BasicSetupOptions, EditorView } from '@uiw/react-codemirror'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
import { CodeEditorProps } from './types'
import { prepareCodeChanges } from './utils'
/**
* A code editor component based on CodeMirror.
* This is a wrapper of ReactCodeMirror.
*/
const CodeEditor = ({
ref,
value,
placeholder,
language,
onSave,
onChange,
onBlur,
onHeightChange,
height,
maxHeight,
minHeight,
options,
extensions,
theme = 'light',
fontSize = 16,
style,
className,
editable = true,
expanded = true,
wrapped = true
}: CodeEditorProps) => {
const basicSetup = useMemo(() => {
return {
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLineGutter: false,
highlightSelectionMatches: true,
closeBracketsKeymap: options?.keymap,
searchKeymap: options?.keymap,
foldKeymap: options?.keymap,
completionKeymap: options?.keymap,
lintKeymap: options?.keymap,
...(options as BasicSetupOptions)
}
}, [options])
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
const editorViewRef = useRef<EditorView | null>(null)
const langExtensions = useLanguageExtensions(language, options?.lint)
const handleSave = useCallback(() => {
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
onSave?.(currentDoc)
}, [onSave])
// Calculate changes during streaming response to update EditorView
// Cannot handle user editing code during streaming response (and probably doesn't need to)
useEffect(() => {
if (!editorViewRef.current) return
const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
const currentDoc = editorViewRef.current.state.doc.toString()
const changes = prepareCodeChanges(currentDoc, newContent)
if (changes && changes.length > 0) {
editorViewRef.current.dispatch({
changes,
annotations: [Annotation.define<boolean>().of(true)]
})
}
}, [options?.stream, value])
const saveKeymapExtension = useSaveKeymap({ onSave, enabled: options?.keymap })
const blurExtension = useBlurHandler({ onBlur })
const heightListenerExtension = useHeightListener({ onHeightChange })
const customExtensions = useMemo(() => {
return [
...(extensions ?? []),
...langExtensions,
...(wrapped ? [EditorView.lineWrapping] : []),
saveKeymapExtension,
blurExtension,
heightListenerExtension
].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
useImperativeHandle(ref, () => ({
save: handleSave
}))
return (
<CodeMirror
// Set to a stable value to avoid triggering CodeMirror reset
value={initialContent.current}
placeholder={placeholder}
width="100%"
height={expanded ? undefined : height}
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight}
editable={editable}
theme={theme}
extensions={customExtensions}
onCreateEditor={(view: EditorView) => {
editorViewRef.current = view
onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0)
}}
onChange={(value, viewUpdate) => {
if (onChange && viewUpdate.docChanged) onChange(value)
}}
basicSetup={basicSetup}
style={{
fontSize,
marginTop: 0,
borderRadius: 'inherit',
...style
}}
className={`code-editor ${className ?? ''}`}
/>
)
}
CodeEditor.displayName = 'CodeEditor'
export default memo(CodeEditor)

View File

@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getNormalizedExtension } from '../utils'
const hoisted = vi.hoisted(() => ({
languages: {
svg: { extensions: ['.svg'] },
TypeScript: { extensions: ['.ts'] }
}
}))
vi.mock('@shared/config/languages', () => ({
languages: hoisted.languages
}))
describe('getNormalizedExtension', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return custom mapping for custom language', async () => {
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
await expect(getNormalizedExtension('SVG')).resolves.toBe('xml')
})
it('should prefer custom mapping when both custom and linguist exist', async () => {
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
})
it('should return linguist mapping when available (strip leading dot)', async () => {
await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts')
})
it('should return extension when input already looks like extension (leading dot)', async () => {
await expect(getNormalizedExtension('.json')).resolves.toBe('json')
})
it('should return language as-is when no rules matched', async () => {
await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage')
})
})

View File

@ -0,0 +1,202 @@
import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup
import { EditorView } from '@codemirror/view'
import { Extension, keymap } from '@uiw/react-codemirror'
import { useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils'
/** linter
* key: 语言文件扩展名 `.`
*/
const linterLoaders: Record<string, () => Promise<any>> = {
json: async () => {
const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
return linter(jsonParseLinter())
}
}
/**
*
* key: 语言文件扩展名 `.`
*/
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
dot: async () => {
const mod = await import('@viz-js/lang-dot')
return mod.dot()
},
// @uiw/codemirror-extensions-langs 4.25.1 移除了 mermaid 支持,这里加回来
mmd: async () => {
const mod = await import('codemirror-lang-mermaid')
return mod.mermaid()
}
}
/**
*
*/
async function loadLanguageExtension(language: string): Promise<Extension | null> {
const fileExt = await getNormalizedExtension(language)
// 尝试加载特殊语言
const specialLoader = specialLanguageLoaders[fileExt]
if (specialLoader) {
try {
return await specialLoader()
} catch (error) {
console.debug(`Failed to load language ${language} (${fileExt})`, error as Error)
return null
}
}
// 回退到 uiw/codemirror 包含的语言
try {
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
const extension = loadLanguage(fileExt as any)
return extension || null
} catch (error) {
console.debug(`Failed to load language ${language} (${fileExt})`, error as Error)
return null
}
}
/**
* linter
*/
async function loadLinterExtension(language: string): Promise<Extension | null> {
const fileExt = await getNormalizedExtension(language)
const loader = linterLoaders[fileExt]
if (!loader) return null
try {
return await loader()
} catch (error) {
console.debug(`Failed to load linter for ${language} (${fileExt})`, error as Error)
return null
}
}
/**
*
*/
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const [extensions, setExtensions] = useState<Extension[]>([])
useEffect(() => {
let cancelled = false
const loadAllExtensions = async () => {
try {
// 加载所有扩展
const [languageResult, linterResult] = await Promise.allSettled([
loadLanguageExtension(language),
lint ? loadLinterExtension(language) : Promise.resolve(null)
])
if (cancelled) return
const results: Extension[] = []
// 语言扩展
if (languageResult.status === 'fulfilled' && languageResult.value) {
results.push(languageResult.value)
}
// linter 扩展
if (linterResult.status === 'fulfilled' && linterResult.value) {
results.push(linterResult.value)
}
setExtensions(results)
} catch (error) {
if (!cancelled) {
console.debug('Failed to load language extensions:', error as Error)
setExtensions([])
}
}
}
loadAllExtensions()
return () => {
cancelled = true
}
}, [language, lint])
return extensions
}
interface UseSaveKeymapProps {
onSave?: (content: string) => void
enabled?: boolean
}
/**
* CodeMirror (Cmd/Ctrl + S)
* @param onSave
* @param enabled
* @returns
*/
export function useSaveKeymap({ onSave, enabled = true }: UseSaveKeymapProps) {
return useMemo(() => {
if (!enabled || !onSave) {
return []
}
return keymap.of([
{
key: 'Mod-s',
run: (view: EditorView) => {
onSave(view.state.doc.toString())
return true
},
preventDefault: true
}
])
}, [onSave, enabled])
}
interface UseBlurHandlerProps {
onBlur?: (content: string) => void
}
/**
* CodeMirror blur
* @param onBlur blur
* @returns
*/
export function useBlurHandler({ onBlur }: UseBlurHandlerProps) {
return useMemo(() => {
if (!onBlur) {
return []
}
return EditorView.domEventHandlers({
blur: (_event, view) => {
onBlur(view.state.doc.toString())
}
})
}, [onBlur])
}
interface UseHeightListenerProps {
onHeightChange?: (scrollHeight: number) => void
}
/**
* CodeMirror
* @param onHeightChange
* @returns
*/
export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
return useMemo(() => {
if (!onHeightChange) {
return []
}
return EditorView.updateListener.of((update) => {
if (update.docChanged || update.heightChanged) {
onHeightChange(update.view.scrollDOM?.scrollHeight ?? 0)
}
})
}, [onHeightChange])
}

View File

@ -0,0 +1,3 @@
export { default } from './CodeEditor'
export * from './types'
export { getCmThemeByName, getCmThemeNames } from './utils'

View File

@ -0,0 +1,93 @@
import { BasicSetupOptions, Extension } from '@uiw/react-codemirror'
export type CodeMirrorTheme = 'light' | 'dark' | 'none' | Extension
export interface CodeEditorHandles {
save?: () => void
}
export interface CodeEditorProps {
ref?: React.RefObject<CodeEditorHandles | null>
/** Value used in controlled mode, e.g., code blocks. */
value: string
/** Placeholder when the editor content is empty. */
placeholder?: string | HTMLElement
/**
* Code language string.
* - Case-insensitive.
* - Supports common names: javascript, json, python, etc.
* - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc.
* - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc.
*/
language: string
/** Fired when ref.save() is called or the save shortcut is triggered. */
onSave?: (newContent: string) => void
/** Fired when the editor content changes. */
onChange?: (newContent: string) => void
/** Fired when the editor loses focus. */
onBlur?: (newContent: string) => void
/** Fired when the editor height changes. */
onHeightChange?: (scrollHeight: number) => void
/**
* Fixed editor height, not exceeding maxHeight.
* Only works when expanded is false.
*/
height?: string
/**
* Maximum editor height.
* Only works when expanded is false.
*/
maxHeight?: string
/** Minimum editor height. */
minHeight?: string
/** Editor options that extend BasicSetupOptions. */
options?: {
/**
* Whether to enable special treatment for stream response.
* @default false
*/
stream?: boolean
/**
* Whether to enable linting.
* @default false
*/
lint?: boolean
/**
* Whether to enable keymap.
* @default false
*/
keymap?: boolean
} & BasicSetupOptions
/** Additional extensions for CodeMirror. */
extensions?: Extension[]
/**
* CodeMirror theme name: 'light', 'dark', 'none', Extension.
* @default 'light'
*/
theme?: CodeMirrorTheme
/**
* Font size that overrides the app setting.
* @default 16
*/
fontSize?: number
/** Style overrides for the editor, passed directly to CodeMirror's style property. */
style?: React.CSSProperties
/** CSS class name appended to the default `code-editor` class. */
className?: string
/**
* Whether the editor is editable.
* @default true
*/
editable?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
* @default true
*/
expanded?: boolean
/**
* Whether the code lines are wrapped.
* @default true
*/
wrapped?: boolean
}

View File

@ -0,0 +1,126 @@
import * as cmThemes from '@uiw/codemirror-themes-all'
import { Extension } from '@uiw/react-codemirror'
import diff from 'fast-diff'
import { getExtensionByLanguage } from '../../../utils/codeLanguage'
import { CodeMirrorTheme } from './types'
/**
* Computes code changes using fast-diff and converts them to CodeMirror changes.
* Could handle all types of changes, though insertions are most common during streaming responses.
* @param oldCode The old code content
* @param newCode The new code content
* @returns An array of changes for EditorView.dispatch
*/
export function prepareCodeChanges(oldCode: string, newCode: string) {
const diffResult = diff(oldCode, newCode)
const changes: { from: number; to: number; insert: string }[] = []
let offset = 0
// operation: 1=insert, -1=delete, 0=equal
for (const [operation, text] of diffResult) {
if (operation === 1) {
changes.push({
from: offset,
to: offset,
insert: text
})
} else if (operation === -1) {
changes.push({
from: offset,
to: offset + text.length,
insert: ''
})
offset += text.length
} else {
offset += text.length
}
}
return changes
}
// Custom language file extension mapping
// key: language name in lowercase
// value: file extension
const _customLanguageExtensions: Record<string, string> = {
svg: 'xml',
vab: 'vb',
graphviz: 'dot'
}
/**
* Get the file extension of the language, for @uiw/codemirror-extensions-langs
* - First, search for custom extensions
* - Then, search for github linguist extensions
* - Finally, assume the name is already an extension
* @param language language name
* @returns file extension (without `.` prefix)
*/
export async function getNormalizedExtension(language: string) {
let lang = language
// If the language name looks like an extension, remove the dot
if (language.startsWith('.') && language.length > 1) {
lang = language.slice(1)
}
const lowerLanguage = lang.toLowerCase()
// 1. Search for custom extensions
const customExt = _customLanguageExtensions[lowerLanguage]
if (customExt) {
return customExt
}
// 2. Search for github linguist extensions
const linguistExt = getExtensionByLanguage(lang)
if (linguistExt) {
return linguistExt.slice(1)
}
// Fallback to language name
return lang
}
/**
* Get the list of CodeMirror theme names
* - Include auto, light, dark
* - Include all themes in @uiw/codemirror-themes-all
*
* A more robust approach might be to hardcode the theme list
* @returns theme name list
*/
export function getCmThemeNames(): string[] {
return ['auto', 'light', 'dark']
.concat(Object.keys(cmThemes))
.filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function')
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
}
/**
* Get the CodeMirror theme object by theme name
* @param name theme name
* @returns theme object
*/
export function getCmThemeByName(name: string): CodeMirrorTheme {
// 1. Search for the extension of the corresponding theme in @uiw/codemirror-themes-all
const candidate = (cmThemes as Record<string, unknown>)[name]
if (
Object.prototype.hasOwnProperty.call(cmThemes, name) &&
typeof candidate !== 'function' &&
!/^defaultSettings/i.test(name) &&
!/(Style)$/.test(name)
) {
return candidate as Extension
}
// 2. Basic string theme
if (name === 'light' || name === 'dark' || name === 'none') {
return name
}
// 3. If not found, fallback to light
return 'light'
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
import { languages } from '../config/languages'
/**
* Get the file extension of the language, by language name
* - First, exact match
* - Then, case-insensitive match
* - Finally, match aliases
* If there are multiple file extensions, only the first one will be returned
* @param language language name
* @returns file extension
*/
export function getExtensionByLanguage(language: string): string {
const lowerLanguage = language.toLowerCase()
// Exact match language name
const directMatch = languages[language]
if (directMatch?.extensions?.[0]) {
return directMatch.extensions[0]
}
const languageEntries = Object.entries(languages)
// Case-insensitive match language name
for (const [langName, data] of languageEntries) {
if (langName.toLowerCase() === lowerLanguage && data.extensions?.[0]) {
return data.extensions[0]
}
}
// Match aliases
for (const [, data] of languageEntries) {
if (data.aliases?.some((alias) => alias.toLowerCase() === lowerLanguage)) {
return data.extensions?.[0] || `.${language}`
}
}
// Fallback to language name
return `.${language}`
}

View File

@ -0,0 +1,109 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { action } from 'storybook/actions'
import CodeEditor, { getCmThemeByName, getCmThemeNames } from '../../../src/components/interactive/CodeEditor'
const meta: Meta<typeof CodeEditor> = {
title: 'Interactive/CodeEditor',
component: CodeEditor,
parameters: { layout: 'centered' },
tags: ['autodocs'],
argTypes: {
language: {
control: 'select',
options: ['typescript', 'javascript', 'json', 'markdown', 'python', 'dot', 'mmd']
},
theme: {
control: 'select',
options: getCmThemeNames()
},
fontSize: { control: { type: 'range', min: 12, max: 22, step: 1 } },
editable: { control: 'boolean' },
expanded: { control: 'boolean' },
wrapped: { control: 'boolean' },
height: { control: 'text' },
maxHeight: { control: 'text' },
minHeight: { control: 'text' }
}
}
export default meta
type Story = StoryObj<typeof meta>
// 基础示例(非流式)
export const Default: Story = {
args: {
language: 'typescript',
theme: 'light',
value: `function greet(name: string) {\n return 'Hello ' + name\n}`,
fontSize: 16,
editable: true,
expanded: true,
wrapped: true
},
render: (args) => (
<div className="w-[720px]">
<CodeEditor
value={args.value as string}
language={args.language as string}
theme={getCmThemeByName((args as any).theme || 'light')}
fontSize={args.fontSize as number}
editable={args.editable as boolean}
expanded={args.expanded as boolean}
wrapped={args.wrapped as boolean}
height={args.height as string | undefined}
maxHeight={args.maxHeight as string | undefined}
minHeight={args.minHeight as string | undefined}
onChange={action('change')}
onBlur={action('blur')}
onHeightChange={action('height')}
/>
</div>
)
}
// JSON + Lint非流式
export const JSONLint: Story = {
args: {
language: 'json',
theme: 'light',
value: `{\n "valid": true,\n "missingComma": true\n "another": 123\n}`,
wrapped: true
},
render: (args) => (
<div className="w-[720px]">
<CodeEditor
value={args.value as string}
language="json"
theme={getCmThemeByName((args as any).theme || 'light')}
options={{ lint: true }}
wrapped
onChange={action('change')}
/>
</div>
)
}
// 保存快捷键Mod/Ctrl + S 触发 onSave
export const SaveShortcut: Story = {
args: {
language: 'markdown',
theme: 'light',
value: `# Press Mod/Ctrl + S to fire onSave`,
wrapped: true
},
render: (args) => (
<div className="w-[720px] space-y-3">
<CodeEditor
value={args.value as string}
language={args.language as string}
theme={getCmThemeByName((args as any).theme || 'light')}
options={{ keymap: true }}
onSave={action('save')}
onChange={action('change')}
wrapped
/>
<p className="text-xs text-gray-500">使 Mod/Ctrl + S </p>
</div>
)
}

View File

@ -1,6 +1,8 @@
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { CodeEditor, type CodeEditorHandles } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { CopyIcon, FilePngIcon } from '@renderer/components/Icons'
import { isMac } from '@renderer/config/constant'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
import { classNames } from '@renderer/utils'
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
@ -23,6 +25,8 @@ type ViewMode = 'split' | 'code' | 'preview'
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onSave, onClose }) => {
const { t } = useTranslation()
const [fontSize] = usePreference('chat.message.font_size')
const { activeCmTheme } = useCodeStyle()
const [viewMode, setViewMode] = useState<ViewMode>('split')
const [isFullscreen, setIsFullscreen] = useState(false)
const [saved, setSaved] = useTemporaryValue(false, 2000)
@ -141,6 +145,8 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
<CodeSection>
<CodeEditor
ref={codeEditorRef}
theme={activeCmTheme}
fontSize={fontSize - 1}
value={html}
language="html"
editable={true}

View File

@ -1,7 +1,7 @@
import { usePreference } from '@data/hooks/usePreference'
import { CodeEditor, type CodeEditorHandles } from '@cherrystudio/ui'
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { ActionTool } from '@renderer/components/ActionTools'
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import {
CodeToolbar,
useCopyTool,
@ -17,6 +17,7 @@ import CodeViewer from '@renderer/components/CodeViewer'
import ImageViewer from '@renderer/components/ImageViewer'
import { BasicPreviewHandles } from '@renderer/components/Preview'
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { pyodideService } from '@renderer/services/PyodideService'
import { getExtensionByLanguage } from '@renderer/utils/code-language'
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
@ -28,6 +29,7 @@ import styled, { css } from 'styled-components'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import StatusBar from './StatusBar'
import { ViewMode } from './types'
const logger = loggerService.withContext('CodeBlockView')
interface Props {
@ -55,12 +57,24 @@ interface Props {
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
const { t } = useTranslation()
const [codeEditorEnabled] = usePreference('chat.code.editor.enabled')
const [codeExecutionEnabled] = usePreference('chat.code.execution.enabled')
const [codeExecutionTimeoutMinutes] = usePreference('chat.code.execution.timeout_minutes')
const [codeCollapsible] = usePreference('chat.code.collapsible')
const [codeWrappable] = usePreference('chat.code.wrappable')
const [codeImageTools] = usePreference('chat.code.image_tools')
const [fontSize] = usePreference('chat.message.font_size')
const [codeShowLineNumbers] = usePreference('chat.code.show_line_numbers')
const [codeEditor] = useMultiplePreferences({
enabled: 'chat.code.editor.enabled',
autocompletion: 'chat.code.editor.autocompletion',
foldGutter: 'chat.code.editor.fold_gutter',
highlightActiveLine: 'chat.code.editor.highlight_active_line',
keymap: 'chat.code.editor.keymap',
themeLight: 'chat.code.editor.theme_light',
themeDark: 'chat.code.editor.theme_dark'
})
const { activeCmTheme } = useCodeStyle()
const [viewState, setViewState] = useState({
mode: 'special' as ViewMode,
@ -196,7 +210,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
// 特殊视图的编辑/查看源码按钮,在分屏模式下不可用
useViewSourceTool({
enabled: hasSpecialView,
editable: codeEditorEnabled,
editable: codeEditor.enabled,
viewMode,
onViewModeChange: setViewMode,
setTools
@ -238,7 +252,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
// 代码编辑器的保存按钮
useSaveTool({
enabled: codeEditorEnabled && !isInSpecialView,
enabled: codeEditor.enabled && !isInSpecialView,
sourceViewRef,
setTools
})
@ -246,16 +260,18 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
// 源代码视图组件
const sourceView = useMemo(
() =>
codeEditorEnabled ? (
codeEditor.enabled ? (
<CodeEditor
className="source-view"
ref={sourceViewRef}
theme={activeCmTheme}
fontSize={fontSize - 1}
value={children}
language={language}
onSave={onSave}
onHeightChange={handleHeightChange}
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
options={{ stream: true }}
options={{ stream: true, lineNumbers: codeShowLineNumbers, ...codeEditor }}
expanded={shouldExpand}
wrapped={shouldWrap}
/>
@ -270,7 +286,18 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
/>
),
[children, codeEditorEnabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap]
[
activeCmTheme,
children,
codeEditor,
codeShowLineNumbers,
fontSize,
handleHeightChange,
language,
onSave,
shouldExpand,
shouldWrap
]
)
// 特殊视图组件映射

View File

@ -0,0 +1,136 @@
import CodeMirror, { Annotation, BasicSetupOptions, EditorView } from '@uiw/react-codemirror'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
import { CodeEditorProps } from './types'
import { prepareCodeChanges } from './utils'
/**
* A code editor component based on CodeMirror.
* This is a wrapper of ReactCodeMirror.
* @deprecated Import CodeEditor from @cherrystudio/ui instead.
*/
const CodeEditor = ({
ref,
value,
placeholder,
language,
onSave,
onChange,
onBlur,
onHeightChange,
height,
maxHeight,
minHeight,
options,
extensions,
theme = 'light',
fontSize = 16,
style,
className,
editable = true,
expanded = true,
wrapped = true
}: CodeEditorProps) => {
const basicSetup = useMemo(() => {
return {
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLineGutter: false,
highlightSelectionMatches: true,
closeBracketsKeymap: options?.keymap,
searchKeymap: options?.keymap,
foldKeymap: options?.keymap,
completionKeymap: options?.keymap,
lintKeymap: options?.keymap,
...(options as BasicSetupOptions)
}
}, [options])
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
const editorViewRef = useRef<EditorView | null>(null)
const langExtensions = useLanguageExtensions(language, options?.lint)
const handleSave = useCallback(() => {
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
onSave?.(currentDoc)
}, [onSave])
// Calculate changes during streaming response to update EditorView
// Cannot handle user editing code during streaming response (and probably doesn't need to)
useEffect(() => {
if (!editorViewRef.current) return
const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
const currentDoc = editorViewRef.current.state.doc.toString()
const changes = prepareCodeChanges(currentDoc, newContent)
if (changes && changes.length > 0) {
editorViewRef.current.dispatch({
changes,
annotations: [Annotation.define<boolean>().of(true)]
})
}
}, [options?.stream, value])
const saveKeymapExtension = useSaveKeymap({ onSave, enabled: options?.keymap })
const blurExtension = useBlurHandler({ onBlur })
const heightListenerExtension = useHeightListener({ onHeightChange })
const customExtensions = useMemo(() => {
return [
...(extensions ?? []),
...langExtensions,
...(wrapped ? [EditorView.lineWrapping] : []),
saveKeymapExtension,
blurExtension,
heightListenerExtension
].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
useImperativeHandle(ref, () => ({
save: handleSave
}))
return (
<CodeMirror
// Set to a stable value to avoid triggering CodeMirror reset
value={initialContent.current}
placeholder={placeholder}
width="100%"
height={expanded ? undefined : height}
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight}
editable={editable}
theme={theme}
extensions={customExtensions}
onCreateEditor={(view: EditorView) => {
editorViewRef.current = view
onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0)
}}
onChange={(value, viewUpdate) => {
if (onChange && viewUpdate.docChanged) onChange(value)
}}
basicSetup={basicSetup}
style={{
fontSize,
marginTop: 0,
borderRadius: 'inherit',
...style
}}
className={`code-editor ${className ?? ''}`}
/>
)
}
CodeEditor.displayName = 'CodeEditor'
export default memo(CodeEditor)

View File

@ -2,12 +2,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getNormalizedExtension } from '../utils'
const mocks = vi.hoisted(() => ({
getExtensionByLanguage: vi.fn()
const hoisted = vi.hoisted(() => ({
languages: {
svg: { extensions: ['.svg'] },
TypeScript: { extensions: ['.ts'] }
}
}))
vi.mock('@renderer/utils/code-language', () => ({
getExtensionByLanguage: mocks.getExtensionByLanguage
vi.mock('@shared/config/languages', () => ({
languages: hoisted.languages
}))
describe('getNormalizedExtension', () => {
@ -16,28 +19,23 @@ describe('getNormalizedExtension', () => {
})
it('should return custom mapping for custom language', async () => {
mocks.getExtensionByLanguage.mockReturnValue(undefined)
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
await expect(getNormalizedExtension('SVG')).resolves.toBe('xml')
})
it('should prefer custom mapping when both custom and linguist exist', async () => {
mocks.getExtensionByLanguage.mockReturnValue('.svg')
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
})
it('should return linguist mapping when available (strip leading dot)', async () => {
mocks.getExtensionByLanguage.mockReturnValue('.ts')
await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts')
})
it('should return extension when input already looks like extension (leading dot)', async () => {
mocks.getExtensionByLanguage.mockReturnValue(undefined)
await expect(getNormalizedExtension('.json')).resolves.toBe('json')
})
it('should return language as-is when no rules matched', async () => {
mocks.getExtensionByLanguage.mockReturnValue(undefined)
await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage')
})
})

View File

@ -0,0 +1,3 @@
export { default } from './CodeEditor'
export * from './types'
export { getCmThemeByName, getCmThemeNames } from './utils'

View File

@ -1,278 +0,0 @@
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror'
import diff from 'fast-diff'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
export interface CodeEditorHandles {
save?: () => void
}
export interface CodeEditorProps {
ref?: React.RefObject<CodeEditorHandles | null>
/** Value used in controlled mode, e.g., code blocks. */
value: string
/** Placeholder when the editor content is empty. */
placeholder?: string | HTMLElement
/**
* Code language string.
* - Case-insensitive.
* - Supports common names: javascript, json, python, etc.
* - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc.
* - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc.
*/
language: string
/** Fired when ref.save() is called or the save shortcut is triggered. */
onSave?: (newContent: string) => void
/** Fired when the editor content changes. */
onChange?: (newContent: string) => void
/** Fired when the editor loses focus. */
onBlur?: (newContent: string) => void
/** Fired when the editor height changes. */
onHeightChange?: (scrollHeight: number) => void
/**
* Fixed editor height, not exceeding maxHeight.
* Only works when expanded is false.
*/
height?: string
/**
* Maximum editor height.
* Only works when expanded is false.
*/
maxHeight?: string
/** Minimum editor height. */
minHeight?: string
/** Editor options that extend BasicSetupOptions. */
options?: {
/**
* Whether to enable special treatment for stream response.
* @default false
*/
stream?: boolean
/**
* Whether to enable linting.
* @default false
*/
lint?: boolean
/**
* Whether to enable keymap.
* @default false
*/
keymap?: boolean
} & BasicSetupOptions
/** Additional extensions for CodeMirror. */
extensions?: Extension[]
/** Font size that overrides the app setting. */
fontSize?: number
/** Style overrides for the editor, passed directly to CodeMirror's style property. */
style?: React.CSSProperties
/** CSS class name appended to the default `code-editor` class. */
className?: string
/**
* Whether the editor is editable.
* @default true
*/
editable?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
* @default true
*/
expanded?: boolean
/**
* Whether the code lines are wrapped.
* @default true
*/
wrapped?: boolean
}
/**
* A code editor component based on CodeMirror.
* This is a wrapper of ReactCodeMirror.
*/
const CodeEditor = ({
ref,
value,
placeholder,
language,
onSave,
onChange,
onBlur,
onHeightChange,
height,
maxHeight,
minHeight,
options,
extensions,
fontSize: customFontSize,
style,
className,
editable = true,
expanded = true,
wrapped = true
}: CodeEditorProps) => {
const [_fontSize] = usePreference('chat.message.font_size')
const [_lineNumbers] = usePreference('chat.code.show_line_numbers')
const [codeEditor] = useMultiplePreferences({
autocompletion: 'chat.code.editor.autocompletion',
foldGutter: 'chat.code.editor.fold_gutter',
highlightActiveLine: 'chat.code.editor.highlight_active_line',
keymap: 'chat.code.editor.keymap',
themeLight: 'chat.code.editor.theme_light',
themeDark: 'chat.code.editor.theme_dark'
})
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
// 合并 codeEditor 和 options 的 basicSetupoptions 优先
const basicSetup = useMemo(() => {
return {
lineNumbers: _lineNumbers,
...(codeEditor as BasicSetupOptions),
...(options as BasicSetupOptions)
}
}, [codeEditor, _lineNumbers, options])
const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize])
const { activeCmTheme } = useCodeStyle()
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
const editorViewRef = useRef<EditorView | null>(null)
const langExtensions = useLanguageExtensions(language, options?.lint)
const handleSave = useCallback(() => {
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
onSave?.(currentDoc)
}, [onSave])
// 流式响应过程中计算 changes 来更新 EditorView
// 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理)
useEffect(() => {
if (!editorViewRef.current) return
const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
const currentDoc = editorViewRef.current.state.doc.toString()
const changes = prepareCodeChanges(currentDoc, newContent)
if (changes && changes.length > 0) {
editorViewRef.current.dispatch({
changes,
annotations: [External.of(true)]
})
}
}, [options?.stream, value])
const saveKeymapExtension = useSaveKeymap({ onSave, enabled: enableKeymap })
const blurExtension = useBlurHandler({ onBlur })
const heightListenerExtension = useHeightListener({ onHeightChange })
const customExtensions = useMemo(() => {
return [
...(extensions ?? []),
...langExtensions,
...(wrapped ? [EditorView.lineWrapping] : []),
saveKeymapExtension,
blurExtension,
heightListenerExtension
].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
useImperativeHandle(ref, () => ({
save: handleSave
}))
return (
<CodeMirror
// 维持一个稳定值,避免触发 CodeMirror 重置
value={initialContent.current}
placeholder={placeholder}
width="100%"
height={expanded ? undefined : height}
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight}
editable={editable}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}
onCreateEditor={(view: EditorView) => {
editorViewRef.current = view
onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0)
}}
onChange={(value, viewUpdate) => {
if (onChange && viewUpdate.docChanged) onChange(value)
}}
basicSetup={{
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLineGutter: false,
highlightSelectionMatches: true,
closeBracketsKeymap: enableKeymap,
searchKeymap: enableKeymap,
foldKeymap: enableKeymap,
completionKeymap: enableKeymap,
lintKeymap: enableKeymap,
...basicSetup // override basicSetup
}}
style={{
fontSize,
marginTop: 0,
borderRadius: 'inherit',
...style
}}
className={`code-editor ${className ?? ''}`}
/>
)
}
CodeEditor.displayName = 'CodeEditor'
/**
* 使 fast-diff CodeMirror changes
*
* @param oldCode
* @param newCode
* @returns EditorView.dispatch changes
*/
function prepareCodeChanges(oldCode: string, newCode: string) {
const diffResult = diff(oldCode, newCode)
const changes: { from: number; to: number; insert: string }[] = []
let offset = 0
// operation: 1=插入, -1=删除, 0=相等
for (const [operation, text] of diffResult) {
if (operation === 1) {
changes.push({
from: offset,
to: offset,
insert: text
})
} else if (operation === -1) {
changes.push({
from: offset,
to: offset + text.length,
insert: ''
})
offset += text.length
} else {
offset += text.length
}
}
return changes
}
export default memo(CodeEditor)

View File

@ -0,0 +1,93 @@
import { BasicSetupOptions, Extension } from '@uiw/react-codemirror'
export type CodeMirrorTheme = 'light' | 'dark' | 'none' | Extension
export interface CodeEditorHandles {
save?: () => void
}
export interface CodeEditorProps {
ref?: React.RefObject<CodeEditorHandles | null>
/** Value used in controlled mode, e.g., code blocks. */
value: string
/** Placeholder when the editor content is empty. */
placeholder?: string | HTMLElement
/**
* Code language string.
* - Case-insensitive.
* - Supports common names: javascript, json, python, etc.
* - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc.
* - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc.
*/
language: string
/** Fired when ref.save() is called or the save shortcut is triggered. */
onSave?: (newContent: string) => void
/** Fired when the editor content changes. */
onChange?: (newContent: string) => void
/** Fired when the editor loses focus. */
onBlur?: (newContent: string) => void
/** Fired when the editor height changes. */
onHeightChange?: (scrollHeight: number) => void
/**
* Fixed editor height, not exceeding maxHeight.
* Only works when expanded is false.
*/
height?: string
/**
* Maximum editor height.
* Only works when expanded is false.
*/
maxHeight?: string
/** Minimum editor height. */
minHeight?: string
/** Editor options that extend BasicSetupOptions. */
options?: {
/**
* Whether to enable special treatment for stream response.
* @default false
*/
stream?: boolean
/**
* Whether to enable linting.
* @default false
*/
lint?: boolean
/**
* Whether to enable keymap.
* @default false
*/
keymap?: boolean
} & BasicSetupOptions
/** Additional extensions for CodeMirror. */
extensions?: Extension[]
/**
* CodeMirror theme name: 'light', 'dark', 'none', Extension.
* @default 'light'
*/
theme?: CodeMirrorTheme
/**
* Font size that overrides the app setting.
* @default 16
*/
fontSize?: number
/** Style overrides for the editor, passed directly to CodeMirror's style property. */
style?: React.CSSProperties
/** CSS class name appended to the default `code-editor` class. */
className?: string
/**
* Whether the editor is editable.
* @default true
*/
editable?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
* @default true
*/
expanded?: boolean
/**
* Whether the code lines are wrapped.
* @default true
*/
wrapped?: boolean
}

View File

@ -1,8 +1,49 @@
import { getExtensionByLanguage } from '@renderer/utils/code-language'
import { languages } from '@shared/config/languages'
import * as cmThemes from '@uiw/codemirror-themes-all'
import { Extension } from '@uiw/react-codemirror'
import diff from 'fast-diff'
// 自定义语言文件扩展名映射
// key: 语言名小写
// value: 扩展名
import { CodeMirrorTheme } from './types'
/**
* Computes code changes using fast-diff and converts them to CodeMirror changes.
* Could handle all types of changes, though insertions are most common during streaming responses.
* @param oldCode The old code content
* @param newCode The new code content
* @returns An array of changes for EditorView.dispatch
*/
export function prepareCodeChanges(oldCode: string, newCode: string) {
const diffResult = diff(oldCode, newCode)
const changes: { from: number; to: number; insert: string }[] = []
let offset = 0
// operation: 1=insert, -1=delete, 0=equal
for (const [operation, text] of diffResult) {
if (operation === 1) {
changes.push({
from: offset,
to: offset,
insert: text
})
} else if (operation === -1) {
changes.push({
from: offset,
to: offset + text.length,
insert: ''
})
offset += text.length
} else {
offset += text.length
}
}
return changes
}
// Custom language file extension mapping
// key: language name in lowercase
// value: file extension
const _customLanguageExtensions: Record<string, string> = {
svg: 'xml',
vab: 'vb',
@ -10,31 +51,112 @@ const _customLanguageExtensions: Record<string, string> = {
}
/**
* @uiw/codemirror-extensions-langs
* -
* - github linguist
* -
* @param language
* @returns `.`
* Get the file extension of the language, for @uiw/codemirror-extensions-langs
* - First, search for custom extensions
* - Then, search for github linguist extensions
* - Finally, assume the name is already an extension
* @param language language name
* @returns file extension (without `.` prefix)
*/
export async function getNormalizedExtension(language: string) {
const lowerLanguage = language.toLowerCase()
let lang = language
// If the language name looks like an extension, remove the dot
if (language.startsWith('.') && language.length > 1) {
lang = language.slice(1)
}
const lowerLanguage = lang.toLowerCase()
// 1. Search for custom extensions
const customExt = _customLanguageExtensions[lowerLanguage]
if (customExt) {
return customExt
}
const linguistExt = getExtensionByLanguage(language)
// 2. Search for github linguist extensions
const linguistExt = getExtensionByLanguage(lang)
if (linguistExt) {
return linguistExt.slice(1)
}
// 如果语言名称像扩展名
if (language.startsWith('.') && language.length > 1) {
return language.slice(1)
// Fallback to language name
return lang
}
/**
* Get the file extension of the language, by language name
* - First, exact match
* - Then, case-insensitive match
* - Finally, match aliases
* If there are multiple file extensions, only the first one will be returned
* @param language language name
* @returns file extension
*/
export function getExtensionByLanguage(language: string): string {
const lowerLanguage = language.toLowerCase()
// Exact match language name
const directMatch = languages[language]
if (directMatch?.extensions?.[0]) {
return directMatch.extensions[0]
}
// 回退到语言名称
return language
// Case-insensitive match language name
for (const [langName, data] of Object.entries(languages)) {
if (langName.toLowerCase() === lowerLanguage && data.extensions?.[0]) {
return data.extensions[0]
}
}
// Match aliases
for (const [, data] of Object.entries(languages)) {
if (data.aliases?.some((alias) => alias.toLowerCase() === lowerLanguage)) {
return data.extensions?.[0] || `.${language}`
}
}
// Fallback to language name
return `.${language}`
}
/**
* Get the list of CodeMirror theme names
* - Include auto, light, dark
* - Include all themes in @uiw/codemirror-themes-all
*
* A more robust approach might be to hardcode the theme list
* @returns theme name list
*/
export function getCmThemeNames(): string[] {
return ['auto', 'light', 'dark']
.concat(Object.keys(cmThemes))
.filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function')
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
}
/**
* Get the CodeMirror theme object by theme name
* @param name theme name
* @returns theme object
*/
export function getCmThemeByName(name: string): CodeMirrorTheme {
// 1. Search for the extension of the corresponding theme in @uiw/codemirror-themes-all
const candidate = (cmThemes as Record<string, unknown>)[name]
if (
Object.prototype.hasOwnProperty.call(cmThemes, name) &&
typeof candidate !== 'function' &&
!/^defaultSettings/i.test(name) &&
!/(Style)$/.test(name)
) {
return candidate as Extension
}
// 2. Basic string theme
if (name === 'light' || name === 'dark' || name === 'none') {
return name
}
// 3. If not found, fallback to light
return 'light'
}

View File

@ -1,5 +1,5 @@
import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
import { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { type CodeEditorHandles } from '@renderer/components/CodeEditor'
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
import { Check, SaveIcon } from 'lucide-react'
import { useCallback, useEffect } from 'react'

View File

@ -1,8 +1,10 @@
import { CodeEditor } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Modal } from 'antd'
import { useState } from 'react'
import styled from 'styled-components'
import CodeEditor from '../CodeEditor'
import { TopView } from '../TopView'
interface Props {
@ -14,6 +16,8 @@ interface Props {
const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) => {
const [open, setOpen] = useState(true)
const [fontSize] = usePreference('chat.message.font_size')
const { activeCmTheme } = useCodeStyle()
const onOk = () => {
setOpen(false)
@ -55,6 +59,8 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
footer={null}>
{extension !== undefined ? (
<Editor
theme={activeCmTheme}
fontSize={fontSize - 1}
editable={false}
expanded={false}
height="100%"

View File

@ -1,10 +1,10 @@
import { CodeMirrorTheme, getCmThemeByName, getCmThemeNames } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService'
import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/shiki'
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import * as cmThemes from '@uiw/codemirror-themes-all'
import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
import type { BundledThemeInfo } from 'shiki/types'
@ -18,7 +18,7 @@ interface CodeStyleContextType {
themeNames: string[]
activeShikiTheme: string
isShikiThemeDark: boolean
activeCmTheme: any
activeCmTheme: CodeMirrorTheme
}
const defaultCodeStyleContext: CodeStyleContextType = {
@ -31,7 +31,7 @@ const defaultCodeStyleContext: CodeStyleContextType = {
themeNames: ['auto'],
activeShikiTheme: 'auto',
isShikiThemeDark: false,
activeCmTheme: null
activeCmTheme: 'none'
}
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
@ -60,10 +60,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
// CodeMirror 主题
// 更保险的做法可能是硬编码主题列表
if (codeEditorEnabled) {
return ['auto', 'light', 'dark']
.concat(Object.keys(cmThemes))
.filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function')
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
return getCmThemeNames()
}
// Shiki 主题,取出所有 BundledThemeInfo 的 id 作为主题名
@ -92,7 +89,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
if (!themeName || themeName === 'auto' || !themeNames.includes(themeName)) {
themeName = theme === ThemeMode.light ? 'materialLight' : 'dark'
}
return cmThemes[themeName as keyof typeof cmThemes] || themeName
return getCmThemeByName(themeName)
}, [theme, codeEditorThemeLight, codeEditorThemeDark, themeNames])
// 自定义 shiki 语言别名

View File

@ -1,8 +1,9 @@
import CodeEditor from '@renderer/components/CodeEditor'
import { CodeEditor } from '@cherrystudio/ui'
import { HSpaceBetweenStack } from '@renderer/components/Layout'
import RichEditor from '@renderer/components/RichEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import Selector from '@renderer/components/Selector'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { EditorView } from '@renderer/types'
import { Empty, Spin } from 'antd'
@ -23,6 +24,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => {
const { t } = useTranslation()
const { settings } = useNotesSettings()
const { activeCmTheme } = useCodeStyle()
const currentViewMode = useMemo(() => {
if (settings.defaultViewMode === 'edit') {
return settings.defaultEditMode
@ -61,14 +63,15 @@ const NotesEditor: FC<NotesEditorProps> = memo(
{tmpViewMode === 'source' ? (
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
<CodeEditor
theme={activeCmTheme}
fontSize={settings.fontSize}
value={currentContent}
language="markdown"
onChange={onMarkdownChange}
height="100%"
expanded={false}
style={{
height: '100%',
fontSize: `${settings.fontSize}px`
height: '100%'
}}
/>
</SourceEditorWrapper>

View File

@ -1,10 +1,12 @@
import 'emoji-picker-element'
import { CloseCircleFilled } from '@ant-design/icons'
import CodeEditor from '@renderer/components/CodeEditor'
import { CodeEditor } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import EmojiPicker from '@renderer/components/EmojiPicker'
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
import { estimateTextTokens } from '@renderer/services/TokenService'
import { Assistant, AssistantSettings } from '@renderer/types'
@ -26,6 +28,8 @@ interface Props {
}
const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }) => {
const [fontSize] = usePreference('chat.message.font_size')
const { activeCmTheme } = useCodeStyle()
const [emoji, setEmoji] = useState(getLeadingEmoji(assistant.name) || assistant.emoji)
const [name, setName] = useState(assistant.name.replace(getLeadingEmoji(assistant.name) || '', '').trim())
const [prompt, setPrompt] = useState(assistant.prompt)
@ -132,6 +136,8 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
</MarkdownContainer>
) : (
<CodeEditor
theme={activeCmTheme}
fontSize={fontSize - 1}
value={prompt}
language="markdown"
onChange={setPrompt}

View File

@ -1,9 +1,10 @@
import { CodeEditor } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import CodeEditor from '@renderer/components/CodeEditor'
import { ResetIcon } from '@renderer/components/Icons'
import { HStack } from '@renderer/components/Layout'
import TextBadge from '@renderer/components/TextBadge'
import { isMac, THEME_COLOR_PRESETS } from '@renderer/config/constant'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
import useUserTheme from '@renderer/hooks/useUserTheme'
@ -56,12 +57,14 @@ const DisplaySettings: FC = () => {
const [pinTopicsToTop, setPinTopicsToTop] = usePreference('topic.tab.pin_to_top')
const [showTopicTime, setShowTopicTime] = usePreference('topic.tab.show_time')
const [assistantIconType, setAssistantIconType] = usePreference('assistant.icon_type')
const [fontSize] = usePreference('chat.message.font_size')
const { navbarPosition, setNavbarPosition } = useNavbarPosition()
const { theme, settedTheme, setTheme } = useTheme()
const { t } = useTranslation()
const [currentZoom, setCurrentZoom] = useState(1.0)
const { userTheme, setUserTheme } = useUserTheme()
const { activeCmTheme } = useCodeStyle()
// const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS)
// const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || [])
const [fontList, setFontList] = useState<string[]>([])
@ -414,6 +417,8 @@ const DisplaySettings: FC = () => {
</SettingTitle>
<SettingDivider />
<CodeEditor
theme={activeCmTheme}
fontSize={fontSize - 1}
value={customCss}
language="css"
placeholder={t('settings.display.custom.css.placeholder')}

View File

@ -1,7 +1,9 @@
import { UploadOutlined } from '@ant-design/icons'
import { CodeEditor } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { nanoid } from '@reduxjs/toolkit'
import CodeEditor from '@renderer/components/CodeEditor'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import { useAppDispatch } from '@renderer/store'
import { setMCPServerActive } from '@renderer/store/mcp'
@ -70,6 +72,8 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
initialImportMethod = 'json'
}) => {
const { t } = useTranslation()
const [fontSize] = usePreference('chat.message.font_size')
const { activeCmTheme } = useCodeStyle()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [importMethod, setImportMethod] = useState<'json' | 'dxt'>(initialImportMethod)
@ -321,7 +325,8 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
label={t('settings.mcp.addServer.importFrom.tooltip')}
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
<CodeEditor
// 如果表單值為空,顯示範例 JSON否則顯示表單值
theme={activeCmTheme}
fontSize={fontSize - 1}
value={serverConfigValue}
placeholder={initialJsonExample}
language="json"

View File

@ -1,6 +1,8 @@
import { CodeEditor } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import CodeEditor from '@renderer/components/CodeEditor'
import { TopView } from '@renderer/components/TopView'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp'
import { MCPServer, safeValidateMcpConfig } from '@renderer/types'
@ -23,7 +25,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [jsonError, setJsonError] = useState('')
const [isLoading, setIsLoading] = useState(true)
const mcpServers = useAppSelector((state) => state.mcp.servers)
const [fontSize] = usePreference('chat.message.font_size')
const { activeCmTheme } = useCodeStyle()
const dispatch = useAppDispatch()
const { t } = useTranslation()
@ -131,6 +134,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<Spin size="large" />
) : (
<CodeEditor
theme={activeCmTheme}
fontSize={fontSize - 1}
value={jsonConfig}
language="json"
onChange={(value) => setJsonConfig(value)}

View File

@ -1,5 +1,7 @@
import CodeEditor from '@renderer/components/CodeEditor'
import { CodeEditor } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { TopView } from '@renderer/components/TopView'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useCopilot } from '@renderer/hooks/useCopilot'
import { useProvider } from '@renderer/hooks/useProvider'
import { Provider } from '@renderer/types'
@ -22,6 +24,8 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
const { t } = useTranslation()
const { updateProvider } = useProvider(provider.id)
const { defaultHeaders, updateDefaultHeaders } = useCopilot()
const [fontSize] = usePreference('chat.message.font_size')
const { activeCmTheme } = useCodeStyle()
const headers =
provider.id === 'copilot'
@ -74,6 +78,8 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
<Space.Compact direction="vertical" style={{ width: '100%', marginTop: 5 }}>
<SettingHelpText>{t('settings.provider.copilot.headers_description')}</SettingHelpText>
<CodeEditor
theme={activeCmTheme}
fontSize={fontSize - 1}
value={headerText}
language="json"
onChange={(value) => setHeaderText(value)}

View File

@ -2721,16 +2721,21 @@ __metadata:
"@types/react": "npm:^19.0.12"
"@types/react-dom": "npm:^19.0.4"
"@types/styled-components": "npm:^5.1.34"
"@uiw/codemirror-extensions-langs": "npm:^4.25.1"
"@uiw/codemirror-themes-all": "npm:^4.25.1"
"@uiw/react-codemirror": "npm:^4.25.1"
antd: "npm:^5.22.5"
clsx: "npm:^2.1.1"
eslint-plugin-storybook: "npm:9.1.6"
framer-motion: "npm:^12.23.12"
linguist-languages: "npm:^9.0.0"
lucide-react: "npm:^0.525.0"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
storybook: "npm:^9.1.6"
styled-components: "npm:^6.1.15"
tsdown: "npm:^0.12.9"
tsx: "npm:^4.20.5"
typescript: "npm:^5.6.2"
vitest: "npm:^3.2.4"
peerDependencies:
@ -22715,6 +22720,13 @@ __metadata:
languageName: node
linkType: hard
"linguist-languages@npm:^9.0.0":
version: 9.0.0
resolution: "linguist-languages@npm:9.0.0"
checksum: 10c0/c3fe0f158d0e4a68b925324ea3f69f229daf7438d7206b0775b818e6e84317770bc9742c6ce68711e9ca772f996a897f8f22c8a775f07dbdb7fb4b28d0c8c509
languageName: node
linkType: hard
"linkify-it@npm:^5.0.0":
version: 5.0.0
resolution: "linkify-it@npm:5.0.0"
@ -30032,6 +30044,22 @@ __metadata:
languageName: node
linkType: hard
"tsx@npm:^4.20.5":
version: 4.20.5
resolution: "tsx@npm:4.20.5"
dependencies:
esbuild: "npm:~0.25.0"
fsevents: "npm:~2.3.3"
get-tsconfig: "npm:^4.7.5"
dependenciesMeta:
fsevents:
optional: true
bin:
tsx: dist/cli.mjs
checksum: 10c0/70f9bf746be69281312a369c712902dbf9bcbdd9db9184a4859eb4859c36ef0c5a6d79b935c1ec429158ee73fd6584089400ae8790345dae34c5b0222bdb94f3
languageName: node
linkType: hard
"tunnel-agent@npm:^0.6.0":
version: 0.6.0
resolution: "tunnel-agent@npm:0.6.0"