mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
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:
parent
6cd324f8ba
commit
75901e2714
@ -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])
|
||||
|
||||
// 特殊视图组件映射
|
||||
|
||||
65
src/renderer/src/components/CodeEditor/hook.ts
Normal file
65
src/renderer/src/components/CodeEditor/hook.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user