From 8486c675c81366be2ad464752b35f931810c083a Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 19 Jan 2026 23:25:48 +0800 Subject: [PATCH] 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. --- .../skill/hooks/use-file-type-info.ts | 44 +++++ .../skill/hooks/use-skill-file-data.ts | 44 +++++ .../skill/hooks/use-skill-file-save.ts | 83 +++++++++ .../workflow/skill/skill-doc-editor.tsx | 176 ++++++------------ 4 files changed, 232 insertions(+), 115 deletions(-) create mode 100644 web/app/components/workflow/skill/hooks/use-file-type-info.ts create mode 100644 web/app/components/workflow/skill/hooks/use-skill-file-data.ts create mode 100644 web/app/components/workflow/skill/hooks/use-skill-file-save.ts diff --git a/web/app/components/workflow/skill/hooks/use-file-type-info.ts b/web/app/components/workflow/skill/hooks/use-file-type-info.ts new file mode 100644 index 0000000000..95e9ed334a --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-file-type-info.ts @@ -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]) +} diff --git a/web/app/components/workflow/skill/hooks/use-skill-file-data.ts b/web/app/components/workflow/skill/hooks/use-skill-file-data.ts new file mode 100644 index 0000000000..c762dfa354 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-skill-file-data.ts @@ -0,0 +1,44 @@ +import { useGetAppAssetFileContent, useGetAppAssetFileDownloadUrl } from '@/service/use-app-asset' + +export type SkillFileDataResult = { + fileContent: ReturnType['data'] + downloadUrlData: ReturnType['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, + } +} diff --git a/web/app/components/workflow/skill/hooks/use-skill-file-save.ts b/web/app/components/workflow/skill/hooks/use-skill-file-save.ts new file mode 100644 index 0000000000..a6e1df2d2e --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-skill-file-save.ts @@ -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 + dirtyMetadataIds: Set + originalContent: string + currentMetadata: Record | undefined + storeApi: StoreApi + 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 { + 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 +} diff --git a/web/app/components/workflow/skill/skill-doc-editor.tsx b/web/app/components/workflow/skill/skill-doc-editor.tsx index 5b19b5895a..b5f7bc0d94 100644 --- a/web/app/components/workflow/skill/skill-doc-editor.tsx +++ b/web/app/components/workflow/skill/skill-doc-editor.tsx @@ -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 (
- {isMarkdown && ( - - )} - {isCodeOrText && ( - - )} - {(isImage || isVideo) && ( - - )} - {isOffice && ( - - )} - {!isMarkdown && !isCodeOrText && !isImage && !isVideo && !isOffice && ( - - )} + {isMarkdown + ? ( + + ) + : null} + {isCodeOrText + ? ( + + ) + : null} + {isImage || isVideo + ? ( + + ) + : null} + {isOffice + ? ( + + ) + : null} + {isUnsupportedFile + ? ( + + ) + : null}
) }