mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 05:09:09 +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",
|
"lint": "eslint src --ext .ts,.tsx --fix",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build",
|
||||||
|
"update:languages": "tsx scripts/update-languages.ts"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ui",
|
"ui",
|
||||||
@ -53,17 +54,27 @@
|
|||||||
"@types/react": "^19.0.12",
|
"@types/react": "^19.0.12",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@types/styled-components": "^5.1.34",
|
"@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",
|
"antd": "^5.22.5",
|
||||||
"eslint-plugin-storybook": "9.1.6",
|
"eslint-plugin-storybook": "9.1.6",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
|
"linguist-languages": "^9.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"storybook": "^9.1.6",
|
"storybook": "^9.1.6",
|
||||||
"styled-components": "^6.1.15",
|
"styled-components": "^6.1.15",
|
||||||
"tsdown": "^0.12.9",
|
"tsdown": "^0.12.9",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@codemirror/language": "6.11.3",
|
||||||
|
"@codemirror/lint": "6.8.5",
|
||||||
|
"@codemirror/view": "6.38.1"
|
||||||
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"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'
|
export { default as WrapIcon } from './icons/WrapIcon'
|
||||||
|
|
||||||
// Interactive Components
|
// 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 { default as CollapsibleSearchBar } from './interactive/CollapsibleSearchBar'
|
||||||
export { DraggableList, useDraggableReorder } from './interactive/DraggableList'
|
export { DraggableList, useDraggableReorder } from './interactive/DraggableList'
|
||||||
export type { EditableNumberProps } from './interactive/EditableNumber'
|
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 { CopyIcon, FilePngIcon } from '@renderer/components/Icons'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isMac } from '@renderer/config/constant'
|
||||||
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
|
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 HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onSave, onClose }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [fontSize] = usePreference('chat.message.font_size')
|
||||||
|
const { activeCmTheme } = useCodeStyle()
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
const [saved, setSaved] = useTemporaryValue(false, 2000)
|
const [saved, setSaved] = useTemporaryValue(false, 2000)
|
||||||
@ -141,6 +145,8 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
|||||||
<CodeSection>
|
<CodeSection>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
ref={codeEditorRef}
|
ref={codeEditorRef}
|
||||||
|
theme={activeCmTheme}
|
||||||
|
fontSize={fontSize - 1}
|
||||||
value={html}
|
value={html}
|
||||||
language="html"
|
language="html"
|
||||||
editable={true}
|
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 { loggerService } from '@logger'
|
||||||
import { ActionTool } from '@renderer/components/ActionTools'
|
import { ActionTool } from '@renderer/components/ActionTools'
|
||||||
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
|
||||||
import {
|
import {
|
||||||
CodeToolbar,
|
CodeToolbar,
|
||||||
useCopyTool,
|
useCopyTool,
|
||||||
@ -17,6 +17,7 @@ import CodeViewer from '@renderer/components/CodeViewer'
|
|||||||
import ImageViewer from '@renderer/components/ImageViewer'
|
import ImageViewer from '@renderer/components/ImageViewer'
|
||||||
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
import { BasicPreviewHandles } from '@renderer/components/Preview'
|
||||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||||
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { pyodideService } from '@renderer/services/PyodideService'
|
import { pyodideService } from '@renderer/services/PyodideService'
|
||||||
import { getExtensionByLanguage } from '@renderer/utils/code-language'
|
import { getExtensionByLanguage } from '@renderer/utils/code-language'
|
||||||
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
|
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 { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
||||||
import StatusBar from './StatusBar'
|
import StatusBar from './StatusBar'
|
||||||
import { ViewMode } from './types'
|
import { ViewMode } from './types'
|
||||||
|
|
||||||
const logger = loggerService.withContext('CodeBlockView')
|
const logger = loggerService.withContext('CodeBlockView')
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -55,12 +57,24 @@ interface Props {
|
|||||||
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [codeEditorEnabled] = usePreference('chat.code.editor.enabled')
|
|
||||||
const [codeExecutionEnabled] = usePreference('chat.code.execution.enabled')
|
const [codeExecutionEnabled] = usePreference('chat.code.execution.enabled')
|
||||||
const [codeExecutionTimeoutMinutes] = usePreference('chat.code.execution.timeout_minutes')
|
const [codeExecutionTimeoutMinutes] = usePreference('chat.code.execution.timeout_minutes')
|
||||||
const [codeCollapsible] = usePreference('chat.code.collapsible')
|
const [codeCollapsible] = usePreference('chat.code.collapsible')
|
||||||
const [codeWrappable] = usePreference('chat.code.wrappable')
|
const [codeWrappable] = usePreference('chat.code.wrappable')
|
||||||
const [codeImageTools] = usePreference('chat.code.image_tools')
|
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({
|
const [viewState, setViewState] = useState({
|
||||||
mode: 'special' as ViewMode,
|
mode: 'special' as ViewMode,
|
||||||
@ -196,7 +210,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
|||||||
// 特殊视图的编辑/查看源码按钮,在分屏模式下不可用
|
// 特殊视图的编辑/查看源码按钮,在分屏模式下不可用
|
||||||
useViewSourceTool({
|
useViewSourceTool({
|
||||||
enabled: hasSpecialView,
|
enabled: hasSpecialView,
|
||||||
editable: codeEditorEnabled,
|
editable: codeEditor.enabled,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange: setViewMode,
|
onViewModeChange: setViewMode,
|
||||||
setTools
|
setTools
|
||||||
@ -238,7 +252,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
|||||||
|
|
||||||
// 代码编辑器的保存按钮
|
// 代码编辑器的保存按钮
|
||||||
useSaveTool({
|
useSaveTool({
|
||||||
enabled: codeEditorEnabled && !isInSpecialView,
|
enabled: codeEditor.enabled && !isInSpecialView,
|
||||||
sourceViewRef,
|
sourceViewRef,
|
||||||
setTools
|
setTools
|
||||||
})
|
})
|
||||||
@ -246,16 +260,18 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
|||||||
// 源代码视图组件
|
// 源代码视图组件
|
||||||
const sourceView = useMemo(
|
const sourceView = useMemo(
|
||||||
() =>
|
() =>
|
||||||
codeEditorEnabled ? (
|
codeEditor.enabled ? (
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
className="source-view"
|
className="source-view"
|
||||||
ref={sourceViewRef}
|
ref={sourceViewRef}
|
||||||
|
theme={activeCmTheme}
|
||||||
|
fontSize={fontSize - 1}
|
||||||
value={children}
|
value={children}
|
||||||
language={language}
|
language={language}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
onHeightChange={handleHeightChange}
|
onHeightChange={handleHeightChange}
|
||||||
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
|
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
|
||||||
options={{ stream: true }}
|
options={{ stream: true, lineNumbers: codeShowLineNumbers, ...codeEditor }}
|
||||||
expanded={shouldExpand}
|
expanded={shouldExpand}
|
||||||
wrapped={shouldWrap}
|
wrapped={shouldWrap}
|
||||||
/>
|
/>
|
||||||
@ -270,7 +286,18 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
|||||||
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
|
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'
|
import { getNormalizedExtension } from '../utils'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
getExtensionByLanguage: vi.fn()
|
languages: {
|
||||||
|
svg: { extensions: ['.svg'] },
|
||||||
|
TypeScript: { extensions: ['.ts'] }
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@renderer/utils/code-language', () => ({
|
vi.mock('@shared/config/languages', () => ({
|
||||||
getExtensionByLanguage: mocks.getExtensionByLanguage
|
languages: hoisted.languages
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('getNormalizedExtension', () => {
|
describe('getNormalizedExtension', () => {
|
||||||
@ -16,28 +19,23 @@ describe('getNormalizedExtension', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return custom mapping for custom language', async () => {
|
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')
|
||||||
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 () => {
|
it('should prefer custom mapping when both custom and linguist exist', async () => {
|
||||||
mocks.getExtensionByLanguage.mockReturnValue('.svg')
|
|
||||||
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
|
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return linguist mapping when available (strip leading dot)', async () => {
|
it('should return linguist mapping when available (strip leading dot)', async () => {
|
||||||
mocks.getExtensionByLanguage.mockReturnValue('.ts')
|
|
||||||
await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts')
|
await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return extension when input already looks like extension (leading dot)', async () => {
|
it('should return extension when input already looks like extension (leading dot)', async () => {
|
||||||
mocks.getExtensionByLanguage.mockReturnValue(undefined)
|
|
||||||
await expect(getNormalizedExtension('.json')).resolves.toBe('json')
|
await expect(getNormalizedExtension('.json')).resolves.toBe('json')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return language as-is when no rules matched', async () => {
|
it('should return language as-is when no rules matched', async () => {
|
||||||
mocks.getExtensionByLanguage.mockReturnValue(undefined)
|
|
||||||
await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage')
|
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'
|
||||||
|
|
||||||
// 自定义语言文件扩展名映射
|
import { CodeMirrorTheme } from './types'
|
||||||
// key: 语言名小写
|
|
||||||
// value: 扩展名
|
/**
|
||||||
|
* 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> = {
|
const _customLanguageExtensions: Record<string, string> = {
|
||||||
svg: 'xml',
|
svg: 'xml',
|
||||||
vab: 'vb',
|
vab: 'vb',
|
||||||
@ -10,31 +51,112 @@ const _customLanguageExtensions: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs
|
* Get the file extension of the language, for @uiw/codemirror-extensions-langs
|
||||||
* - 先搜索自定义扩展名
|
* - First, search for custom extensions
|
||||||
* - 再搜索 github linguist 扩展名
|
* - Then, search for github linguist extensions
|
||||||
* - 最后假定名称已经是扩展名
|
* - Finally, assume the name is already an extension
|
||||||
* @param language 语言名称
|
* @param language language name
|
||||||
* @returns 扩展名(不包含 `.`)
|
* @returns file extension (without `.` prefix)
|
||||||
*/
|
*/
|
||||||
export async function getNormalizedExtension(language: string) {
|
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]
|
const customExt = _customLanguageExtensions[lowerLanguage]
|
||||||
if (customExt) {
|
if (customExt) {
|
||||||
return customExt
|
return customExt
|
||||||
}
|
}
|
||||||
|
|
||||||
const linguistExt = getExtensionByLanguage(language)
|
// 2. Search for github linguist extensions
|
||||||
|
const linguistExt = getExtensionByLanguage(lang)
|
||||||
if (linguistExt) {
|
if (linguistExt) {
|
||||||
return linguistExt.slice(1)
|
return linguistExt.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果语言名称像扩展名
|
// Fallback to language name
|
||||||
if (language.startsWith('.') && language.length > 1) {
|
return lang
|
||||||
return language.slice(1)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退到语言名称
|
// Case-insensitive match language name
|
||||||
return language
|
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 { 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 { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||||
import { Check, SaveIcon } from 'lucide-react'
|
import { Check, SaveIcon } from 'lucide-react'
|
||||||
import { useCallback, useEffect } from '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 { Modal } from 'antd'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import CodeEditor from '../CodeEditor'
|
|
||||||
import { TopView } from '../TopView'
|
import { TopView } from '../TopView'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -14,6 +16,8 @@ interface Props {
|
|||||||
|
|
||||||
const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) => {
|
const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) => {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
|
const [fontSize] = usePreference('chat.message.font_size')
|
||||||
|
const { activeCmTheme } = useCodeStyle()
|
||||||
|
|
||||||
const onOk = () => {
|
const onOk = () => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@ -55,6 +59,8 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
|
|||||||
footer={null}>
|
footer={null}>
|
||||||
{extension !== undefined ? (
|
{extension !== undefined ? (
|
||||||
<Editor
|
<Editor
|
||||||
|
theme={activeCmTheme}
|
||||||
|
fontSize={fontSize - 1}
|
||||||
editable={false}
|
editable={false}
|
||||||
expanded={false}
|
expanded={false}
|
||||||
height="100%"
|
height="100%"
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
|
import { CodeMirrorTheme, getCmThemeByName, getCmThemeNames } from '@cherrystudio/ui'
|
||||||
import { usePreference } from '@data/hooks/usePreference'
|
import { usePreference } from '@data/hooks/usePreference'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||||
import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService'
|
import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService'
|
||||||
import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/shiki'
|
import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/shiki'
|
||||||
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||||
import * as cmThemes from '@uiw/codemirror-themes-all'
|
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
|
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import type { BundledThemeInfo } from 'shiki/types'
|
import type { BundledThemeInfo } from 'shiki/types'
|
||||||
@ -18,7 +18,7 @@ interface CodeStyleContextType {
|
|||||||
themeNames: string[]
|
themeNames: string[]
|
||||||
activeShikiTheme: string
|
activeShikiTheme: string
|
||||||
isShikiThemeDark: boolean
|
isShikiThemeDark: boolean
|
||||||
activeCmTheme: any
|
activeCmTheme: CodeMirrorTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCodeStyleContext: CodeStyleContextType = {
|
const defaultCodeStyleContext: CodeStyleContextType = {
|
||||||
@ -31,7 +31,7 @@ const defaultCodeStyleContext: CodeStyleContextType = {
|
|||||||
themeNames: ['auto'],
|
themeNames: ['auto'],
|
||||||
activeShikiTheme: 'auto',
|
activeShikiTheme: 'auto',
|
||||||
isShikiThemeDark: false,
|
isShikiThemeDark: false,
|
||||||
activeCmTheme: null
|
activeCmTheme: 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
|
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
|
||||||
@ -60,10 +60,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
|||||||
// CodeMirror 主题
|
// CodeMirror 主题
|
||||||
// 更保险的做法可能是硬编码主题列表
|
// 更保险的做法可能是硬编码主题列表
|
||||||
if (codeEditorEnabled) {
|
if (codeEditorEnabled) {
|
||||||
return ['auto', 'light', 'dark']
|
return getCmThemeNames()
|
||||||
.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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shiki 主题,取出所有 BundledThemeInfo 的 id 作为主题名
|
// Shiki 主题,取出所有 BundledThemeInfo 的 id 作为主题名
|
||||||
@ -92,7 +89,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
|||||||
if (!themeName || themeName === 'auto' || !themeNames.includes(themeName)) {
|
if (!themeName || themeName === 'auto' || !themeNames.includes(themeName)) {
|
||||||
themeName = theme === ThemeMode.light ? 'materialLight' : 'dark'
|
themeName = theme === ThemeMode.light ? 'materialLight' : 'dark'
|
||||||
}
|
}
|
||||||
return cmThemes[themeName as keyof typeof cmThemes] || themeName
|
return getCmThemeByName(themeName)
|
||||||
}, [theme, codeEditorThemeLight, codeEditorThemeDark, themeNames])
|
}, [theme, codeEditorThemeLight, codeEditorThemeDark, themeNames])
|
||||||
|
|
||||||
// 自定义 shiki 语言别名
|
// 自定义 shiki 语言别名
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import CodeEditor from '@renderer/components/CodeEditor'
|
import { CodeEditor } from '@cherrystudio/ui'
|
||||||
import { HSpaceBetweenStack } from '@renderer/components/Layout'
|
import { HSpaceBetweenStack } from '@renderer/components/Layout'
|
||||||
import RichEditor from '@renderer/components/RichEditor'
|
import RichEditor from '@renderer/components/RichEditor'
|
||||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||||
import Selector from '@renderer/components/Selector'
|
import Selector from '@renderer/components/Selector'
|
||||||
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { EditorView } from '@renderer/types'
|
import { EditorView } from '@renderer/types'
|
||||||
import { Empty, Spin } from 'antd'
|
import { Empty, Spin } from 'antd'
|
||||||
@ -23,6 +24,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => {
|
({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { settings } = useNotesSettings()
|
const { settings } = useNotesSettings()
|
||||||
|
const { activeCmTheme } = useCodeStyle()
|
||||||
const currentViewMode = useMemo(() => {
|
const currentViewMode = useMemo(() => {
|
||||||
if (settings.defaultViewMode === 'edit') {
|
if (settings.defaultViewMode === 'edit') {
|
||||||
return settings.defaultEditMode
|
return settings.defaultEditMode
|
||||||
@ -61,14 +63,15 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
{tmpViewMode === 'source' ? (
|
{tmpViewMode === 'source' ? (
|
||||||
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
|
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
theme={activeCmTheme}
|
||||||
|
fontSize={settings.fontSize}
|
||||||
value={currentContent}
|
value={currentContent}
|
||||||
language="markdown"
|
language="markdown"
|
||||||
onChange={onMarkdownChange}
|
onChange={onMarkdownChange}
|
||||||
height="100%"
|
height="100%"
|
||||||
expanded={false}
|
expanded={false}
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%'
|
||||||
fontSize: `${settings.fontSize}px`
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SourceEditorWrapper>
|
</SourceEditorWrapper>
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import 'emoji-picker-element'
|
import 'emoji-picker-element'
|
||||||
|
|
||||||
import { CloseCircleFilled } from '@ant-design/icons'
|
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 EmojiPicker from '@renderer/components/EmojiPicker'
|
||||||
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
||||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||||
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
|
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
|
||||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||||
import { Assistant, AssistantSettings } from '@renderer/types'
|
import { Assistant, AssistantSettings } from '@renderer/types'
|
||||||
@ -26,6 +28,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }) => {
|
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 [emoji, setEmoji] = useState(getLeadingEmoji(assistant.name) || assistant.emoji)
|
||||||
const [name, setName] = useState(assistant.name.replace(getLeadingEmoji(assistant.name) || '', '').trim())
|
const [name, setName] = useState(assistant.name.replace(getLeadingEmoji(assistant.name) || '', '').trim())
|
||||||
const [prompt, setPrompt] = useState(assistant.prompt)
|
const [prompt, setPrompt] = useState(assistant.prompt)
|
||||||
@ -132,6 +136,8 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
|||||||
</MarkdownContainer>
|
</MarkdownContainer>
|
||||||
) : (
|
) : (
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
theme={activeCmTheme}
|
||||||
|
fontSize={fontSize - 1}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
language="markdown"
|
language="markdown"
|
||||||
onChange={setPrompt}
|
onChange={setPrompt}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
|
import { CodeEditor } from '@cherrystudio/ui'
|
||||||
import { usePreference } from '@data/hooks/usePreference'
|
import { usePreference } from '@data/hooks/usePreference'
|
||||||
import CodeEditor from '@renderer/components/CodeEditor'
|
|
||||||
import { ResetIcon } from '@renderer/components/Icons'
|
import { ResetIcon } from '@renderer/components/Icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import TextBadge from '@renderer/components/TextBadge'
|
import TextBadge from '@renderer/components/TextBadge'
|
||||||
import { isMac, THEME_COLOR_PRESETS } from '@renderer/config/constant'
|
import { isMac, THEME_COLOR_PRESETS } from '@renderer/config/constant'
|
||||||
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
|
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
|
||||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||||
@ -56,12 +57,14 @@ const DisplaySettings: FC = () => {
|
|||||||
const [pinTopicsToTop, setPinTopicsToTop] = usePreference('topic.tab.pin_to_top')
|
const [pinTopicsToTop, setPinTopicsToTop] = usePreference('topic.tab.pin_to_top')
|
||||||
const [showTopicTime, setShowTopicTime] = usePreference('topic.tab.show_time')
|
const [showTopicTime, setShowTopicTime] = usePreference('topic.tab.show_time')
|
||||||
const [assistantIconType, setAssistantIconType] = usePreference('assistant.icon_type')
|
const [assistantIconType, setAssistantIconType] = usePreference('assistant.icon_type')
|
||||||
|
const [fontSize] = usePreference('chat.message.font_size')
|
||||||
|
|
||||||
const { navbarPosition, setNavbarPosition } = useNavbarPosition()
|
const { navbarPosition, setNavbarPosition } = useNavbarPosition()
|
||||||
const { theme, settedTheme, setTheme } = useTheme()
|
const { theme, settedTheme, setTheme } = useTheme()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [currentZoom, setCurrentZoom] = useState(1.0)
|
const [currentZoom, setCurrentZoom] = useState(1.0)
|
||||||
const { userTheme, setUserTheme } = useUserTheme()
|
const { userTheme, setUserTheme } = useUserTheme()
|
||||||
|
const { activeCmTheme } = useCodeStyle()
|
||||||
// const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS)
|
// const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS)
|
||||||
// const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || [])
|
// const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || [])
|
||||||
const [fontList, setFontList] = useState<string[]>([])
|
const [fontList, setFontList] = useState<string[]>([])
|
||||||
@ -414,6 +417,8 @@ const DisplaySettings: FC = () => {
|
|||||||
</SettingTitle>
|
</SettingTitle>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
theme={activeCmTheme}
|
||||||
|
fontSize={fontSize - 1}
|
||||||
value={customCss}
|
value={customCss}
|
||||||
language="css"
|
language="css"
|
||||||
placeholder={t('settings.display.custom.css.placeholder')}
|
placeholder={t('settings.display.custom.css.placeholder')}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { UploadOutlined } from '@ant-design/icons'
|
import { UploadOutlined } from '@ant-design/icons'
|
||||||
|
import { CodeEditor } from '@cherrystudio/ui'
|
||||||
|
import { usePreference } from '@data/hooks/usePreference'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { nanoid } from '@reduxjs/toolkit'
|
import { nanoid } from '@reduxjs/toolkit'
|
||||||
import CodeEditor from '@renderer/components/CodeEditor'
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setMCPServerActive } from '@renderer/store/mcp'
|
import { setMCPServerActive } from '@renderer/store/mcp'
|
||||||
@ -70,6 +72,8 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
|||||||
initialImportMethod = 'json'
|
initialImportMethod = 'json'
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [fontSize] = usePreference('chat.message.font_size')
|
||||||
|
const { activeCmTheme } = useCodeStyle()
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [importMethod, setImportMethod] = useState<'json' | 'dxt'>(initialImportMethod)
|
const [importMethod, setImportMethod] = useState<'json' | 'dxt'>(initialImportMethod)
|
||||||
@ -321,7 +325,8 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
|||||||
label={t('settings.mcp.addServer.importFrom.tooltip')}
|
label={t('settings.mcp.addServer.importFrom.tooltip')}
|
||||||
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
|
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
// 如果表單值為空,顯示範例 JSON;否則顯示表單值
|
theme={activeCmTheme}
|
||||||
|
fontSize={fontSize - 1}
|
||||||
value={serverConfigValue}
|
value={serverConfigValue}
|
||||||
placeholder={initialJsonExample}
|
placeholder={initialJsonExample}
|
||||||
language="json"
|
language="json"
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import { CodeEditor } from '@cherrystudio/ui'
|
||||||
|
import { usePreference } from '@data/hooks/usePreference'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import CodeEditor from '@renderer/components/CodeEditor'
|
|
||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
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, safeValidateMcpConfig } from '@renderer/types'
|
import { MCPServer, safeValidateMcpConfig } from '@renderer/types'
|
||||||
@ -23,7 +25,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
const [jsonError, setJsonError] = useState('')
|
const [jsonError, setJsonError] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
||||||
|
const [fontSize] = usePreference('chat.message.font_size')
|
||||||
|
const { activeCmTheme } = useCodeStyle()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@ -131,6 +134,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
) : (
|
) : (
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
theme={activeCmTheme}
|
||||||
|
fontSize={fontSize - 1}
|
||||||
value={jsonConfig}
|
value={jsonConfig}
|
||||||
language="json"
|
language="json"
|
||||||
onChange={(value) => setJsonConfig(value)}
|
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 { TopView } from '@renderer/components/TopView'
|
||||||
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { useCopilot } from '@renderer/hooks/useCopilot'
|
import { useCopilot } from '@renderer/hooks/useCopilot'
|
||||||
import { useProvider } from '@renderer/hooks/useProvider'
|
import { useProvider } from '@renderer/hooks/useProvider'
|
||||||
import { Provider } from '@renderer/types'
|
import { Provider } from '@renderer/types'
|
||||||
@ -22,6 +24,8 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { updateProvider } = useProvider(provider.id)
|
const { updateProvider } = useProvider(provider.id)
|
||||||
const { defaultHeaders, updateDefaultHeaders } = useCopilot()
|
const { defaultHeaders, updateDefaultHeaders } = useCopilot()
|
||||||
|
const [fontSize] = usePreference('chat.message.font_size')
|
||||||
|
const { activeCmTheme } = useCodeStyle()
|
||||||
|
|
||||||
const headers =
|
const headers =
|
||||||
provider.id === 'copilot'
|
provider.id === 'copilot'
|
||||||
@ -74,6 +78,8 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
<Space.Compact direction="vertical" style={{ width: '100%', marginTop: 5 }}>
|
<Space.Compact direction="vertical" style={{ width: '100%', marginTop: 5 }}>
|
||||||
<SettingHelpText>{t('settings.provider.copilot.headers_description')}</SettingHelpText>
|
<SettingHelpText>{t('settings.provider.copilot.headers_description')}</SettingHelpText>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
theme={activeCmTheme}
|
||||||
|
fontSize={fontSize - 1}
|
||||||
value={headerText}
|
value={headerText}
|
||||||
language="json"
|
language="json"
|
||||||
onChange={(value) => setHeaderText(value)}
|
onChange={(value) => setHeaderText(value)}
|
||||||
|
|||||||
28
yarn.lock
28
yarn.lock
@ -2721,16 +2721,21 @@ __metadata:
|
|||||||
"@types/react": "npm:^19.0.12"
|
"@types/react": "npm:^19.0.12"
|
||||||
"@types/react-dom": "npm:^19.0.4"
|
"@types/react-dom": "npm:^19.0.4"
|
||||||
"@types/styled-components": "npm:^5.1.34"
|
"@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"
|
antd: "npm:^5.22.5"
|
||||||
clsx: "npm:^2.1.1"
|
clsx: "npm:^2.1.1"
|
||||||
eslint-plugin-storybook: "npm:9.1.6"
|
eslint-plugin-storybook: "npm:9.1.6"
|
||||||
framer-motion: "npm:^12.23.12"
|
framer-motion: "npm:^12.23.12"
|
||||||
|
linguist-languages: "npm:^9.0.0"
|
||||||
lucide-react: "npm:^0.525.0"
|
lucide-react: "npm:^0.525.0"
|
||||||
react: "npm:^19.0.0"
|
react: "npm:^19.0.0"
|
||||||
react-dom: "npm:^19.0.0"
|
react-dom: "npm:^19.0.0"
|
||||||
storybook: "npm:^9.1.6"
|
storybook: "npm:^9.1.6"
|
||||||
styled-components: "npm:^6.1.15"
|
styled-components: "npm:^6.1.15"
|
||||||
tsdown: "npm:^0.12.9"
|
tsdown: "npm:^0.12.9"
|
||||||
|
tsx: "npm:^4.20.5"
|
||||||
typescript: "npm:^5.6.2"
|
typescript: "npm:^5.6.2"
|
||||||
vitest: "npm:^3.2.4"
|
vitest: "npm:^3.2.4"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -22715,6 +22720,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"linkify-it@npm:^5.0.0":
|
||||||
version: 5.0.0
|
version: 5.0.0
|
||||||
resolution: "linkify-it@npm:5.0.0"
|
resolution: "linkify-it@npm:5.0.0"
|
||||||
@ -30032,6 +30044,22 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"tunnel-agent@npm:^0.6.0":
|
||||||
version: 0.6.0
|
version: 0.6.0
|
||||||
resolution: "tunnel-agent@npm:0.6.0"
|
resolution: "tunnel-agent@npm:0.6.0"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user