mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-08 08:27:39 +08:00
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.
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
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>
|
||
)
|
||
})
|