mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 09:58:04 +08:00
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.
This commit is contained in:
@ -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,
|
||||
})
|
||||
|
||||
|
||||
@ -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<string | null>(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 },
|
||||
)
|
||||
}
|
||||
@ -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<string, unknown> | undefined
|
||||
storeApi: StoreApi<Shape>
|
||||
t: TFunction<'workflow'>
|
||||
}
|
||||
|
||||
@ -25,57 +21,43 @@ export function useSkillFileSave({
|
||||
appId,
|
||||
activeTabId,
|
||||
isEditable,
|
||||
draftContent,
|
||||
isMetadataDirty,
|
||||
originalContent,
|
||||
currentMetadata,
|
||||
storeApi,
|
||||
t,
|
||||
}: UseSkillFileSaveParams): () => Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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<string, unknown>
|
||||
hasDraftContent: boolean
|
||||
hasMetadataDirty: boolean
|
||||
}
|
||||
|
||||
type CachedFileContent = {
|
||||
content?: string
|
||||
metadata?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SaveFileOptions = {
|
||||
fallbackContent?: string
|
||||
fallbackMetadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SaveResult = {
|
||||
saved: boolean
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
type SkillSaveContextValue = {
|
||||
saveFile: (fileId: string, options?: SaveFileOptions) => Promise<SaveResult>
|
||||
saveAllDirty: () => void
|
||||
}
|
||||
|
||||
type SkillSaveProviderProps = {
|
||||
appId: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const SkillSaveContext = React.createContext<SkillSaveContextValue | null>(null)
|
||||
|
||||
export const SkillSaveProvider = ({
|
||||
appId,
|
||||
children,
|
||||
}: SkillSaveProviderProps) => {
|
||||
const storeApi = useWorkflowStore()
|
||||
const queryClient = useQueryClient()
|
||||
const updateContent = useUpdateAppAssetFileContent()
|
||||
const queueRef = useRef<Map<string, Promise<SaveResult>>>(new Map())
|
||||
|
||||
const getCachedContent = useCallback((fileId: string): string | undefined => {
|
||||
if (!appId)
|
||||
return undefined
|
||||
|
||||
const cached = queryClient.getQueryData<CachedFileContent>(
|
||||
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<string, unknown>,
|
||||
): 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<CachedFileContent>(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<SaveResult> => {
|
||||
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<SaveResult> => {
|
||||
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<string>()
|
||||
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<SkillSaveContextValue>(() => ({
|
||||
saveFile,
|
||||
saveAllDirty,
|
||||
}), [saveAllDirty, saveFile])
|
||||
|
||||
return (
|
||||
<SkillSaveContext.Provider value={value}>
|
||||
{children}
|
||||
</SkillSaveContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSkillSaveManager = () => {
|
||||
const context = React.useContext(SkillSaveContext)
|
||||
if (!context)
|
||||
throw new Error('Missing SkillSaveProvider in the tree')
|
||||
|
||||
return context
|
||||
}
|
||||
@ -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 (
|
||||
<div className="h-full bg-workflow-canvas-workflow-top-bar-1 pl-3 pt-[52px]">
|
||||
<SkillPageLayout>
|
||||
<Sidebar>
|
||||
<SidebarSearchAdd />
|
||||
<FileTree />
|
||||
</Sidebar>
|
||||
<ContentArea>
|
||||
<FileTabs />
|
||||
<ContentBody>
|
||||
<FileContentPanel />
|
||||
</ContentBody>
|
||||
</ContentArea>
|
||||
</SkillPageLayout>
|
||||
<SkillSaveProvider appId={appId}>
|
||||
<SkillAutoSaveManager />
|
||||
<SkillPageLayout>
|
||||
<Sidebar>
|
||||
<SidebarSearchAdd />
|
||||
<FileTree />
|
||||
</Sidebar>
|
||||
<ContentArea>
|
||||
<FileTabs />
|
||||
<ContentBody>
|
||||
<FileContentPanel />
|
||||
</ContentBody>
|
||||
</ContentArea>
|
||||
</SkillPageLayout>
|
||||
</SkillSaveProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user