mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 23:20:12 -05:00
feat(skill-editor): implement file tree, tab management, and dirty state tracking
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)
This commit is contained in:
52
web/app/components/workflow/skill/context.tsx
Normal file
52
web/app/components/workflow/skill/context.tsx
Normal file
@@ -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<SkillEditorStore | undefined>(undefined)
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id
|
||||
const prevAppIdRef = useRef<string | undefined>(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 (
|
||||
<SkillEditorContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</SkillEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { SkillEditorContext } from './store'
|
||||
@@ -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<EditorTabItemProps> = ({ item }) => {
|
||||
const EditorTabItem: FC<EditorTabItemProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex shrink-0 items-center gap-1.5 border-r border-components-panel-border-subtle px-2.5 pb-2 pt-2.5',
|
||||
isActive ? 'bg-components-panel-bg' : 'bg-transparent',
|
||||
'group relative flex shrink-0 cursor-pointer items-center gap-1.5 border-r border-components-panel-border-subtle px-2.5 pb-2 pt-2.5',
|
||||
isActive ? 'bg-components-panel-bg' : 'bg-transparent hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('flex size-5 items-center justify-center', !isActive && 'opacity-70')}>
|
||||
{isStart
|
||||
? (
|
||||
<RiHome9Line className="size-4 text-text-tertiary" />
|
||||
)
|
||||
: (
|
||||
<FileTypeIcon type={iconType as FileAppearanceType} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'max-w-40 truncate text-[13px] leading-4',
|
||||
isStart ? 'uppercase text-text-tertiary' : 'text-text-tertiary',
|
||||
isActive && 'font-medium text-text-primary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* Icon with dirty indicator */}
|
||||
<div className="relative flex size-5 shrink-0 items-center justify-center">
|
||||
<FileTypeIcon type={iconType as FileAppearanceType} size="sm" />
|
||||
{/* Dirty indicator dot */}
|
||||
{isDirty && (
|
||||
<span className="absolute -bottom-px -right-px size-[7px] rounded-full border border-white bg-text-warning-secondary" />
|
||||
)}
|
||||
</div>
|
||||
{!isStart && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'ml-0.5 flex size-4 items-center justify-center rounded-[6px] text-text-tertiary',
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
)}
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* File name */}
|
||||
<span
|
||||
className={cn(
|
||||
'max-w-40 truncate text-[13px] leading-4',
|
||||
isActive
|
||||
? 'font-medium text-text-primary'
|
||||
: 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'ml-0.5 flex size-4 items-center justify-center rounded-[6px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
)}
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, AppAssetTreeView>()
|
||||
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<EditorTabsProps> = ({ items }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center overflow-hidden rounded-t-lg border-b border-components-panel-border-subtle bg-components-panel-bg-alt',
|
||||
)}
|
||||
>
|
||||
{items.map(item => (
|
||||
<EditorTabItem key={item.id} item={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 (
|
||||
<EditorTabItem
|
||||
key={fileId}
|
||||
fileId={fileId}
|
||||
name={name}
|
||||
extension={extension}
|
||||
isActive={isActive}
|
||||
isDirty={isDirty}
|
||||
onClick={handleTabClick}
|
||||
onClose={handleTabClose}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
101
web/app/components/workflow/skill/file-tree-node.tsx
Normal file
101
web/app/components/workflow/skill/file-tree-node.tsx
Normal file
@@ -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<TreeNodeData>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
className={cn(
|
||||
'group flex h-6 cursor-pointer items-center gap-2 rounded-md px-2',
|
||||
'hover:bg-state-base-hover',
|
||||
isSelected && 'bg-state-base-active',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isFolder
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="flex size-full items-center justify-center"
|
||||
>
|
||||
{node.isOpen
|
||||
? <RiFolderOpenLine className="size-4 text-text-accent" />
|
||||
: <RiFolderLine className="size-4 text-text-secondary" />}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<div className="relative flex size-full items-center justify-center">
|
||||
<FileTypeIcon type={fileIconType as FileAppearanceType} size="sm" />
|
||||
{/* Dirty indicator dot */}
|
||||
{isDirty && (
|
||||
<span className="absolute -bottom-px -right-px size-[7px] rounded-full border border-white bg-text-warning-secondary" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[13px] leading-4',
|
||||
isSelected
|
||||
? 'font-medium text-text-primary'
|
||||
: 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{node.data.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FileTreeNode)
|
||||
@@ -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 (
|
||||
<div className="flex h-full items-stretch">
|
||||
{Array.from({ length: depth }).map((_, index) => (
|
||||
<span key={index} className="relative w-5 shrink-0">
|
||||
<span className="absolute left-1/2 top-0 h-full w-px bg-components-panel-border-subtle" />
|
||||
</span>
|
||||
))}
|
||||
<div className="flex shrink-0 items-center justify-center gap-2 py-4 text-text-quaternary">
|
||||
<RiDragDropLine className="size-4" />
|
||||
<span className="system-xs-regular">
|
||||
{t('skillSidebar.dropTip')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FilesProps = {
|
||||
items: ResourceItemList
|
||||
activeItemId?: string
|
||||
}
|
||||
const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const treeRef = useRef<TreeApi<TreeNodeData>>(null)
|
||||
|
||||
const buildChildrenMap = (items: ResourceItemList) => {
|
||||
const map = new Map<ParentId, ResourceItem[]>()
|
||||
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<FilesProps> = ({ 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 = <TreeIndent depth={depth} />
|
||||
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(
|
||||
<FoldItem
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
prefix={prefix}
|
||||
active={isActive}
|
||||
open
|
||||
/>,
|
||||
)
|
||||
nodes.push(...renderNodes(item.id, depth + 1))
|
||||
}
|
||||
else {
|
||||
nodes.push(
|
||||
<FileItem
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
prefix={prefix}
|
||||
active={isActive}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
// 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<TreeNodeData>) => {
|
||||
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 (
|
||||
<div className={cn('flex min-h-0 flex-1 items-center justify-center', className)}>
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col items-center justify-center gap-2 text-text-tertiary', className)}>
|
||||
<span className="system-xs-regular">
|
||||
{t('skillSidebar.loadError')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!treeData?.children || treeData.children.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('skillSidebar.empty')}
|
||||
</span>
|
||||
</div>
|
||||
<DropTip />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-px overflow-auto px-1 pb-0 pt-1">
|
||||
{renderNodes(SKILL_ROOT_ID, 0)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-text-quaternary">
|
||||
<RiDragDropLine className="size-4" />
|
||||
<span className="system-xs-regular">
|
||||
{t('skillSidebar.dropTip', { ns: 'workflow' })}
|
||||
</span>
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-1 pt-1">
|
||||
<Tree<TreeNodeData>
|
||||
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}
|
||||
</Tree>
|
||||
</div>
|
||||
<DropTip />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-full bg-workflow-canvas-workflow-top-bar-1 pl-3 pt-[52px]">
|
||||
<SkillPageLayout>
|
||||
<Sidebar>
|
||||
<SidebarSearchAdd />
|
||||
<Files items={mockSkillItems} activeItemId={activeItemId} />
|
||||
</Sidebar>
|
||||
<EditorArea>
|
||||
<EditorTabs items={mockSkillTabs} />
|
||||
<EditorBody>
|
||||
<SkillDocEditor />
|
||||
</EditorBody>
|
||||
</EditorArea>
|
||||
</SkillPageLayout>
|
||||
</div>
|
||||
<SkillEditorProvider>
|
||||
<div className="h-full bg-workflow-canvas-workflow-top-bar-1 pl-3 pt-[52px]">
|
||||
<SkillPageLayout>
|
||||
<Sidebar>
|
||||
<SidebarSearchAdd />
|
||||
<Files />
|
||||
</Sidebar>
|
||||
<EditorArea>
|
||||
<EditorTabs />
|
||||
<EditorBody>
|
||||
<SkillDocEditor />
|
||||
</EditorBody>
|
||||
</EditorArea>
|
||||
</SkillPageLayout>
|
||||
</div>
|
||||
</SkillEditorProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SkillMain)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center gap-1 bg-components-panel-bg p-2">
|
||||
<SearchInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
className="h-8 flex-1"
|
||||
placeholder={t('skillSidebar.searchPlaceholder')}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
className={cn('!h-8 !w-8 !px-0')}
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
<PortalToFollowElem
|
||||
open={showMenu}
|
||||
onOpenChange={setShowMenu}
|
||||
placement="bottom-end"
|
||||
offset={{ mainAxis: 4 }}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</Button>
|
||||
<PortalToFollowElemTrigger onClick={() => setShowMenu(!showMenu)}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
className={cn('!h-8 !w-8 !px-0')}
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[30]">
|
||||
<div className="flex min-w-[160px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
|
||||
{/* New Folder */}
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-state-base-hover"
|
||||
onClick={handleNewFolder}
|
||||
>
|
||||
<RiFolderAddLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('skillSidebar.addFolder')}
|
||||
</span>
|
||||
</div>
|
||||
{/* Upload File */}
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-state-base-hover"
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
<RiFile3Line className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('skillSidebar.addFile')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<any>(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<string, AppAssetTreeView>()
|
||||
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 (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
||||
<span className="system-sm-regular">
|
||||
{t('skillSidebar.empty')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
||||
<span className="system-sm-regular">
|
||||
{t('skillSidebar.loadError')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto bg-components-panel-bg" />
|
||||
<div className="h-full w-full overflow-hidden bg-components-panel-bg">
|
||||
<Editor
|
||||
language={language}
|
||||
theme={isMounted ? theme : 'default-theme'}
|
||||
value={currentContent}
|
||||
loading={<Loading type="area" />}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
233
web/app/components/workflow/skill/store/index.ts
Normal file
233
web/app/components/workflow/skill/store/index.ts
Normal file
@@ -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<TabSliceShape> = (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<string>
|
||||
|
||||
/** Update expanded folder IDs (controlled mode) */
|
||||
setExpandedFolderIds: (ids: Set<string>) => 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<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: (_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<string, string>
|
||||
|
||||
/** 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<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)
|
||||
},
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Combined Store Shape
|
||||
// ============================================================================
|
||||
|
||||
export type SkillEditorShape
|
||||
= TabSliceShape
|
||||
& FileTreeSliceShape
|
||||
& DirtySliceShape
|
||||
& {
|
||||
/** Reset all state (called when appId changes) */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Store Factory
|
||||
// ============================================================================
|
||||
|
||||
export const createSkillEditorStore = (): StoreApi<SkillEditorShape> => {
|
||||
return createStore<SkillEditorShape>((...args) => ({
|
||||
...createTabSlice(...args),
|
||||
...createFileTreeSlice(...args),
|
||||
...createDirtySlice(...args),
|
||||
|
||||
reset: () => {
|
||||
const [set] = args
|
||||
set({
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
previewTabId: null,
|
||||
expandedFolderIds: new Set<string>(),
|
||||
dirtyContents: new Map<string, string>(),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context and Hooks
|
||||
// ============================================================================
|
||||
|
||||
export type SkillEditorStore = StoreApi<SkillEditorShape>
|
||||
|
||||
export const SkillEditorContext = React.createContext<SkillEditorStore | null>(null)
|
||||
|
||||
export function useSkillEditorStore<T>(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
|
||||
}
|
||||
@@ -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<string, AppAssetTreeView> {
|
||||
const map = new Map<string, AppAssetTreeView>()
|
||||
|
||||
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<string>): Record<string, boolean> {
|
||||
const opens: Record<string, boolean> = {}
|
||||
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<string, boolean>): Set<string> {
|
||||
const set = new Set<string>()
|
||||
Object.entries(opens).forEach(([id, isOpen]) => {
|
||||
if (isOpen)
|
||||
set.add(id)
|
||||
})
|
||||
return set
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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'
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 策略",
|
||||
|
||||
@@ -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",
|
||||
|
||||
93
web/pnpm-lock.yaml
generated
93
web/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user