diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 35aea9f5b8..687db348f3 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -2,7 +2,7 @@ import type { NodeApi, TreeApi } from 'react-arborist' import type { TreeNodeData } from '../type' -import type { OpensObject } from '@/app/components/workflow/store/workflow/skill-editor-slice' +import type { OpensObject } from '@/app/components/workflow/store/workflow/skill-editor/file-tree-slice' import { RiDragDropLine } from '@remixicon/react' import { useIsMutating } from '@tanstack/react-query' import { useSize } from 'ahooks' diff --git a/web/app/components/workflow/store/workflow/index.ts b/web/app/components/workflow/store/workflow/index.ts index 5c00de2d2c..7d2eab84ff 100644 --- a/web/app/components/workflow/store/workflow/index.ts +++ b/web/app/components/workflow/store/workflow/index.ts @@ -10,7 +10,7 @@ import type { HistorySliceShape } from './history-slice' import type { LayoutSliceShape } from './layout-slice' import type { NodeSliceShape } from './node-slice' import type { PanelSliceShape } from './panel-slice' -import type { SkillEditorSliceShape } from './skill-editor-slice' +import type { SkillEditorSliceShape } from './skill-editor' import type { ToolSliceShape } from './tool-slice' import type { VersionSliceShape } from './version-slice' import type { WorkflowDraftSliceShape } from './workflow-draft-slice' @@ -32,7 +32,7 @@ import { createHistorySlice } from './history-slice' import { createLayoutSlice } from './layout-slice' import { createNodeSlice } from './node-slice' import { createPanelSlice } from './panel-slice' -import { createSkillEditorSlice } from './skill-editor-slice' +import { createSkillEditorSlice } from './skill-editor' import { createToolSlice } from './tool-slice' import { createVersionSlice } from './version-slice' import { createWorkflowDraftSlice } from './workflow-draft-slice' diff --git a/web/app/components/workflow/store/workflow/skill-editor-slice.ts b/web/app/components/workflow/store/workflow/skill-editor-slice.ts deleted file mode 100644 index 2f1dce53f5..0000000000 --- a/web/app/components/workflow/store/workflow/skill-editor-slice.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type { StateCreator } from 'zustand' - -export type OpenTabOptions = { - /** true = Pinned (permanent), false/undefined = Preview (temporary) */ - pinned?: boolean -} - -export type TabSliceShape = { - /** Ordered list of open tab file IDs */ - openTabIds: string[] - /** Currently active tab file ID */ - activeTabId: string | null - /** Current preview tab file ID (at most one) */ - previewTabId: string | null - /** Open a file tab with optional pinned mode */ - openTab: (fileId: string, options?: OpenTabOptions) => void - /** Close a tab */ - closeTab: (fileId: string) => void - /** Activate an existing tab */ - activateTab: (fileId: string) => void - /** Convert preview tab to pinned tab */ - pinTab: (fileId: string) => void - /** Check if a tab is in preview mode */ - isPreviewTab: (fileId: string) => boolean -} - -const createTabSlice: StateCreator = (set, get) => ({ - openTabIds: [], - activeTabId: null, - previewTabId: null, - - openTab: (fileId: string, options?: OpenTabOptions) => { - const { openTabIds, activeTabId, previewTabId } = get() - const isPinned = options?.pinned ?? false - - if (openTabIds.includes(fileId)) { - if (isPinned && previewTabId === fileId) - set({ activeTabId: fileId, previewTabId: null }) - else if (activeTabId !== fileId) - set({ activeTabId: fileId }) - return - } - - let newOpenTabIds = [...openTabIds] - - if (!isPinned) { - if (previewTabId && openTabIds.includes(previewTabId)) - newOpenTabIds = newOpenTabIds.filter(id => id !== previewTabId) - set({ - openTabIds: [...newOpenTabIds, fileId], - activeTabId: fileId, - previewTabId: fileId, - }) - } - else { - set({ - openTabIds: [...newOpenTabIds, fileId], - activeTabId: fileId, - }) - } - }, - - closeTab: (fileId: string) => { - const { openTabIds, activeTabId, previewTabId } = get() - const newOpenTabIds = openTabIds.filter(id => id !== fileId) - - let newActiveTabId = activeTabId - if (activeTabId === fileId) { - const closedIndex = openTabIds.indexOf(fileId) - if (newOpenTabIds.length > 0) - newActiveTabId = newOpenTabIds[Math.min(closedIndex, newOpenTabIds.length - 1)] - else - newActiveTabId = null - } - - const newPreviewTabId = previewTabId === fileId - ? null - : (previewTabId && newOpenTabIds.includes(previewTabId) ? previewTabId : null) - - set({ - openTabIds: newOpenTabIds, - activeTabId: newActiveTabId, - previewTabId: newPreviewTabId, - }) - }, - - activateTab: (fileId: string) => { - const { openTabIds } = get() - if (openTabIds.includes(fileId)) - set({ activeTabId: fileId }) - }, - - pinTab: (fileId: string) => { - const { previewTabId, openTabIds } = get() - if (!openTabIds.includes(fileId)) - return - if (previewTabId === fileId) - set({ previewTabId: null }) - }, - - isPreviewTab: (fileId: string) => { - return get().previewTabId === fileId - }, -}) - -export type OpensObject = Record - -export type FileTreeSliceShape = { - expandedFolderIds: Set - setExpandedFolderIds: (ids: Set) => void - toggleFolder: (folderId: string) => void - revealFile: (ancestorFolderIds: string[]) => void - setExpandedFromOpens: (opens: OpensObject) => void - getOpensObject: () => OpensObject -} - -const createFileTreeSlice: StateCreator = (set, get) => ({ - expandedFolderIds: new Set(), - - setExpandedFolderIds: (ids: Set) => { - set({ expandedFolderIds: ids }) - }, - - toggleFolder: (folderId: string) => { - const { expandedFolderIds } = get() - const newSet = new Set(expandedFolderIds) - if (newSet.has(folderId)) - newSet.delete(folderId) - else - newSet.add(folderId) - - set({ expandedFolderIds: newSet }) - }, - - revealFile: (ancestorFolderIds: string[]) => { - const { expandedFolderIds } = get() - const newSet = new Set(expandedFolderIds) - ancestorFolderIds.forEach(id => newSet.add(id)) - set({ expandedFolderIds: newSet }) - }, - - setExpandedFromOpens: (opens: OpensObject) => { - const newSet = new Set( - Object.entries(opens) - .filter(([_, isOpen]) => isOpen) - .map(([id]) => id), - ) - set({ expandedFolderIds: newSet }) - }, - - getOpensObject: () => { - const { expandedFolderIds } = get() - return Object.fromEntries( - [...expandedFolderIds].map(id => [id, true]), - ) - }, -}) - -export type DirtySliceShape = { - dirtyContents: Map - setDraftContent: (fileId: string, content: string) => void - clearDraftContent: (fileId: string) => void - isDirty: (fileId: string) => boolean - getDraftContent: (fileId: string) => string | undefined -} - -const createDirtySlice: StateCreator = (set, get) => ({ - dirtyContents: new Map(), - - setDraftContent: (fileId: string, content: string) => { - const { dirtyContents } = get() - const newMap = new Map(dirtyContents) - newMap.set(fileId, content) - set({ dirtyContents: newMap }) - }, - - clearDraftContent: (fileId: string) => { - const { dirtyContents } = get() - const newMap = new Map(dirtyContents) - newMap.delete(fileId) - set({ dirtyContents: newMap }) - }, - - isDirty: (fileId: string) => { - return get().dirtyContents.has(fileId) - }, - - getDraftContent: (fileId: string) => { - return get().dirtyContents.get(fileId) - }, -}) - -export type MetadataSliceShape = { - fileMetadata: Map> - dirtyMetadataIds: Set - setFileMetadata: (fileId: string, metadata: Record) => void - setDraftMetadata: (fileId: string, metadata: Record) => void - clearDraftMetadata: (fileId: string) => void - clearFileMetadata: (fileId: string) => void - isMetadataDirty: (fileId: string) => boolean - getFileMetadata: (fileId: string) => Record | undefined -} - -const createMetadataSlice: StateCreator = (set, get) => ({ - fileMetadata: new Map>(), - dirtyMetadataIds: new Set(), - - setFileMetadata: (fileId: string, metadata: Record) => { - const { fileMetadata } = get() - const nextMap = new Map(fileMetadata) - if (metadata) - nextMap.set(fileId, metadata) - else - nextMap.delete(fileId) - set({ fileMetadata: nextMap }) - }, - - setDraftMetadata: (fileId: string, metadata: Record) => { - const { fileMetadata, dirtyMetadataIds } = get() - const nextMap = new Map(fileMetadata) - nextMap.set(fileId, metadata || {}) - const nextDirty = new Set(dirtyMetadataIds) - nextDirty.add(fileId) - set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty }) - }, - - clearDraftMetadata: (fileId: string) => { - const { dirtyMetadataIds } = get() - if (!dirtyMetadataIds.has(fileId)) - return - const nextDirty = new Set(dirtyMetadataIds) - nextDirty.delete(fileId) - set({ dirtyMetadataIds: nextDirty }) - }, - - clearFileMetadata: (fileId: string) => { - const { fileMetadata, dirtyMetadataIds } = get() - const nextMap = new Map(fileMetadata) - nextMap.delete(fileId) - const nextDirty = new Set(dirtyMetadataIds) - nextDirty.delete(fileId) - set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty }) - }, - - isMetadataDirty: (fileId: string) => { - return get().dirtyMetadataIds.has(fileId) - }, - - getFileMetadata: (fileId: string) => { - return get().fileMetadata.get(fileId) - }, -}) - -export type FileOperationsMenuSliceShape = { - contextMenu: { - top: number - left: number - nodeId: string - } | null - setContextMenu: (menu: FileOperationsMenuSliceShape['contextMenu']) => void -} - -const createFileOperationsMenuSlice: StateCreator = set => ({ - contextMenu: null, - - setContextMenu: (contextMenu) => { - set({ contextMenu }) - }, -}) - -export type SkillEditorSliceShape - = TabSliceShape - & FileTreeSliceShape - & DirtySliceShape - & MetadataSliceShape - & FileOperationsMenuSliceShape - & { - resetSkillEditor: () => void - } - -export const createSkillEditorSlice: StateCreator = (set, get, store) => { - // Type assertion via unknown to allow composition with other slices in a larger store - // This is safe because all slice creators only use set/get for their own properties - const tabArgs = [set, get, store] as unknown as Parameters> - const fileTreeArgs = [set, get, store] as unknown as Parameters> - const dirtyArgs = [set, get, store] as unknown as Parameters> - const metadataArgs = [set, get, store] as unknown as Parameters> - const menuArgs = [set, get, store] as unknown as Parameters> - - return { - ...createTabSlice(...tabArgs), - ...createFileTreeSlice(...fileTreeArgs), - ...createDirtySlice(...dirtyArgs), - ...createMetadataSlice(...metadataArgs), - ...createFileOperationsMenuSlice(...menuArgs), - - resetSkillEditor: () => { - set({ - openTabIds: [], - activeTabId: null, - previewTabId: null, - expandedFolderIds: new Set(), - dirtyContents: new Map(), - fileMetadata: new Map>(), - dirtyMetadataIds: new Set(), - contextMenu: null, - }) - }, - } -} diff --git a/web/app/components/workflow/store/workflow/skill-editor/dirty-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/dirty-slice.ts new file mode 100644 index 0000000000..bba7a00622 --- /dev/null +++ b/web/app/components/workflow/store/workflow/skill-editor/dirty-slice.ts @@ -0,0 +1,35 @@ +import type { StateCreator } from 'zustand' + +export type DirtySliceShape = { + dirtyContents: Map + setDraftContent: (fileId: string, content: string) => void + clearDraftContent: (fileId: string) => void + isDirty: (fileId: string) => boolean + getDraftContent: (fileId: string) => string | undefined +} + +export const createDirtySlice: StateCreator = (set, get) => ({ + dirtyContents: new Map(), + + setDraftContent: (fileId: string, content: string) => { + const { dirtyContents } = get() + const newMap = new Map(dirtyContents) + newMap.set(fileId, content) + set({ dirtyContents: newMap }) + }, + + clearDraftContent: (fileId: string) => { + const { dirtyContents } = get() + const newMap = new Map(dirtyContents) + newMap.delete(fileId) + set({ dirtyContents: newMap }) + }, + + isDirty: (fileId: string) => { + return get().dirtyContents.has(fileId) + }, + + getDraftContent: (fileId: string) => { + return get().dirtyContents.get(fileId) + }, +}) diff --git a/web/app/components/workflow/store/workflow/skill-editor/file-operations-menu-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/file-operations-menu-slice.ts new file mode 100644 index 0000000000..8b0b5fd15c --- /dev/null +++ b/web/app/components/workflow/store/workflow/skill-editor/file-operations-menu-slice.ts @@ -0,0 +1,18 @@ +import type { StateCreator } from 'zustand' + +export type FileOperationsMenuSliceShape = { + contextMenu: { + top: number + left: number + nodeId: string + } | null + setContextMenu: (menu: FileOperationsMenuSliceShape['contextMenu']) => void +} + +export const createFileOperationsMenuSlice: StateCreator = set => ({ + contextMenu: null, + + setContextMenu: (contextMenu) => { + set({ contextMenu }) + }, +}) diff --git a/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts new file mode 100644 index 0000000000..498c2846ab --- /dev/null +++ b/web/app/components/workflow/store/workflow/skill-editor/file-tree-slice.ts @@ -0,0 +1,54 @@ +import type { StateCreator } from 'zustand' + +export type OpensObject = Record + +export type FileTreeSliceShape = { + expandedFolderIds: Set + setExpandedFolderIds: (ids: Set) => void + toggleFolder: (folderId: string) => void + revealFile: (ancestorFolderIds: string[]) => void + setExpandedFromOpens: (opens: OpensObject) => void + getOpensObject: () => OpensObject +} + +export const createFileTreeSlice: StateCreator = (set, get) => ({ + expandedFolderIds: new Set(), + + setExpandedFolderIds: (ids: Set) => { + set({ expandedFolderIds: ids }) + }, + + toggleFolder: (folderId: string) => { + const { expandedFolderIds } = get() + const newSet = new Set(expandedFolderIds) + if (newSet.has(folderId)) + newSet.delete(folderId) + else + newSet.add(folderId) + + set({ expandedFolderIds: newSet }) + }, + + revealFile: (ancestorFolderIds: string[]) => { + const { expandedFolderIds } = get() + const newSet = new Set(expandedFolderIds) + ancestorFolderIds.forEach(id => newSet.add(id)) + set({ expandedFolderIds: newSet }) + }, + + setExpandedFromOpens: (opens: OpensObject) => { + const newSet = new Set( + Object.entries(opens) + .filter(([_, isOpen]) => isOpen) + .map(([id]) => id), + ) + set({ expandedFolderIds: newSet }) + }, + + getOpensObject: () => { + const { expandedFolderIds } = get() + return Object.fromEntries( + [...expandedFolderIds].map(id => [id, true]), + ) + }, +}) diff --git a/web/app/components/workflow/store/workflow/skill-editor/index.ts b/web/app/components/workflow/store/workflow/skill-editor/index.ts new file mode 100644 index 0000000000..d62de8a62d --- /dev/null +++ b/web/app/components/workflow/store/workflow/skill-editor/index.ts @@ -0,0 +1,52 @@ +import type { StateCreator } from 'zustand' +import type { DirtySliceShape } from './dirty-slice' +import type { FileOperationsMenuSliceShape } from './file-operations-menu-slice' +import type { FileTreeSliceShape } from './file-tree-slice' +import type { MetadataSliceShape } from './metadata-slice' +import type { TabSliceShape } from './tab-slice' +import { createDirtySlice } from './dirty-slice' +import { createFileOperationsMenuSlice } from './file-operations-menu-slice' +import { createFileTreeSlice } from './file-tree-slice' +import { createMetadataSlice } from './metadata-slice' +import { createTabSlice } from './tab-slice' + +export type SkillEditorSliceShape + = TabSliceShape + & FileTreeSliceShape + & DirtySliceShape + & MetadataSliceShape + & FileOperationsMenuSliceShape + & { + resetSkillEditor: () => void + } + +export const createSkillEditorSlice: StateCreator = (set, get, store) => { + // Type assertion via unknown to allow composition with other slices in a larger store + // This is safe because all slice creators only use set/get for their own properties + const tabArgs = [set, get, store] as unknown as Parameters> + const fileTreeArgs = [set, get, store] as unknown as Parameters> + const dirtyArgs = [set, get, store] as unknown as Parameters> + const metadataArgs = [set, get, store] as unknown as Parameters> + const menuArgs = [set, get, store] as unknown as Parameters> + + return { + ...createTabSlice(...tabArgs), + ...createFileTreeSlice(...fileTreeArgs), + ...createDirtySlice(...dirtyArgs), + ...createMetadataSlice(...metadataArgs), + ...createFileOperationsMenuSlice(...menuArgs), + + resetSkillEditor: () => { + set({ + openTabIds: [], + activeTabId: null, + previewTabId: null, + expandedFolderIds: new Set(), + dirtyContents: new Map(), + fileMetadata: new Map>(), + dirtyMetadataIds: new Set(), + contextMenu: null, + }) + }, + } +} diff --git a/web/app/components/workflow/store/workflow/skill-editor/metadata-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/metadata-slice.ts new file mode 100644 index 0000000000..393ba99091 --- /dev/null +++ b/web/app/components/workflow/store/workflow/skill-editor/metadata-slice.ts @@ -0,0 +1,62 @@ +import type { StateCreator } from 'zustand' + +export type MetadataSliceShape = { + fileMetadata: Map> + dirtyMetadataIds: Set + setFileMetadata: (fileId: string, metadata: Record) => void + setDraftMetadata: (fileId: string, metadata: Record) => void + clearDraftMetadata: (fileId: string) => void + clearFileMetadata: (fileId: string) => void + isMetadataDirty: (fileId: string) => boolean + getFileMetadata: (fileId: string) => Record | undefined +} + +export const createMetadataSlice: StateCreator = (set, get) => ({ + fileMetadata: new Map>(), + dirtyMetadataIds: new Set(), + + setFileMetadata: (fileId: string, metadata: Record) => { + const { fileMetadata } = get() + const nextMap = new Map(fileMetadata) + if (metadata) + nextMap.set(fileId, metadata) + else + nextMap.delete(fileId) + set({ fileMetadata: nextMap }) + }, + + setDraftMetadata: (fileId: string, metadata: Record) => { + const { fileMetadata, dirtyMetadataIds } = get() + const nextMap = new Map(fileMetadata) + nextMap.set(fileId, metadata || {}) + const nextDirty = new Set(dirtyMetadataIds) + nextDirty.add(fileId) + set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty }) + }, + + clearDraftMetadata: (fileId: string) => { + const { dirtyMetadataIds } = get() + if (!dirtyMetadataIds.has(fileId)) + return + const nextDirty = new Set(dirtyMetadataIds) + nextDirty.delete(fileId) + set({ dirtyMetadataIds: nextDirty }) + }, + + clearFileMetadata: (fileId: string) => { + const { fileMetadata, dirtyMetadataIds } = get() + const nextMap = new Map(fileMetadata) + nextMap.delete(fileId) + const nextDirty = new Set(dirtyMetadataIds) + nextDirty.delete(fileId) + set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty }) + }, + + isMetadataDirty: (fileId: string) => { + return get().dirtyMetadataIds.has(fileId) + }, + + getFileMetadata: (fileId: string) => { + return get().fileMetadata.get(fileId) + }, +}) 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 new file mode 100644 index 0000000000..41ce5aba85 --- /dev/null +++ b/web/app/components/workflow/store/workflow/skill-editor/tab-slice.ts @@ -0,0 +1,104 @@ +import type { StateCreator } from 'zustand' + +export type OpenTabOptions = { + /** true = Pinned (permanent), false/undefined = Preview (temporary) */ + pinned?: boolean +} + +export type TabSliceShape = { + /** Ordered list of open tab file IDs */ + openTabIds: string[] + /** Currently active tab file ID */ + activeTabId: string | null + /** Current preview tab file ID (at most one) */ + previewTabId: string | null + /** Open a file tab with optional pinned mode */ + openTab: (fileId: string, options?: OpenTabOptions) => void + /** Close a tab */ + closeTab: (fileId: string) => void + /** Activate an existing tab */ + activateTab: (fileId: string) => void + /** Convert preview tab to pinned tab */ + pinTab: (fileId: string) => void + /** Check if a tab is in preview mode */ + isPreviewTab: (fileId: string) => boolean +} + +export const createTabSlice: StateCreator = (set, get) => ({ + openTabIds: [], + activeTabId: null, + previewTabId: null, + + openTab: (fileId: string, options?: OpenTabOptions) => { + const { openTabIds, activeTabId, previewTabId } = get() + const isPinned = options?.pinned ?? false + + if (openTabIds.includes(fileId)) { + if (isPinned && previewTabId === fileId) + set({ activeTabId: fileId, previewTabId: null }) + else if (activeTabId !== fileId) + set({ activeTabId: fileId }) + return + } + + let newOpenTabIds = [...openTabIds] + + if (!isPinned) { + if (previewTabId && openTabIds.includes(previewTabId)) + newOpenTabIds = newOpenTabIds.filter(id => id !== previewTabId) + set({ + openTabIds: [...newOpenTabIds, fileId], + activeTabId: fileId, + previewTabId: fileId, + }) + } + else { + set({ + openTabIds: [...newOpenTabIds, fileId], + activeTabId: fileId, + }) + } + }, + + closeTab: (fileId: string) => { + const { openTabIds, activeTabId, previewTabId } = get() + const newOpenTabIds = openTabIds.filter(id => id !== fileId) + + let newActiveTabId = activeTabId + if (activeTabId === fileId) { + const closedIndex = openTabIds.indexOf(fileId) + if (newOpenTabIds.length > 0) + newActiveTabId = newOpenTabIds[Math.min(closedIndex, newOpenTabIds.length - 1)] + else + newActiveTabId = null + } + + const newPreviewTabId = previewTabId === fileId + ? null + : (previewTabId && newOpenTabIds.includes(previewTabId) ? previewTabId : null) + + set({ + openTabIds: newOpenTabIds, + activeTabId: newActiveTabId, + previewTabId: newPreviewTabId, + }) + }, + + activateTab: (fileId: string) => { + const { openTabIds } = get() + if (openTabIds.includes(fileId)) + set({ activeTabId: fileId }) + }, + + pinTab: (fileId: string) => { + const { previewTabId, openTabIds } = get() + if (!openTabIds.includes(fileId)) + return + if (previewTabId === fileId) + set({ previewTabId: null }) + }, + + isPreviewTab: (fileId: string) => { + return get().previewTabId === fileId + }, +})