feat: sync file tree

This commit is contained in:
hjlarry 2026-01-29 16:33:33 +08:00
parent 72c712b3bb
commit 079484d21c
11 changed files with 104 additions and 7 deletions

View File

@ -55,6 +55,8 @@ class SkillCollaborationManager {
private cursorEmitter = new EventEmitter()
private fileEmitter = new EventEmitter()
private fileSavedGlobalKey = 'skill_file_saved:all'
private treeEmitter = new EventEmitter()
private treeUpdateKey = 'skill_tree_update:all'
private handleSkillUpdate = (payload: SkillUpdatePayload) => {
if (!payload || !payload.file_id || !payload.update)
@ -97,6 +99,11 @@ class SkillCollaborationManager {
return
}
if (update.type === 'skill_tree_update') {
this.treeEmitter.emit(this.treeUpdateKey, update.data)
return
}
if (update.type === 'skill_cursor') {
const data = update.data as SkillCursorPayload | undefined
const fileId = data?.file_id
@ -157,6 +164,7 @@ class SkillCollaborationManager {
this.cursorByFile.clear()
this.cursorEmitter.removeAllListeners()
this.fileEmitter.removeAllListeners()
this.treeEmitter.removeAllListeners()
}
this.appId = appId
@ -278,6 +286,12 @@ class SkillCollaborationManager {
return this.fileEmitter.on(this.fileSavedGlobalKey, callback)
}
onTreeUpdate(appId: string, callback: (payload: Record<string, unknown>) => void): () => void {
if (appId)
this.ensureSocket(appId)
return this.treeEmitter.on(this.treeUpdateKey, callback)
}
isLeader(fileId: string): boolean {
return this.leaderByFile.get(fileId) || false
}
@ -319,6 +333,21 @@ class SkillCollaborationManager {
})
}
emitTreeUpdate(appId: string, payload: Record<string, unknown> = {}): void {
if (!appId)
return
const socket = this.ensureSocket(appId)
if (!socket || !socket.connected)
return
emitWithAuthGuard(socket, 'collaboration_event', {
type: 'skill_tree_update',
data: payload,
timestamp: Date.now(),
})
}
setActiveFile(appId: string, fileId: string, active: boolean): void {
if (!appId || !fileId)
return

View File

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

View File

@ -24,6 +24,7 @@ import { usePasteOperation } from '../hooks/use-paste-operation'
import { useRootFileDrop } from '../hooks/use-root-file-drop'
import { useSkillAssetTreeData } from '../hooks/use-skill-asset-tree'
import { useSkillShortcuts } from '../hooks/use-skill-shortcuts'
import { useSkillTreeCollaboration } from '../hooks/use-skill-tree-collaboration'
import { useSyncTreeWithActiveTab } from '../hooks/use-sync-tree-with-active-tab'
import { isDescendantOf } from '../utils/tree-utils'
import DragActionTooltip from './drag-action-tooltip'
@ -107,6 +108,8 @@ const FileTree = ({ className }: FileTreeProps) => {
const treeChildren = treeData?.children ?? emptyTreeNodes
useSkillTreeCollaboration()
const {
handleRootDragEnter,
handleRootDragLeave,

View File

@ -10,6 +10,7 @@ import {
useUploadFileWithPresignedUrl,
} from '@/service/use-app-asset'
import { prepareSkillUploadFile } from '../utils/skill-upload-utils'
import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration'
type UseCreateOperationsOptions = {
parentId: string | null
@ -34,6 +35,7 @@ export function useCreateOperations({
const createFolder = useCreateAppAssetFolder()
const uploadFile = useUploadFileWithPresignedUrl()
const batchUpload = useBatchUpload()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const handleNewFile = useCallback(() => {
storeApi.getState().startCreateNode('file', parentId)
@ -80,10 +82,12 @@ export function useCreateOperations({
storeApi.getState().setUploadStatus('partial_error')
}
finally {
if (progress.uploaded > 0)
emitTreeUpdate()
e.target.value = ''
onClose()
}
}, [appId, uploadFile, onClose, parentId, storeApi])
}, [appId, uploadFile, onClose, parentId, storeApi, emitTreeUpdate])
const handleFolderChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
@ -152,6 +156,7 @@ export function useCreateOperations({
storeApi.getState().setUploadStatus('success')
storeApi.getState().setUploadProgress({ uploaded: files.length, total: files.length, failed: 0 })
emitTreeUpdate()
}
catch {
storeApi.getState().setUploadStatus('partial_error')
@ -160,7 +165,7 @@ export function useCreateOperations({
e.target.value = ''
onClose()
}
}, [appId, batchUpload, onClose, parentId, storeApi])
}, [appId, batchUpload, onClose, parentId, storeApi, emitTreeUpdate])
return {
fileInputRef,

View File

@ -11,6 +11,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
import { useUploadFileWithPresignedUrl } from '@/service/use-app-asset'
import { ROOT_ID } from '../constants'
import { prepareSkillUploadFile } from '../utils/skill-upload-utils'
import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration'
type FileDropTarget = {
folderId: string | null
@ -23,6 +24,7 @@ export function useFileDrop() {
const appId = appDetail?.id || ''
const storeApi = useWorkflowStore()
const uploadFile = useUploadFileWithPresignedUrl()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const handleDragOver = useCallback((e: React.DragEvent, target: FileDropTarget) => {
e.preventDefault()
@ -90,6 +92,7 @@ export function useFileDrop() {
),
)
emitTreeUpdate()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.filesUploaded', { count: files.length }),
@ -101,7 +104,7 @@ export function useFileDrop() {
message: t('skillSidebar.menu.uploadError'),
})
}
}, [appId, uploadFile, t, storeApi])
}, [appId, uploadFile, t, storeApi, emitTreeUpdate])
return {
handleDragOver,

View File

@ -14,6 +14,7 @@ import {
} from '@/service/use-app-asset'
import { getFileExtension, isTextLikeFile } from '../utils/file-utils'
import { createDraftTreeNode, insertDraftTreeNode } from '../utils/tree-utils'
import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration'
type UseInlineCreateNodeOptions = {
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
@ -38,6 +39,7 @@ export function useInlineCreateNode({
const uploadFile = useUploadFileWithPresignedUrl()
const createFolder = useCreateAppAssetFolder()
const renameNode = useRenameAppAssetNode()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const pendingCreateId = pendingCreateNode?.id ?? null
const pendingCreateType = pendingCreateNode?.nodeType ?? null
@ -71,6 +73,7 @@ export function useInlineCreateNode({
parent_id: pendingCreateParentId,
},
})
emitTreeUpdate()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.folderCreated'),
@ -84,6 +87,7 @@ export function useInlineCreateNode({
file,
parentId: pendingCreateParentId,
})
emitTreeUpdate()
const extension = getFileExtension(trimmedName, createdFile.extension)
if (isTextLikeFile(extension))
storeApi.getState().openTab(createdFile.id, { pinned: true })
@ -110,6 +114,7 @@ export function useInlineCreateNode({
nodeId: id,
payload: { name },
}).then(() => {
emitTreeUpdate()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.renamed'),
@ -130,6 +135,7 @@ export function useInlineCreateNode({
renameNode,
storeApi,
t,
emitTreeUpdate,
])
const searchMatch = useCallback(

View File

@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useDeleteAppAssetNode } from '@/service/use-app-asset'
import { getAllDescendantFileIds } from '../utils/tree-utils'
import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration'
type UseModifyOperationsOptions = {
nodeId: string
@ -35,6 +36,7 @@ export function useModifyOperations({
const { t } = useTranslation('workflow')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const deleteNode = useDeleteAppAssetNode()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const handleRename = useCallback(() => {
if (treeRef?.current) {
@ -59,6 +61,7 @@ export function useModifyOperations({
: []
await deleteNode.mutateAsync({ appId, nodeId })
emitTreeUpdate()
descendantFileIds.forEach((fileId) => {
storeApi.getState().closeTab(fileId)
@ -90,7 +93,7 @@ export function useModifyOperations({
setShowDeleteConfirm(false)
onClose()
}
}, [appId, nodeId, node?.data?.node_type, deleteNode, storeApi, treeData?.children, onClose, t])
}, [appId, nodeId, node?.data?.node_type, deleteNode, storeApi, treeData?.children, onClose, t, emitTreeUpdate])
const handleDeleteCancel = useCallback(() => {
setShowDeleteConfirm(false)

View File

@ -9,12 +9,14 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { useMoveAppAssetNode } from '@/service/use-app-asset'
import { toApiParentId } from '../utils/tree-utils'
import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration'
export function useNodeMove() {
const { t } = useTranslation('workflow')
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const moveNode = useMoveAppAssetNode()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
// Execute move API call - validation is handled by react-arborist's disableDrop callback
const executeMoveNode = useCallback(async (nodeId: string, targetFolderId: string | null) => {
@ -25,6 +27,7 @@ export function useNodeMove() {
payload: { parent_id: toApiParentId(targetFolderId) },
})
emitTreeUpdate()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.moved'),
@ -36,7 +39,7 @@ export function useNodeMove() {
message: t('skillSidebar.menu.moveError'),
})
}
}, [appId, moveNode, t])
}, [appId, moveNode, t, emitTreeUpdate])
return {
executeMoveNode,

View File

@ -7,12 +7,14 @@ import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { useReorderAppAssetNode } from '@/service/use-app-asset'
import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration'
export function useNodeReorder() {
const { t } = useTranslation('workflow')
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const reorderNode = useReorderAppAssetNode()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const executeReorderNode = useCallback(async (nodeId: string, afterNodeId: string | null) => {
try {
@ -22,6 +24,7 @@ export function useNodeReorder() {
payload: { after_node_id: afterNodeId },
})
emitTreeUpdate()
Toast.notify({
type: 'success',
message: t('skillSidebar.menu.moved'),
@ -33,7 +36,7 @@ export function useNodeReorder() {
message: t('skillSidebar.menu.moveError'),
})
}
}, [appId, reorderNode, t])
}, [appId, reorderNode, t, emitTreeUpdate])
return {
executeReorderNode,

View File

@ -11,6 +11,7 @@ import Toast from '@/app/components/base/toast'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useMoveAppAssetNode } from '@/service/use-app-asset'
import { findNodeById, getTargetFolderIdFromSelection, toApiParentId } from '../utils/tree-utils'
import { useSkillTreeUpdateEmitter } from './use-skill-tree-collaboration'
type UsePasteOperationOptions = {
treeRef: RefObject<TreeApi<TreeNodeData> | null>
@ -33,6 +34,7 @@ export function usePasteOperation({
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const moveNode = useMoveAppAssetNode()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const isPastingRef = useRef(false)
const handlePaste = useCallback(async () => {
@ -84,6 +86,7 @@ export function usePasteOperation({
)
storeApi.getState().clearClipboard()
emitTreeUpdate()
Toast.notify({
type: 'success',
@ -100,7 +103,7 @@ export function usePasteOperation({
isPastingRef.current = false
}
}
}, [appId, moveNode, storeApi, t, treeData?.children, treeRef])
}, [appId, moveNode, storeApi, t, treeData?.children, treeRef, emitTreeUpdate])
useEffect(() => {
if (!enabled)

View File

@ -0,0 +1,38 @@
'use client'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { skillCollaborationManager } from '@/app/components/workflow/collaboration/skills/skill-collaboration-manager'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { consoleQuery } from '@/service/client'
export const useSkillTreeUpdateEmitter = () => {
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
return useCallback((payload: Record<string, unknown> = {}) => {
if (!appId || !isCollaborationEnabled)
return
skillCollaborationManager.emitTreeUpdate(appId, payload)
}, [appId, isCollaborationEnabled])
}
export const useSkillTreeCollaboration = () => {
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
const queryClient = useQueryClient()
useEffect(() => {
if (!appId || !isCollaborationEnabled)
return
return skillCollaborationManager.onTreeUpdate(appId, () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId } } }),
})
})
}, [appId, isCollaborationEnabled, queryClient])
}