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:
yyh
2026-01-25 19:05:00 +08:00
parent 150730d322
commit e1e7b7e88a
5 changed files with 328 additions and 57 deletions

View File

@ -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,
})

View File

@ -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 },
)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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>
)
}