From 90bb7bf2f349c68c9da9f4e3ecf90b748d6bcca3 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 29 Jan 2026 09:16:52 +0800 Subject: [PATCH] feat: code/txt editor sync --- .../skills/use-skill-code-collaboration.ts | 91 +++++++++++++++++++ .../workflow/skill/file-content-panel.tsx | 20 +++- 2 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts diff --git a/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts b/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts new file mode 100644 index 0000000000..792e210e30 --- /dev/null +++ b/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { skillCollaborationManager } from './skill-collaboration-manager' + +type UseSkillCodeCollaborationProps = { + appId: string + fileId: string | null + enabled: boolean + initialContent: string + baselineContent: string + onLocalChange: (value: string) => void + onLeaderSync: () => void +} + +export const useSkillCodeCollaboration = ({ + appId, + fileId, + enabled, + initialContent, + baselineContent, + onLocalChange, + onLeaderSync, +}: UseSkillCodeCollaborationProps) => { + const storeApi = useWorkflowStore() + const suppressNextChangeRef = useRef(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 + + skillCollaborationManager.openFile(appId, fileId, initialContent) + skillCollaborationManager.setActiveFile(appId, fileId, true) + + const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => { + suppressNextChangeRef.current = nextText + const state = storeApi.getState() + if (nextText === baselineContentRef.current) { + state.clearDraftContent(fileId) + } + else { + state.setDraftContent(fileId, nextText) + state.pinTab(fileId) + } + }) + + const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, onLeaderSync) + + return () => { + unsubscribe() + unsubscribeSync() + skillCollaborationManager.setActiveFile(appId, fileId, false) + skillCollaborationManager.closeFile(fileId) + } + }, [appId, enabled, fileId, initialContent, onLeaderSync, storeApi]) + + const handleCollaborativeChange = useCallback((value: string | undefined) => { + const nextValue = value ?? '' + if (!fileId) { + onLocalChange(nextValue) + return + } + + if (!enabled) { + onLocalChange(nextValue) + return + } + + if (suppressNextChangeRef.current === nextValue) { + suppressNextChangeRef.current = null + return + } + + skillCollaborationManager.updateText(fileId, nextValue) + onLocalChange(nextValue) + }, [enabled, fileId, onLocalChange]) + + return { + handleCollaborativeChange, + isLeader: fileId ? skillCollaborationManager.isLeader(fileId) : false, + } +} diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index b530830abb..c2ebeaebac 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -12,6 +12,7 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' import { basePath } from '@/utils/var' +import { useSkillCodeCollaboration } from '../collaboration/skills/use-skill-code-collaboration' import { useSkillMarkdownCollaboration } from '../collaboration/skills/use-skill-markdown-collaboration' import { START_TAB_ID } from './constants' import CodeFileEditor from './editor/code-file-editor' @@ -62,7 +63,7 @@ const FileContentPanel = () => { const originalContent = fileContent?.content ?? '' const currentContent = draftContent !== undefined ? draftContent : originalContent const initialContentRegistryRef = useRef>(new Map()) - const canInitCollaboration = Boolean(appId && fileTabId && isMarkdown && isEditable && !isLoading && !error) + const canInitCollaboration = Boolean(appId && fileTabId && isEditable && !isLoading && !error) if (canInitCollaboration && fileTabId && !initialContentRegistryRef.current.has(fileTabId)) initialContentRegistryRef.current.set(fileTabId, currentContent) @@ -155,10 +156,19 @@ const FileContentPanel = () => { const language = currentFileNode ? getFileLanguage(currentFileNode.name) : 'plaintext' const theme = appTheme === Theme.light ? 'light' : 'vs-dark' - const { handleCollaborativeChange } = useSkillMarkdownCollaboration({ + const { handleCollaborativeChange: handleMarkdownCollaborativeChange } = useSkillMarkdownCollaboration({ appId, fileId: fileTabId, - enabled: canInitCollaboration, + enabled: canInitCollaboration && isMarkdown, + initialContent: initialCollaborativeContent, + baselineContent: originalContent, + onLocalChange: handleEditorChange, + onLeaderSync: handleLeaderSync, + }) + const { handleCollaborativeChange: handleCodeCollaborativeChange } = useSkillCodeCollaboration({ + appId, + fileId: fileTabId, + enabled: canInitCollaboration && isCodeOrText, initialContent: initialCollaborativeContent, baselineContent: originalContent, onLocalChange: handleEditorChange, @@ -210,7 +220,7 @@ const FileContentPanel = () => { key={fileTabId} instanceId={fileTabId || undefined} value={currentContent} - onChange={handleCollaborativeChange} + onChange={handleMarkdownCollaborativeChange} collaborationEnabled={canInitCollaboration} /> ) @@ -222,7 +232,7 @@ const FileContentPanel = () => { language={language} theme={isMounted ? theme : 'default-theme'} value={currentContent} - onChange={handleEditorChange} + onChange={handleCodeCollaborativeChange} onMount={handleEditorDidMount} /> )