mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
feat(skill-editor): add persistent Start tab and optimize store subscriptions
- Add START_TAB_ID constant and StartTabItem/StartTabContent components - Default to Start tab when no file tabs are open - Optimize zustand selectors to subscribe to specific Map values instead of entire Map objects, reducing unnecessary re-renders when other tabs change - Refactor useSkillFileSave to accept precise values instead of Map/Set
This commit is contained in:
@ -5,6 +5,9 @@
|
||||
// Root folder identifier (convert to null for API calls via toApiParentId)
|
||||
export const ROOT_ID = 'root' as const
|
||||
|
||||
// Start tab identifier - a special tab that is always present
|
||||
export const START_TAB_ID = '__start__' as const
|
||||
|
||||
// Drag type identifier for internal tree node dragging
|
||||
export const INTERNAL_NODE_DRAG_TYPE = 'application/x-dify-tree-node'
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import type { FC } from 'react'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } 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'
|
||||
@ -13,12 +13,14 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { START_TAB_ID } from './constants'
|
||||
import CodeFileEditor from './editor/code-file-editor'
|
||||
import MarkdownFileEditor from './editor/markdown-file-editor'
|
||||
import { useFileTypeInfo } from './hooks/use-file-type-info'
|
||||
import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
|
||||
import { useSkillFileData } from './hooks/use-skill-file-data'
|
||||
import { useSkillFileSave } from './hooks/use-skill-file-save'
|
||||
import StartTabContent from './start-tab-content'
|
||||
import { getFileLanguage } from './utils/file-utils'
|
||||
import MediaFilePreview from './viewer/media-file-preview'
|
||||
import UnsupportedFileDownload from './viewer/unsupported-file-download'
|
||||
@ -41,39 +43,29 @@ const FileContentPanel: FC = () => {
|
||||
const appId = appDetail?.id || ''
|
||||
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const dirtyContents = useStore(s => s.dirtyContents)
|
||||
const dirtyMetadataIds = useStore(s => s.dirtyMetadataIds)
|
||||
const fileMetadata = useStore(s => s.fileMetadata)
|
||||
const storeApi = useWorkflowStore()
|
||||
const { data: nodeMap } = useSkillAssetNodeMap()
|
||||
|
||||
const currentFileNode = activeTabId ? nodeMap?.get(activeTabId) : undefined
|
||||
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 { isMarkdown, isCodeOrText, isImage, isVideo, isSQLite, isEditable } = useFileTypeInfo(currentFileNode)
|
||||
|
||||
const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, activeTabId, isEditable)
|
||||
const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, fileTabId, isEditable)
|
||||
|
||||
const originalContent = fileContent?.content ?? ''
|
||||
|
||||
const currentContent = useMemo(() => {
|
||||
if (!activeTabId)
|
||||
return ''
|
||||
const draft = dirtyContents.get(activeTabId)
|
||||
if (draft !== undefined)
|
||||
return draft
|
||||
return originalContent
|
||||
}, [activeTabId, dirtyContents, originalContent])
|
||||
|
||||
const currentMetadata = useMemo(() => {
|
||||
if (!activeTabId)
|
||||
return undefined
|
||||
return fileMetadata.get(activeTabId)
|
||||
}, [activeTabId, fileMetadata])
|
||||
const currentContent = draftContent !== undefined ? draftContent : originalContent
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !fileContent)
|
||||
if (!fileTabId || !fileContent)
|
||||
return
|
||||
if (dirtyMetadataIds.has(activeTabId))
|
||||
if (isMetadataDirty)
|
||||
return
|
||||
let nextMetadata: Record<string, unknown> = {}
|
||||
if (fileContent.metadata) {
|
||||
@ -89,29 +81,29 @@ const FileContentPanel: FC = () => {
|
||||
nextMetadata = fileContent.metadata
|
||||
}
|
||||
}
|
||||
storeApi.getState().setFileMetadata(activeTabId, nextMetadata)
|
||||
storeApi.getState().clearDraftMetadata(activeTabId)
|
||||
}, [activeTabId, dirtyMetadataIds, fileContent, storeApi])
|
||||
storeApi.getState().setFileMetadata(fileTabId, nextMetadata)
|
||||
storeApi.getState().clearDraftMetadata(fileTabId)
|
||||
}, [fileTabId, isMetadataDirty, fileContent, storeApi])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (!activeTabId || !isEditable)
|
||||
if (!fileTabId || !isEditable)
|
||||
return
|
||||
const newValue = value ?? ''
|
||||
|
||||
if (newValue === originalContent)
|
||||
storeApi.getState().clearDraftContent(activeTabId)
|
||||
storeApi.getState().clearDraftContent(fileTabId)
|
||||
else
|
||||
storeApi.getState().setDraftContent(activeTabId, newValue)
|
||||
storeApi.getState().setDraftContent(fileTabId, newValue)
|
||||
|
||||
storeApi.getState().pinTab(activeTabId)
|
||||
}, [activeTabId, isEditable, originalContent, storeApi])
|
||||
storeApi.getState().pinTab(fileTabId)
|
||||
}, [fileTabId, isEditable, originalContent, storeApi])
|
||||
|
||||
useSkillFileSave({
|
||||
appId,
|
||||
activeTabId,
|
||||
activeTabId: fileTabId,
|
||||
isEditable,
|
||||
dirtyContents,
|
||||
dirtyMetadataIds,
|
||||
draftContent,
|
||||
isMetadataDirty,
|
||||
originalContent,
|
||||
currentMetadata,
|
||||
storeApi,
|
||||
@ -127,7 +119,10 @@ const FileContentPanel: FC = () => {
|
||||
const language = currentFileNode ? getFileLanguage(currentFileNode.name) : 'plaintext'
|
||||
const theme = appTheme === Theme.light ? 'light' : 'vs-dark'
|
||||
|
||||
if (!activeTabId) {
|
||||
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">
|
||||
@ -166,7 +161,7 @@ const FileContentPanel: FC = () => {
|
||||
{isMarkdown
|
||||
? (
|
||||
<MarkdownFileEditor
|
||||
key={activeTabId}
|
||||
key={fileTabId}
|
||||
value={currentContent}
|
||||
onChange={handleEditorChange}
|
||||
/>
|
||||
@ -175,7 +170,7 @@ const FileContentPanel: FC = () => {
|
||||
{isCodeOrText
|
||||
? (
|
||||
<CodeFileEditor
|
||||
key={activeTabId}
|
||||
key={fileTabId}
|
||||
language={language}
|
||||
theme={isMounted ? theme : 'default-theme'}
|
||||
value={currentContent}
|
||||
@ -195,7 +190,7 @@ const FileContentPanel: FC = () => {
|
||||
{isSQLite
|
||||
? (
|
||||
<SQLiteFilePreview
|
||||
key={activeTabId}
|
||||
key={fileTabId}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -7,8 +7,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { START_TAB_ID } from './constants'
|
||||
import FileTabItem from './file-tab-item'
|
||||
import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
|
||||
import StartTabItem from './start-tab-item'
|
||||
|
||||
const FileTabs: FC = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
@ -20,6 +22,12 @@ const FileTabs: FC = () => {
|
||||
const storeApi = useWorkflowStore()
|
||||
const { data: nodeMap } = useSkillAssetNodeMap()
|
||||
|
||||
const isStartTabActive = activeTabId === START_TAB_ID
|
||||
|
||||
const handleStartTabClick = useCallback(() => {
|
||||
storeApi.getState().activateTab(START_TAB_ID)
|
||||
}, [storeApi])
|
||||
|
||||
const [pendingCloseId, setPendingCloseId] = useState<string | null>(null)
|
||||
|
||||
const handleTabClick = useCallback((fileId: string) => {
|
||||
@ -55,9 +63,6 @@ const FileTabs: FC = () => {
|
||||
setPendingCloseId(null)
|
||||
}, [])
|
||||
|
||||
if (openTabIds.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -65,6 +70,10 @@ const FileTabs: FC = () => {
|
||||
'flex items-center overflow-hidden rounded-t-lg border-b border-components-panel-border-subtle bg-components-panel-bg-alt',
|
||||
)}
|
||||
>
|
||||
<StartTabItem
|
||||
isActive={isStartTabActive}
|
||||
onClick={handleStartTabClick}
|
||||
/>
|
||||
{openTabIds.map((fileId) => {
|
||||
const node = nodeMap?.get(fileId)
|
||||
const name = node?.name ?? fileId
|
||||
|
||||
@ -9,8 +9,8 @@ type UseSkillFileSaveParams = {
|
||||
appId: string
|
||||
activeTabId: string | null
|
||||
isEditable: boolean
|
||||
dirtyContents: Map<string, string>
|
||||
dirtyMetadataIds: Set<string>
|
||||
draftContent: string | undefined
|
||||
isMetadataDirty: boolean
|
||||
originalContent: string
|
||||
currentMetadata: Record<string, unknown> | undefined
|
||||
storeApi: StoreApi<Shape>
|
||||
@ -25,8 +25,8 @@ export function useSkillFileSave({
|
||||
appId,
|
||||
activeTabId,
|
||||
isEditable,
|
||||
dirtyContents,
|
||||
dirtyMetadataIds,
|
||||
draftContent,
|
||||
isMetadataDirty,
|
||||
originalContent,
|
||||
currentMetadata,
|
||||
storeApi,
|
||||
@ -38,9 +38,7 @@ export function useSkillFileSave({
|
||||
if (!activeTabId || !appId || !isEditable)
|
||||
return
|
||||
|
||||
const content = dirtyContents.get(activeTabId)
|
||||
const hasDirtyMetadata = dirtyMetadataIds.has(activeTabId)
|
||||
if (content === undefined && !hasDirtyMetadata)
|
||||
if (draftContent === undefined && !isMetadataDirty)
|
||||
return
|
||||
|
||||
try {
|
||||
@ -48,7 +46,7 @@ export function useSkillFileSave({
|
||||
appId,
|
||||
nodeId: activeTabId,
|
||||
payload: {
|
||||
content: content ?? originalContent,
|
||||
content: draftContent ?? originalContent,
|
||||
...(currentMetadata ? { metadata: currentMetadata } : {}),
|
||||
},
|
||||
})
|
||||
@ -65,7 +63,7 @@ export function useSkillFileSave({
|
||||
message: String(error),
|
||||
})
|
||||
}
|
||||
}, [activeTabId, appId, currentMetadata, dirtyContents, dirtyMetadataIds, isEditable, originalContent, storeApi, t, updateContent])
|
||||
}, [activeTabId, appId, currentMetadata, draftContent, isMetadataDirty, isEditable, originalContent, storeApi, t, updateContent])
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
|
||||
23
web/app/components/workflow/skill/start-tab-content.tsx
Normal file
23
web/app/components/workflow/skill/start-tab-content.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import Home from '@/app/components/base/icons/src/vender/workflow/Home'
|
||||
|
||||
// TODO: use translations
|
||||
const StartTabContent: FC = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-components-panel-bg">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-components-panel-bg-blur">
|
||||
<Home className="size-6 text-text-tertiary" />
|
||||
</div>
|
||||
<span className="system-sm-regular text-text-tertiary">
|
||||
Coming soon...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(StartTabContent)
|
||||
55
web/app/components/workflow/skill/start-tab-item.tsx
Normal file
55
web/app/components/workflow/skill/start-tab-item.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Home from '@/app/components/base/icons/src/vender/workflow/Home'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type StartTabItemProps = {
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const StartTabItem: FC<StartTabItemProps> = ({
|
||||
isActive,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex shrink-0 items-center border-r border-components-panel-border-subtle',
|
||||
isActive ? 'bg-components-panel-bg' : 'bg-transparent hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2.5 pb-2 pt-2.5',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex size-5 shrink-0 items-center justify-center">
|
||||
<Home className={cn(
|
||||
'size-4',
|
||||
isActive ? 'text-text-secondary' : 'text-text-tertiary',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[13px] font-medium uppercase leading-4',
|
||||
isActive ? 'text-text-primary' : 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{t('skillSidebar.startTab')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(StartTabItem)
|
||||
@ -1,5 +1,6 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { SkillEditorSliceShape } from './types'
|
||||
import { START_TAB_ID } from '@/app/components/workflow/skill/constants'
|
||||
import { createClipboardSlice } from './clipboard-slice'
|
||||
import { createDirtySlice } from './dirty-slice'
|
||||
import { createFileOperationsMenuSlice } from './file-operations-menu-slice'
|
||||
@ -27,7 +28,7 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
|
||||
const [set] = args
|
||||
set({
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
activeTabId: START_TAB_ID,
|
||||
previewTabId: null,
|
||||
expandedFolderIds: new Set<string>(),
|
||||
selectedTreeNodeId: null,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { OpenTabOptions, SkillEditorSliceShape, TabSliceShape } from './types'
|
||||
import { START_TAB_ID } from '@/app/components/workflow/skill/constants'
|
||||
|
||||
export type { OpenTabOptions, TabSliceShape } from './types'
|
||||
|
||||
@ -10,7 +11,7 @@ export const createTabSlice: StateCreator<
|
||||
TabSliceShape
|
||||
> = (set, get) => ({
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
activeTabId: START_TAB_ID,
|
||||
previewTabId: null,
|
||||
|
||||
openTab: (fileId: string, options?: OpenTabOptions) => {
|
||||
@ -54,7 +55,7 @@ export const createTabSlice: StateCreator<
|
||||
if (newOpenTabIds.length > 0)
|
||||
newActiveTabId = newOpenTabIds[Math.min(closedIndex, newOpenTabIds.length - 1)]
|
||||
else
|
||||
newActiveTabId = null
|
||||
newActiveTabId = START_TAB_ID
|
||||
}
|
||||
|
||||
let newPreviewTabId: string | null = null
|
||||
@ -70,7 +71,7 @@ export const createTabSlice: StateCreator<
|
||||
|
||||
activateTab: (fileId: string) => {
|
||||
const { openTabIds } = get()
|
||||
if (openTabIds.includes(fileId))
|
||||
if (fileId === START_TAB_ID || openTabIds.includes(fileId))
|
||||
set({ activeTabId: fileId })
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user