Files
CherryHQ-cherry-studio/src/renderer/components/CodeBlockView/view.tsx
fullex 53a3577389 refactor(renderer): flatten src/renderer/src to src/renderer
Move all renderer source from src/renderer/src/* up one level to
src/renderer/*, removing the redundant nested src directory.

- Update path aliases (@renderer, @types, @logger, @data) and TanStack
  Router paths in electron.vite.config.ts; update tsconfig.{json,web,node}
  path mappings and include globs.
- Fix Vite root-relative script paths in the 8 renderer HTML entries.
- Update cross-process relative imports in main/preload (language,
  apiServer models, preload index) to drop the /src segment.
- Switch renderer test imports of the logger mock to the @test-mocks alias.
- Update hardcoded renderer paths in scripts and their fixtures, lint
  configs (eslint/oxlint/biome), CODEOWNERS, docs, and the data-classify tool.
- Convert deep (../../+) relative imports within the renderer to the
  @renderer alias (69 files, 108 imports); keep single-level relatives.
- Fix doc links broken by the move and correct one pre-existing broken
  link in naming-conventions.md.
2026-05-28 21:40:20 -07:00

392 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Props> = 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<ActionTool[]>([])
const isExecutable = useMemo(() => {
return codeExecutionEnabled && language === 'python'
}, [codeExecutionEnabled, language])
const sourceViewRef = useRef<CodeEditorHandles>(null)
const specialViewRef = useRef<BasicPreviewHandles>(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 ? (
<CodeEditor
className="source-view"
ref={sourceViewRef}
theme={activeCmTheme}
fontSize={fontSize - 1}
value={children}
language={language}
onSave={onSave}
onHeightChange={handleHeightChange}
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
options={{ stream: true, lineNumbers: codeShowLineNumbers, ...codeEditor }}
expanded={shouldExpand}
wrapped={shouldWrap}
/>
) : (
<CodeViewer
className="source-view"
value={children}
language={language}
onHeightChange={handleHeightChange}
expanded={shouldExpand}
wrapped={shouldWrap}
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
onRequestExpand={codeCollapsible ? () => 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 (
<SpecialView ref={specialViewRef} enableToolbar={codeImageTools}>
{children}
</SpecialView>
)
}, [children, codeImageTools, language])
const renderHeader = useMemo(() => {
if (isInSpecialView) {
return (
<div className="mt-1.5 flex h-4 items-center rounded-t-lg bg-transparent px-2.5 font-bold text-foreground text-sm leading-none" />
)
}
const ext = getExtensionByLanguage(language)
const iconName = getFileIconName(`file${ext}`)
return (
<div className="flex h-[34px] items-center rounded-t-lg bg-muted px-2.5 font-bold text-foreground text-sm leading-none">
<Icon icon={`material-icon-theme:${iconName}`} style={{ fontSize: '1.1em', marginRight: 6 }} />
{language.charAt(0).toUpperCase() + language.slice(1)}
</div>
)
}, [isInSpecialView, language])
// 根据视图模式和语言选择组件优先展示特殊视图fallback是源代码视图
const renderContent = useMemo(() => {
const showSpecialView = !!specialView && ['special', 'split'].includes(viewMode)
const showSourceView = !specialView || viewMode !== 'special'
return (
<div
className={cn(
'split-view-wrapper flex [&>*]: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}
</div>
)
}, [hasStatusBar, specialView, sourceView, viewMode])
return (
<div
className={cn(
'code-block relative w-full min-w-[35ch]',
'[&_.code-toolbar]:transform-gpu [&_.code-toolbar]:opacity-0 [&_.code-toolbar]:transition-opacity [&_.code-toolbar]:duration-200 [&_.code-toolbar]:ease-in-out [&_.code-toolbar]:will-change-[opacity]',
'[&:hover_.code-toolbar]:opacity-100 [&_.code-toolbar.show]:opacity-100',
isInSpecialView
? '[&_.code-toolbar]:rounded-none [&_.code-toolbar]:bg-transparent'
: '[&_.code-toolbar]:rounded-[4px] [&_.code-toolbar]:bg-muted'
)}>
{renderHeader}
<CodeToolbar tools={tools} />
{renderContent}
{isExecutable && executionResult && (
<StatusBar>
{executionResult.text}
{executionResult.image && (
<ImageViewer src={executionResult.image} alt="Matplotlib plot" style={{ cursor: 'pointer' }} />
)}
</StatusBar>
)}
</div>
)
})