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:
yyh
2026-01-15 13:53:19 +08:00
parent 63b3e71909
commit fe17cbc1a8
16 changed files with 1362 additions and 328 deletions

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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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)

View File

@@ -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>
)
}

View File

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

View File

@@ -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',
},
]

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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
}

View File

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

View File

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

View File

@@ -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",

View File

@@ -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 策略",

View File

@@ -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
View File

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