fix: improve CodeEditor controlled mode, prevent unnecessary trimming (#6221)

* fix: improve CodeEditor controlled mode, prevent unnecessary trimming

* fix: useEffect dependency
This commit is contained in:
one 2025-05-21 03:51:30 +08:00 committed by GitHub
parent 6cd324f8ba
commit 75901e2714
5 changed files with 112 additions and 87 deletions

View File

@ -199,12 +199,11 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
// 源代码视图组件
const sourceView = useMemo(() => {
const SourceView = codeEditor.enabled ? CodeEditor : CodePreview
return (
<SourceView language={language} onSave={onSave}>
{children}
</SourceView>
)
if (codeEditor.enabled) {
return <CodeEditor value={children} language={language} onSave={onSave} options={{ stream: true }} />
} else {
return <CodePreview language={language}>{children}</CodePreview>
}
}, [children, codeEditor.enabled, language, onSave])
// 特殊视图组件映射

View File

@ -0,0 +1,65 @@
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Extension } from '@uiw/react-codemirror'
import { useEffect, useState } from 'react'
let linterPromise: Promise<any> | null = null
function importLintPackage() {
if (!linterPromise) {
linterPromise = import('@codemirror/lint').then((mod) => mod.linter)
}
return linterPromise
}
// 语言对应的 linter 加载器
const linterLoaders: Record<string, () => Promise<any>> = {
json: async () => {
const [linter, jsonParseLinter] = await Promise.all([
importLintPackage(),
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
])
return linter(jsonParseLinter())
}
}
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([])
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setExtensions((prev) => [...prev, extension])
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
useEffect(() => {
if (!lint) return
const loader = linterLoaders[language]
if (loader) {
loader()
.then((extension) => {
setExtensions((prev) => [...prev, extension])
})
.catch((error) => {
console.error(`Failed to load linter for ${language}`, error)
})
}
}, [language, lint])
return extensions
}

View File

@ -14,17 +14,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLanguageExtensions } from './hook'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
interface Props {
children: string
value: string
placeholder?: string | HTMLElement
language: string
onSave?: (newContent: string) => void
onChange?: (newContent: string) => void
minHeight?: string
maxHeight?: string
/** 用于覆写编辑器的某些设置 */
options?: {
stream?: boolean // 用于流式响应场景,默认 false
lint?: boolean
collapsible?: boolean
wrappable?: boolean
keymap?: boolean
@ -36,11 +42,22 @@ interface Props {
}
/**
* CodeMirror
* CodeMirror ReactCodeMirror
*
* CodeToolbar 使
*/
const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, extensions, style }: Props) => {
const CodeEditor = ({
value,
placeholder,
language,
onSave,
onChange,
minHeight,
maxHeight,
options,
extensions,
style
}: Props) => {
const {
fontSize,
codeShowLineNumbers: _lineNumbers,
@ -61,38 +78,18 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
}
}, [codeEditor, _lineNumbers, options])
const { activeCmTheme, languageMap } = useCodeStyle()
const { activeCmTheme } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!collapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
const initialContent = useRef(children?.trimEnd() ?? '')
const [langExtension, setLangExtension] = useState<Extension[]>([])
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
const [editorReady, setEditorReady] = useState(false)
const editorViewRef = useRef<EditorView | null>(null)
const { t } = useTranslation()
const langExtensions = useLanguageExtensions(language, options?.lint)
const { registerTool, removeTool } = useCodeToolbar()
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setLangExtension([extension])
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
// 展开/折叠工具
useEffect(() => {
registerTool({
@ -144,7 +141,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
useEffect(() => {
if (!editorViewRef.current) return
const newContent = children?.trimEnd() ?? ''
const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
const currentDoc = editorViewRef.current.state.doc.toString()
const changes = prepareCodeChanges(currentDoc, newContent)
@ -155,7 +152,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
annotations: [External.of(true)]
})
}
}, [children])
}, [options?.stream, value])
useEffect(() => {
setIsExpanded(!collapsible)
@ -182,17 +179,19 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
const customExtensions = useMemo(() => {
return [
...(extensions ?? []),
...langExtension,
...langExtensions,
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
...(enableKeymap ? [saveKeymap] : [])
]
}, [extensions, langExtension, isUnwrapped, enableKeymap, saveKeymap])
}, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap])
return (
<CodeMirror
// 维持一个稳定值,避免触发 CodeMirror 重置
value={initialContent.current}
placeholder={placeholder}
width="100%"
minHeight={minHeight}
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
editable={true}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx

View File

@ -4,9 +4,8 @@ import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
import { useAppDispatch } from '@renderer/store'
import { setMCPServerActive } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { Extension } from '@uiw/react-codemirror'
import { Form, Modal } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react'
import { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface AddMcpServerModalProps {
@ -57,23 +56,6 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const dispatch = useAppDispatch()
const [editorExtensions, setEditorExtensions] = useState<Extension[]>([]) // 新增 editorExtensions 狀態
// 載入 CodeMirror JSON Linter 擴充功能
useEffect(() => {
let isMounted = true
Promise.all([
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter),
import('@codemirror/lint').then((mod) => mod.linter)
]).then(([jsonParseLinter, linter]) => {
if (isMounted) {
setEditorExtensions([linter(jsonParseLinter())])
}
})
return () => {
isMounted = false
}
}, [])
const handleOk = async () => {
try {
@ -177,10 +159,14 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
<CodeToolbarProvider>
<CodeEditor
// 如果表單值為空,顯示範例 JSON否則顯示表單值
value={serverConfigValue}
placeholder={initialJsonExample}
language="json"
onChange={handleEditorChange}
maxHeight="300px"
options={{
lint: true,
collapsible: true,
wrappable: true,
lineNumbers: true,
@ -188,11 +174,7 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
highlightActiveLine: true,
keymap: true
}}
extensions={editorExtensions}
// 如果表單值為空,顯示範例 JSON否則顯示表單值
>
{serverConfigValue ?? initialJsonExample}
</CodeEditor>
/>
</CodeToolbarProvider>
</Form.Item>
</Form>

View File

@ -4,17 +4,16 @@ import { TopView } from '@renderer/components/TopView'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { Extension } from '@uiw/react-codemirror'
import { Modal, Typography } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const [editorExtensions, setEditorExtensions] = useState<Extension[]>([])
const [jsonConfig, setJsonConfig] = useState('')
const [jsonSaving, setJsonSaving] = useState(false)
const [jsonError, setJsonError] = useState('')
@ -23,21 +22,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
let isMounted = true
Promise.all([
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter),
import('@codemirror/lint').then((mod) => mod.linter)
]).then(([jsonParseLinter, linter]) => {
if (isMounted) {
setEditorExtensions([linter(jsonParseLinter())])
}
})
return () => {
isMounted = false
}
}, [])
useEffect(() => {
try {
const mcpServersObj: Record<string, any> = {}
@ -116,10 +100,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
resolve({})
}
const handleChange = useCallback((newContent: string) => {
setJsonConfig(newContent)
}, [])
EditMcpJsonPopup.hide = onCancel
return (
@ -143,10 +123,12 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<div style={{ marginBottom: '16px' }}>
<CodeToolbarProvider>
<CodeEditor
value={jsonConfig}
language="json"
onChange={handleChange}
onChange={(value) => setJsonConfig(value)}
maxHeight="60vh"
options={{
lint: true,
collapsible: true,
wrappable: true,
lineNumbers: true,
@ -154,9 +136,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
highlightActiveLine: true,
keymap: true
}}
extensions={editorExtensions}>
{jsonConfig}
</CodeEditor>
/>
</CodeToolbarProvider>
</div>
)}