From fe17cbc1a825b1da91f5e0bc44949154af3c4c2b Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 15 Jan 2026 13:53:19 +0800 Subject: [PATCH] feat(skill-editor): implement file tree, tab management, and dirty state tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement MVP features for skill editor based on design doc: - Add Zustand store with Tab, FileTree, and Dirty slices - Rewrite file tree using react-arborist for virtual scrolling - Implement Tab↔FileTree sync with auto-reveal on tab activation - Add upload functionality (new folder, upload file) - Implement Monaco editor with dirty state tracking and Ctrl+S save - Add i18n translations (en-US and zh-Hans) --- web/app/components/workflow/skill/context.tsx | 52 ++++ .../workflow/skill/editor-tab-item.tsx | 123 +++++---- .../components/workflow/skill/editor-tabs.tsx | 84 ++++++- .../workflow/skill/file-tree-node.tsx | 101 ++++++++ web/app/components/workflow/skill/files.tsx | 231 +++++++++++------ web/app/components/workflow/skill/main.tsx | 48 ++-- .../components/workflow/skill/mock-data.ts | 153 ------------ .../workflow/skill/sidebar-search-add.tsx | 161 +++++++++++- .../workflow/skill/skill-doc-editor.tsx | 212 +++++++++++++++- .../components/workflow/skill/store/index.ts | 233 ++++++++++++++++++ web/app/components/workflow/skill/type.ts | 145 +++++++++-- web/app/components/workflow/skill/utils.ts | 36 +++ web/i18n/en-US/workflow.json | 8 + web/i18n/zh-Hans/workflow.json | 9 + web/package.json | 1 + web/pnpm-lock.yaml | 93 +++++++ 16 files changed, 1362 insertions(+), 328 deletions(-) create mode 100644 web/app/components/workflow/skill/context.tsx create mode 100644 web/app/components/workflow/skill/file-tree-node.tsx delete mode 100644 web/app/components/workflow/skill/mock-data.ts create mode 100644 web/app/components/workflow/skill/store/index.ts diff --git a/web/app/components/workflow/skill/context.tsx b/web/app/components/workflow/skill/context.tsx new file mode 100644 index 0000000000..6c103d29d8 --- /dev/null +++ b/web/app/components/workflow/skill/context.tsx @@ -0,0 +1,52 @@ +'use client' + +import type { SkillEditorStore } from './store' +import { + useEffect, + useRef, +} from 'react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { + createSkillEditorStore, + SkillEditorContext, +} from './store' + +/** + * SkillEditorProvider + * + * Provides the SkillEditor store to all child components. + * The store is created once per mount and persists across view switches. + * When appId changes, the store is reset. + */ +export type SkillEditorProviderProps = { + children: React.ReactNode +} + +export const SkillEditorProvider = ({ children }: SkillEditorProviderProps) => { + const storeRef = useRef(undefined) + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id + const prevAppIdRef = useRef(undefined) + + // Create store on first render (pattern recommended by React) + if (storeRef.current === null || storeRef.current === undefined) + storeRef.current = createSkillEditorStore() + + // Reset store when appId changes + useEffect(() => { + if (prevAppIdRef.current !== undefined && prevAppIdRef.current !== appId) { + // appId changed, reset the store + storeRef.current?.getState().reset() + } + prevAppIdRef.current = appId + }, [appId]) + + return ( + + {children} + + ) +} + +// Re-export for convenience +export { SkillEditorContext } from './store' diff --git a/web/app/components/workflow/skill/editor-tab-item.tsx b/web/app/components/workflow/skill/editor-tab-item.tsx index 1f497a32ce..aca03d58c4 100644 --- a/web/app/components/workflow/skill/editor-tab-item.tsx +++ b/web/app/components/workflow/skill/editor-tab-item.tsx @@ -1,63 +1,102 @@ +'use client' + import type { FC } from 'react' -import type { FileAppearanceType } from '../../base/file-uploader/types' -import type { SkillTabItem } from './mock-data' -import { RiCloseLine, RiHome9Line } from '@remixicon/react' +import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' +import { RiCloseLine } from '@remixicon/react' import * as React from 'react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' import { cn } from '@/utils/classnames' import { getFileIconType } from './utils' +/** + * EditorTabItem - Single tab item in the tab bar + * + * Features: + * - Click to activate + * - Close button (shown on hover or when active) + * - Dirty indicator (orange dot) + * - File type icon based on extension + * + * Design specs from Figma: + * - Height: 32px (pb-2 pt-2.5 = 18px content + padding) + * - Font: 13px, medium (500) when active + * - Icon: 16x16 in 20x20 container + */ + type EditorTabItemProps = { - item: SkillTabItem + fileId: string + name: string + extension?: string + isActive: boolean + isDirty: boolean + onClick: (fileId: string) => void + onClose: (fileId: string) => void } -const EditorTabItem: FC = ({ item }) => { +const EditorTabItem: FC = ({ + fileId, + name, + extension: _extension, + isActive, + isDirty, + onClick, + onClose, +}) => { const { t } = useTranslation() - const isStart = item.type === 'start' - const isActive = Boolean(item.active) - const label = isStart ? item.name.toUpperCase() : item.name - const iconType = isStart ? null : getFileIconType(item.name) + const iconType = getFileIconType(name) + + const handleClick = useCallback(() => { + onClick(fileId) + }, [onClick, fileId]) + + const handleClose = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + onClose(fileId) + }, [onClose, fileId]) return (
-
-
- {isStart - ? ( - - ) - : ( - - )} -
- - {label} - + {/* Icon with dirty indicator */} +
+ + {/* Dirty indicator dot */} + {isDirty && ( + + )}
- {!isStart && ( - - )} + + {/* File name */} + + {name} + + + {/* Close button */} +
) } diff --git a/web/app/components/workflow/skill/editor-tabs.tsx b/web/app/components/workflow/skill/editor-tabs.tsx index 603585fc05..3fd568af33 100644 --- a/web/app/components/workflow/skill/editor-tabs.tsx +++ b/web/app/components/workflow/skill/editor-tabs.tsx @@ -1,23 +1,91 @@ +'use client' + import type { FC } from 'react' -import type { SkillTabItem } from './mock-data' +import type { AppAssetTreeView } from './type' import * as React from 'react' +import { useMemo } from 'react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useGetAppAssetTree } from '@/service/use-app-asset' import { cn } from '@/utils/classnames' import EditorTabItem from './editor-tab-item' +import { useSkillEditorStore, useSkillEditorStoreApi } from './store' +import { buildNodeMap } from './type' -type EditorTabsProps = { - items: SkillTabItem[] -} +/** + * EditorTabs - Tab bar for open files + * + * Features: + * - Displays open tabs from store + * - Click to activate, close button to remove + * - Shows dirty indicator for unsaved files + * - Derives tab names from tree data (fileId -> file.name) + */ + +const EditorTabs: FC = () => { + // Get appId + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + + // Get tree data for deriving file names + const { data: treeData } = useGetAppAssetTree(appId) + + // Store state + const openTabIds = useSkillEditorStore(s => s.openTabIds) + const activeTabId = useSkillEditorStore(s => s.activeTabId) + const dirtyContents = useSkillEditorStore(s => s.dirtyContents) + const storeApi = useSkillEditorStoreApi() + + // Build node map for quick lookup + const nodeMap = useMemo(() => { + if (!treeData?.children) + return new Map() + return buildNodeMap(treeData.children) + }, [treeData?.children]) + + // Handle tab click + const handleTabClick = (fileId: string) => { + storeApi.getState().activateTab(fileId) + } + + // Handle tab close + const handleTabClose = (fileId: string) => { + // MVP: No dirty confirmation, just close + // TODO: Add confirmation dialog when file is dirty + storeApi.getState().closeTab(fileId) + // Clear dirty content if exists + storeApi.getState().clearDraftContent(fileId) + } + + // No tabs open - don't render + if (openTabIds.length === 0) + return null -const EditorTabs: FC = ({ items }) => { return (
- {items.map(item => ( - - ))} + {openTabIds.map((fileId) => { + const node = nodeMap.get(fileId) + const name = node?.name ?? fileId + const extension = node?.extension ?? '' + const isActive = activeTabId === fileId + const isDirty = dirtyContents.has(fileId) + + return ( + + ) + })}
) } diff --git a/web/app/components/workflow/skill/file-tree-node.tsx b/web/app/components/workflow/skill/file-tree-node.tsx new file mode 100644 index 0000000000..8cedc06bbb --- /dev/null +++ b/web/app/components/workflow/skill/file-tree-node.tsx @@ -0,0 +1,101 @@ +'use client' + +import type { NodeRendererProps } from 'react-arborist' +import type { TreeNodeData } from './type' +import type { FileAppearanceType } from '@/app/components/base/file-uploader/types' +import { RiFolderLine, RiFolderOpenLine } from '@remixicon/react' +import * as React from 'react' +import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' +import { cn } from '@/utils/classnames' +import { useSkillEditorStore } from './store' +import { getFileIconType } from './utils' + +/** + * FileTreeNode - Custom node renderer for react-arborist + * + * Matches Figma design specifications: + * - Row height: 24px + * - Icon size: 16x16 in 20x20 container + * - Font: 13px Inter, regular (400) / medium (500) for selected + * - Colors: text-secondary (#354052), text-primary (#101828) for selected + * - Hover bg: rgba(200,206,218,0.2), Active bg: rgba(200,206,218,0.4) + * - Folder icon: blue (#155aef) when open + */ +const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps) => { + const isFolder = node.data.node_type === 'folder' + const isSelected = node.isSelected + const isDirty = useSkillEditorStore(s => s.dirtyContents.has(node.data.id)) + + // Get file icon type for files + const fileIconType = !isFolder ? getFileIconType(node.data.name) : null + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + node.handleClick(e) + } + + const handleDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation() + // For files, activate (open in editor) + if (!isFolder) + node.activate() + } + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation() + node.toggle() + } + + return ( +
+ {/* Icon */} +
+ {isFolder + ? ( + + ) + : ( +
+ + {/* Dirty indicator dot */} + {isDirty && ( + + )} +
+ )} +
+ + {/* Name */} + + {node.data.name} + +
+ ) +} + +export default React.memo(FileTreeNode) diff --git a/web/app/components/workflow/skill/files.tsx b/web/app/components/workflow/skill/files.tsx index 765dbe2c69..714df68ec8 100644 --- a/web/app/components/workflow/skill/files.tsx +++ b/web/app/components/workflow/skill/files.tsx @@ -1,97 +1,182 @@ 'use client' -import type { FC, ReactNode } from 'react' -import type { ParentId, ResourceItem, ResourceItemList } from './type' + +import type { NodeApi, TreeApi } from 'react-arborist' +import type { TreeNodeData } from './type' import { RiDragDropLine } from '@remixicon/react' import * as React from 'react' -import { useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { Tree } from 'react-arborist' import { useTranslation } from 'react-i18next' -import FileItem from './file-item' -import FoldItem from './fold-item' -import { ResourceKind, SKILL_ROOT_ID } from './type' +import { useStore as useAppStore } from '@/app/components/app/store' +import Loading from '@/app/components/base/loading' +import { useGetAppAssetTree } from '@/service/use-app-asset' +import { cn } from '@/utils/classnames' +import FileTreeNode from './file-tree-node' +import { useSkillEditorStore, useSkillEditorStoreApi } from './store' +import { getAncestorIds, toOpensObject } from './type' -const TreeIndent = ({ depth }: { depth: number }) => { - if (depth <= 0) - return null +/** + * Files - File tree component using react-arborist + * + * Key features: + * - Controlled open state via TreeApi (synced with SkillEditorStore) + * - Click to select, double-click to open in tab + * - Auto-expand when tab is activated + * - Virtual scrolling for large trees + * + * Design specs from Figma: + * - Row height: 24px + * - Indent: 20px + * - Container padding: 4px + */ +type FilesProps = { + className?: string +} + +const DropTip = () => { + const { t } = useTranslation('workflow') return ( -
- {Array.from({ length: depth }).map((_, index) => ( - - - - ))} +
+ + + {t('skillSidebar.dropTip')} +
) } -type FilesProps = { - items: ResourceItemList - activeItemId?: string -} +const Files: React.FC = ({ className }) => { + const { t } = useTranslation('workflow') + const treeRef = useRef>(null) -const buildChildrenMap = (items: ResourceItemList) => { - const map = new Map() - items.forEach((item) => { - const parentId = item.parent_id ?? null - const existing = map.get(parentId) - if (existing) - existing.push(item) - else - map.set(parentId, [item]) - }) - return map -} + // Get appId from app store + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' -const Files: FC = ({ items, activeItemId }) => { - const { t } = useTranslation() - const childrenMap = useMemo(() => buildChildrenMap(items), [items]) + // Fetch tree data from API + const { data: treeData, isLoading, error } = useGetAppAssetTree(appId) - const renderNodes = (parentId: ParentId, depth: number): ReactNode[] => { - const children = childrenMap.get(parentId) || [] + // Store state and actions + const expandedFolderIds = useSkillEditorStore(s => s.expandedFolderIds) + const activeTabId = useSkillEditorStore(s => s.activeTabId) + const storeApi = useSkillEditorStoreApi() - return children.flatMap((item) => { - const prefix = - const isActive = item.id === activeItemId - const nodes: ReactNode[] = [] + // Convert Set to react-arborist OpenMap for initial state + const initialOpenState = useMemo(() => toOpensObject(expandedFolderIds), [expandedFolderIds]) - if (item.kind === ResourceKind.folder) { - nodes.push( - , - ) - nodes.push(...renderNodes(item.id, depth + 1)) - } - else { - nodes.push( - , - ) - } + // Handle toggle event from react-arborist + const handleToggle = useCallback((id: string) => { + storeApi.getState().toggleFolder(id) + }, [storeApi]) - return nodes - }) + // Handle node activation (double-click or Enter) + const handleActivate = useCallback((node: NodeApi) => { + if (node.data.node_type === 'file') { + // Open file in tab + storeApi.getState().openTab(node.data.id) + } + else { + // For folders, toggle open state + node.toggle() + } + }, [storeApi]) + + // Auto-reveal when activeTabId changes (sync from tab click to tree) + useEffect(() => { + if (!activeTabId || !treeData?.children) + return + + // Get ancestors and expand them + const ancestors = getAncestorIds(activeTabId, treeData.children) + if (ancestors.length > 0) { + storeApi.getState().revealFile(activeTabId, ancestors) + } + + // Scroll to and select the node + if (treeRef.current) { + // Small delay to allow tree to update + const timeoutId = setTimeout(() => { + const node = treeRef.current?.get(activeTabId) + if (node) { + node.select() + // Open all parents programmatically + ancestors.forEach((ancestorId) => { + const ancestorNode = treeRef.current?.get(ancestorId) + if (ancestorNode && !ancestorNode.isOpen) + ancestorNode.open() + }) + } + }, 0) + return () => clearTimeout(timeoutId) + } + }, [activeTabId, treeData?.children, storeApi]) + + // Loading state + if (isLoading) { + return ( +
+ +
+ ) + } + + // Error state + if (error) { + return ( +
+ + {t('skillSidebar.loadError')} + +
+ ) + } + + // Empty state + if (!treeData?.children || treeData.children.length === 0) { + return ( +
+
+ + {t('skillSidebar.empty')} + +
+ +
+ ) } return ( -
-
- {renderNodes(SKILL_ROOT_ID, 0)} -
-
- - - {t('skillSidebar.dropTip', { ns: 'workflow' })} - +
+
+ + ref={treeRef} + data={treeData.children} + // Structure accessors + idAccessor="id" + childrenAccessor="children" + // Layout + width="100%" + height={1000} + rowHeight={24} + indent={20} + overscanCount={5} + // Initial open state + initialOpenState={initialOpenState} + // Selection (controlled by activeTabId) + selection={activeTabId ?? undefined} + // Events + onToggle={handleToggle} + onActivate={handleActivate} + // Disable features not in MVP + disableDrag + disableDrop + disableEdit + > + {FileTreeNode} +
+
) } diff --git a/web/app/components/workflow/skill/main.tsx b/web/app/components/workflow/skill/main.tsx index f085dc0f82..df2436176d 100644 --- a/web/app/components/workflow/skill/main.tsx +++ b/web/app/components/workflow/skill/main.tsx @@ -1,34 +1,48 @@ 'use client' + import type { FC } from 'react' import * as React from 'react' +import { SkillEditorProvider } from './context' import EditorArea from './editor-area' import EditorBody from './editor-body' import EditorTabs from './editor-tabs' import Files from './files' -import { mockSkillItems, mockSkillTabs } from './mock-data' import Sidebar from './sidebar' import SidebarSearchAdd from './sidebar-search-add' import SkillDocEditor from './skill-doc-editor' import SkillPageLayout from './skill-page-layout' +/** + * SkillMain - Main entry point for Skill Editor view + * + * This component provides the SkillEditorContext and renders the + * complete Skill Editor UI including: + * - File tree sidebar + * - Tab bar + * - Editor area + * + * The store is created at this level and shared with all child components. + * API data is fetched using TanStack Query hooks within child components. + */ const SkillMain: FC = () => { - const activeItemId = 'skills/_schemas/email-writer/output-schema' - return ( -
- - - - - - - - - - - - -
+ +
+ + + + + + + + + + + + +
+
) } + export default React.memo(SkillMain) diff --git a/web/app/components/workflow/skill/mock-data.ts b/web/app/components/workflow/skill/mock-data.ts deleted file mode 100644 index 830fca5014..0000000000 --- a/web/app/components/workflow/skill/mock-data.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { ResourceItem } from './type' -import { ResourceKind, SKILL_ROOT_ID } from './type' - -export const mockSkillItems: ResourceItem[] = [ - { - id: 'skills', - name: 'skills', - parent_id: SKILL_ROOT_ID, - kind: ResourceKind.folder, - }, - { - id: 'skills/_schemas', - name: '_schemas', - parent_id: 'skills', - kind: ResourceKind.folder, - }, - { - id: 'skills/_schemas/email-writer', - name: 'email-writer', - parent_id: 'skills/_schemas', - kind: ResourceKind.folder, - }, - { - id: 'skills/_schemas/email-writer/skill', - name: 'SKILL.md', - parent_id: 'skills/_schemas/email-writer', - kind: ResourceKind.file, - ext: 'md', - size: 1820, - }, - { - id: 'skills/_schemas/email-writer/prompt', - name: 'prompt.md', - parent_id: 'skills/_schemas/email-writer', - kind: ResourceKind.file, - ext: 'md', - size: 964, - }, - { - id: 'skills/_schemas/email-writer/output-schema', - name: 'output.schema.json', - parent_id: 'skills/_schemas/email-writer', - kind: ResourceKind.file, - ext: 'json', - size: 742, - }, - { - id: 'skills/_schemas/email-writer/toolmap', - name: 'toolmap.yaml', - parent_id: 'skills/_schemas/email-writer', - kind: ResourceKind.file, - ext: 'yaml', - size: 540, - }, - { - id: 'skills/_schemas/email-writer/examples', - name: 'examples.jsonl', - parent_id: 'skills/_schemas/email-writer', - kind: ResourceKind.file, - ext: 'jsonl', - size: 1205, - }, - { - id: 'skills/_index', - name: '_index.json', - parent_id: 'skills', - kind: ResourceKind.file, - ext: 'json', - size: 356, - }, - { - id: 'skills/_tags', - name: '_tags.json', - parent_id: 'skills', - kind: ResourceKind.file, - ext: 'json', - size: 212, - }, - { - id: 'skills/web-research', - name: 'web-research', - parent_id: 'skills', - kind: ResourceKind.folder, - }, - { - id: 'skills/support-triage', - name: 'support-triage', - parent_id: 'skills', - kind: ResourceKind.folder, - }, - { - id: 'knowledge', - name: 'knowledge', - parent_id: SKILL_ROOT_ID, - kind: ResourceKind.folder, - }, - { - id: 'tools', - name: 'tools', - parent_id: SKILL_ROOT_ID, - kind: ResourceKind.folder, - }, - { - id: 'templates', - name: 'templates', - parent_id: SKILL_ROOT_ID, - kind: ResourceKind.folder, - }, - { - id: 'evals', - name: 'evals', - parent_id: SKILL_ROOT_ID, - kind: ResourceKind.folder, - }, - { - id: 'dist', - name: 'dist', - parent_id: SKILL_ROOT_ID, - kind: ResourceKind.folder, - }, -] - -export type SkillTabType = 'start' | 'file' -export type SkillTabItem = { - id: string - type: SkillTabType - name: string - active?: boolean -} - -export const mockSkillTabs: SkillTabItem[] = [ - { - id: 'tab-start', - type: 'start', - name: 'Start', - }, - { - id: 'tab-skill', - type: 'file', - name: 'SKILL.md', - active: true, - }, - { - id: 'tab-output', - type: 'file', - name: 'output.schema.json', - }, - { - id: 'tab-prompt', - type: 'file', - name: 'prompt.md', - }, -] diff --git a/web/app/components/workflow/skill/sidebar-search-add.tsx b/web/app/components/workflow/skill/sidebar-search-add.tsx index 73ef5273df..9e851bdf03 100644 --- a/web/app/components/workflow/skill/sidebar-search-add.tsx +++ b/web/app/components/workflow/skill/sidebar-search-add.tsx @@ -1,32 +1,167 @@ 'use client' + import type { FC } from 'react' -import { RiAddLine } from '@remixicon/react' +import { RiAddLine, RiFile3Line, RiFolderAddLine } from '@remixicon/react' import * as React from 'react' -import { useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' import Button from '@/app/components/base/button' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' import SearchInput from '@/app/components/base/search-input' +import Toast from '@/app/components/base/toast' +import { useCreateAppAssetFile, useCreateAppAssetFolder } from '@/service/use-app-asset' import { cn } from '@/utils/classnames' +/** + * SidebarSearchAdd - Search input and add button for file operations + * + * Features: + * - Search input for filtering files (TODO: implement filter logic) + * - Add button with dropdown menu: + * - New folder: creates a folder at root level + * - Upload file: opens file picker to upload + */ const SidebarSearchAdd: FC = () => { - const [value, setValue] = useState('') - const { t } = useTranslation() + const { t } = useTranslation('workflow') + const [searchValue, setSearchValue] = useState('') + const [showMenu, setShowMenu] = useState(false) + const fileInputRef = useRef(null) + + // Get appId from app store + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + + // Mutations + const createFolder = useCreateAppAssetFolder() + const createFile = useCreateAppAssetFile() + + // Handle new folder + const handleNewFolder = useCallback(async () => { + setShowMenu(false) + if (!appId) + return + + // For MVP, create folder with default name at root level + // TODO: Add inline rename UI after creation + const timestamp = Date.now() + const folderName = `${t('skillSidebar.newFolder')}-${timestamp}` + + try { + await createFolder.mutateAsync({ + appId, + payload: { + name: folderName, + parent_id: null, // Root level + }, + }) + Toast.notify({ + type: 'success', + message: t('skillSidebar.addFolder'), + }) + } + catch (error) { + Toast.notify({ + type: 'error', + message: String(error), + }) + } + }, [appId, createFolder, t]) + + // Handle upload file click + const handleUploadClick = useCallback(() => { + setShowMenu(false) + fileInputRef.current?.click() + }, []) + + // Handle file selection + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0 || !appId) + return + + const file = files[0] + + try { + await createFile.mutateAsync({ + appId, + name: file.name, + file, + parentId: null, // Root level + }) + Toast.notify({ + type: 'success', + message: t('skillSidebar.addFile'), + }) + } + catch (error) { + Toast.notify({ + type: 'error', + message: String(error), + }) + } + + // Reset input to allow re-uploading same file + e.target.value = '' + }, [appId, createFile, t]) return (
- + setShowMenu(!showMenu)}> + + + +
+ {/* New Folder */} +
+ + + {t('skillSidebar.addFolder')} + +
+ {/* Upload File */} +
+ + + {t('skillSidebar.addFile')} + +
+
+
+ + + {/* Hidden file input */} +
) } diff --git a/web/app/components/workflow/skill/skill-doc-editor.tsx b/web/app/components/workflow/skill/skill-doc-editor.tsx index 29e0385316..245a086b61 100644 --- a/web/app/components/workflow/skill/skill-doc-editor.tsx +++ b/web/app/components/workflow/skill/skill-doc-editor.tsx @@ -1,9 +1,215 @@ -import type { FC } from 'react' -import * as React from 'react' +'use client' +import type { FC } from 'react' +import type { AppAssetTreeView } from './type' +import Editor, { loader } from '@monaco-editor/react' +import * as React from 'react' +import { useCallback, useEffect, useMemo, 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' +import Toast from '@/app/components/base/toast' +import useTheme from '@/hooks/use-theme' +import { useGetAppAssetFileContent, useGetAppAssetTree, useUpdateAppAssetFileContent } from '@/service/use-app-asset' +import { Theme } from '@/types/app' +import { basePath } from '@/utils/var' +import { useSkillEditorStore, useSkillEditorStoreApi } from './store' +import { buildNodeMap } from './type' +import { getFileLanguage } from './utils' + +// load file from local instead of cdn +if (typeof window !== 'undefined') + loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } }) + +/** + * SkillDocEditor - Document editor for skill files + * + * Features: + * - Monaco editor for code/text editing + * - Auto-load content when tab is activated + * - Dirty state tracking via store + * - Save with Ctrl+S / Cmd+S + * + * Design notes from MVP: + * - `dirtyContents` only stores modified content, not full cache + * - `dirty = dirtyContents.has(fileId)`, no diff with server content + * - closeTab doesn't show dirty confirmation dialog (MVP) + */ const SkillDocEditor: FC = () => { + const { t } = useTranslation('workflow') + const { theme: appTheme } = useTheme() + const [isMounted, setIsMounted] = useState(false) + const editorRef = useRef(null) + + // Get appId from app store + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id || '' + + // Store state + const activeTabId = useSkillEditorStore(s => s.activeTabId) + const dirtyContents = useSkillEditorStore(s => s.dirtyContents) + const storeApi = useSkillEditorStoreApi() + + // Fetch tree data for file name lookup + const { data: treeData } = useGetAppAssetTree(appId) + + // Build node map for quick lookup + const nodeMap = useMemo(() => { + if (!treeData?.children) + return new Map() + return buildNodeMap(treeData.children) + }, [treeData?.children]) + + // Get current file node + const currentFileNode = activeTabId ? nodeMap.get(activeTabId) : undefined + + // Fetch file content from API + const { + data: fileContent, + isLoading, + error, + } = useGetAppAssetFileContent(appId, activeTabId || '') + + // Save mutation + const updateContent = useUpdateAppAssetFileContent() + + // Get draft content or server content + const currentContent = useMemo(() => { + if (!activeTabId) + return '' + // Check if there's a draft first + const draft = dirtyContents.get(activeTabId) + if (draft !== undefined) + return draft + // Otherwise use server content + return fileContent?.content ?? '' + }, [activeTabId, dirtyContents, fileContent?.content]) + + // Handle editor content change + const handleEditorChange = useCallback((value: string | undefined) => { + if (!activeTabId) + return + // Set draft content in store + storeApi.getState().setDraftContent(activeTabId, value ?? '') + }, [activeTabId, storeApi]) + + // Handle save + const handleSave = useCallback(async () => { + if (!activeTabId || !appId) + return + + const content = dirtyContents.get(activeTabId) + if (content === undefined) + return // No changes to save + + try { + await updateContent.mutateAsync({ + appId, + nodeId: activeTabId, + payload: { content }, + }) + // Clear draft on success + storeApi.getState().clearDraftContent(activeTabId) + Toast.notify({ + type: 'success', + message: t('api.saved', { ns: 'common' }), + }) + } + catch (error) { + Toast.notify({ + type: 'error', + message: String(error), + }) + } + }, [activeTabId, appId, dirtyContents, storeApi, t, updateContent]) + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+S / Cmd+S to save + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault() + handleSave() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleSave]) + + // Handle editor mount + const handleEditorDidMount = useCallback((editor: any, monaco: any) => { + editorRef.current = editor + monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark') + setIsMounted(true) + }, [appTheme]) + + // Determine editor language from file extension + const language = useMemo(() => { + if (!activeTabId || !currentFileNode) + return 'plaintext' + // Get language from file name in tree data + return getFileLanguage(currentFileNode.name) + }, [activeTabId, currentFileNode]) + + const theme = useMemo(() => { + return appTheme === Theme.light ? 'light' : 'vs-dark' + }, [appTheme]) + + // No active tab + if (!activeTabId) { + return ( +
+ + {t('skillSidebar.empty')} + +
+ ) + } + + // Loading state + if (isLoading) { + return ( +
+ +
+ ) + } + + // Error state + if (error) { + return ( +
+ + {t('skillSidebar.loadError')} + +
+ ) + } + return ( -
+
+ } + onChange={handleEditorChange} + options={{ + minimap: { enabled: false }, + lineNumbersMinChars: 3, + wordWrap: 'on', + unicodeHighlight: { + ambiguousCharacters: false, + }, + stickyScroll: { enabled: false }, + fontSize: 13, + lineHeight: 20, + padding: { top: 12, bottom: 12 }, + }} + onMount={handleEditorDidMount} + /> +
) } diff --git a/web/app/components/workflow/skill/store/index.ts b/web/app/components/workflow/skill/store/index.ts new file mode 100644 index 0000000000..7e184debdd --- /dev/null +++ b/web/app/components/workflow/skill/store/index.ts @@ -0,0 +1,233 @@ +import type { StateCreator, StoreApi } from 'zustand' +import * as React from 'react' +import { useContext } from 'react' +import { useStore as useZustandStore } from 'zustand' +import { createStore } from 'zustand/vanilla' + +/** + * SkillEditorStore - Zustand Store for Skill Editor + * + * Based on MVP Design Document (docs/design/skill-editor-file-list-tab-mvp-design.md) + * + * Key principles: + * - Server data via TanStack Query (useGetAppAssetTree, etc.) + * - Client store only for UI state (tabs, expanded folders, dirty contents) + * - Store uses fileId only, tab display name derived from tree data + */ + +// ============================================================================ +// Tab Slice +// ============================================================================ + +export type TabSliceShape = { + /** Ordered list of open tab file IDs */ + openTabIds: string[] + /** Currently active tab file ID */ + activeTabId: string | null + /** Preview tab file ID (MVP: not enabled, kept null) */ + previewTabId: string | null + + /** Open a file as a tab (and activate it) */ + openTab: (fileId: string) => void + /** Close a tab */ + closeTab: (fileId: string) => void + /** Activate a tab (without opening) */ + activateTab: (fileId: string) => void +} + +export const createTabSlice: StateCreator = (set, get) => ({ + openTabIds: [], + activeTabId: null, + previewTabId: null, // MVP: Preview mode not enabled + + openTab: (fileId: string) => { + const { openTabIds, activeTabId } = get() + // If already open, just activate + if (openTabIds.includes(fileId)) { + if (activeTabId !== fileId) + set({ activeTabId: fileId }) + return + } + // Add to tabs and activate + set({ + openTabIds: [...openTabIds, fileId], + activeTabId: fileId, + }) + }, + + closeTab: (fileId: string) => { + const { openTabIds, activeTabId } = get() + const newOpenTabIds = openTabIds.filter(id => id !== fileId) + + // If closing the active tab, activate adjacent tab + let newActiveTabId = activeTabId + if (activeTabId === fileId) { + const closedIndex = openTabIds.indexOf(fileId) + if (newOpenTabIds.length > 0) { + // Prefer next, fallback to previous + newActiveTabId = newOpenTabIds[Math.min(closedIndex, newOpenTabIds.length - 1)] + } + else { + newActiveTabId = null + } + } + + set({ + openTabIds: newOpenTabIds, + activeTabId: newActiveTabId, + }) + }, + + activateTab: (fileId: string) => { + const { openTabIds } = get() + if (openTabIds.includes(fileId)) + set({ activeTabId: fileId }) + }, +}) + +// ============================================================================ +// File Tree Slice +// ============================================================================ + +export type FileTreeSliceShape = { + /** Set of expanded folder IDs (controlled by react-arborist) */ + expandedFolderIds: Set + + /** Update expanded folder IDs (controlled mode) */ + setExpandedFolderIds: (ids: Set) => void + /** Toggle a folder's expanded state */ + toggleFolder: (folderId: string) => void + /** Reveal a file by expanding all ancestor folders */ + revealFile: (fileId: string, ancestorFolderIds: string[]) => void +} + +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: (_fileId: string, ancestorFolderIds: string[]) => { + const { expandedFolderIds } = get() + const newSet = new Set(expandedFolderIds) + // Expand all ancestors + ancestorFolderIds.forEach(id => newSet.add(id)) + set({ expandedFolderIds: newSet }) + }, +}) + +// ============================================================================ +// Dirty State Slice +// ============================================================================ + +export type DirtySliceShape = { + /** Map of fileId -> edited content (only stores modified files) */ + dirtyContents: Map + + /** Set draft content for a file (marks as dirty) */ + setDraftContent: (fileId: string, content: string) => void + /** Clear draft content (after successful save) */ + clearDraftContent: (fileId: string) => void + /** Check if a file has unsaved changes */ + isDirty: (fileId: string) => boolean + /** Get draft content for a file (or undefined if not dirty) */ + 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) + }, +}) + +// ============================================================================ +// Combined Store Shape +// ============================================================================ + +export type SkillEditorShape + = TabSliceShape + & FileTreeSliceShape + & DirtySliceShape + & { + /** Reset all state (called when appId changes) */ + reset: () => void + } + +// ============================================================================ +// Store Factory +// ============================================================================ + +export const createSkillEditorStore = (): StoreApi => { + return createStore((...args) => ({ + ...createTabSlice(...args), + ...createFileTreeSlice(...args), + ...createDirtySlice(...args), + + reset: () => { + const [set] = args + set({ + openTabIds: [], + activeTabId: null, + previewTabId: null, + expandedFolderIds: new Set(), + dirtyContents: new Map(), + }) + }, + })) +} + +// ============================================================================ +// Context and Hooks +// ============================================================================ + +export type SkillEditorStore = StoreApi + +export const SkillEditorContext = React.createContext(null) + +export function useSkillEditorStore(selector: (state: SkillEditorShape) => T): T { + const store = useContext(SkillEditorContext) + if (!store) + throw new Error('Missing SkillEditorContext.Provider in the tree') + + return useZustandStore(store, selector) +} + +export const useSkillEditorStoreApi = (): SkillEditorStore => { + const store = useContext(SkillEditorContext) + if (!store) + throw new Error('Missing SkillEditorContext.Provider in the tree') + + return store +} diff --git a/web/app/components/workflow/skill/type.ts b/web/app/components/workflow/skill/type.ts index ac7777c381..cd52d44818 100644 --- a/web/app/components/workflow/skill/type.ts +++ b/web/app/components/workflow/skill/type.ts @@ -1,29 +1,136 @@ -export const SKILL_ROOT_ID = 'root' as const -export type ItemId = string -export type ParentId = ItemId | typeof SKILL_ROOT_ID | null +import type { AppAssetTreeView } from '@/types/app-asset' -export enum ResourceKind { - folder = 'folder', - file = 'file', -} +/** + * Skill Editor Types + * + * This file defines types for the Skill Editor component. + * Primary data comes from API (AppAssetTreeView), these types provide + * local aliases and helper types for component props. + */ -export type ResourceItemBase = { - id: ItemId +// ============================================================================ +// Re-export API types for convenience +// ============================================================================ + +export type { AppAssetNode, AppAssetTreeView, AssetNodeType } from '@/types/app-asset' + +// ============================================================================ +// Tree Node Types (for react-arborist) +// ============================================================================ + +/** + * Tree node data type for react-arborist + * This matches AppAssetTreeView structure directly + */ +export type TreeNodeData = AppAssetTreeView + +// ============================================================================ +// Tab Types +// ============================================================================ + +export type SkillTabType = 'start' | 'file' + +export type SkillTabItem = { + /** Unique ID (for 'file' type, this is the fileId; for 'start', a constant) */ + id: string + /** Tab type: 'start' for home tab, 'file' for file tabs */ + type: SkillTabType + /** Display name (file name or 'Start') */ name: string - parent_id: ParentId - path?: string + /** File extension (for file type only) */ + extension?: string + /** Whether this tab has unsaved changes */ + isDirty?: boolean } -export type FolderItem = ResourceItemBase & { - kind: ResourceKind.folder +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Convert API tree data to a flat map for quick lookup + * @param nodes - Tree nodes from API (nested structure) + * @returns Map of nodeId -> node data + */ +export function buildNodeMap(nodes: AppAssetTreeView[]): Map { + const map = new Map() + + function traverse(nodeList: AppAssetTreeView[]) { + for (const node of nodeList) { + map.set(node.id, node) + if (node.children && node.children.length > 0) + traverse(node.children) + } + } + + traverse(nodes) + return map } -export type FileItem = ResourceItemBase & { - kind: ResourceKind.file - ext?: string - size?: number +/** + * Get ancestor folder IDs for a given node + * Used for revealFile to expand all parent folders + * @param nodeId - Target node ID + * @param nodes - Tree nodes from API + * @returns Array of ancestor folder IDs (from root to parent) + */ +export function getAncestorIds(nodeId: string, nodes: AppAssetTreeView[]): string[] { + const ancestors: string[] = [] + + function findPath(nodeList: AppAssetTreeView[], targetId: string, currentPath: string[]): boolean { + for (const node of nodeList) { + if (node.id === targetId) { + ancestors.push(...currentPath) + return true + } + if (node.children && node.children.length > 0) { + const newPath = node.node_type === 'folder' ? [...currentPath, node.id] : currentPath + if (findPath(node.children, targetId, newPath)) + return true + } + } + return false + } + + findPath(nodes, nodeId, []) + return ancestors } -export type ResourceItem = FolderItem | FileItem +/** + * Get file extension from file name + * @param name - File name (e.g., 'file.txt') + * @returns Extension without dot (e.g., 'txt') or empty string + */ +export function getExtension(name: string): string { + const lastDot = name.lastIndexOf('.') + if (lastDot === -1 || lastDot === 0) + return '' + return name.slice(lastDot + 1).toLowerCase() +} -export type ResourceItemList = ResourceItem[] +/** + * Convert expanded folder IDs set to react-arborist opens object + * @param expandedIds - Set of expanded folder IDs + * @returns Object for react-arborist opens prop + */ +export function toOpensObject(expandedIds: Set): Record { + const opens: Record = {} + expandedIds.forEach((id) => { + opens[id] = true + }) + return opens +} + +/** + * Convert react-arborist opens object to Set + * @param opens - Opens object from react-arborist + * @returns Set of expanded folder IDs + */ +export function fromOpensObject(opens: Record): Set { + const set = new Set() + Object.entries(opens).forEach(([id, isOpen]) => { + if (isOpen) + set.add(id) + }) + return set +} diff --git a/web/app/components/workflow/skill/utils.ts b/web/app/components/workflow/skill/utils.ts index 4746d75194..0e4497fbf8 100644 --- a/web/app/components/workflow/skill/utils.ts +++ b/web/app/components/workflow/skill/utils.ts @@ -11,3 +11,39 @@ export const getFileIconType = (name: string) => { return FileAppearanceTypeEnum.document } + +/** + * Get Monaco editor language from file name extension + */ +export const getFileLanguage = (name: string): string => { + const extension = name.split('.').pop()?.toLowerCase() ?? '' + + const languageMap: Record = { + // Markdown + md: 'markdown', + markdown: 'markdown', + mdx: 'markdown', + // JSON + json: 'json', + jsonl: 'json', + // YAML + yaml: 'yaml', + yml: 'yaml', + // JavaScript/TypeScript + js: 'javascript', + jsx: 'javascript', + ts: 'typescript', + tsx: 'typescript', + // Python + py: 'python', + // Others + html: 'html', + css: 'css', + xml: 'xml', + sql: 'sql', + sh: 'shell', + bash: 'shell', + } + + return languageMap[extension] ?? 'plaintext' +} diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 751d56084f..4c2bad1ddc 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -995,7 +995,15 @@ "singleRun.testRun": "Test Run", "singleRun.testRunIteration": "Test Run Iteration", "singleRun.testRunLoop": "Test Run Loop", + "skillSidebar.addFile": "Upload File", + "skillSidebar.addFolder": "New Folder", "skillSidebar.dropTip": "Drop files here to upload", + "skillSidebar.empty": "No files yet", + "skillSidebar.folderName": "Folder name", + "skillSidebar.loadError": "Failed to load files", + "skillSidebar.newFolder": "New folder", + "skillSidebar.searchPlaceholder": "Search files...", + "skillSidebar.uploading": "Uploading...", "tabs.-": "Default", "tabs.addAll": "Add all", "tabs.agent": "Agent Strategy", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 7f34761111..63ed8689c9 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -989,6 +989,15 @@ "singleRun.testRun": "测试运行", "singleRun.testRunIteration": "测试运行迭代", "singleRun.testRunLoop": "测试运行循环", + "skillSidebar.addFile": "上传文件", + "skillSidebar.addFolder": "新建文件夹", + "skillSidebar.dropTip": "拖放文件到此处上传", + "skillSidebar.empty": "暂无文件", + "skillSidebar.folderName": "文件夹名称", + "skillSidebar.loadError": "加载文件失败", + "skillSidebar.newFolder": "新建文件夹", + "skillSidebar.searchPlaceholder": "搜索文件...", + "skillSidebar.uploading": "上传中...", "tabs.-": "默认", "tabs.addAll": "添加全部", "tabs.agent": "Agent 策略", diff --git a/web/package.json b/web/package.json index bdbac2af83..101f145028 100644 --- a/web/package.json +++ b/web/package.json @@ -119,6 +119,7 @@ "qs": "^6.14.1", "react": "19.2.3", "react-18-input-autosize": "^3.0.0", + "react-arborist": "^3.4.3", "react-dom": "19.2.3", "react-easy-crop": "^5.5.3", "react-hotkeys-hook": "^4.6.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index c8797e3d65..a2b3c365d1 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: react-18-input-autosize: specifier: ^3.0.0 version: 3.0.0(react@19.2.3) + react-arborist: + specifier: ^3.4.3 + version: 3.4.3(@types/node@18.15.0)(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) @@ -2790,6 +2793,15 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@react-dnd/asap@4.0.1': + resolution: {integrity: sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==} + + '@react-dnd/invariant@2.0.0': + resolution: {integrity: sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==} + + '@react-dnd/shallowequal@2.0.0': + resolution: {integrity: sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==} + '@react-stately/flags@3.1.2': resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} @@ -4927,6 +4939,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dnd-core@14.0.1: + resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -7157,6 +7172,30 @@ packages: peerDependencies: react: ^16.3.0 || ^17.0.0 || ^18.0.0 + react-arborist@3.4.3: + resolution: {integrity: sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ==} + peerDependencies: + react: '>= 16.14' + react-dom: '>= 16.14' + + react-dnd-html5-backend@14.1.0: + resolution: {integrity: sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==} + + react-dnd@14.0.5: + resolution: {integrity: sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==} + peerDependencies: + '@types/hoist-non-react-statics': '>= 3.3.1' + '@types/node': '>= 12' + '@types/react': ~19.2.7 + react: '>= 16.14' + peerDependenciesMeta: + '@types/hoist-non-react-statics': + optional: true + '@types/node': + optional: true + '@types/react': + optional: true + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -7390,6 +7429,12 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + refa@0.12.1: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -11114,6 +11159,12 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@react-dnd/asap@4.0.1': {} + + '@react-dnd/invariant@2.0.0': {} + + '@react-dnd/shallowequal@2.0.0': {} + '@react-stately/flags@3.1.2': dependencies: '@swc/helpers': 0.5.17 @@ -13532,6 +13583,12 @@ snapshots: dlv@1.1.3: {} + dnd-core@14.0.1: + dependencies: + '@react-dnd/asap': 4.0.1 + '@react-dnd/invariant': 2.0.0 + redux: 4.2.1 + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -16333,6 +16390,36 @@ snapshots: prop-types: 15.8.1 react: 19.2.3 + react-arborist@3.4.3(@types/node@18.15.0)(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dnd: 14.0.5(@types/node@18.15.0)(@types/react@19.2.7)(react@19.2.3) + react-dnd-html5-backend: 14.1.0 + react-dom: 19.2.3(react@19.2.3) + react-window: 1.8.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + redux: 5.0.1 + use-sync-external-store: 1.6.0(react@19.2.3) + transitivePeerDependencies: + - '@types/hoist-non-react-statics' + - '@types/node' + - '@types/react' + + react-dnd-html5-backend@14.1.0: + dependencies: + dnd-core: 14.0.1 + + react-dnd@14.0.5(@types/node@18.15.0)(@types/react@19.2.7)(react@19.2.3): + dependencies: + '@react-dnd/invariant': 2.0.0 + '@react-dnd/shallowequal': 2.0.0 + dnd-core: 14.0.1 + fast-deep-equal: 3.1.3 + hoist-non-react-statics: 3.3.2 + react: 19.2.3 + optionalDependencies: + '@types/node': 18.15.0 + '@types/react': 19.2.7 + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -16634,6 +16721,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redux@4.2.1: + dependencies: + '@babel/runtime': 7.28.4 + + redux@5.0.1: {} + refa@0.12.1: dependencies: '@eslint-community/regexpp': 4.12.1