refactor(skill): extract hooks from skill-doc-editor for better separation

Extract business logic into dedicated hooks to reduce component complexity:
- useFileTypeInfo: file type detection (markdown, code, image, video, etc.)
- useSkillFileData: data fetching with conditional API calls
- useSkillFileSave: save logic with Ctrl+S keyboard shortcut

Also fix Vercel best practice: use ternary instead of && for conditional rendering.
This commit is contained in:
yyh 2026-01-19 23:25:48 +08:00
parent b6df7b3afe
commit 8486c675c8
No known key found for this signature in database
4 changed files with 232 additions and 115 deletions

View File

@ -0,0 +1,44 @@
import type { AppAssetTreeView } from '@/types/app-asset'
import { useMemo } from 'react'
import {
getFileExtension,
isCodeOrTextFile,
isImageFile,
isMarkdownFile,
isOfficeFile,
isVideoFile,
} from '../utils/file-utils'
export type FileTypeInfo = {
isMarkdown: boolean
isCodeOrText: boolean
isImage: boolean
isVideo: boolean
isOffice: boolean
isEditable: boolean
isMediaFile: boolean
}
/**
* Hook to determine file type information based on file node.
* Returns flags for markdown, code/text, image, video, office files.
*/
export function useFileTypeInfo(fileNode: AppAssetTreeView | undefined): FileTypeInfo {
return useMemo(() => {
const ext = getFileExtension(fileNode?.name, fileNode?.extension)
const markdown = isMarkdownFile(ext)
const codeOrText = isCodeOrTextFile(ext)
const image = isImageFile(ext)
const video = isVideoFile(ext)
return {
isMarkdown: markdown,
isCodeOrText: codeOrText,
isImage: image,
isVideo: video,
isOffice: isOfficeFile(ext),
isEditable: markdown || codeOrText,
isMediaFile: image || video,
}
}, [fileNode?.name, fileNode?.extension])
}

View File

@ -0,0 +1,44 @@
import { useGetAppAssetFileContent, useGetAppAssetFileDownloadUrl } from '@/service/use-app-asset'
export type SkillFileDataResult = {
fileContent: ReturnType<typeof useGetAppAssetFileContent>['data']
downloadUrlData: ReturnType<typeof useGetAppAssetFileDownloadUrl>['data']
isLoading: boolean
error: Error | null
}
/**
* Hook to fetch file data for skill documents.
* Fetches content for editable files and download URL for media files.
*/
export function useSkillFileData(
appId: string,
nodeId: string | null | undefined,
isMediaFile: boolean,
): SkillFileDataResult {
const {
data: fileContent,
isLoading: isContentLoading,
error: contentError,
} = useGetAppAssetFileContent(appId, nodeId || '', {
enabled: !isMediaFile,
})
const {
data: downloadUrlData,
isLoading: isDownloadUrlLoading,
error: downloadUrlError,
} = useGetAppAssetFileDownloadUrl(appId, nodeId || '', {
enabled: isMediaFile && !!nodeId,
})
const isLoading = isMediaFile ? isDownloadUrlLoading : isContentLoading
const error = isMediaFile ? downloadUrlError : contentError
return {
fileContent,
downloadUrlData,
isLoading,
error,
}
}

View File

@ -0,0 +1,83 @@
import type { TFunction } from 'i18next'
import type { StoreApi } from 'zustand'
import type { Shape } from '@/app/components/workflow/store'
import { useCallback, useEffect } from 'react'
import Toast from '@/app/components/base/toast'
import { useUpdateAppAssetFileContent } from '@/service/use-app-asset'
type UseSkillFileSaveParams = {
appId: string
activeTabId: string | null
isEditable: boolean
dirtyContents: Map<string, string>
dirtyMetadataIds: Set<string>
originalContent: string
currentMetadata: Record<string, unknown> | undefined
storeApi: StoreApi<Shape>
t: TFunction<'workflow'>
}
/**
* Hook to handle file save logic and Ctrl+S keyboard shortcut.
* Returns the save handler function.
*/
export function useSkillFileSave({
appId,
activeTabId,
isEditable,
dirtyContents,
dirtyMetadataIds,
originalContent,
currentMetadata,
storeApi,
t,
}: UseSkillFileSaveParams): () => Promise<void> {
const updateContent = useUpdateAppAssetFileContent()
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 ?? originalContent,
...(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, isEditable, originalContent, 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])
return handleSave
}

View File

