import { CodeEditor, type CodeEditorHandles } from '@cherrystudio/ui' import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference' import { Icon } from '@iconify/react' import { loggerService } from '@logger' import type { ActionTool } from '@renderer/components/ActionTools' import { CodeToolbar, useCopyTool, useDownloadTool, useExpandTool, useRunTool, useSaveTool, useSplitViewTool, useViewSourceTool, useWrapTool } from '@renderer/components/CodeToolbar' import CodeViewer from '@renderer/components/CodeViewer' import ImageViewer from '@renderer/components/ImageViewer' import type { BasicPreviewHandles } from '@renderer/components/Preview' import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { pyodideService } from '@renderer/services/PyodideService' import { getExtensionByLanguage } from '@renderer/utils/codeLanguage' import { getFileIconName } from '@renderer/utils/fileIconName' import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats' import { cn } from '@renderer/utils/style' import dayjs from 'dayjs' import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants' import StatusBar from './StatusBar' import type { ViewMode } from './types' const logger = loggerService.withContext('CodeBlockView') interface Props { children: string language: string onSave?: (newContent: string) => void } /** * 代码块视图 * * 视图类型: * - preview: 预览视图,其中非源代码的是特殊视图 * - edit: 编辑视图 * * 视图模式: * - source: 源代码视图模式 * - special: 特殊视图模式(Mermaid、PlantUML、SVG) * - split: 分屏模式(源代码和特殊视图并排显示) * * 顶部 sticky 工具栏: * - quick 工具 * - core 工具 */ export const CodeBlockView: React.FC = memo(({ children, language, onSave }) => { const { t } = useTranslation() const [codeExecutionEnabled] = usePreference('chat.code.execution.enabled') const [codeExecutionTimeoutMinutes] = usePreference('chat.code.execution.timeout_minutes') const [codeCollapsible] = usePreference('chat.code.collapsible') const [codeWrappable] = usePreference('chat.code.wrappable') const [codeImageTools] = usePreference('chat.code.image_tools') const [fontSize] = usePreference('chat.message.font_size') const [codeShowLineNumbers] = usePreference('chat.code.show_line_numbers') const [codeEditor] = useMultiplePreferences({ enabled: 'chat.code.editor.enabled', autocompletion: 'chat.code.editor.autocompletion', foldGutter: 'chat.code.editor.fold_gutter', highlightActiveLine: 'chat.code.editor.highlight_active_line', keymap: 'chat.code.editor.keymap', themeLight: 'chat.code.editor.theme_light', themeDark: 'chat.code.editor.theme_dark' }) const { activeCmTheme } = useCodeStyle() const [viewState, setViewState] = useState({ mode: 'special' as ViewMode, previousMode: 'special' as ViewMode }) const { mode: viewMode } = viewState const setViewMode = useCallback((newMode: ViewMode) => { setViewState((current) => ({ mode: newMode, // 当新模式不是 'split' 时才更新 previousMode: newMode !== 'split' ? newMode : current.previousMode })) }, []) const toggleSplitView = useCallback(() => { setViewState((current) => { // 如果当前是 split 模式,恢复到上一个模式 if (current.mode === 'split') { return { ...current, mode: current.previousMode } } return { mode: 'split', previousMode: current.mode } }) }, []) const [isRunning, setIsRunning] = useState(false) const [executionResult, setExecutionResult] = useState<{ text: string; image?: string } | null>(null) const [tools, setTools] = useState([]) const isExecutable = useMemo(() => { return codeExecutionEnabled && language === 'python' }, [codeExecutionEnabled, language]) const sourceViewRef = useRef(null) const specialViewRef = useRef(null) const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language]) const isInSpecialView = useMemo(() => { return hasSpecialView && viewMode === 'special' }, [hasSpecialView, viewMode]) const [expandOverride, setExpandOverride] = useState(!codeCollapsible) const [wrapOverride, setWrapOverride] = useState(codeWrappable) // 重置用户操作 useEffect(() => { setExpandOverride(!codeCollapsible) }, [codeCollapsible]) // 重置用户操作 useEffect(() => { setWrapOverride(codeWrappable) }, [codeWrappable]) const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride]) const shouldWrap = useMemo(() => codeWrappable && wrapOverride, [codeWrappable, wrapOverride]) const [sourceScrollHeight, setSourceScrollHeight] = useState(0) const expandable = useMemo(() => { return codeCollapsible && sourceScrollHeight > MAX_COLLAPSED_CODE_HEIGHT }, [codeCollapsible, sourceScrollHeight]) const handleHeightChange = useCallback((height: number) => { startTransition(() => { setSourceScrollHeight((prev) => (prev === height ? prev : height)) }) }, []) const handleCopySource = useCallback(async () => { try { // Prioritize getting content from editor, fallback to children const content = sourceViewRef.current?.getContent?.() ?? children await navigator.clipboard.writeText(content.trimEnd()) window.toast.success(t('code_block.copy.success')) } catch (error) { logger.error('Failed to copy to clipboard:', { error }) window.toast.error(t('code_block.copy.failed')) } }, [children, t]) // Note: sourceViewRef not in deps because it's a stable ref, // and getContent reads content in real-time from editorViewRef.current.state.doc const handleDownloadSource = useCallback(() => { let fileName = '' // 尝试提取 HTML 标题 if (language === 'html') { fileName = getFileNameFromHtmlTitle(extractHtmlTitle(children)) || '' } // 默认使用日期格式命名 if (!fileName) { fileName = `${dayjs().format('YYYYMMDDHHmm')}` } const ext = getExtensionByLanguage(language) void window.api.file.save(`${fileName}${ext}`, children) }, [children, language]) const handleRunScript = useCallback(() => { setIsRunning(true) setExecutionResult(null) pyodideService .runScript(children, {}, codeExecutionTimeoutMinutes * 60000) .then((result) => { setExecutionResult(result) }) .catch((error) => { logger.error('Unexpected error:', error) setExecutionResult({ text: `Unexpected error: ${error.message || 'Unknown error'}` }) }) .finally(() => { setIsRunning(false) }) }, [children, codeExecutionTimeoutMinutes]) const showPreviewTools = useMemo(() => { return viewMode !== 'source' && hasSpecialView }, [hasSpecialView, viewMode]) const hasStatusBar = isExecutable && !!executionResult // 复制按钮 useCopyTool({ showPreviewTools, previewRef: specialViewRef, onCopySource: handleCopySource, setTools }) // 下载按钮 useDownloadTool({ showPreviewTools, previewRef: specialViewRef, onDownloadSource: handleDownloadSource, setTools }) // 特殊视图的编辑/查看源码按钮,在分屏模式下不可用 useViewSourceTool({ enabled: hasSpecialView, editable: codeEditor.enabled, viewMode, onViewModeChange: setViewMode, setTools }) // 特殊视图存在时的分屏按钮 useSplitViewTool({ enabled: hasSpecialView, viewMode, onToggleSplitView: toggleSplitView, setTools }) // 运行按钮 useRunTool({ enabled: isExecutable, isRunning, onRun: handleRunScript, setTools }) // 源代码视图的展开/折叠按钮 useExpandTool({ enabled: !isInSpecialView, expanded: shouldExpand, expandable, toggle: useCallback(() => setExpandOverride((prev) => !prev), []), setTools }) // 源代码视图的自动换行按钮 useWrapTool({ enabled: !isInSpecialView, wrapped: shouldWrap, wrappable: codeWrappable, toggle: useCallback(() => setWrapOverride((prev) => !prev), []), setTools }) // 代码编辑器的保存按钮 useSaveTool({ enabled: codeEditor.enabled && !isInSpecialView, sourceViewRef, setTools }) // 源代码视图组件 const sourceView = useMemo( () => codeEditor.enabled ? ( ) : ( setExpandOverride(true) : undefined} /> ), [ activeCmTheme, children, codeCollapsible, codeEditor, codeShowLineNumbers, fontSize, handleHeightChange, language, onSave, shouldExpand, shouldWrap ] ) // 特殊视图组件映射 const specialView = useMemo(() => { const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS] if (!SpecialView) return null return ( {children} ) }, [children, codeImageTools, language]) const renderHeader = useMemo(() => { if (isInSpecialView) { return (
) } const ext = getExtensionByLanguage(language) const iconName = getFileIconName(`file${ext}`) return (
{language.charAt(0).toUpperCase() + language.slice(1)}
) }, [isInSpecialView, language]) // 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图 const renderContent = useMemo(() => { const showSpecialView = !!specialView && ['special', 'split'].includes(viewMode) const showSourceView = !specialView || viewMode !== 'special' return (
*]:w-full [&>*]:flex-[1_1_auto]', !hasStatusBar && (showSpecialView && !showSourceView ? 'rounded-lg' : 'rounded-b-lg'), !hasStatusBar && '[&_.code-viewer]:rounded-[inherit]', showSpecialView && showSourceView && "before:-translate-x-1/2 relative before:absolute before:top-0 before:bottom-0 before:left-1/2 before:z-[1] before:w-px before:bg-muted before:content-['']" )}> {showSpecialView && specialView} {showSourceView && sourceView}
) }, [hasStatusBar, specialView, sourceView, viewMode]) return (
{renderHeader} {renderContent} {isExecutable && executionResult && ( {executionResult.text} {executionResult.image && ( )} )}
) })