mirror of
https://github.com/langgenius/dify.git
synced 2026-03-28 14:01:53 -04:00
refactor(skill): split file content panel architecture
This commit is contained in:
@@ -9,6 +9,7 @@ type UseSkillCodeCollaborationProps = {
|
||||
initialContent: string
|
||||
baselineContent: string
|
||||
onLocalChange: (value: string) => void
|
||||
onRemoteChange?: (value: string) => void
|
||||
onLeaderSync: () => void
|
||||
}
|
||||
|
||||
@@ -19,12 +20,14 @@ export const useSkillCodeCollaboration = ({
|
||||
initialContent,
|
||||
baselineContent,
|
||||
onLocalChange,
|
||||
onRemoteChange,
|
||||
onLeaderSync,
|
||||
}: UseSkillCodeCollaborationProps) => {
|
||||
const storeApi = useWorkflowStore()
|
||||
const suppressNextChangeRef = useRef<string | null>(null)
|
||||
// Keep the latest server baseline to avoid marking the editor dirty on initial sync.
|
||||
const baselineContentRef = useRef(baselineContent)
|
||||
const onRemoteChangeRef = useRef(onRemoteChange)
|
||||
const onLeaderSyncRef = useRef(onLeaderSync)
|
||||
|
||||
useEffect(() => {
|
||||
suppressNextChangeRef.current = null
|
||||
@@ -34,6 +37,14 @@ export const useSkillCodeCollaboration = ({
|
||||
baselineContentRef.current = baselineContent
|
||||
}, [baselineContent])
|
||||
|
||||
useEffect(() => {
|
||||
onRemoteChangeRef.current = onRemoteChange
|
||||
}, [onRemoteChange])
|
||||
|
||||
useEffect(() => {
|
||||
onLeaderSyncRef.current = onLeaderSync
|
||||
}, [onLeaderSync])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !fileId)
|
||||
return
|
||||
@@ -43,17 +54,24 @@ export const useSkillCodeCollaboration = ({
|
||||
|
||||
const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => {
|
||||
suppressNextChangeRef.current = nextText
|
||||
const state = storeApi.getState()
|
||||
if (nextText === baselineContentRef.current) {
|
||||
state.clearDraftContent(fileId)
|
||||
if (onRemoteChangeRef.current) {
|
||||
onRemoteChangeRef.current(nextText)
|
||||
}
|
||||
else {
|
||||
state.setDraftContent(fileId, nextText)
|
||||
state.pinTab(fileId)
|
||||
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)
|
||||
const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, () => {
|
||||
onLeaderSyncRef.current()
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
@@ -61,7 +79,7 @@ export const useSkillCodeCollaboration = ({
|
||||
skillCollaborationManager.setActiveFile(appId, fileId, false)
|
||||
skillCollaborationManager.closeFile(fileId)
|
||||
}
|
||||
}, [appId, enabled, fileId, initialContent, onLeaderSync, storeApi])
|
||||
}, [appId, enabled, fileId, initialContent, storeApi])
|
||||
|
||||
const handleCollaborativeChange = useCallback((value: string | undefined) => {
|
||||
const nextValue = value ?? ''
|
||||
|
||||
@@ -11,6 +11,7 @@ type UseSkillMarkdownCollaborationProps = {
|
||||
initialContent: string
|
||||
baselineContent: string
|
||||
onLocalChange: (value: string) => void
|
||||
onRemoteChange?: (value: string) => void
|
||||
onLeaderSync: () => void
|
||||
}
|
||||
|
||||
@@ -21,13 +22,15 @@ export const useSkillMarkdownCollaboration = ({
|
||||
initialContent,
|
||||
baselineContent,
|
||||
onLocalChange,
|
||||
onRemoteChange,
|
||||
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)
|
||||
const onRemoteChangeRef = useRef(onRemoteChange)
|
||||
const onLeaderSyncRef = useRef(onLeaderSync)
|
||||
|
||||
useEffect(() => {
|
||||
suppressNextChangeRef.current = null
|
||||
@@ -37,6 +40,14 @@ export const useSkillMarkdownCollaboration = ({
|
||||
baselineContentRef.current = baselineContent
|
||||
}, [baselineContent])
|
||||
|
||||
useEffect(() => {
|
||||
onRemoteChangeRef.current = onRemoteChange
|
||||
}, [onRemoteChange])
|
||||
|
||||
useEffect(() => {
|
||||
onLeaderSyncRef.current = onLeaderSync
|
||||
}, [onLeaderSync])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !fileId)
|
||||
return
|
||||
@@ -46,13 +57,18 @@ export const useSkillMarkdownCollaboration = ({
|
||||
|
||||
const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => {
|
||||
suppressNextChangeRef.current = nextText
|
||||
const state = storeApi.getState()
|
||||
if (nextText === baselineContentRef.current) {
|
||||
state.clearDraftContent(fileId)
|
||||
if (onRemoteChangeRef.current) {
|
||||
onRemoteChangeRef.current(nextText)
|
||||
}
|
||||
else {
|
||||
state.setDraftContent(fileId, nextText)
|
||||
state.pinTab(fileId)
|
||||
const state = storeApi.getState()
|
||||
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,
|
||||
@@ -61,7 +77,9 @@ export const useSkillMarkdownCollaboration = ({
|
||||
} as unknown as string)
|
||||
})
|
||||
|
||||
const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, onLeaderSync)
|
||||
const unsubscribeSync = skillCollaborationManager.onSyncRequest(fileId, () => {
|
||||
onLeaderSyncRef.current()
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
@@ -69,7 +87,7 @@ export const useSkillMarkdownCollaboration = ({
|
||||
skillCollaborationManager.setActiveFile(appId, fileId, false)
|
||||
skillCollaborationManager.closeFile(fileId)
|
||||
}
|
||||
}, [appId, enabled, eventEmitter, fileId, initialContent, onLeaderSync, storeApi])
|
||||
}, [appId, enabled, eventEmitter, fileId, initialContent, storeApi])
|
||||
|
||||
const handleCollaborativeChange = useCallback((value: string | undefined) => {
|
||||
const nextValue = value ?? ''
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { OnMount } from '@monaco-editor/react'
|
||||
import type { SkillFileDataMode } from '../../hooks/use-skill-file-data'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import dynamic from '@/next/dynamic'
|
||||
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'
|
||||
import MarkdownFileEditor from '../../editor/markdown-file-editor'
|
||||
import { useSkillAssetNodeMap } from '../../hooks/file-tree/data/use-skill-asset-tree'
|
||||
import { useSkillSaveManager } from '../../hooks/skill-save-context'
|
||||
import { useFileNodeViewState } from '../../hooks/use-file-node-view-state'
|
||||
import { useFileTypeInfo } from '../../hooks/use-file-type-info'
|
||||
import { useSkillFileData } from '../../hooks/use-skill-file-data'
|
||||
import StartTabContent from '../../start-tab'
|
||||
import { getFileLanguage } from '../../utils/file-utils'
|
||||
import MediaFilePreview from '../../viewer/media-file-preview'
|
||||
import UnsupportedFileDownload from '../../viewer/unsupported-file-download'
|
||||
|
||||
type SkillFileMetadata = {
|
||||
files?: Record<string, AppAssetTreeView>
|
||||
}
|
||||
|
||||
const extractFileReferenceIds = (content: string) => {
|
||||
const ids = new Set<string>()
|
||||
const regex = /§\[file\]\.\[app\]\.\[([a-fA-F0-9-]{36})\]§/g
|
||||
let match: RegExpExecArray | null
|
||||
match = regex.exec(content)
|
||||
while (match !== null) {
|
||||
if (match[1])
|
||||
ids.add(match[1])
|
||||
match = regex.exec(content)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const SQLiteFilePreview = dynamic(
|
||||
() => import('../../viewer/sqlite-file-preview'),
|
||||
{ ssr: false, loading: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
const PdfFilePreview = dynamic(
|
||||
() => import('../../viewer/pdf-file-preview'),
|
||||
{ ssr: false, loading: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
if (typeof window !== 'undefined')
|
||||
loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } })
|
||||
|
||||
const FileContentPanel = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const { theme: appTheme } = useTheme()
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const editorAutoFocusFileId = useStore(s => s.editorAutoFocusFileId)
|
||||
const storeApi = useWorkflowStore()
|
||||
const {
|
||||
data: nodeMap,
|
||||
isLoading: isNodeMapLoading,
|
||||
isFetching: isNodeMapFetching,
|
||||
isFetched: isNodeMapFetched,
|
||||
} = useSkillAssetNodeMap()
|
||||
|
||||
const isStartTab = activeTabId === START_TAB_ID
|
||||
const fileTabId = isStartTab ? null : activeTabId
|
||||
|
||||
const draftContent = useStore(s => fileTabId ? s.dirtyContents.get(fileTabId) : undefined)
|
||||
const currentMetadata = useStore(s => fileTabId ? s.fileMetadata.get(fileTabId) : undefined)
|
||||
const isMetadataDirty = useStore(s => fileTabId ? s.dirtyMetadataIds.has(fileTabId) : false)
|
||||
|
||||
const currentFileNode = fileTabId ? nodeMap?.get(fileTabId) : undefined
|
||||
const shouldAutoFocusEditor = Boolean(fileTabId && editorAutoFocusFileId === fileTabId)
|
||||
const fileNodeViewState = useFileNodeViewState({
|
||||
fileTabId,
|
||||
hasCurrentFileNode: Boolean(currentFileNode),
|
||||
isNodeMapLoading,
|
||||
isNodeMapFetching,
|
||||
isNodeMapFetched,
|
||||
})
|
||||
const isNodeReady = fileNodeViewState === 'ready'
|
||||
|
||||
const { isMarkdown, isCodeOrText, isImage, isVideo, isPdf, isSQLite, isEditable, isPreviewable } = useFileTypeInfo(isNodeReady ? currentFileNode : undefined)
|
||||
const fileDataMode: SkillFileDataMode = !fileTabId || !isNodeReady
|
||||
? 'none'
|
||||
: isEditable
|
||||
? 'content'
|
||||
: 'download'
|
||||
|
||||
const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, fileTabId, fileDataMode)
|
||||
|
||||
const originalContent = fileContent?.content ?? ''
|
||||
const currentContent = draftContent !== undefined ? draftContent : originalContent
|
||||
const initialContentRegistryRef = useRef<Map<string, string>>(new Map())
|
||||
const canInitCollaboration = Boolean(appId && fileTabId && isEditable && !isLoading && !error)
|
||||
|
||||
if (canInitCollaboration && fileTabId && !initialContentRegistryRef.current.has(fileTabId))
|
||||
initialContentRegistryRef.current.set(fileTabId, currentContent)
|
||||
|
||||
const initialCollaborativeContent = fileTabId
|
||||
? (initialContentRegistryRef.current.get(fileTabId) ?? currentContent)
|
||||
: ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileTabId || !fileContent)
|
||||
return
|
||||
if (isMetadataDirty)
|
||||
return
|
||||
let nextMetadata: Record<string, unknown> = {}
|
||||
if (fileContent.metadata) {
|
||||
if (typeof fileContent.metadata === 'string') {
|
||||
try {
|
||||
nextMetadata = JSON.parse(fileContent.metadata)
|
||||
}
|
||||
catch {
|
||||
nextMetadata = {}
|
||||
}
|
||||
}
|
||||
else {
|
||||
nextMetadata = fileContent.metadata
|
||||
}
|
||||
}
|
||||
const { setFileMetadata, clearDraftMetadata } = storeApi.getState()
|
||||
setFileMetadata(fileTabId, nextMetadata)
|
||||
clearDraftMetadata(fileTabId)
|
||||
}, [fileTabId, isMetadataDirty, fileContent, storeApi])
|
||||
|
||||
const updateFileReferenceMetadata = useCallback((content: string) => {
|
||||
if (!fileTabId)
|
||||
return
|
||||
|
||||
const referenceIds = extractFileReferenceIds(content)
|
||||
const metadata = (currentMetadata || {}) as SkillFileMetadata
|
||||
const existingFiles = metadata.files || {}
|
||||
const nextFiles: Record<string, AppAssetTreeView> = {}
|
||||
|
||||
referenceIds.forEach((id) => {
|
||||
const node = nodeMap?.get(id)
|
||||
if (node)
|
||||
nextFiles[id] = node
|
||||
else if (existingFiles[id])
|
||||
nextFiles[id] = existingFiles[id]
|
||||
})
|
||||
|
||||
const nextMetadata: SkillFileMetadata = { ...metadata }
|
||||
if (Object.keys(nextFiles).length > 0)
|
||||
nextMetadata.files = nextFiles
|
||||
else if ('files' in nextMetadata)
|
||||
delete nextMetadata.files
|
||||
|
||||
if (isDeepEqual(metadata, nextMetadata))
|
||||
return
|
||||
storeApi.getState().setDraftMetadata(fileTabId, nextMetadata)
|
||||
}, [currentMetadata, fileTabId, nodeMap, storeApi])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (!fileTabId || !isEditable)
|
||||
return
|
||||
const newValue = value ?? ''
|
||||
|
||||
if (newValue === originalContent)
|
||||
storeApi.getState().clearDraftContent(fileTabId)
|
||||
else
|
||||
storeApi.getState().setDraftContent(fileTabId, newValue)
|
||||
updateFileReferenceMetadata(newValue)
|
||||
storeApi.getState().pinTab(fileTabId)
|
||||
}, [fileTabId, isEditable, originalContent, storeApi, updateFileReferenceMetadata])
|
||||
|
||||
const { saveFile, registerFallback, unregisterFallback } = useSkillSaveManager()
|
||||
const handleLeaderSync = useCallback(() => {
|
||||
if (!fileTabId || !isEditable)
|
||||
return
|
||||
void saveFile(fileTabId)
|
||||
}, [fileTabId, isEditable, saveFile])
|
||||
|
||||
const saveFileRef = useRef(saveFile)
|
||||
saveFileRef.current = saveFile
|
||||
|
||||
const fallbackRef = useRef({ content: originalContent, metadata: currentMetadata })
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileTabId || fileContent?.content === undefined)
|
||||
return
|
||||
|
||||
const fallback = { content: originalContent, metadata: currentMetadata }
|
||||
fallbackRef.current = fallback
|
||||
registerFallback(fileTabId, fallback)
|
||||
|
||||
return () => {
|
||||
unregisterFallback(fileTabId)
|
||||
}
|
||||
}, [fileTabId, fileContent?.content, originalContent, currentMetadata, registerFallback, unregisterFallback])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileTabId || !isEditable)
|
||||
return
|
||||
|
||||
return () => {
|
||||
const { content: fallbackContent, metadata: fallbackMetadata } = fallbackRef.current
|
||||
void saveFileRef.current(fileTabId, {
|
||||
fallbackContent,
|
||||
fallbackMetadata,
|
||||
})
|
||||
}
|
||||
}, [fileTabId, isEditable])
|
||||
|
||||
const handleEditorAutoFocus = useCallback(() => {
|
||||
if (!fileTabId)
|
||||
return
|
||||
storeApi.getState().clearEditorAutoFocus(fileTabId)
|
||||
}, [fileTabId, storeApi])
|
||||
|
||||
const handleEditorDidMount: OnMount = useCallback((_editor, monaco) => {
|
||||
monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark')
|
||||
setIsMounted(true)
|
||||
}, [appTheme])
|
||||
|
||||
const language = currentFileNode ? getFileLanguage(currentFileNode.name) : 'plaintext'
|
||||
const theme = appTheme === Theme.light ? 'light' : 'vs-dark'
|
||||
|
||||
const { handleCollaborativeChange: handleMarkdownCollaborativeChange } = useSkillMarkdownCollaboration({
|
||||
appId,
|
||||
fileId: fileTabId,
|
||||
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,
|
||||
onLeaderSync: handleLeaderSync,
|
||||
})
|
||||
|
||||
if (isStartTab)
|
||||
return <StartTabContent />
|
||||
|
||||
if (!fileTabId) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
||||
<span className="system-sm-regular">
|
||||
{t('skillSidebar.empty')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fileNodeViewState === 'resolving') {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fileNodeViewState === 'missing') {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
||||
<span className="system-sm-regular">
|
||||
{t('skillSidebar.loadError')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
||||
<span className="system-sm-regular">
|
||||
{t('skillSidebar.loadError')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For non-editable files (media, sqlite, unsupported), use download URL
|
||||
const downloadUrl = downloadUrlData?.download_url || ''
|
||||
const fileName = currentFileNode?.name || ''
|
||||
const fileSize = currentFileNode?.size
|
||||
const isUnsupportedFile = !isPreviewable
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-components-panel-bg">
|
||||
{isMarkdown
|
||||
? (
|
||||
<MarkdownFileEditor
|
||||
key={fileTabId}
|
||||
instanceId={fileTabId || undefined}
|
||||
value={currentContent}
|
||||
onChange={handleMarkdownCollaborativeChange}
|
||||
autoFocus={shouldAutoFocusEditor}
|
||||
onAutoFocus={handleEditorAutoFocus}
|
||||
collaborationEnabled={canInitCollaboration}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{isCodeOrText
|
||||
? (
|
||||
<CodeFileEditor
|
||||
key={fileTabId}
|
||||
language={language}
|
||||
theme={isMounted ? theme : 'default-theme'}
|
||||
value={currentContent}
|
||||
onChange={handleCodeCollaborativeChange}
|
||||
onMount={handleEditorDidMount}
|
||||
autoFocus={shouldAutoFocusEditor}
|
||||
onAutoFocus={handleEditorAutoFocus}
|
||||
fileId={fileTabId}
|
||||
collaborationEnabled={canInitCollaboration}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{isImage || isVideo
|
||||
? (
|
||||
<MediaFilePreview
|
||||
type={isImage ? 'image' : 'video'}
|
||||
src={downloadUrl}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{isSQLite
|
||||
? (
|
||||
<SQLiteFilePreview
|
||||
key={fileTabId}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{isPdf
|
||||
? (
|
||||
<PdfFilePreview
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{isUnsupportedFile
|
||||
? (
|
||||
<UnsupportedFileDownload
|
||||
name={fileName}
|
||||
size={fileSize}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileContentPanel)
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { OnMount } from '@monaco-editor/react'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { Theme } from '@/types/app'
|
||||
import { START_TAB_ID } from '../../constants'
|
||||
import FileContentPanel from './file-content-panel'
|
||||
import FileContentPanel from '..'
|
||||
import { START_TAB_ID } from '../../../../constants'
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail: {
|
||||
@@ -62,10 +63,14 @@ type UseSkillFileDataMode = 'none' | 'content' | 'download'
|
||||
|
||||
type UseSkillMarkdownCollaborationArgs = {
|
||||
onLocalChange: (value: string) => void
|
||||
onRemoteChange?: (value: string) => void
|
||||
onLeaderSync: () => void
|
||||
}
|
||||
|
||||
type UseSkillCodeCollaborationArgs = {
|
||||
onLocalChange: (value: string) => void
|
||||
onLocalChange: (value: string | undefined) => void
|
||||
onRemoteChange?: (value: string) => void
|
||||
onLeaderSync: () => void
|
||||
}
|
||||
|
||||
const FILE_REFERENCE_ID = '123e4567-e89b-12d3-a456-426614174000'
|
||||
@@ -191,9 +196,23 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => {
|
||||
return ({ downloadUrl }: { downloadUrl: string }) => (
|
||||
<div data-testid="dynamic-preview">{downloadUrl}</div>
|
||||
default: (
|
||||
loader: () => Promise<unknown>,
|
||||
options?: { loading?: () => React.ReactNode },
|
||||
) => {
|
||||
const LazyComponent = React.lazy(async (): Promise<{ default: React.ComponentType<Record<string, unknown>> }> => {
|
||||
const mod = await loader()
|
||||
if (typeof mod === 'function')
|
||||
return { default: mod as React.ComponentType<Record<string, unknown>> }
|
||||
if (mod && typeof mod === 'object' && 'default' in mod)
|
||||
return mod as { default: React.ComponentType<Record<string, unknown>> }
|
||||
return { default: () => null }
|
||||
})
|
||||
|
||||
return (props: Record<string, unknown>) => (
|
||||
<React.Suspense fallback={options?.loading ? options.loading() : null}>
|
||||
<LazyComponent {...props} />
|
||||
</React.Suspense>
|
||||
)
|
||||
},
|
||||
}))
|
||||
@@ -213,7 +232,7 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: mocks.appTheme }),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
vi.mock('../../../../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
useSkillAssetNodeMap: () => ({
|
||||
data: mocks.nodeMapData,
|
||||
isLoading: mocks.nodeMapStatus.isLoading,
|
||||
@@ -222,22 +241,22 @@ vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/use-file-node-view-state', () => ({
|
||||
vi.mock('../../../../hooks/use-file-node-view-state', () => ({
|
||||
useFileNodeViewState: () => mocks.fileNodeViewState,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/use-file-type-info', () => ({
|
||||
vi.mock('../../../../hooks/use-file-type-info', () => ({
|
||||
useFileTypeInfo: () => mocks.fileTypeInfo,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/use-skill-file-data', () => ({
|
||||
vi.mock('../../../../hooks/use-skill-file-data', () => ({
|
||||
useSkillFileData: (appId: string, fileId: string | null, mode: UseSkillFileDataMode) => {
|
||||
mocks.useSkillFileData(appId, fileId, mode)
|
||||
return mocks.fileData
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/skill-save-context', () => ({
|
||||
vi.mock('../../../../hooks/skill-save-context', () => ({
|
||||
useSkillSaveManager: () => ({
|
||||
saveFile: mocks.saveFile,
|
||||
registerFallback: mocks.registerFallback,
|
||||
@@ -245,7 +264,7 @@ vi.mock('../../hooks/skill-save-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../collaboration/skills/use-skill-markdown-collaboration', () => ({
|
||||
vi.mock('../../../../../collaboration/skills/use-skill-markdown-collaboration', () => ({
|
||||
useSkillMarkdownCollaboration: (args: UseSkillMarkdownCollaborationArgs) => {
|
||||
mocks.useSkillMarkdownCollaboration(args)
|
||||
return {
|
||||
@@ -254,7 +273,7 @@ vi.mock('../../../collaboration/skills/use-skill-markdown-collaboration', () =>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../collaboration/skills/use-skill-code-collaboration', () => ({
|
||||
vi.mock('../../../../../collaboration/skills/use-skill-code-collaboration', () => ({
|
||||
useSkillCodeCollaboration: (args: UseSkillCodeCollaborationArgs) => {
|
||||
mocks.useSkillCodeCollaboration(args)
|
||||
return {
|
||||
@@ -263,11 +282,11 @@ vi.mock('../../../collaboration/skills/use-skill-code-collaboration', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../start-tab', () => ({
|
||||
vi.mock('../../../../start-tab', () => ({
|
||||
default: () => <div data-testid="start-tab-content" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../editor/markdown-file-editor', () => ({
|
||||
vi.mock('../../../../editor/markdown-file-editor', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -298,7 +317,7 @@ vi.mock('../../editor/markdown-file-editor', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../editor/code-file-editor', () => ({
|
||||
vi.mock('../../../../editor/code-file-editor', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -344,20 +363,20 @@ vi.mock('../../editor/code-file-editor', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../viewer/media-file-preview', () => ({
|
||||
vi.mock('../../../../viewer/media-file-preview', () => ({
|
||||
default: ({ type, src }: { type: 'image' | 'video', src: string }) => (
|
||||
<div data-testid="media-preview">{`${type}|${src}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../viewer/unsupported-file-download', () => ({
|
||||
vi.mock('../../../../viewer/unsupported-file-download', () => ({
|
||||
default: ({ name, size, downloadUrl }: { name: string, size?: number, downloadUrl: string }) => (
|
||||
<div data-testid="unsupported-preview">{`${name}|${String(size)}|${downloadUrl}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/file-utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../utils/file-utils')>('../../utils/file-utils')
|
||||
vi.mock('../../../../utils/file-utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../../utils/file-utils')>('../../../../utils/file-utils')
|
||||
return {
|
||||
...actual,
|
||||
getFileLanguage: (name: string) => mocks.getFileLanguage(name),
|
||||
@@ -479,6 +498,7 @@ describe('FileContentPanel', () => {
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
await screen.findByTestId('markdown-editor')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'markdown-change' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'markdown-autofocus' }))
|
||||
|
||||
@@ -497,7 +517,7 @@ describe('FileContentPanel', () => {
|
||||
expect(mocks.workflowActions.clearEditorAutoFocus).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should clear draft content when code editor value matches original content', () => {
|
||||
it('should clear draft content when code editor value matches original content', async () => {
|
||||
// Arrange
|
||||
mocks.fileData.fileContent = {
|
||||
content: '',
|
||||
@@ -506,6 +526,7 @@ describe('FileContentPanel', () => {
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
await screen.findByTestId('code-editor')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'code-clear' }))
|
||||
|
||||
// Assert
|
||||
@@ -520,6 +541,7 @@ describe('FileContentPanel', () => {
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
await screen.findByTestId('code-editor')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'code-mount' }))
|
||||
|
||||
// Assert
|
||||
@@ -542,7 +564,7 @@ describe('FileContentPanel', () => {
|
||||
expect(mocks.saveFile).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should ignore editor content updates when file is not editable', () => {
|
||||
it('should ignore editor content updates when file is not editable', async () => {
|
||||
// Arrange
|
||||
mocks.fileTypeInfo = {
|
||||
isMarkdown: false,
|
||||
@@ -557,6 +579,7 @@ describe('FileContentPanel', () => {
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
await screen.findByTestId('code-editor')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'code-change' }))
|
||||
|
||||
// Assert
|
||||
@@ -587,6 +610,78 @@ describe('FileContentPanel', () => {
|
||||
// Assert
|
||||
expect(mocks.saveFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should sync draft content and metadata when collaboration receives remote markdown changes', async () => {
|
||||
// Arrange
|
||||
mocks.fileTypeInfo = {
|
||||
isMarkdown: true,
|
||||
isCodeOrText: false,
|
||||
isImage: false,
|
||||
isVideo: false,
|
||||
isPdf: false,
|
||||
isSQLite: false,
|
||||
isEditable: true,
|
||||
isPreviewable: true,
|
||||
}
|
||||
mocks.workflowState.fileMetadata = new Map<string, Record<string, unknown>>([
|
||||
['file-1', {}],
|
||||
])
|
||||
mocks.nodeMapData = new Map<string, AppAssetTreeView>([
|
||||
['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
|
||||
[FILE_REFERENCE_ID, createNode({ id: FILE_REFERENCE_ID, name: 'kb.txt', extension: 'txt' })],
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
await screen.findByTestId('markdown-editor')
|
||||
const firstCall = mocks.useSkillMarkdownCollaboration.mock.calls[0]
|
||||
const args = firstCall?.[0] as UseSkillMarkdownCollaborationArgs | undefined
|
||||
args?.onRemoteChange?.(`linked §[file].[app].[${FILE_REFERENCE_ID}]§`)
|
||||
|
||||
// Assert
|
||||
expect(mocks.workflowActions.setDraftContent).toHaveBeenCalledWith(
|
||||
'file-1',
|
||||
`linked §[file].[app].[${FILE_REFERENCE_ID}]§`,
|
||||
)
|
||||
expect(mocks.workflowActions.setDraftMetadata).toHaveBeenCalledWith(
|
||||
'file-1',
|
||||
expect.objectContaining({
|
||||
files: expect.objectContaining({
|
||||
[FILE_REFERENCE_ID]: expect.objectContaining({ id: FILE_REFERENCE_ID }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mocks.workflowActions.pinTab).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should not pin the tab when remote collaboration sync matches the original content', async () => {
|
||||
// Arrange
|
||||
mocks.fileTypeInfo = {
|
||||
isMarkdown: true,
|
||||
isCodeOrText: false,
|
||||
isImage: false,
|
||||
isVideo: false,
|
||||
isPdf: false,
|
||||
isSQLite: false,
|
||||
isEditable: true,
|
||||
isPreviewable: true,
|
||||
}
|
||||
mocks.fileData.fileContent = {
|
||||
content: 'server-content',
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
await screen.findByTestId('markdown-editor')
|
||||
const firstCall = mocks.useSkillMarkdownCollaboration.mock.calls[0]
|
||||
const args = firstCall?.[0] as UseSkillMarkdownCollaborationArgs | undefined
|
||||
args?.onRemoteChange?.('server-content')
|
||||
|
||||
// Assert
|
||||
expect(mocks.workflowActions.clearDraftContent).toHaveBeenCalledWith('file-1')
|
||||
expect(mocks.workflowActions.pinTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Preview modes', () => {
|
||||
@@ -678,6 +773,25 @@ describe('FileContentPanel', () => {
|
||||
expect(mocks.workflowActions.clearDraftMetadata).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should clear stale metadata when loaded file content has no metadata field', async () => {
|
||||
// Arrange
|
||||
mocks.workflowState.fileMetadata = new Map<string, Record<string, unknown>>([
|
||||
['file-1', { source: 'stale' }],
|
||||
])
|
||||
mocks.fileData.fileContent = {
|
||||
content: 'markdown',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mocks.workflowActions.setFileMetadata).toHaveBeenCalledWith('file-1', {})
|
||||
})
|
||||
expect(mocks.workflowActions.clearDraftMetadata).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should skip metadata sync when current metadata is marked dirty', async () => {
|
||||
// Arrange
|
||||
mocks.workflowState.dirtyMetadataIds = new Set(['file-1'])
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import type { OnMount } from '@monaco-editor/react'
|
||||
import type { FileEditorState } from './types'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import CodeFileEditor from '../../../editor/code-file-editor'
|
||||
import MarkdownFileEditor from '../../../editor/markdown-file-editor'
|
||||
import { getFileLanguage } from '../../../utils/file-utils'
|
||||
|
||||
if (typeof window !== 'undefined')
|
||||
loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } })
|
||||
|
||||
type FileEditorRendererProps = {
|
||||
state: FileEditorState
|
||||
}
|
||||
|
||||
const FileEditorRenderer = ({ state }: FileEditorRendererProps) => {
|
||||
const { theme: appTheme } = useTheme()
|
||||
const [isMonacoMounted, setIsMonacoMounted] = useState(false)
|
||||
|
||||
const handleEditorDidMount: OnMount = useCallback((_editor, monaco) => {
|
||||
monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark')
|
||||
setIsMonacoMounted(true)
|
||||
}, [appTheme])
|
||||
|
||||
if (state.editor === 'markdown') {
|
||||
return (
|
||||
<MarkdownFileEditor
|
||||
key={state.fileTabId}
|
||||
instanceId={state.fileTabId}
|
||||
value={state.content}
|
||||
onChange={state.onChange}
|
||||
autoFocus={state.autoFocus}
|
||||
onAutoFocus={state.onAutoFocus}
|
||||
collaborationEnabled={state.collaborationEnabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeFileEditor
|
||||
key={state.fileTabId}
|
||||
language={getFileLanguage(state.fileName)}
|
||||
theme={isMonacoMounted
|
||||
? appTheme === Theme.light ? 'light' : 'vs-dark'
|
||||
: 'default-theme'}
|
||||
value={state.content}
|
||||
onChange={state.onChange}
|
||||
onMount={handleEditorDidMount}
|
||||
autoFocus={state.autoFocus}
|
||||
onAutoFocus={state.onAutoFocus}
|
||||
fileId={state.fileTabId}
|
||||
collaborationEnabled={state.collaborationEnabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileEditorRenderer)
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { FilePreviewState } from './types'
|
||||
import * as React from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import MediaFilePreview from '../../../viewer/media-file-preview'
|
||||
import UnsupportedFileDownload from '../../../viewer/unsupported-file-download'
|
||||
|
||||
const SQLiteFilePreview = dynamic(
|
||||
() => import('../../../viewer/sqlite-file-preview'),
|
||||
{ ssr: false, loading: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
const PdfFilePreview = dynamic(
|
||||
() => import('../../../viewer/pdf-file-preview'),
|
||||
{ ssr: false, loading: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
type FilePreviewRendererProps = {
|
||||
state: FilePreviewState
|
||||
}
|
||||
|
||||
const FilePreviewRenderer = ({ state }: FilePreviewRendererProps) => {
|
||||
if (state.preview === 'media') {
|
||||
return (
|
||||
<MediaFilePreview
|
||||
type={state.mediaType}
|
||||
src={state.downloadUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.preview === 'sqlite') {
|
||||
return (
|
||||
<SQLiteFilePreview
|
||||
key={state.fileTabId}
|
||||
downloadUrl={state.downloadUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.preview === 'pdf')
|
||||
return <PdfFilePreview downloadUrl={state.downloadUrl} />
|
||||
|
||||
return (
|
||||
<UnsupportedFileDownload
|
||||
name={state.fileName}
|
||||
size={state.fileSize}
|
||||
downloadUrl={state.downloadUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FilePreviewRenderer)
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import StartTabContent from '../../../start-tab'
|
||||
import FilePreviewRenderer from './file-preview-renderer'
|
||||
import { useFileContentController } from './use-file-content-controller'
|
||||
|
||||
const FileEditorRenderer = dynamic(
|
||||
() => import('./file-editor-renderer'),
|
||||
{ ssr: false, loading: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
type CenteredPanelProps = {
|
||||
children: React.ReactNode
|
||||
muted?: boolean
|
||||
}
|
||||
|
||||
const CenteredPanel = ({ children, muted = false }: CenteredPanelProps) => (
|
||||
<div className={muted
|
||||
? 'flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary'
|
||||
: 'flex h-full w-full items-center justify-center bg-components-panel-bg'}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const FileContentPanel = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const state = useFileContentController()
|
||||
|
||||
if (state.kind === 'start')
|
||||
return <StartTabContent />
|
||||
|
||||
if (state.kind === 'empty') {
|
||||
return (
|
||||
<CenteredPanel muted>
|
||||
<span className="system-sm-regular">
|
||||
{t('skillSidebar.empty')}
|
||||
</span>
|
||||
</CenteredPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'resolving' || state.kind === 'loading') {
|
||||
return (
|
||||
<CenteredPanel>
|
||||
<Loading type="area" />
|
||||
</CenteredPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'missing' || state.kind === 'error') {
|
||||
return (
|
||||
<CenteredPanel muted>
|
||||
<span className="system-sm-regular">
|
||||
{t('skillSidebar.loadError')}
|
||||
</span>
|
||||
</CenteredPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'editor') {
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-components-panel-bg">
|
||||
<FileEditorRenderer state={state} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind !== 'preview')
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-components-panel-bg">
|
||||
<FilePreviewRenderer state={state} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileContentPanel)
|
||||
@@ -0,0 +1,62 @@
|
||||
type FileContentStatusState = {
|
||||
kind: 'start' | 'empty' | 'resolving' | 'missing' | 'loading' | 'error'
|
||||
}
|
||||
|
||||
type EditorStateBase = {
|
||||
kind: 'editor'
|
||||
fileTabId: string
|
||||
fileName: string
|
||||
content: string
|
||||
autoFocus: boolean
|
||||
collaborationEnabled: boolean
|
||||
onAutoFocus: () => void
|
||||
}
|
||||
|
||||
export type MarkdownEditorState = EditorStateBase & {
|
||||
editor: 'markdown'
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export type CodeEditorState = EditorStateBase & {
|
||||
editor: 'code'
|
||||
onChange: (value: string | undefined) => void
|
||||
}
|
||||
|
||||
export type FileEditorState = MarkdownEditorState | CodeEditorState
|
||||
|
||||
type MediaPreviewState = {
|
||||
kind: 'preview'
|
||||
preview: 'media'
|
||||
mediaType: 'image' | 'video'
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
type SQLitePreviewState = {
|
||||
kind: 'preview'
|
||||
preview: 'sqlite'
|
||||
fileTabId: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
type PdfPreviewState = {
|
||||
kind: 'preview'
|
||||
preview: 'pdf'
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
type UnsupportedPreviewState = {
|
||||
kind: 'preview'
|
||||
preview: 'unsupported'
|
||||
fileName: string
|
||||
fileSize?: number
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
export type FilePreviewState = MediaPreviewState
|
||||
| SQLitePreviewState
|
||||
| PdfPreviewState
|
||||
| UnsupportedPreviewState
|
||||
|
||||
export type FileContentControllerState = FileContentStatusState
|
||||
| FileEditorState
|
||||
| FilePreviewState
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { SkillFileDataMode } from '../../../hooks/use-skill-file-data'
|
||||
import type { FileContentControllerState } from './types'
|
||||
import type { SkillFileMetadata } from './utils'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
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 { useSkillAssetNodeMap } from '../../../hooks/file-tree/data/use-skill-asset-tree'
|
||||
import { useSkillSaveManager } from '../../../hooks/skill-save-context'
|
||||
import { useFileNodeViewState as useFileNodeStatus } from '../../../hooks/use-file-node-view-state'
|
||||
import { useFileTypeInfo } from '../../../hooks/use-file-type-info'
|
||||
import { useSkillFileData } from '../../../hooks/use-skill-file-data'
|
||||
import { useFileFallbackLifecycle } from './use-file-fallback-lifecycle'
|
||||
import { useFileMetadataSync } from './use-file-metadata-sync'
|
||||
import { extractFileReferenceIds } from './utils'
|
||||
|
||||
export const useFileContentController = (): FileContentControllerState => {
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const editorAutoFocusFileId = useStore(s => s.editorAutoFocusFileId)
|
||||
const storeApi = useWorkflowStore()
|
||||
const {
|
||||
data: nodeMap,
|
||||
isLoading: isNodeMapLoading,
|
||||
isFetching: isNodeMapFetching,
|
||||
isFetched: isNodeMapFetched,
|
||||
} = useSkillAssetNodeMap()
|
||||
|
||||
const isStartTab = activeTabId === START_TAB_ID
|
||||
const fileTabId = isStartTab ? null : activeTabId
|
||||
|
||||
const draftContent = useStore(s => fileTabId ? s.dirtyContents.get(fileTabId) : undefined)
|
||||
const currentMetadata = useStore(s => fileTabId ? s.fileMetadata.get(fileTabId) : undefined)
|
||||
const isMetadataDirty = useStore(s => fileTabId ? s.dirtyMetadataIds.has(fileTabId) : false)
|
||||
|
||||
const currentFileNode = fileTabId ? nodeMap?.get(fileTabId) : undefined
|
||||
const shouldAutoFocusEditor = Boolean(fileTabId && editorAutoFocusFileId === fileTabId)
|
||||
const nodeViewStatus = useFileNodeStatus({
|
||||
fileTabId,
|
||||
hasCurrentFileNode: Boolean(currentFileNode),
|
||||
isNodeMapLoading,
|
||||
isNodeMapFetching,
|
||||
isNodeMapFetched,
|
||||
})
|
||||
const isNodeReady = nodeViewStatus === 'ready'
|
||||
const {
|
||||
isMarkdown,
|
||||
isCodeOrText,
|
||||
isImage,
|
||||
isVideo,
|
||||
isPdf,
|
||||
isSQLite,
|
||||
isEditable,
|
||||
} = useFileTypeInfo(isNodeReady ? currentFileNode : undefined)
|
||||
const fileDataMode: SkillFileDataMode = !fileTabId || !isNodeReady
|
||||
? 'none'
|
||||
: isEditable
|
||||
? 'content'
|
||||
: 'download'
|
||||
|
||||
const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, fileTabId, fileDataMode)
|
||||
const originalContent = fileContent?.content ?? ''
|
||||
const currentContent = draftContent !== undefined ? draftContent : originalContent
|
||||
const initialContentRegistryRef = useRef<Map<string, string>>(new Map())
|
||||
const canInitCollaboration = Boolean(appId && fileTabId && isEditable && !isLoading && !error)
|
||||
|
||||
if (canInitCollaboration && fileTabId && !initialContentRegistryRef.current.has(fileTabId))
|
||||
initialContentRegistryRef.current.set(fileTabId, currentContent)
|
||||
|
||||
const initialCollaborativeContent = fileTabId
|
||||
? (initialContentRegistryRef.current.get(fileTabId) ?? currentContent)
|
||||
: ''
|
||||
|
||||
useFileMetadataSync({
|
||||
fileTabId,
|
||||
hasLoadedContent: fileContent !== undefined,
|
||||
metadataSource: fileContent?.metadata,
|
||||
isMetadataDirty,
|
||||
storeApi,
|
||||
})
|
||||
|
||||
const updateFileReferenceMetadata = useCallback((content: string) => {
|
||||
if (!fileTabId)
|
||||
return
|
||||
|
||||
const referenceIds = extractFileReferenceIds(content)
|
||||
const metadata = (currentMetadata || {}) as SkillFileMetadata
|
||||
const existingFiles = metadata.files || {}
|
||||
const nextFiles: Record<string, AppAssetTreeView> = {}
|
||||
|
||||
referenceIds.forEach((id) => {
|
||||
const node = nodeMap?.get(id)
|
||||
if (node)
|
||||
nextFiles[id] = node
|
||||
else if (existingFiles[id])
|
||||
nextFiles[id] = existingFiles[id]
|
||||
})
|
||||
|
||||
const nextMetadata: SkillFileMetadata = { ...metadata }
|
||||
if (Object.keys(nextFiles).length > 0)
|
||||
nextMetadata.files = nextFiles
|
||||
else if ('files' in nextMetadata)
|
||||
delete nextMetadata.files
|
||||
|
||||
if (isDeepEqual(metadata, nextMetadata))
|
||||
return
|
||||
|
||||
storeApi.getState().setDraftMetadata(fileTabId, nextMetadata)
|
||||
}, [currentMetadata, fileTabId, nodeMap, storeApi])
|
||||
|
||||
const applyContentChange = useCallback((
|
||||
value: string | undefined,
|
||||
options?: {
|
||||
pinWhenContentMatchesOriginal?: boolean
|
||||
},
|
||||
) => {
|
||||
if (!fileTabId || !isEditable)
|
||||
return
|
||||
|
||||
const nextValue = value ?? ''
|
||||
const state = storeApi.getState()
|
||||
|
||||
if (nextValue === originalContent)
|
||||
state.clearDraftContent(fileTabId)
|
||||
else
|
||||
state.setDraftContent(fileTabId, nextValue)
|
||||
|
||||
updateFileReferenceMetadata(nextValue)
|
||||
if (nextValue !== originalContent || options?.pinWhenContentMatchesOriginal)
|
||||
state.pinTab(fileTabId)
|
||||
}, [fileTabId, isEditable, originalContent, storeApi, updateFileReferenceMetadata])
|
||||
|
||||
const handleLocalContentChange = useCallback((value: string | undefined) => {
|
||||
applyContentChange(value, { pinWhenContentMatchesOriginal: true })
|
||||
}, [applyContentChange])
|
||||
|
||||
const handleRemoteContentChange = useCallback((value: string) => {
|
||||
applyContentChange(value)
|
||||
}, [applyContentChange])
|
||||
|
||||
const { saveFile, registerFallback, unregisterFallback } = useSkillSaveManager()
|
||||
const handleLeaderSync = useCallback(() => {
|
||||
if (!fileTabId || !isEditable)
|
||||
return
|
||||
void saveFile(fileTabId)
|
||||
}, [fileTabId, isEditable, saveFile])
|
||||
|
||||
useFileFallbackLifecycle({
|
||||
fileTabId,
|
||||
isEditable,
|
||||
hasLoadedContent: fileContent?.content !== undefined,
|
||||
originalContent,
|
||||
currentMetadata,
|
||||
saveFile,
|
||||
registerFallback,
|
||||
unregisterFallback,
|
||||
})
|
||||
|
||||
const handleEditorAutoFocus = useCallback(() => {
|
||||
if (!fileTabId)
|
||||
return
|
||||
storeApi.getState().clearEditorAutoFocus(fileTabId)
|
||||
}, [fileTabId, storeApi])
|
||||
|
||||
const { handleCollaborativeChange: handleMarkdownCollaborativeChange } = useSkillMarkdownCollaboration({
|
||||
appId,
|
||||
fileId: fileTabId,
|
||||
enabled: canInitCollaboration && isMarkdown,
|
||||
initialContent: initialCollaborativeContent,
|
||||
baselineContent: originalContent,
|
||||
onLocalChange: handleLocalContentChange,
|
||||
onRemoteChange: handleRemoteContentChange,
|
||||
onLeaderSync: handleLeaderSync,
|
||||
})
|
||||
const { handleCollaborativeChange: handleCodeCollaborativeChange } = useSkillCodeCollaboration({
|
||||
appId,
|
||||
fileId: fileTabId,
|
||||
enabled: canInitCollaboration && isCodeOrText,
|
||||
initialContent: initialCollaborativeContent,
|
||||
baselineContent: originalContent,
|
||||
onLocalChange: handleLocalContentChange,
|
||||
onRemoteChange: handleRemoteContentChange,
|
||||
onLeaderSync: handleLeaderSync,
|
||||
})
|
||||
|
||||
if (isStartTab)
|
||||
return { kind: 'start' }
|
||||
|
||||
if (!fileTabId)
|
||||
return { kind: 'empty' }
|
||||
|
||||
if (nodeViewStatus === 'resolving')
|
||||
return { kind: 'resolving' }
|
||||
|
||||
if (nodeViewStatus === 'missing')
|
||||
return { kind: 'missing' }
|
||||
|
||||
if (isLoading)
|
||||
return { kind: 'loading' }
|
||||
|
||||
if (error)
|
||||
return { kind: 'error' }
|
||||
|
||||
const downloadUrl = downloadUrlData?.download_url || ''
|
||||
const fileName = currentFileNode?.name || ''
|
||||
const fileSize = currentFileNode?.size
|
||||
|
||||
if (isMarkdown) {
|
||||
return {
|
||||
kind: 'editor',
|
||||
editor: 'markdown',
|
||||
fileTabId,
|
||||
fileName,
|
||||
content: currentContent,
|
||||
onChange: handleMarkdownCollaborativeChange,
|
||||
autoFocus: shouldAutoFocusEditor,
|
||||
onAutoFocus: handleEditorAutoFocus,
|
||||
collaborationEnabled: canInitCollaboration,
|
||||
}
|
||||
}
|
||||
|
||||
if (isCodeOrText) {
|
||||
return {
|
||||
kind: 'editor',
|
||||
editor: 'code',
|
||||
fileTabId,
|
||||
fileName,
|
||||
content: currentContent,
|
||||
onChange: handleCodeCollaborativeChange,
|
||||
autoFocus: shouldAutoFocusEditor,
|
||||
onAutoFocus: handleEditorAutoFocus,
|
||||
collaborationEnabled: canInitCollaboration,
|
||||
}
|
||||
}
|
||||
|
||||
if (isImage || isVideo) {
|
||||
return {
|
||||
kind: 'preview',
|
||||
preview: 'media',
|
||||
mediaType: isImage ? 'image' : 'video',
|
||||
downloadUrl,
|
||||
}
|
||||
}
|
||||
|
||||
if (isSQLite) {
|
||||
return {
|
||||
kind: 'preview',
|
||||
preview: 'sqlite',
|
||||
fileTabId,
|
||||
downloadUrl,
|
||||
}
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
return {
|
||||
kind: 'preview',
|
||||
preview: 'pdf',
|
||||
downloadUrl,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'preview',
|
||||
preview: 'unsupported',
|
||||
fileName,
|
||||
fileSize,
|
||||
downloadUrl,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { SaveFileOptions, SaveResult } from '../../../hooks/use-skill-save-manager'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type UseFileFallbackLifecycleProps = {
|
||||
fileTabId: string | null
|
||||
isEditable: boolean
|
||||
hasLoadedContent: boolean
|
||||
originalContent: string
|
||||
currentMetadata: Record<string, unknown> | undefined
|
||||
saveFile: (fileId: string, options?: SaveFileOptions) => Promise<SaveResult>
|
||||
registerFallback: (fileId: string, entry: { content: string, metadata?: Record<string, unknown> }) => void
|
||||
unregisterFallback: (fileId: string) => void
|
||||
}
|
||||
|
||||
export const useFileFallbackLifecycle = ({
|
||||
fileTabId,
|
||||
isEditable,
|
||||
hasLoadedContent,
|
||||
originalContent,
|
||||
currentMetadata,
|
||||
saveFile,
|
||||
registerFallback,
|
||||
unregisterFallback,
|
||||
}: UseFileFallbackLifecycleProps) => {
|
||||
const saveFileRef = useRef(saveFile)
|
||||
saveFileRef.current = saveFile
|
||||
|
||||
const fallbackRef = useRef({
|
||||
content: originalContent,
|
||||
metadata: currentMetadata,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileTabId || !hasLoadedContent)
|
||||
return
|
||||
|
||||
const fallback = {
|
||||
content: originalContent,
|
||||
metadata: currentMetadata,
|
||||
}
|
||||
|
||||
fallbackRef.current = fallback
|
||||
registerFallback(fileTabId, fallback)
|
||||
|
||||
return () => {
|
||||
unregisterFallback(fileTabId)
|
||||
}
|
||||
}, [
|
||||
currentMetadata,
|
||||
fileTabId,
|
||||
hasLoadedContent,
|
||||
originalContent,
|
||||
registerFallback,
|
||||
unregisterFallback,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileTabId || !isEditable)
|
||||
return
|
||||
|
||||
return () => {
|
||||
const { content: fallbackContent, metadata: fallbackMetadata } = fallbackRef.current
|
||||
void saveFileRef.current(fileTabId, {
|
||||
fallbackContent,
|
||||
fallbackMetadata,
|
||||
})
|
||||
}
|
||||
}, [fileTabId, isEditable])
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useEffect } from 'react'
|
||||
import { parseSkillFileMetadata } from './utils'
|
||||
|
||||
type UseFileMetadataSyncProps = {
|
||||
fileTabId: string | null
|
||||
hasLoadedContent: boolean
|
||||
metadataSource: Record<string, unknown> | string | undefined
|
||||
isMetadataDirty: boolean
|
||||
storeApi: ReturnType<typeof useWorkflowStore>
|
||||
}
|
||||
|
||||
export const useFileMetadataSync = ({
|
||||
fileTabId,
|
||||
hasLoadedContent,
|
||||
metadataSource,
|
||||
isMetadataDirty,
|
||||
storeApi,
|
||||
}: UseFileMetadataSyncProps) => {
|
||||
useEffect(() => {
|
||||
if (!fileTabId || !hasLoadedContent || isMetadataDirty)
|
||||
return
|
||||
|
||||
const { setFileMetadata, clearDraftMetadata } = storeApi.getState()
|
||||
setFileMetadata(fileTabId, parseSkillFileMetadata(metadataSource))
|
||||
clearDraftMetadata(fileTabId)
|
||||
}, [fileTabId, hasLoadedContent, isMetadataDirty, metadataSource, storeApi])
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
|
||||
export type SkillFileMetadata = {
|
||||
files?: Record<string, AppAssetTreeView>
|
||||
}
|
||||
|
||||
export const extractFileReferenceIds = (content: string) => {
|
||||
const ids = new Set<string>()
|
||||
const regex = /§\[file\]\.\[app\]\.\[([a-fA-F0-9-]{36})\]§/g
|
||||
let match: RegExpExecArray | null
|
||||
match = regex.exec(content)
|
||||
while (match !== null) {
|
||||
if (match[1])
|
||||
ids.add(match[1])
|
||||
match = regex.exec(content)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export const parseSkillFileMetadata = (metadata: unknown): Record<string, unknown> => {
|
||||
if (!metadata)
|
||||
return {}
|
||||
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata) as unknown
|
||||
return typeof parsed === 'object' && parsed ? parsed as Record<string, unknown> : {}
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
return typeof metadata === 'object' ? metadata as Record<string, unknown> : {}
|
||||
}
|
||||
Reference in New Issue
Block a user