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:
yyh
2026-01-23 13:12:22 +08:00
parent 693a9c5b95
commit 98d1aac765
10 changed files with 141 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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