mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 04:31:27 +08:00
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:
parent
de44938d9b
commit
8981d0a09d
@ -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"
|
||||
|
||||
135
packages/ui/scripts/update-languages.ts
Normal file
135
packages/ui/scripts/update-languages.ts
Normal 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 }
|
||||
@ -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'
|
||||
|
||||
135
packages/ui/src/components/interactive/CodeEditor/CodeEditor.tsx
Normal file
135
packages/ui/src/components/interactive/CodeEditor/CodeEditor.tsx
Normal 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)
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
202
packages/ui/src/components/interactive/CodeEditor/hooks.ts
Normal file
202
packages/ui/src/components/interactive/CodeEditor/hooks.ts
Normal 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])
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export { default } from './CodeEditor'
|
||||
export * from './types'
|
||||
export { getCmThemeByName, getCmThemeNames } from './utils'
|
||||
93
packages/ui/src/components/interactive/CodeEditor/types.ts
Normal file
93
packages/ui/src/components/interactive/CodeEditor/types.ts
Normal 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
|
||||
}
|
||||
126
packages/ui/src/components/interactive/CodeEditor/utils.ts
Normal file
126
packages/ui/src/components/interactive/CodeEditor/utils.ts
Normal 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'
|
||||
}
|
||||
3695
packages/ui/src/config/languages.ts
Normal file
3695
packages/ui/src/config/languages.ts
Normal file
File diff suppressed because it is too large
Load Diff
39
packages/ui/src/utils/codeLanguage.ts
Normal file
39
packages/ui/src/utils/codeLanguage.ts
Normal 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}`
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
]
|
||||
)
|
||||
|
||||
// 特殊视图组件映射
|
||||
|
||||
136
src/renderer/src/components/CodeEditor/CodeEditor.tsx
Normal file
136
src/renderer/src/components/CodeEditor/CodeEditor.tsx
Normal 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)
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
3
src/renderer/src/components/CodeEditor/index.ts
Normal file
3
src/renderer/src/components/CodeEditor/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default } from './CodeEditor'
|
||||
export * from './types'
|
||||
export { getCmThemeByName, getCmThemeNames } from './utils'
|
||||
@ -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 的 basicSetup,options 优先
|
||||
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)
|
||||
93
src/renderer/src/components/CodeEditor/types.ts
Normal file
93
src/renderer/src/components/CodeEditor/types.ts
Normal 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
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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%"
|
||||
|
||||
@ -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 语言别名
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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)}
|
||||
|
||||
28
yarn.lock
28
yarn.lock
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user