mirror of https://github.com/langgenius/dify.git
feat: sync file tree
This commit is contained in:
parent
72c712b3bb
commit
079484d21c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
Loading…
Reference in New Issue