From e1e7b7e88a6a98bcee1ee5407ad4a77185f77e4b Mon Sep 17 00:00:00 2001 From: yyh Date: Sun, 25 Jan 2026 19:05:00 +0800 Subject: [PATCH] refactor(skill): extract save logic into SkillSaveProvider with auto-save support Centralize file save operations using Context/Provider pattern for better maintainability. Add auto-save on tab switch, visibility change, page unload, and component unmount. --- .../workflow/skill/file-content-panel.tsx | 8 +- .../skill/hooks/use-skill-auto-save.ts | 44 ++++ .../skill/hooks/use-skill-file-save.ts | 62 ++--- .../skill/hooks/use-skill-save-manager.tsx | 229 ++++++++++++++++++ web/app/components/workflow/skill/main.tsx | 42 +++- 5 files changed, 328 insertions(+), 57 deletions(-) create mode 100644 web/app/components/workflow/skill/hooks/use-skill-auto-save.ts create mode 100644 web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index e89b865ea9..7aa148d311 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -81,8 +81,9 @@ const FileContentPanel: FC = () => { nextMetadata = fileContent.metadata } } - storeApi.getState().setFileMetadata(fileTabId, nextMetadata) - storeApi.getState().clearDraftMetadata(fileTabId) + const { setFileMetadata, clearDraftMetadata } = storeApi.getState() + setFileMetadata(fileTabId, nextMetadata) + clearDraftMetadata(fileTabId) }, [fileTabId, isMetadataDirty, fileContent, storeApi]) const handleEditorChange = useCallback((value: string | undefined) => { @@ -102,11 +103,8 @@ const FileContentPanel: FC = () => { appId, activeTabId: fileTabId, isEditable, - draftContent, - isMetadataDirty, originalContent, currentMetadata, - storeApi, t, }) diff --git a/web/app/components/workflow/skill/hooks/use-skill-auto-save.ts b/web/app/components/workflow/skill/hooks/use-skill-auto-save.ts new file mode 100644 index 0000000000..09d091f098 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-skill-auto-save.ts @@ -0,0 +1,44 @@ +import { useEventListener, useUnmount } from 'ahooks' +import { useEffect, useRef } from 'react' +import { START_TAB_ID } from '../constants' +import { useSkillSaveManager } from './use-skill-save-manager' + +type UseSkillAutoSaveParams = { + activeTabId: string | null +} + +export function useSkillAutoSave({ + activeTabId, +}: UseSkillAutoSaveParams): void { + const { saveFile, saveAllDirty } = useSkillSaveManager() + const prevActiveTabIdRef = useRef(activeTabId) + + useEffect(() => { + const prevActiveTabId = prevActiveTabIdRef.current + if (prevActiveTabId && prevActiveTabId !== activeTabId && prevActiveTabId !== START_TAB_ID) + void saveFile(prevActiveTabId) + + prevActiveTabIdRef.current = activeTabId + }, [activeTabId, saveFile]) + + useUnmount(() => { + saveAllDirty() + }) + + useEventListener( + 'visibilitychange', + () => { + if (document.visibilityState === 'hidden') + saveAllDirty() + }, + { target: document }, + ) + + useEventListener( + 'beforeunload', + () => { + saveAllDirty() + }, + { target: window }, + ) +} 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 index e72c61cc9e..e3295b2b50 100644 --- a/web/app/components/workflow/skill/hooks/use-skill-file-save.ts +++ b/web/app/components/workflow/skill/hooks/use-skill-file-save.ts @@ -1,19 +1,15 @@ import type { TFunction } from 'i18next' -import type { StoreApi } from 'zustand' -import type { Shape } from '@/app/components/workflow/store' -import { useCallback, useEffect } from 'react' +import { useEventListener } from 'ahooks' +import { useCallback } from 'react' import Toast from '@/app/components/base/toast' -import { useUpdateAppAssetFileContent } from '@/service/use-app-asset' +import { useSkillSaveManager } from './use-skill-save-manager' type UseSkillFileSaveParams = { appId: string activeTabId: string | null isEditable: boolean - draftContent: string | undefined - isMetadataDirty: boolean originalContent: string currentMetadata: Record | undefined - storeApi: StoreApi t: TFunction<'workflow'> } @@ -25,57 +21,43 @@ export function useSkillFileSave({ appId, activeTabId, isEditable, - draftContent, - isMetadataDirty, originalContent, currentMetadata, - storeApi, t, }: UseSkillFileSaveParams): () => Promise { - const updateContent = useUpdateAppAssetFileContent() + const { saveFile } = useSkillSaveManager() const handleSave = useCallback(async () => { if (!activeTabId || !appId || !isEditable) return - if (draftContent === undefined && !isMetadataDirty) - return + const result = await saveFile(activeTabId, { + fallbackContent: originalContent, + fallbackMetadata: currentMetadata, + }) - try { - await updateContent.mutateAsync({ - appId, - nodeId: activeTabId, - payload: { - content: draftContent ?? originalContent, - ...(currentMetadata ? { metadata: currentMetadata } : {}), - }, + if (result.error) { + Toast.notify({ + type: 'error', + message: String(result.error), }) - storeApi.getState().clearDraftContent(activeTabId) - storeApi.getState().clearDraftMetadata(activeTabId) + return + } + + if (result.saved) { Toast.notify({ type: 'success', message: t('api.saved', { ns: 'common' }), }) } - catch (error) { - Toast.notify({ - type: 'error', - message: String(error), - }) - } - }, [activeTabId, appId, currentMetadata, draftContent, isMetadataDirty, isEditable, originalContent, storeApi, t, updateContent]) + }, [activeTabId, appId, currentMetadata, isEditable, originalContent, saveFile, t]) - useEffect(() => { - function handleKeyDown(e: KeyboardEvent): void { - if ((e.ctrlKey || e.metaKey) && e.key === 's') { - e.preventDefault() - handleSave() - } + useEventListener('keydown', (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault() + handleSave() } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [handleSave]) + }, { target: window }) return handleSave } diff --git a/web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx b/web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx new file mode 100644 index 0000000000..8e93375c56 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx @@ -0,0 +1,229 @@ +import { useQueryClient } from '@tanstack/react-query' +import isDeepEqual from 'fast-deep-equal' +import * as React from 'react' +import { useCallback, useMemo, useRef } from 'react' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { consoleQuery } from '@/service/client' +import { useUpdateAppAssetFileContent } from '@/service/use-app-asset' +import { START_TAB_ID } from '../constants' + +type SaveSnapshot = { + content: string + metadata?: Record + hasDraftContent: boolean + hasMetadataDirty: boolean +} + +type CachedFileContent = { + content?: string + metadata?: Record + [key: string]: unknown +} + +export type SaveFileOptions = { + fallbackContent?: string + fallbackMetadata?: Record +} + +export type SaveResult = { + saved: boolean + error?: unknown +} + +type SkillSaveContextValue = { + saveFile: (fileId: string, options?: SaveFileOptions) => Promise + saveAllDirty: () => void +} + +type SkillSaveProviderProps = { + appId: string + children: React.ReactNode +} + +const SkillSaveContext = React.createContext(null) + +export const SkillSaveProvider = ({ + appId, + children, +}: SkillSaveProviderProps) => { + const storeApi = useWorkflowStore() + const queryClient = useQueryClient() + const updateContent = useUpdateAppAssetFileContent() + const queueRef = useRef>>(new Map()) + + const getCachedContent = useCallback((fileId: string): string | undefined => { + if (!appId) + return undefined + + const cached = queryClient.getQueryData( + consoleQuery.appAsset.getFileContent.queryKey({ + input: { params: { appId, nodeId: fileId } }, + }), + ) + + const rawContent = cached?.content + if (!rawContent) + return undefined + + try { + const parsed = JSON.parse(rawContent) as { content?: unknown } + if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') + return parsed.content + } + catch { + // Fall back to raw content when it's not a JSON wrapper. + } + + return rawContent + }, [appId, queryClient]) + + const buildSnapshot = useCallback(( + fileId: string, + fallbackContent?: string, + fallbackMetadata?: Record, + ): SaveSnapshot | null => { + const state = storeApi.getState() + const draftContent = state.dirtyContents.get(fileId) + const isMetadataDirty = state.dirtyMetadataIds.has(fileId) + + if (draftContent === undefined && !isMetadataDirty) + return null + + const metadata = state.fileMetadata.get(fileId) ?? fallbackMetadata + const content = draftContent ?? getCachedContent(fileId) ?? fallbackContent + + if (content === undefined) + return null + + return { + content, + metadata, + hasDraftContent: draftContent !== undefined, + hasMetadataDirty: isMetadataDirty, + } + }, [getCachedContent, storeApi]) + + const updateCachedContent = useCallback((fileId: string, snapshot: SaveSnapshot) => { + if (!appId) + return + + const queryKey = consoleQuery.appAsset.getFileContent.queryKey({ + input: { params: { appId, nodeId: fileId } }, + }) + const existing = queryClient.getQueryData(queryKey) + const serialized = JSON.stringify({ + content: snapshot.content, + ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), + }) + const nextData: CachedFileContent = { + ...(existing && typeof existing === 'object' ? existing : {}), + content: serialized, + } + + queryClient.setQueryData(queryKey, nextData) + }, [appId, queryClient]) + + const performSave = useCallback(async ( + fileId: string, + options?: SaveFileOptions, + ): Promise => { + if (!appId || !fileId || fileId === START_TAB_ID) + return { saved: false } + + const snapshot = buildSnapshot(fileId, options?.fallbackContent, options?.fallbackMetadata) + if (!snapshot) + return { saved: false } + + try { + await updateContent.mutateAsync({ + appId, + nodeId: fileId, + payload: { + content: snapshot.content, + ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), + }, + }) + + updateCachedContent(fileId, snapshot) + + const latestState = storeApi.getState() + if (snapshot.hasDraftContent) { + const latestDraft = latestState.dirtyContents.get(fileId) + if (latestDraft === snapshot.content) + latestState.clearDraftContent(fileId) + } + + if (snapshot.hasMetadataDirty) { + const latestMetadata = latestState.fileMetadata.get(fileId) + if (isDeepEqual(latestMetadata, snapshot.metadata)) + latestState.clearDraftMetadata(fileId) + } + + return { saved: true } + } + catch (error) { + return { saved: false, error } + } + }, [appId, buildSnapshot, storeApi, updateCachedContent, updateContent]) + + const saveFile = useCallback(async ( + fileId: string, + options?: SaveFileOptions, + ): Promise => { + if (!fileId || fileId === START_TAB_ID) + return { saved: false } + + const previous = queueRef.current.get(fileId) || Promise.resolve({ saved: false }) + const next = previous.then(() => performSave(fileId, options)) + queueRef.current.set(fileId, next) + return next.finally(() => { + if (queueRef.current.get(fileId) === next) + queueRef.current.delete(fileId) + }) + }, [performSave]) + + const saveAllDirty = useCallback(() => { + if (!appId) + return + + const { dirtyContents, dirtyMetadataIds } = storeApi.getState() + if (dirtyContents.size === 0 && dirtyMetadataIds.size === 0) + return + + const dirtyIds = new Set() + dirtyContents.forEach((_value, fileId) => { + dirtyIds.add(fileId) + }) + dirtyMetadataIds.forEach((fileId) => { + dirtyIds.add(fileId) + }) + + const tasks = Array.from(dirtyIds) + .filter(fileId => fileId !== START_TAB_ID) + .map(fileId => saveFile(fileId)) + + if (tasks.length === 0) + return + + void Promise.allSettled(tasks) + }, [appId, saveFile, storeApi]) + + const value = useMemo(() => ({ + saveFile, + saveAllDirty, + }), [saveAllDirty, saveFile]) + + return ( + + {children} + + ) +} + +export const useSkillSaveManager = () => { + const context = React.useContext(SkillSaveContext) + if (!context) + throw new Error('Missing SkillSaveProvider in the tree') + + return context +} diff --git a/web/app/components/workflow/skill/main.tsx b/web/app/components/workflow/skill/main.tsx index c786832cd6..4faf8b04a5 100644 --- a/web/app/components/workflow/skill/main.tsx +++ b/web/app/components/workflow/skill/main.tsx @@ -2,30 +2,48 @@ import type { FC } from 'react' import * as React from 'react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useStore } from '@/app/components/workflow/store' import ContentArea from './content-area' import ContentBody from './content-body' import FileContentPanel from './file-content-panel' import FileTabs from './file-tabs' import FileTree from './file-tree' +import { useSkillAutoSave } from './hooks/use-skill-auto-save' +import { SkillSaveProvider } from './hooks/use-skill-save-manager' import Sidebar from './sidebar' import SidebarSearchAdd from './sidebar-search-add' import SkillPageLayout from './skill-page-layout' +const SkillAutoSaveManager: FC = () => { + const activeTabId = useStore(s => s.activeTabId) + + useSkillAutoSave({ activeTabId }) + + return null +} + const SkillMain: FC = () => { + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + return (
- - - - - - - - - - - - + + + + + + + + + + + + + + +
) }