mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 22:10:21 +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 = useMemo(() => {
|
||||||
const SourceView = codeEditor.enabled ? CodeEditor : CodePreview
|
if (codeEditor.enabled) {
|
||||||
return (
|
return <CodeEditor value={children} language={language} onSave={onSave} options={{ stream: true }} />
|
||||||
<SourceView language={language} onSave={onSave}>
|
} else {
|
||||||
{children}
|
return <CodePreview language={language}>{children}</CodePreview>
|
||||||
</SourceView>
|
}
|
||||||
)
|
|
||||||
}, [children, codeEditor.enabled, language, onSave])
|
}, [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 { memo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { useLanguageExtensions } from './hook'
|
||||||
|
|
||||||
// 标记非用户编辑的变更
|
// 标记非用户编辑的变更
|
||||||
const External = Annotation.define<boolean>()
|
const External = Annotation.define<boolean>()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: string
|
value: string
|
||||||
|
placeholder?: string | HTMLElement
|
||||||
language: string
|
language: string
|
||||||
onSave?: (newContent: string) => void
|
onSave?: (newContent: string) => void
|
||||||
onChange?: (newContent: string) => void
|
onChange?: (newContent: string) => void
|
||||||
|
minHeight?: string
|
||||||
maxHeight?: string
|
maxHeight?: string
|
||||||
/** 用于覆写编辑器的某些设置 */
|
/** 用于覆写编辑器的某些设置 */
|
||||||
options?: {
|
options?: {
|
||||||
|
stream?: boolean // 用于流式响应场景,默认 false
|
||||||
|
lint?: boolean
|
||||||
collapsible?: boolean
|
collapsible?: boolean
|
||||||
wrappable?: boolean
|
wrappable?: boolean
|
||||||
keymap?: boolean
|
keymap?: boolean
|
||||||
@ -36,11 +42,22 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 源代码编辑器,基于 CodeMirror
|
* 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror。
|
||||||
*
|
*
|
||||||
* 目前必须和 CodeToolbar 配合使用。
|
* 目前必须和 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 {
|
const {
|
||||||
fontSize,
|
fontSize,
|
||||||
codeShowLineNumbers: _lineNumbers,
|
codeShowLineNumbers: _lineNumbers,
|
||||||
@ -61,38 +78,18 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
|||||||
}
|
}
|
||||||
}, [codeEditor, _lineNumbers, options])
|
}, [codeEditor, _lineNumbers, options])
|
||||||
|
|
||||||
const { activeCmTheme, languageMap } = useCodeStyle()
|
const { activeCmTheme } = useCodeStyle()
|
||||||
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
||||||
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
|
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
|
||||||
const initialContent = useRef(children?.trimEnd() ?? '')
|
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
|
||||||
const [langExtension, setLangExtension] = useState<Extension[]>([])
|
|
||||||
const [editorReady, setEditorReady] = useState(false)
|
const [editorReady, setEditorReady] = useState(false)
|
||||||
const editorViewRef = useRef<EditorView | null>(null)
|
const editorViewRef = useRef<EditorView | null>(null)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const langExtensions = useLanguageExtensions(language, options?.lint)
|
||||||
|
|
||||||
const { registerTool, removeTool } = useCodeToolbar()
|
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(() => {
|
useEffect(() => {
|
||||||
registerTool({
|
registerTool({
|
||||||
@ -144,7 +141,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorViewRef.current) return
|
if (!editorViewRef.current) return
|
||||||
|
|
||||||
const newContent = children?.trimEnd() ?? ''
|
const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
|
||||||
const currentDoc = editorViewRef.current.state.doc.toString()
|
const currentDoc = editorViewRef.current.state.doc.toString()
|
||||||
|
|
||||||
const changes = prepareCodeChanges(currentDoc, newContent)
|
const changes = prepareCodeChanges(currentDoc, newContent)
|
||||||
@ -155,7 +152,7 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
|||||||
annotations: [External.of(true)]
|
annotations: [External.of(true)]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [children])
|
}, [options?.stream, value])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsExpanded(!collapsible)
|
setIsExpanded(!collapsible)
|
||||||
@ -182,17 +179,19 @@ const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options,
|
|||||||
const customExtensions = useMemo(() => {
|
const customExtensions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
...(extensions ?? []),
|
...(extensions ?? []),
|
||||||
...langExtension,
|
...langExtensions,
|
||||||
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
|
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
|
||||||
...(enableKeymap ? [saveKeymap] : [])
|
...(enableKeymap ? [saveKeymap] : [])
|
||||||
]
|
]
|
||||||
}, [extensions, langExtension, isUnwrapped, enableKeymap, saveKeymap])
|
}, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
// 维持一个稳定值,避免触发 CodeMirror 重置
|
// 维持一个稳定值,避免触发 CodeMirror 重置
|
||||||
value={initialContent.current}
|
value={initialContent.current}
|
||||||
|
placeholder={placeholder}
|
||||||
width="100%"
|
width="100%"
|
||||||
|
minHeight={minHeight}
|
||||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||||
editable={true}
|
editable={true}
|
||||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||||
|
|||||||
@ -4,9 +4,8 @@ import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
|
|||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setMCPServerActive } from '@renderer/store/mcp'
|
import { setMCPServerActive } from '@renderer/store/mcp'
|
||||||
import { MCPServer } from '@renderer/types'
|
import { MCPServer } from '@renderer/types'
|
||||||
import { Extension } from '@uiw/react-codemirror'
|
|
||||||
import { Form, Modal } from 'antd'
|
import { Form, Modal } from 'antd'
|
||||||
import { FC, useCallback, useEffect, useState } from 'react'
|
import { FC, useCallback, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface AddMcpServerModalProps {
|
interface AddMcpServerModalProps {
|
||||||
@ -57,23 +56,6 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
|
|||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const dispatch = useAppDispatch()
|
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 () => {
|
const handleOk = async () => {
|
||||||
try {
|
try {
|
||||||
@ -177,10 +159,14 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
|
|||||||
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
|
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
|
||||||
<CodeToolbarProvider>
|
<CodeToolbarProvider>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
// 如果表單值為空,顯示範例 JSON;否則顯示表單值
|
||||||
|
value={serverConfigValue}
|
||||||
|
placeholder={initialJsonExample}
|
||||||
language="json"
|
language="json"
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
maxHeight="300px"
|
maxHeight="300px"
|
||||||
options={{
|
options={{
|
||||||
|
lint: true,
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
wrappable: true,
|
wrappable: true,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
@ -188,11 +174,7 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
|
|||||||
highlightActiveLine: true,
|
highlightActiveLine: true,
|
||||||
keymap: true
|
keymap: true
|
||||||
}}
|
}}
|
||||||
extensions={editorExtensions}
|
/>
|
||||||
// 如果表單值為空,顯示範例 JSON;否則顯示表單值
|
|
||||||
>
|
|
||||||
{serverConfigValue ?? initialJsonExample}
|
|
||||||
</CodeEditor>
|
|
||||||
</CodeToolbarProvider>
|
</CodeToolbarProvider>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -4,17 +4,16 @@ import { TopView } from '@renderer/components/TopView'
|
|||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { setMCPServers } from '@renderer/store/mcp'
|
import { setMCPServers } from '@renderer/store/mcp'
|
||||||
import { MCPServer } from '@renderer/types'
|
import { MCPServer } from '@renderer/types'
|
||||||
import { Extension } from '@uiw/react-codemirror'
|
|
||||||
import { Modal, Typography } from 'antd'
|
import { Modal, Typography } from 'antd'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
resolve: (data: any) => void
|
resolve: (data: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
const [editorExtensions, setEditorExtensions] = useState<Extension[]>([])
|
|
||||||
const [jsonConfig, setJsonConfig] = useState('')
|
const [jsonConfig, setJsonConfig] = useState('')
|
||||||
const [jsonSaving, setJsonSaving] = useState(false)
|
const [jsonSaving, setJsonSaving] = useState(false)
|
||||||
const [jsonError, setJsonError] = useState('')
|
const [jsonError, setJsonError] = useState('')
|
||||||
@ -23,21 +22,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { t } = useTranslation()
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const mcpServersObj: Record<string, any> = {}
|
const mcpServersObj: Record<string, any> = {}
|
||||||
@ -116,10 +100,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
resolve({})
|
resolve({})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = useCallback((newContent: string) => {
|
|
||||||
setJsonConfig(newContent)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
EditMcpJsonPopup.hide = onCancel
|
EditMcpJsonPopup.hide = onCancel
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -143,10 +123,12 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<CodeToolbarProvider>
|
<CodeToolbarProvider>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
value={jsonConfig}
|
||||||
language="json"
|
language="json"
|
||||||
onChange={handleChange}
|
onChange={(value) => setJsonConfig(value)}
|
||||||
maxHeight="60vh"
|
maxHeight="60vh"
|
||||||
options={{
|
options={{
|
||||||
|
lint: true,
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
wrappable: true,
|
wrappable: true,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
@ -154,9 +136,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
highlightActiveLine: true,
|
highlightActiveLine: true,
|
||||||
keymap: true
|
keymap: true
|
||||||
}}
|
}}
|
||||||
extensions={editorExtensions}>
|
/>
|
||||||
{jsonConfig}
|
|
||||||
</CodeEditor>
|
|
||||||
</CodeToolbarProvider>
|
</CodeToolbarProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user