feat: sync the markdown file dirty status

This commit is contained in:
hjlarry
2026-01-28 16:29:17 +08:00
parent 7cf54238c3
commit 4c77b5f5c5
5 changed files with 90 additions and 4 deletions

View File

@ -21,6 +21,12 @@ type SkillCursorPayload = {
end?: number | null
}
type SkillFileSavedPayload = {
file_id: string
content?: string
metadata?: Record<string, unknown>
}
type SkillDocEntry = {
doc: LoroDoc
text: ReturnType<LoroDoc['getText']>
@ -47,6 +53,8 @@ class SkillCollaborationManager {
private pendingResync = new Set<string>()
private cursorByFile = new Map<string, SkillCursorMap>()
private cursorEmitter = new EventEmitter()
private fileEmitter = new EventEmitter()
private fileSavedGlobalKey = 'skill_file_saved:all'
private handleSkillUpdate = (payload: SkillUpdatePayload) => {
if (!payload || !payload.file_id || !payload.update)
@ -80,6 +88,15 @@ class SkillCollaborationManager {
if (!update || !update.type)
return
if (update.type === 'skill_file_saved') {
const data = update.data as SkillFileSavedPayload | undefined
const fileId = data?.file_id
if (!fileId)
return
this.fileEmitter.emit(this.fileSavedGlobalKey, data)
return
}
if (update.type === 'skill_cursor') {
const data = update.data as SkillCursorPayload | undefined
const fileId = data?.file_id
@ -139,6 +156,7 @@ class SkillCollaborationManager {
this.pendingResync.clear()
this.cursorByFile.clear()
this.cursorEmitter.removeAllListeners()
this.fileEmitter.removeAllListeners()
}
this.appId = appId
@ -256,6 +274,10 @@ class SkillCollaborationManager {
return off
}
onAnyFileSaved(callback: (payload: SkillFileSavedPayload) => void): () => void {
return this.fileEmitter.on(this.fileSavedGlobalKey, callback)
}
isLeader(fileId: string): boolean {
return this.leaderByFile.get(fileId) || false
}
@ -285,6 +307,18 @@ class SkillCollaborationManager {
})
}
emitFileSaved(fileId: string, content: string, metadata?: Record<string, unknown>): void {
if (!fileId || !this.socket || !this.socket.connected) {
return
}
emitWithAuthGuard(this.socket, 'collaboration_event', {
type: 'skill_file_saved',
data: { file_id: fileId, content, metadata },
timestamp: Date.now(),
})
}
setActiveFile(appId: string, fileId: string, active: boolean): void {
if (!appId || !fileId)
return

View File

@ -9,6 +9,7 @@ type UseSkillMarkdownCollaborationProps = {
fileId: string | null
enabled: boolean
initialContent: string
baselineContent: string
onLocalChange: (value: string) => void
onLeaderSync: () => void
}
@ -18,17 +19,24 @@ export const useSkillMarkdownCollaboration = ({
fileId,
enabled,
initialContent,
baselineContent,
onLocalChange,
onLeaderSync,
}: UseSkillMarkdownCollaborationProps) => {
const storeApi = useWorkflowStore()
const { eventEmitter } = useEventEmitterContextContext()
const suppressNextChangeRef = useRef<string | null>(null)
// Keep the latest server baseline to avoid marking the editor dirty on initial sync.
const baselineContentRef = useRef(baselineContent)
useEffect(() => {
suppressNextChangeRef.current = null
}, [fileId])
useEffect(() => {
baselineContentRef.current = baselineContent
}, [baselineContent])
useEffect(() => {
if (!enabled || !fileId)
return
@ -39,8 +47,13 @@ export const useSkillMarkdownCollaboration = ({
const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => {
suppressNextChangeRef.current = nextText
const state = storeApi.getState()
state.setDraftContent(fileId, nextText)
state.pinTab(fileId)
if (nextText === baselineContentRef.current) {
state.clearDraftContent(fileId)
}
else {
state.setDraftContent(fileId, nextText)
state.pinTab(fileId)
}
eventEmitter?.emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: fileId,

View File

@ -64,6 +64,7 @@ export type CollaborationEventType
| 'app_publish_update'
| 'graph_view_active'
| 'skill_file_active'
| 'skill_file_saved'
| 'skill_cursor'
| 'skill_sync_request'
| 'skill_resync_request'

View File

@ -161,6 +161,7 @@ const FileContentPanel: FC = () => {
fileId: fileTabId,
enabled: canInitCollaboration,
initialContent: initialCollaborativeContent,
baselineContent: originalContent,
onLocalChange: handleEditorChange,
onLeaderSync: handleLeaderSync,
})

View File

@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { useEventListener } from 'ahooks'
import isDeepEqual from 'fast-deep-equal'
import * as React from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useWorkflowStore } from '@/app/components/workflow/store'
@ -181,8 +181,9 @@ export const SkillSaveProvider = ({
}
const snapshot = buildSnapshot(fileId, options?.fallbackContent, options?.fallbackMetadata)
if (!snapshot)
if (!snapshot) {
return { saved: false }
}
try {
await updateContent.mutateAsync({
@ -210,6 +211,10 @@ export const SkillSaveProvider = ({
latestState.clearDraftMetadata(fileId)
}
if (isCollaborationEnabled && skillCollaborationManager.isFileCollaborative(fileId)) {
skillCollaborationManager.emitFileSaved(fileId, snapshot.content, snapshot.metadata)
}
return { saved: true }
}
catch (error) {
@ -299,6 +304,38 @@ export const SkillSaveProvider = ({
unregisterFallback,
}), [saveAllDirty, saveFile, registerFallback, unregisterFallback])
useEffect(() => {
if (!appId || !isCollaborationEnabled)
return
return skillCollaborationManager.onAnyFileSaved((payload) => {
if (!payload?.file_id || typeof payload.content !== 'string')
return
const fileId = payload.file_id
const queryKey = consoleQuery.appAsset.getFileContent.queryKey({
input: { params: { appId, nodeId: fileId } },
})
const serialized = JSON.stringify({
content: payload.content,
...(payload.metadata ? { metadata: payload.metadata } : {}),
})
const existing = queryClient.getQueryData<CachedFileContent>(queryKey)
queryClient.setQueryData(queryKey, {
...(existing && typeof existing === 'object' ? existing : {}),
content: serialized,
})
const state = storeApi.getState()
state.clearDraftContent(fileId)
const latestMetadata = state.fileMetadata.get(fileId)
const normalizedLatest = normalizeMetadata(latestMetadata, payload.content)
if (payload.metadata === undefined || isDeepEqual(normalizedLatest, payload.metadata))
state.clearDraftMetadata(fileId)
})
}, [appId, isCollaborationEnabled, queryClient, storeApi])
return (
<SkillSaveContext.Provider value={value}>
{children}