@ -8,10 +8,8 @@ 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, useGetAppAssetFileDownloadUrl, useUpdateAppAssetFileContent } from '@/service/use-app-asset'
import { Theme } from '@/types/app'
import { basePath } from '@/utils/var'
import CodeFileEditor from './editor/code-file-editor'
@ -19,8 +17,11 @@ 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 { useFileTypeInfo } from './hooks/use-file-type-info'
import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
import { getFileExtension, getFileLanguage, isCodeOrTextFile, isImageFile, isMarkdownFile, isOfficeFile, isVideoFile } from './utils/file-utils'
import { useSkillFileData } from './hooks/use-skill-file-data'
import { useSkillFileSave } from './hooks/use-skill-file-save'
import { getFileLanguage } from './utils/file-utils'
if (typeof window !== 'undefined')
loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } })
@ -43,42 +44,9 @@ const SkillDocEditor: FC = () => {
const currentFileNode = activeTabId ? nodeMap?.get(activeTabId) : undefined
const { isMarkdown, isCodeOrText, isImage, isVideo, isOffice, isEditable } = useMemo(() => {
const ext = getFileExtension(currentFileNode?.name, currentFileNode?.extension)
const markdown = isMarkdownFile(ext)
const codeOrText = isCodeOrTextFile(ext)
return {
isMarkdown: markdown,
isCodeOrText: codeOrText,
isImage: isImageFile(ext),
isVideo: isVideoFile(ext),
isOffice: isOfficeFile(ext),
isEditable: markdown || codeOrText,
}
}, [currentFileNode?.name, currentFileNode?.extension])
const { isMarkdown, isCodeOrText, isImage, isVideo, isOffice, isEditable, isMediaFile } = useFileTypeInfo(currentFileNode)
const isMediaFile = isImage || isVideo
const {
data: fileContent,
isLoading: isContentLoading,
error: contentError,
} = useGetAppAssetFileContent(appId, activeTabId || '', {
enabled: !isMediaFile,
})
const {
data: downloadUrlData,
isLoading: isDownloadUrlLoading,
error: downloadUrlError,
} = useGetAppAssetFileDownloadUrl(appId, activeTabId || '', {
enabled: isMediaFile && !!activeTabId,
})
const isLoading = isMediaFile ? isDownloadUrlLoading : isContentLoading
const error = isMediaFile ? downloadUrlError : contentError
const updateContent = useUpdateAppAssetFileContent()
const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, activeTabId, isMediaFile)
const originalContent = fileContent?.content ?? ''
@ -133,50 +101,17 @@ const SkillDocEditor: FC = () => {
storeApi.getState().pinTab(activeTabId)
}, [activeTabId, isEditable, originalContent, storeApi])
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 ?? originalContent,
...(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, isEditable, originalContent, 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])
useSkillFileSave({
appId,
activeTabId,
isEditable,
dirtyContents,
dirtyMetadataIds,
originalContent,
currentMetadata,
storeApi,
t,
})
const handleEditorDidMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor
@ -219,42 +154,53 @@ const SkillDocEditor: FC = () => {
const textPreviewUrl = fileContent?.content || ''
const fileName = currentFileNode?.name || ''
const fileSize = currentFileNode?.size
const isUnsupportedFile = !isMarkdown && !isCodeOrText && !isImage && !isVideo && !isOffice
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={mediaPreviewUrl}
/>
)}
{isOffice && (
<OfficeFilePlaceholder />
)}
{!isMarkdown && !isCodeOrText && !isImage && !isVideo && !isOffice && (
<UnsupportedFileDownload
name={fileName}
size={fileSize}
downloadUrl={textPreviewUrl}
/>
)}
{isMarkdown
? (
<MarkdownFileEditor
key={activeTabId}
value={currentContent}
onChange={handleEditorChange}
/>
)
: null}
{isCodeOrText
? (
<CodeFileEditor
key={activeTabId}
language={language}
theme={isMounted ? theme : 'default-theme'}
value={currentContent}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
/>
)
: null}
{isImage || isVideo
? (
<MediaFilePreview
type={isImage ? 'image' : 'video'}
src={mediaPreviewUrl}
/>
)
: null}
{isOffice
? (
<OfficeFilePlaceholder />
)
: null}
{isUnsupportedFile
? (
<UnsupportedFileDownload
name={fileName}
size={fileSize}
downloadUrl={textPreviewUrl}
/>
)
: null}
</div>
)
}