mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 05:58:14 +08:00
Previously, any edit would mark the file as dirty even if the content was restored to its original state. Now we compare against the original content and clear the dirty flag when they match.
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
'use client'
|
|
|
|
import type { OnMount } from '@monaco-editor/react'
|
|
import type { FC } from 'react'
|
|
import { loader } from '@monaco-editor/react'
|
|
import * as React from 'react'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
|
import Loading from '@/app/components/base/loading'
|
|
import Toast from '@/app/components/base/toast'
|
|
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
|
import useTheme from '@/hooks/use-theme'
|
|
import { useGetAppAssetFileContent, useUpdateAppAssetFileContent } from '@/service/use-app-asset'
|
|
import { Theme } from '@/types/app'
|
|
import { basePath } from '@/utils/var'
|
|
import CodeFileEditor from './editor/code-file-editor'
|
|
import MarkdownFileEditor from './editor/markdown-file-editor'
|
|
import MediaFilePreview from './editor/media-file-preview'
|
|
import OfficeFilePlaceholder from './editor/office-file-placeholder'
|
|
import UnsupportedFileDownload from './editor/unsupported-file-download'
|
|
import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
|
|
import { getFileExtension, getFileLanguage, isCodeOrTextFile, isImageFile, isMarkdownFile, isOfficeFile, isVideoFile } from './utils/file-utils'
|
|
|
|
if (typeof window !== 'undefined')
|
|
loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } })
|
|
|
|
const SkillDocEditor: FC = () => {
|
|
const { t } = useTranslation('workflow')
|
|
const { theme: appTheme } = useTheme()
|
|
const [isMounted, setIsMounted] = useState(false)
|
|
const editorRef = useRef<Parameters<OnMount>[0] | null>(null)
|
|
|
|
const appDetail = useAppStore(s => s.appDetail)
|
|
const appId = appDetail?.id || ''
|
|
|
|
const activeTabId = useStore(s => s.activeTabId)
|
|
const dirtyContents = useStore(s => s.dirtyContents)
|
|
const dirtyMetadataIds = useStore(s => s.dirtyMetadataIds)
|
|
const fileMetadata = useStore(s => s.fileMetadata)
|
|
const storeApi = useWorkflowStore()
|
|
const { data: nodeMap } = useSkillAssetNodeMap()
|
|
|
|
const currentFileNode = activeTabId ? nodeMap?.get(activeTabId) : undefined
|
|
const fileExtension = getFileExtension(currentFileNode?.name, currentFileNode?.extension)
|
|
const isMarkdown = isMarkdownFile(fileExtension)
|
|
const isCodeOrText = isCodeOrTextFile(fileExtension)
|
|
const isImage = isImageFile(fileExtension)
|
|
const isVideo = isVideoFile(fileExtension)
|
|
const isOffice = isOfficeFile(fileExtension)
|
|
const isEditable = isMarkdown || isCodeOrText
|
|
|
|
const {
|
|
data: fileContent,
|
|
isLoading,
|
|
error,
|
|
} = useGetAppAssetFileContent(appId, activeTabId || '')
|
|
|
|
const updateContent = useUpdateAppAssetFileContent()
|
|
|
|
const currentContent = useMemo(() => {
|
|
if (!activeTabId)
|
|
return ''
|
|
const draft = dirtyContents.get(activeTabId)
|
|
if (draft !== undefined)
|
|
return draft
|
|
return fileContent?.content ?? ''
|
|
}, [activeTabId, dirtyContents, fileContent?.content])
|
|
|
|
const currentMetadata = useMemo(() => {
|
|
if (!activeTabId)
|
|
return undefined
|
|
return fileMetadata.get(activeTabId)
|
|
}, [activeTabId, fileMetadata])
|
|
|
|
useEffect(() => {
|
|
if (!activeTabId || !fileContent)
|
|
return
|
|
if (dirtyMetadataIds.has(activeTabId))
|
|
return
|
|
let nextMetadata: Record<string, any> = {}
|
|
if (fileContent.metadata) {
|
|
if (typeof fileContent.metadata === 'string') {
|
|
try {
|
|
nextMetadata = JSON.parse(fileContent.metadata)
|
|
}
|
|
catch {
|
|
nextMetadata = {}
|
|
}
|
|
}
|
|
else {
|
|
nextMetadata = fileContent.metadata
|
|
}
|
|
}
|
|
storeApi.getState().setFileMetadata(activeTabId, nextMetadata)
|
|
storeApi.getState().clearDraftMetadata(activeTabId)
|
|
}, [activeTabId, dirtyMetadataIds, fileContent, storeApi])
|
|
|
|
const handleEditorChange = useCallback((value: string | undefined) => {
|
|
if (!activeTabId || !isEditable)
|
|
return
|
|
const newValue = value ?? ''
|
|
const originalContent = fileContent?.content ?? ''
|
|
|
|
if (newValue === originalContent)
|
|
storeApi.getState().clearDraftContent(activeTabId)
|
|
else
|
|
storeApi.getState().setDraftContent(activeTabId, newValue)
|
|
|
|
storeApi.getState().pinTab(activeTabId)
|
|
}, [activeTabId, isEditable, storeApi, fileContent?.content])
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!activeTabId || !appId || !isEditable)
|
|
return
|
|
|
|
const content = dirtyContents.get(activeTabId)
|
|
const hasDirtyMetadata = dirtyMetadataIds.has(activeTabId)
|
|
if (content === undefined && !hasDirtyMetadata)
|
|
return
|
|
|
|
try {
|
|
await updateContent.mutateAsync({
|
|
appId,
|
|
nodeId: activeTabId,
|
|
payload: {
|
|
content: content ?? fileContent?.content ?? '',
|
|
...(currentMetadata ? { metadata: currentMetadata } : {}),
|
|
},
|
|
})
|
|
storeApi.getState().clearDraftContent(activeTabId)
|
|
storeApi.getState().clearDraftMetadata(activeTabId)
|
|
Toast.notify({
|
|
type: 'success',
|
|
message: t('api.saved', { ns: 'common' }),
|
|
})
|
|
}
|
|
catch (error) {
|
|
Toast.notify({
|
|
type: 'error',
|
|
message: String(error),
|
|
})
|
|
}
|
|
}, [activeTabId, appId, currentMetadata, dirtyContents, dirtyMetadataIds, fileContent?.content, isEditable, storeApi, t, updateContent])
|
|
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent): void {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault()
|
|
handleSave()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [handleSave])
|
|
|
|
const handleEditorDidMount: OnMount = useCallback((editor, monaco) => {
|
|
editorRef.current = editor
|
|
monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark')
|
|
setIsMounted(true)
|
|
}, [appTheme])
|
|
|
|
const language = currentFileNode ? getFileLanguage(currentFileNode.name) : 'plaintext'
|
|
const theme = appTheme === Theme.light ? 'light' : 'vs-dark'
|
|
|
|
if (!activeTabId) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
|
<span className="system-sm-regular">
|
|
{t('skillSidebar.empty')}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
|
|
<Loading type="area" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
|
<span className="system-sm-regular">
|
|
{t('skillSidebar.loadError')}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const previewUrl = fileContent?.content || ''
|
|
const fileName = currentFileNode?.name || ''
|
|
const fileSize = currentFileNode?.size
|
|
|
|
return (
|
|
<div className="h-full w-full overflow-auto bg-components-panel-bg">
|
|
{isMarkdown && (
|
|
<MarkdownFileEditor
|
|
key={activeTabId}
|
|
value={currentContent}
|
|
onChange={handleEditorChange}
|
|
/>
|
|
)}
|
|
{isCodeOrText && (
|
|
<CodeFileEditor
|
|
key={activeTabId}
|
|
language={language}
|
|
theme={isMounted ? theme : 'default-theme'}
|
|
value={currentContent}
|
|
onChange={handleEditorChange}
|
|
onMount={handleEditorDidMount}
|
|
/>
|
|
)}
|
|
{(isImage || isVideo) && (
|
|
<MediaFilePreview
|
|
type={isImage ? 'image' : 'video'}
|
|
src={previewUrl}
|
|
/>
|
|
)}
|
|
{isOffice && (
|
|
<OfficeFilePlaceholder />
|
|
)}
|
|
{!isMarkdown && !isCodeOrText && !isImage && !isVideo && !isOffice && (
|
|
<UnsupportedFileDownload
|
|
name={fileName}
|
|
size={fileSize}
|
|
downloadUrl={previewUrl}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default React.memo(SkillDocEditor)
|