refactor(skill): split file content panel architecture

This commit is contained in:
yyh
2026-03-27 13:59:07 +08:00
parent 86bfbfc51a
commit 517f42ec3f
12 changed files with 857 additions and 416 deletions

View File

@@ -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 ?? ''

View File

@@ -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 ?? ''

View File

@@ -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)

View File

@@ -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'])

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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])
}

View File

@@ -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])
}

View File

@@ -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> : {}
}