refactor(skill-editor): split slice into separate files for better organization

Split the monolithic skill-editor-slice.ts into a dedicated directory with
individual slice files (tab, file-tree, dirty, metadata, file-operations-menu)
to improve maintainability and code organization.
This commit is contained in:
yyh
2026-01-17 17:28:25 +08:00
parent 15d6f60f25
commit b82b73ef94
9 changed files with 328 additions and 313 deletions

View File

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

View File

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

View File

@ -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<TabSliceShape> = (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<string, boolean>
export type FileTreeSliceShape = {
expandedFolderIds: Set<string>
setExpandedFolderIds: (ids: Set<string>) => void
toggleFolder: (folderId: string) => void
revealFile: (ancestorFolderIds: string[]) => void
setExpandedFromOpens: (opens: OpensObject) => void
getOpensObject: () => OpensObject
}
const createFileTreeSlice: StateCreator<FileTreeSliceShape> = (set, get) => ({
expandedFolderIds: new Set<string>(),
setExpandedFolderIds: (ids: Set<string>) => {
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<string>(
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<string, string>
setDraftContent: (fileId: string, content: string) => void
clearDraftContent: (fileId: string) => void
isDirty: (fileId: string) => boolean
getDraftContent: (fileId: string) => string | undefined
}
const createDirtySlice: StateCreator<DirtySliceShape> = (set, get) => ({
dirtyContents: new Map<string, string>(),
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<string, Record<string, any>>
dirtyMetadataIds: Set<string>
setFileMetadata: (fileId: string, metadata: Record<string, any>) => void
setDraftMetadata: (fileId: string, metadata: Record<string, any>) => void
clearDraftMetadata: (fileId: string) => void
clearFileMetadata: (fileId: string) => void
isMetadataDirty: (fileId: string) => boolean
getFileMetadata: (fileId: string) => Record<string, any> | undefined
}
const createMetadataSlice: StateCreator<MetadataSliceShape> = (set, get) => ({
fileMetadata: new Map<string, Record<string, any>>(),
dirtyMetadataIds: new Set<string>(),
setFileMetadata: (fileId: string, metadata: Record<string, any>) => {
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<string, any>) => {
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<FileOperationsMenuSliceShape> = set => ({
contextMenu: null,
setContextMenu: (contextMenu) => {
set({ contextMenu })
},
})
export type SkillEditorSliceShape
= TabSliceShape
& FileTreeSliceShape
& DirtySliceShape
& MetadataSliceShape
& FileOperationsMenuSliceShape
& {
resetSkillEditor: () => void
}
export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (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<StateCreator<TabSliceShape>>
const fileTreeArgs = [set, get, store] as unknown as Parameters<StateCreator<FileTreeSliceShape>>
const dirtyArgs = [set, get, store] as unknown as Parameters<StateCreator<DirtySliceShape>>
const metadataArgs = [set, get, store] as unknown as Parameters<StateCreator<MetadataSliceShape>>
const menuArgs = [set, get, store] as unknown as Parameters<StateCreator<FileOperationsMenuSliceShape>>
return {
...createTabSlice(...tabArgs),
...createFileTreeSlice(...fileTreeArgs),
...createDirtySlice(...dirtyArgs),
...createMetadataSlice(...metadataArgs),
...createFileOperationsMenuSlice(...menuArgs),
resetSkillEditor: () => {
set({
openTabIds: [],
activeTabId: null,
previewTabId: null,
expandedFolderIds: new Set<string>(),
dirtyContents: new Map<string, string>(),
fileMetadata: new Map<string, Record<string, any>>(),
dirtyMetadataIds: new Set<string>(),
contextMenu: null,
})
},
}
}

View File

@ -0,0 +1,35 @@
import type { StateCreator } from 'zustand'
export type DirtySliceShape = {
dirtyContents: Map<string, string>
setDraftContent: (fileId: string, content: string) => void
clearDraftContent: (fileId: string) => void
isDirty: (fileId: string) => boolean
getDraftContent: (fileId: string) => string | undefined
}
export const createDirtySlice: StateCreator<DirtySliceShape> = (set, get) => ({
dirtyContents: new Map<string, string>(),
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)
},
})

View File

@ -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<FileOperationsMenuSliceShape> = set => ({
contextMenu: null,
setContextMenu: (contextMenu) => {
set({ contextMenu })
},
})

View File

@ -0,0 +1,54 @@
import type { StateCreator } from 'zustand'
export type OpensObject = Record<string, boolean>
export type FileTreeSliceShape = {
expandedFolderIds: Set<string>
setExpandedFolderIds: (ids: Set<string>) => void
toggleFolder: (folderId: string) => void
revealFile: (ancestorFolderIds: string[]) => void
setExpandedFromOpens: (opens: OpensObject) => void
getOpensObject: () => OpensObject
}
export const createFileTreeSlice: StateCreator<FileTreeSliceShape> = (set, get) => ({
expandedFolderIds: new Set<string>(),
setExpandedFolderIds: (ids: Set<string>) => {
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<string>(
Object.entries(opens)
.filter(([_, isOpen]) => isOpen)
.map(([id]) => id),
)
set({ expandedFolderIds: newSet })
},
getOpensObject: () => {
const { expandedFolderIds } = get()
return Object.fromEntries(
[...expandedFolderIds].map(id => [id, true]),
)
},
})

View File

@ -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<SkillEditorSliceShape> = (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<StateCreator<TabSliceShape>>
const fileTreeArgs = [set, get, store] as unknown as Parameters<StateCreator<FileTreeSliceShape>>
const dirtyArgs = [set, get, store] as unknown as Parameters<StateCreator<DirtySliceShape>>
const metadataArgs = [set, get, store] as unknown as Parameters<StateCreator<MetadataSliceShape>>
const menuArgs = [set, get, store] as unknown as Parameters<StateCreator<FileOperationsMenuSliceShape>>
return {
...createTabSlice(...tabArgs),
...createFileTreeSlice(...fileTreeArgs),
...createDirtySlice(...dirtyArgs),
...createMetadataSlice(...metadataArgs),
...createFileOperationsMenuSlice(...menuArgs),
resetSkillEditor: () => {
set({
openTabIds: [],
activeTabId: null,
previewTabId: null,
expandedFolderIds: new Set<string>(),
dirtyContents: new Map<string, string>(),
fileMetadata: new Map<string, Record<string, any>>(),
dirtyMetadataIds: new Set<string>(),
contextMenu: null,
})
},
}
}

View File

@ -0,0 +1,62 @@
import type { StateCreator } from 'zustand'
export type MetadataSliceShape = {
fileMetadata: Map<string, Record<string, any>>
dirtyMetadataIds: Set<string>
setFileMetadata: (fileId: string, metadata: Record<string, any>) => void
setDraftMetadata: (fileId: string, metadata: Record<string, any>) => void
clearDraftMetadata: (fileId: string) => void
clearFileMetadata: (fileId: string) => void
isMetadataDirty: (fileId: string) => boolean
getFileMetadata: (fileId: string) => Record<string, any> | undefined
}
export const createMetadataSlice: StateCreator<MetadataSliceShape> = (set, get) => ({
fileMetadata: new Map<string, Record<string, any>>(),
dirtyMetadataIds: new Set<string>(),
setFileMetadata: (fileId: string, metadata: Record<string, any>) => {
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<string, any>) => {
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)
},
})

View File

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