From 98d1aac7653f660b84c250b77e96108693d996df Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 23 Jan 2026 13:12:22 +0800 Subject: [PATCH] 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 --- .../components/workflow/skill/constants.ts | 3 + .../workflow/skill/file-content-panel.tsx | 71 +++++++++---------- .../components/workflow/skill/file-tabs.tsx | 15 +++- .../skill/hooks/use-skill-file-save.ts | 16 ++--- .../workflow/skill/start-tab-content.tsx | 23 ++++++ .../workflow/skill/start-tab-item.tsx | 55 ++++++++++++++ .../store/workflow/skill-editor/index.ts | 3 +- .../store/workflow/skill-editor/tab-slice.ts | 7 +- web/i18n/en-US/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + 10 files changed, 141 insertions(+), 54 deletions(-) create mode 100644 web/app/components/workflow/skill/start-tab-content.tsx create mode 100644 web/app/components/workflow/skill/start-tab-item.tsx diff --git a/web/app/components/workflow/skill/constants.ts b/web/app/components/workflow/skill/constants.ts index 0779b1d8fb..ccf6cc736b 100644 --- a/web/app/components/workflow/skill/constants.ts +++ b/web/app/components/workflow/skill/constants.ts @@ -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' diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index cb465090dc..6ffc65c675 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -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 = {} 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 + + if (!fileTabId) { return (
@@ -166,7 +161,7 @@ const FileContentPanel: FC = () => { {isMarkdown ? ( @@ -175,7 +170,7 @@ const FileContentPanel: FC = () => { {isCodeOrText ? ( { {isSQLite ? ( ) diff --git a/web/app/components/workflow/skill/file-tabs.tsx b/web/app/components/workflow/skill/file-tabs.tsx index c5de5ad784..116c8c3d31 100644 --- a/web/app/components/workflow/skill/file-tabs.tsx +++ b/web/app/components/workflow/skill/file-tabs.tsx @@ -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(null) const handleTabClick = useCallback((fileId: string) => { @@ -55,9 +63,6 @@ const FileTabs: FC = () => { setPendingCloseId(null) }, []) - if (openTabIds.length === 0) - return null - return ( <>
{ 'flex items-center overflow-hidden rounded-t-lg border-b border-components-panel-border-subtle bg-components-panel-bg-alt', )} > + {openTabIds.map((fileId) => { const node = nodeMap?.get(fileId) const name = node?.name ?? fileId diff --git a/web/app/components/workflow/skill/hooks/use-skill-file-save.ts b/web/app/components/workflow/skill/hooks/use-skill-file-save.ts index a6e1df2d2e..e72c61cc9e 100644 --- a/web/app/components/workflow/skill/hooks/use-skill-file-save.ts +++ b/web/app/components/workflow/skill/hooks/use-skill-file-save.ts @@ -9,8 +9,8 @@ type UseSkillFileSaveParams = { appId: string activeTabId: string | null isEditable: boolean - dirtyContents: Map - dirtyMetadataIds: Set + draftContent: string | undefined + isMetadataDirty: boolean originalContent: string currentMetadata: Record | undefined storeApi: StoreApi @@ -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 { diff --git a/web/app/components/workflow/skill/start-tab-content.tsx b/web/app/components/workflow/skill/start-tab-content.tsx new file mode 100644 index 0000000000..5b8ccf2291 --- /dev/null +++ b/web/app/components/workflow/skill/start-tab-content.tsx @@ -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 ( +
+
+
+ +
+ + Coming soon... + +
+
+ ) +} + +export default React.memo(StartTabContent) diff --git a/web/app/components/workflow/skill/start-tab-item.tsx b/web/app/components/workflow/skill/start-tab-item.tsx new file mode 100644 index 0000000000..8bfdce86c3 --- /dev/null +++ b/web/app/components/workflow/skill/start-tab-item.tsx @@ -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 = ({ + isActive, + onClick, +}) => { + const { t } = useTranslation('workflow') + + return ( +
+ +
+ ) +} + +export default React.memo(StartTabItem) diff --git a/web/app/components/workflow/store/workflow/skill-editor/index.ts b/web/app/components/workflow/store/workflow/skill-editor/index.ts index ddae1bfc8f..5a4d398319 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/index.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/index.ts @@ -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 = (...a const [set] = args set({ openTabIds: [], - activeTabId: null, + activeTabId: START_TAB_ID, previewTabId: null, expandedFolderIds: new Set(), selectedTreeNodeId: null, diff --git a/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts index 80514ad112..fc7f6fec0c 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts @@ -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 }) }, diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 4d44217e1e..9d84328957 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1097,6 +1097,7 @@ "skillSidebar.sqlitePreview.nullValue": "NULL", "skillSidebar.sqlitePreview.rowsTruncated": "Showing first {{limit}} rows. Additional rows are not displayed.", "skillSidebar.sqlitePreview.selectTable": "Select a table", + "skillSidebar.startTab": "Start", "skillSidebar.toggleFolder": "Toggle folder", "skillSidebar.unsavedChanges.confirmClose": "Discard", "skillSidebar.unsavedChanges.content": "You have unsaved changes. Do you want to discard them?", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 9641db0621..9676a50d07 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1088,6 +1088,7 @@ "skillSidebar.sqlitePreview.nullValue": "NULL", "skillSidebar.sqlitePreview.rowsTruncated": "仅显示前 {{limit}} 行,更多行已省略。", "skillSidebar.sqlitePreview.selectTable": "选择表", + "skillSidebar.startTab": "开始", "skillSidebar.unsavedChanges.confirmClose": "放弃", "skillSidebar.unsavedChanges.content": "您有未保存的更改,是否放弃?", "skillSidebar.unsavedChanges.title": "未保存的更改",