mirror of
https://github.com/langgenius/dify.git
synced 2026-05-01 07:58:02 +08:00
refactor(skill-editor): extract hooks and utils into separate directories
- Extract useFileOperations hook to hooks/use-file-operations.ts - Move tree utilities to utils/tree-utils.ts - Move file utilities to utils/file-utils.ts (renamed from utils.ts) - Remove unnecessary JSDoc comments throughout components - Simplify type.ts to only contain local type definitions - Clean up store/index.ts by removing verbose comments
This commit is contained in:
@ -8,22 +8,7 @@ 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
|
||||
*/
|
||||
import { getFileIconType } from './utils/file-utils'
|
||||
|
||||
type EditorTabItemProps = {
|
||||
fileId: string
|
||||
@ -62,16 +47,13 @@ const EditorTabItem: FC<EditorTabItemProps> = ({
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
{/* File name */}
|
||||
<span
|
||||
className={cn(
|
||||
'max-w-40 truncate text-[13px] leading-4',
|
||||
@ -83,7 +65,6 @@ const EditorTabItem: FC<EditorTabItemProps> = ({
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { AppAssetTreeView } from './type'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
@ -9,55 +9,34 @@ 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'
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
import { buildNodeMap } from './utils/tree-utils'
|
||||
|
||||
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 treeChildren = treeData?.children
|
||||
const nodeMap = useMemo(() => {
|
||||
if (!treeChildren)
|
||||
if (!treeData?.children)
|
||||
return new Map<string, AppAssetTreeView>()
|
||||
return buildNodeMap(treeChildren)
|
||||
}, [treeChildren])
|
||||
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
|
||||
|
||||
|
||||
@ -12,32 +12,10 @@ import {
|
||||
RiUploadLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
useCreateAppAssetFile,
|
||||
useCreateAppAssetFolder,
|
||||
useDeleteAppAssetNode,
|
||||
useGetAppAssetTree,
|
||||
} from '@/service/use-app-asset'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useSkillEditorStoreApi } from './store'
|
||||
import { getAllDescendantFileIds } from './type'
|
||||
|
||||
/**
|
||||
* FileOperationsMenu - Menu content for file operations
|
||||
*
|
||||
* Shared by both context menu (right-click) and dropdown menu (... button)
|
||||
*
|
||||
* Features:
|
||||
* - New File: Create empty file (via empty Blob upload)
|
||||
* - New Folder: Create folder in target location
|
||||
* - Upload File: Upload file(s) to target folder
|
||||
* - Upload Folder: Upload entire folder structure (webkitdirectory)
|
||||
*/
|
||||
import { useFileOperations } from './hooks/use-file-operations'
|
||||
|
||||
type MenuItemProps = {
|
||||
icon: React.ElementType
|
||||
@ -64,15 +42,10 @@ const MenuItem: React.FC<MenuItemProps> = ({ icon: Icon, label, onClick, disable
|
||||
)
|
||||
|
||||
type FileOperationsMenuProps = {
|
||||
/** Target folder ID, or 'root' for root level */
|
||||
nodeId: string
|
||||
/** Callback to close menu after action */
|
||||
onClose: () => void
|
||||
/** Optional className */
|
||||
className?: string
|
||||
/** Tree API ref for context menu (to call node.edit()) */
|
||||
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
/** Node API for dropdown menu (to call node.edit()) */
|
||||
node?: NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
@ -84,276 +57,22 @@ const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
|
||||
node,
|
||||
}) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Get appId from app store
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
|
||||
// Store API for tab cleanup
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
// Mutations
|
||||
const createFolder = useCreateAppAssetFolder()
|
||||
const createFile = useCreateAppAssetFile()
|
||||
const deleteNode = useDeleteAppAssetNode()
|
||||
|
||||
// Tree data for descendant lookup
|
||||
const { data: treeData } = useGetAppAssetTree(appId)
|
||||
|
||||
// Delete confirmation state
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
// Determine parent_id (null for root)
|
||||
const parentId = nodeId === 'root' ? null : nodeId
|
||||
|
||||
// Handle New File
|
||||
const handleNewFile = useCallback(async () => {
|
||||
// eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity, will be replaced with modal later
|
||||
const fileName = window.prompt(t('skillSidebar.menu.newFilePrompt'))
|
||||
if (!fileName || !fileName.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Create empty Blob and upload as file
|
||||
const emptyBlob = new Blob([''], { type: 'text/plain' })
|
||||
const file = new File([emptyBlob], fileName.trim())
|
||||
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: fileName.trim(),
|
||||
file,
|
||||
parentId,
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.fileCreated'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.createError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, onClose, parentId, t])
|
||||
|
||||
// Handle New Folder
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
// eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity, will be replaced with modal later
|
||||
const folderName = window.prompt(t('skillSidebar.menu.newFolderPrompt'))
|
||||
if (!folderName || !folderName.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createFolder.mutateAsync({
|
||||
appId,
|
||||
payload: {
|
||||
name: folderName.trim(),
|
||||
parent_id: parentId,
|
||||
},
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.folderCreated'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.createError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFolder, onClose, parentId, t])
|
||||
|
||||
// Handle file input change (single or multiple files)
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Upload files sequentially to avoid overwhelming the server
|
||||
for (const file of files) {
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId,
|
||||
})
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.filesUploaded', { count: files.length }),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
// Reset input to allow re-uploading same file
|
||||
e.target.value = ''
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, onClose, parentId, t])
|
||||
|
||||
// Handle folder input change (webkitdirectory)
|
||||
const handleFolderChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Collect all unique folder paths from file paths
|
||||
const folders = new Set<string>()
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
|
||||
const parts = relativePath.split('/')
|
||||
|
||||
// Collect all folder paths (parent directories)
|
||||
if (parts.length > 1) {
|
||||
let folderPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
folderPath = folderPath ? `${folderPath}/${parts[i]}` : parts[i]
|
||||
folders.add(folderPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort folders by depth (parent before child)
|
||||
const sortedFolders = Array.from(folders).sort((a, b) => {
|
||||
return a.split('/').length - b.split('/').length
|
||||
})
|
||||
|
||||
// Create folders and track their IDs
|
||||
const folderIdMap = new Map<string, string | null>()
|
||||
folderIdMap.set('', parentId) // Root maps to target parent
|
||||
|
||||
for (const folderPath of sortedFolders) {
|
||||
const parts = folderPath.split('/')
|
||||
const folderName = parts[parts.length - 1]
|
||||
const parentPath = parts.slice(0, -1).join('/')
|
||||
const parentFolderId = folderIdMap.get(parentPath) ?? parentId
|
||||
|
||||
const result = await createFolder.mutateAsync({
|
||||
appId,
|
||||
payload: {
|
||||
name: folderName,
|
||||
parent_id: parentFolderId,
|
||||
},
|
||||
})
|
||||
|
||||
folderIdMap.set(folderPath, result.id)
|
||||
}
|
||||
|
||||
// Upload files to their respective folders
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
|
||||
const parts = relativePath.split('/')
|
||||
const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : ''
|
||||
const targetParentId = folderIdMap.get(parentPath) ?? parentId
|
||||
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId: targetParentId,
|
||||
})
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.folderUploaded'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
// Reset input
|
||||
e.target.value = ''
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, createFolder, onClose, parentId, t])
|
||||
|
||||
// Handle Rename - trigger react-arborist inline editing
|
||||
const handleRename = useCallback(() => {
|
||||
// Context menu: use treeRef
|
||||
if (treeRef?.current) {
|
||||
const targetNode = treeRef.current.get(nodeId)
|
||||
targetNode?.edit()
|
||||
}
|
||||
// Dropdown: use node directly
|
||||
else if (node) {
|
||||
node.edit()
|
||||
}
|
||||
onClose()
|
||||
}, [nodeId, node, onClose, treeRef])
|
||||
|
||||
// Handle Delete click - show confirmation
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
setShowDeleteConfirm(true)
|
||||
}, [])
|
||||
|
||||
// Handle Delete confirm
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
try {
|
||||
// Find descendant file IDs for tab cleanup
|
||||
const descendantFileIds = treeData?.children
|
||||
? getAllDescendantFileIds(nodeId, treeData.children)
|
||||
: []
|
||||
|
||||
await deleteNode.mutateAsync({ appId, nodeId })
|
||||
|
||||
// Close tabs for deleted files
|
||||
descendantFileIds.forEach((fileId) => {
|
||||
storeApi.getState().closeTab(fileId)
|
||||
storeApi.getState().clearDraftContent(fileId)
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.deleted'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.deleteError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setShowDeleteConfirm(false)
|
||||
onClose()
|
||||
}
|
||||
}, [appId, nodeId, deleteNode, storeApi, treeData?.children, onClose, t])
|
||||
|
||||
const isLoading = createFile.isPending || createFolder.isPending || deleteNode.isPending
|
||||
const {
|
||||
fileInputRef,
|
||||
folderInputRef,
|
||||
showDeleteConfirm,
|
||||
isLoading,
|
||||
isDeleting,
|
||||
handleNewFile,
|
||||
handleNewFolder,
|
||||
handleFileChange,
|
||||
handleFolderChange,
|
||||
handleRename,
|
||||
handleDeleteClick,
|
||||
handleDeleteConfirm,
|
||||
handleDeleteCancel,
|
||||
} = useFileOperations({ nodeId, onClose, treeRef, node })
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
@ -362,7 +81,6 @@ const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@ -392,7 +110,6 @@ const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
|
||||
<MenuItem
|
||||
@ -408,7 +125,6 @@ const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Divider before destructive actions */}
|
||||
{nodeId !== 'root' && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
@ -437,15 +153,14 @@ const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<Confirm
|
||||
isShow={showDeleteConfirm}
|
||||
type="danger"
|
||||
title={t('skillSidebar.menu.deleteConfirmTitle')}
|
||||
content={t('skillSidebar.menu.deleteConfirmContent')}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
isLoading={deleteNode.isPending}
|
||||
onCancel={handleDeleteCancel}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -13,12 +13,6 @@ type FileTreeContextMenuProps = {
|
||||
treeRef: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* FileTreeContextMenu - Right-click context menu for file tree
|
||||
*
|
||||
* Renders at absolute position when contextMenu state is set.
|
||||
* Uses useClickAway to close when clicking outside.
|
||||
*/
|
||||
const FileTreeContextMenu: FC<FileTreeContextMenuProps> = ({ treeRef }) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const contextMenu = useSkillEditorStore(s => s.contextMenu)
|
||||
|
||||
@ -15,33 +15,16 @@ import {
|
||||
import { cn } from '@/utils/classnames'
|
||||
import FileOperationsMenu from './file-operations-menu'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import { getFileIconType } from './utils'
|
||||
import { getFileIconType } from './utils/file-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
|
||||
*
|
||||
* Features:
|
||||
* - Right-click context menu for folders
|
||||
* - "..." button dropdown for folders (visible on hover)
|
||||
*/
|
||||
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))
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
// Dropdown menu state (for ... button)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
// Get file icon type for files
|
||||
const fileIconType = !isFolder ? getFileIconType(node.data.name) : null
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
@ -51,7 +34,6 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// For files, activate (open in editor)
|
||||
if (!isFolder)
|
||||
node.activate()
|
||||
}
|
||||
@ -61,9 +43,7 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
node.toggle()
|
||||
}
|
||||
|
||||
// Right-click context menu handler (folders only)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
// Only show context menu for folders
|
||||
if (!isFolder)
|
||||
return
|
||||
|
||||
@ -77,7 +57,6 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
})
|
||||
}, [isFolder, node.data.id, storeApi])
|
||||
|
||||
// More button click handler
|
||||
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDropdown(prev => !prev)
|
||||
@ -96,7 +75,6 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isFolder
|
||||
? (
|
||||
@ -113,7 +91,6 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
: (
|
||||
<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" />
|
||||
)}
|
||||
@ -121,7 +98,6 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[13px] leading-4',
|
||||
@ -133,7 +109,6 @@ const FileTreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeDat
|
||||
{node.data.name}
|
||||
</span>
|
||||
|
||||
{/* More button - only for folders, visible on hover */}
|
||||
{isFolder && (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
|
||||
@ -15,22 +15,7 @@ import { cn } from '@/utils/classnames'
|
||||
import FileTreeContextMenu from './file-tree-context-menu'
|
||||
import FileTreeNode from './file-tree-node'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import { getAncestorIds, toOpensObject } from './type'
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
import { getAncestorIds, toOpensObject } from './utils/tree-utils'
|
||||
|
||||
type FilesProps = {
|
||||
className?: string
|
||||
@ -52,42 +37,30 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const treeRef = useRef<TreeApi<TreeNodeData>>(null)
|
||||
|
||||
// Get appId from app store
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
|
||||
// Fetch tree data from API
|
||||
const { data: treeData, isLoading, error } = useGetAppAssetTree(appId)
|
||||
|
||||
// Store state and actions
|
||||
const expandedFolderIds = useSkillEditorStore(s => s.expandedFolderIds)
|
||||
const activeTabId = useSkillEditorStore(s => s.activeTabId)
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
// Rename mutation for inline editing
|
||||
const renameNode = useRenameAppAssetNode()
|
||||
|
||||
// Convert Set to react-arborist OpenMap for initial state
|
||||
const initialOpenState = useMemo(() => toOpensObject(expandedFolderIds), [expandedFolderIds])
|
||||
|
||||
// Handle toggle event from react-arborist
|
||||
const handleToggle = useCallback((id: string) => {
|
||||
storeApi.getState().toggleFolder(id)
|
||||
}, [storeApi])
|
||||
|
||||
// Handle node activation (double-click or Enter)
|
||||
const handleActivate = useCallback((node: NodeApi<TreeNodeData>) => {
|
||||
if (node.data.node_type === 'file') {
|
||||
// Open file in tab
|
||||
if (node.data.node_type === 'file')
|
||||
storeApi.getState().openTab(node.data.id)
|
||||
}
|
||||
else {
|
||||
// For folders, toggle open state
|
||||
else
|
||||
node.toggle()
|
||||
}
|
||||
}, [storeApi])
|
||||
|
||||
// Handle rename from react-arborist inline editing
|
||||
const handleRename = useCallback(({ id, name }: { id: string, name: string }) => {
|
||||
renameNode.mutateAsync({
|
||||
appId,
|
||||
@ -101,31 +74,26 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
})
|
||||
}, [appId, renameNode, t])
|
||||
|
||||
// Auto-reveal when activeTabId changes (sync from tab click to tree)
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !treeData?.children || !treeRef.current)
|
||||
return
|
||||
|
||||
const ancestors = getAncestorIds(activeTabId, treeData.children)
|
||||
|
||||
// Update store for state persistence
|
||||
if (ancestors.length > 0)
|
||||
storeApi.getState().revealFile(ancestors)
|
||||
|
||||
// Use Tree API for immediate UI update (initialOpenState only applies on first render)
|
||||
const timeoutId = setTimeout(() => {
|
||||
const tree = treeRef.current
|
||||
if (!tree)
|
||||
return
|
||||
|
||||
// Open all ancestor folders
|
||||
for (const ancestorId of ancestors) {
|
||||
const ancestorNode = tree.get(ancestorId)
|
||||
if (ancestorNode && !ancestorNode.isOpen)
|
||||
ancestorNode.open()
|
||||
}
|
||||
|
||||
// Select the target node
|
||||
const node = tree.get(activeTabId)
|
||||
if (node)
|
||||
node.select()
|
||||
@ -134,7 +102,6 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
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)}>
|
||||
@ -143,7 +110,6 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 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)}>
|
||||
@ -154,7 +120,6 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!treeData?.children || treeData.children.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
||||
@ -174,24 +139,18 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
<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}
|
||||
onRename={handleRename}
|
||||
// Disable features not in MVP
|
||||
disableDrag
|
||||
disableDrop
|
||||
>
|
||||
@ -199,7 +158,6 @@ const Files: React.FC<FilesProps> = ({ className }) => {
|
||||
</Tree>
|
||||
</div>
|
||||
<DropTip />
|
||||
{/* Right-click context menu */}
|
||||
<FileTreeContextMenu treeRef={treeRef} />
|
||||
</div>
|
||||
)
|
||||
|
||||
293
web/app/components/workflow/skill/hooks/use-file-operations.ts
Normal file
293
web/app/components/workflow/skill/hooks/use-file-operations.ts
Normal file
@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../type'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
useCreateAppAssetFile,
|
||||
useCreateAppAssetFolder,
|
||||
useDeleteAppAssetNode,
|
||||
useGetAppAssetTree,
|
||||
} from '@/service/use-app-asset'
|
||||
import { useSkillEditorStoreApi } from '../store'
|
||||
import { getAllDescendantFileIds } from '../utils/tree-utils'
|
||||
|
||||
type UseFileOperationsOptions = {
|
||||
nodeId: string
|
||||
onClose: () => void
|
||||
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
|
||||
node?: NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
export function useFileOperations({
|
||||
nodeId,
|
||||
onClose,
|
||||
treeRef,
|
||||
node,
|
||||
}: UseFileOperationsOptions) {
|
||||
const { t } = useTranslation('workflow')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const storeApi = useSkillEditorStoreApi()
|
||||
|
||||
const createFolder = useCreateAppAssetFolder()
|
||||
const createFile = useCreateAppAssetFile()
|
||||
const deleteNode = useDeleteAppAssetNode()
|
||||
const { data: treeData } = useGetAppAssetTree(appId)
|
||||
|
||||
const parentId = nodeId === 'root' ? null : nodeId
|
||||
|
||||
const handleNewFile = useCallback(async () => {
|
||||
// eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity
|
||||
const fileName = window.prompt(t('skillSidebar.menu.newFilePrompt'))
|
||||
if (!fileName || !fileName.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const emptyBlob = new Blob([''], { type: 'text/plain' })
|
||||
const file = new File([emptyBlob], fileName.trim())
|
||||
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: fileName.trim(),
|
||||
file,
|
||||
parentId,
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.fileCreated'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.createError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, onClose, parentId, t])
|
||||
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
// eslint-disable-next-line no-alert -- MVP: Using prompt for simplicity
|
||||
const folderName = window.prompt(t('skillSidebar.menu.newFolderPrompt'))
|
||||
if (!folderName || !folderName.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createFolder.mutateAsync({
|
||||
appId,
|
||||
payload: {
|
||||
name: folderName.trim(),
|
||||
parent_id: parentId,
|
||||
},
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.folderCreated'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.createError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFolder, onClose, parentId, t])
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId,
|
||||
})
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.filesUploaded', { count: files.length }),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
e.target.value = ''
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, onClose, parentId, t])
|
||||
|
||||
const handleFolderChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const folders = new Set<string>()
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
|
||||
const parts = relativePath.split('/')
|
||||
|
||||
if (parts.length > 1) {
|
||||
let folderPath = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
folderPath = folderPath ? `${folderPath}/${parts[i]}` : parts[i]
|
||||
folders.add(folderPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedFolders = Array.from(folders).sort((a, b) => {
|
||||
return a.split('/').length - b.split('/').length
|
||||
})
|
||||
|
||||
const folderIdMap = new Map<string, string | null>()
|
||||
folderIdMap.set('', parentId)
|
||||
|
||||
for (const folderPath of sortedFolders) {
|
||||
const parts = folderPath.split('/')
|
||||
const folderName = parts[parts.length - 1]
|
||||
const parentPath = parts.slice(0, -1).join('/')
|
||||
const parentFolderId = folderIdMap.get(parentPath) ?? parentId
|
||||
|
||||
const result = await createFolder.mutateAsync({
|
||||
appId,
|
||||
payload: {
|
||||
name: folderName,
|
||||
parent_id: parentFolderId,
|
||||
},
|
||||
})
|
||||
|
||||
folderIdMap.set(folderPath, result.id)
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
|
||||
const parts = relativePath.split('/')
|
||||
const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : ''
|
||||
const targetParentId = folderIdMap.get(parentPath) ?? parentId
|
||||
|
||||
await createFile.mutateAsync({
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId: targetParentId,
|
||||
})
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.folderUploaded'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.uploadError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
e.target.value = ''
|
||||
onClose()
|
||||
}
|
||||
}, [appId, createFile, createFolder, onClose, parentId, t])
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
if (treeRef?.current) {
|
||||
const targetNode = treeRef.current.get(nodeId)
|
||||
targetNode?.edit()
|
||||
}
|
||||
else if (node) {
|
||||
node.edit()
|
||||
}
|
||||
onClose()
|
||||
}, [nodeId, node, onClose, treeRef])
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
setShowDeleteConfirm(true)
|
||||
}, [])
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
try {
|
||||
const descendantFileIds = treeData?.children
|
||||
? getAllDescendantFileIds(nodeId, treeData.children)
|
||||
: []
|
||||
|
||||
await deleteNode.mutateAsync({ appId, nodeId })
|
||||
|
||||
descendantFileIds.forEach((fileId) => {
|
||||
storeApi.getState().closeTab(fileId)
|
||||
storeApi.getState().clearDraftContent(fileId)
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('skillSidebar.menu.deleted'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('skillSidebar.menu.deleteError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setShowDeleteConfirm(false)
|
||||
onClose()
|
||||
}
|
||||
}, [appId, nodeId, deleteNode, storeApi, treeData?.children, onClose, t])
|
||||
|
||||
const handleDeleteCancel = useCallback(() => {
|
||||
setShowDeleteConfirm(false)
|
||||
}, [])
|
||||
|
||||
const isLoading = createFile.isPending || createFolder.isPending || deleteNode.isPending
|
||||
|
||||
return {
|
||||
fileInputRef,
|
||||
folderInputRef,
|
||||
showDeleteConfirm,
|
||||
isLoading,
|
||||
isDeleting: deleteNode.isPending,
|
||||
handleNewFile,
|
||||
handleNewFolder,
|
||||
handleFileChange,
|
||||
handleFolderChange,
|
||||
handleRename,
|
||||
handleDeleteClick,
|
||||
handleDeleteConfirm,
|
||||
handleDeleteCancel,
|
||||
}
|
||||
}
|
||||
@ -13,37 +13,23 @@ 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 { 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}`
|
||||
|
||||
@ -52,7 +38,7 @@ const SidebarSearchAdd: FC = () => {
|
||||
appId,
|
||||
payload: {
|
||||
name: folderName,
|
||||
parent_id: null, // Root level
|
||||
parent_id: null,
|
||||
},
|
||||
})
|
||||
Toast.notify({
|
||||
@ -68,13 +54,11 @@ const SidebarSearchAdd: FC = () => {
|
||||
}
|
||||
}, [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)
|
||||
@ -87,7 +71,7 @@ const SidebarSearchAdd: FC = () => {
|
||||
appId,
|
||||
name: file.name,
|
||||
file,
|
||||
parentId: null, // Root level
|
||||
parentId: null,
|
||||
})
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
@ -101,7 +85,6 @@ const SidebarSearchAdd: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Reset input to allow re-uploading same file
|
||||
e.target.value = ''
|
||||
}, [appId, createFile, t])
|
||||
|
||||
@ -131,7 +114,6 @@ const SidebarSearchAdd: FC = () => {
|
||||
</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}
|
||||
@ -141,7 +123,6 @@ const SidebarSearchAdd: FC = () => {
|
||||
{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}
|
||||
@ -155,7 +136,6 @@ const SidebarSearchAdd: FC = () => {
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import type { OnMount } from '@monaco-editor/react'
|
||||
import type { FC } from 'react'
|
||||
import type { AppAssetTreeView } from './type'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@ -20,103 +20,72 @@ import MediaFilePreview from './editor/media-file-preview'
|
||||
import OfficeFilePlaceholder from './editor/office-file-placeholder'
|
||||
import UnsupportedFileDownload from './editor/unsupported-file-download'
|
||||
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import { buildNodeMap } from './type'
|
||||
import { getFileExtension, getFileLanguage, isCodeOrTextFile, isImageFile, isMarkdownFile, isOfficeFile, isVideoFile } from './utils'
|
||||
import { getFileExtension, getFileLanguage, isCodeOrTextFile, isImageFile, isMarkdownFile, isOfficeFile, isVideoFile } from './utils/file-utils'
|
||||
import { buildNodeMap } from './utils/tree-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<Parameters<OnMount>[0] | null>(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 treeChildren = treeData?.children
|
||||
const nodeMap = useMemo(() => {
|
||||
if (!treeChildren)
|
||||
if (!treeData?.children)
|
||||
return new Map<string, AppAssetTreeView>()
|
||||
return buildNodeMap(treeChildren)
|
||||
}, [treeChildren])
|
||||
return buildNodeMap(treeData.children)
|
||||
}, [treeData?.children])
|
||||
|
||||
// Get current file node
|
||||
const currentFileNode = activeTabId ? nodeMap.get(activeTabId) : undefined
|
||||
const fileExtension = useMemo(() => {
|
||||
return getFileExtension(currentFileNode?.name, currentFileNode?.extension)
|
||||
}, [currentFileNode?.extension, currentFileNode?.name])
|
||||
const isMarkdown = useMemo(() => isMarkdownFile(fileExtension), [fileExtension])
|
||||
const isCodeOrText = useMemo(() => isCodeOrTextFile(fileExtension), [fileExtension])
|
||||
const isImage = useMemo(() => isImageFile(fileExtension), [fileExtension])
|
||||
const isVideo = useMemo(() => isVideoFile(fileExtension), [fileExtension])
|
||||
const isOffice = useMemo(() => isOfficeFile(fileExtension), [fileExtension])
|
||||
const fileExtension = getFileExtension(currentFileNode?.name, currentFileNode?.extension)
|
||||
const isMarkdown = isMarkdownFile(fileExtension)
|
||||
const isCodeOrText = isCodeOrTextFile(fileExtension)
|
||||
const isImage = isImageFile(fileExtension)
|
||||
const isVideo = isVideoFile(fileExtension)
|
||||
const isOffice = isOfficeFile(fileExtension)
|
||||
const isEditable = isMarkdown || isCodeOrText
|
||||
|
||||
// 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 || !isEditable)
|
||||
return
|
||||
// Set draft content in store
|
||||
storeApi.getState().setDraftContent(activeTabId, value ?? '')
|
||||
}, [activeTabId, isEditable, storeApi])
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!activeTabId || !appId || !isEditable)
|
||||
return
|
||||
|
||||
const content = dirtyContents.get(activeTabId)
|
||||
if (content === undefined)
|
||||
return // No changes to save
|
||||
return
|
||||
|
||||
try {
|
||||
await updateContent.mutateAsync({
|
||||
@ -124,7 +93,6 @@ const SkillDocEditor: FC = () => {
|
||||
nodeId: activeTabId,
|
||||
payload: { content },
|
||||
})
|
||||
// Clear draft on success
|
||||
storeApi.getState().clearDraftContent(activeTabId)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
@ -139,10 +107,8 @@ const SkillDocEditor: FC = () => {
|
||||
}
|
||||
}, [activeTabId, appId, dirtyContents, isEditable, storeApi, t, updateContent])
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+S / Cmd+S to save
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
@ -153,26 +119,15 @@ const SkillDocEditor: FC = () => {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSave])
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorDidMount: OnMount = useCallback((editor, monaco) => {
|
||||
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 language = currentFileNode ? getFileLanguage(currentFileNode.name) : 'plaintext'
|
||||
const theme = appTheme === Theme.light ? 'light' : 'vs-dark'
|
||||
|
||||
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">
|
||||
@ -183,7 +138,6 @@ const SkillDocEditor: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg">
|
||||
@ -192,7 +146,6 @@ const SkillDocEditor: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-components-panel-bg text-text-tertiary">
|
||||
|
||||
@ -4,41 +4,21 @@ 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
|
||||
previewTabId: null,
|
||||
|
||||
openTab: (fileId: string) => {
|
||||
const { openTabIds, activeTabId } = get()
|
||||
@ -85,19 +65,10 @@ export const createTabSlice: StateCreator<TabSliceShape> = (set, get) => ({
|
||||
},
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 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: (ancestorFolderIds: string[]) => void
|
||||
}
|
||||
|
||||
@ -122,27 +93,16 @@ export const createFileTreeSlice: StateCreator<FileTreeSliceShape> = (set, get)
|
||||
revealFile: (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
|
||||
}
|
||||
|
||||
@ -172,19 +132,12 @@ export const createDirtySlice: StateCreator<DirtySliceShape> = (set, get) => ({
|
||||
},
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// File Operations Menu Slice
|
||||
// ============================================================================
|
||||
|
||||
export type FileOperationsMenuSliceShape = {
|
||||
/** Context menu state (right-click) - null when closed */
|
||||
contextMenu: {
|
||||
top: number
|
||||
left: number
|
||||
nodeId: string
|
||||
} | null
|
||||
|
||||
/** Set or clear context menu */
|
||||
setContextMenu: (menu: FileOperationsMenuSliceShape['contextMenu']) => void
|
||||
}
|
||||
|
||||
@ -196,24 +149,15 @@ export const createFileOperationsMenuSlice: StateCreator<FileOperationsMenuSlice
|
||||
},
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Combined Store Shape
|
||||
// ============================================================================
|
||||
|
||||
export type SkillEditorShape
|
||||
= TabSliceShape
|
||||
& FileTreeSliceShape
|
||||
& DirtySliceShape
|
||||
& FileOperationsMenuSliceShape
|
||||
& {
|
||||
/** Reset all state (called when appId changes) */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Store Factory
|
||||
// ============================================================================
|
||||
|
||||
export const createSkillEditorStore = (): StoreApi<SkillEditorShape> => {
|
||||
return createStore<SkillEditorShape>((...args) => ({
|
||||
...createTabSlice(...args),
|
||||
@ -235,10 +179,6 @@ export const createSkillEditorStore = (): StoreApi<SkillEditorShape> => {
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context and Hooks
|
||||
// ============================================================================
|
||||
|
||||
export type SkillEditorStore = StoreApi<SkillEditorShape>
|
||||
|
||||
export const SkillEditorContext = React.createContext<SkillEditorStore | null>(null)
|
||||
|
||||
@ -1,164 +1,13 @@
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
/** File extension (for file type only) */
|
||||
extension?: string
|
||||
/** Whether this tab has unsaved changes */
|
||||
isDirty?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return Object.fromEntries([...expandedIds].map(id => [id, true]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a node by ID in the tree (recursive search)
|
||||
* @param nodes - Tree nodes from API (nested structure)
|
||||
* @param nodeId - Target node ID
|
||||
* @returns Node if found, null otherwise
|
||||
*/
|
||||
export function findNodeById(
|
||||
nodes: AppAssetTreeView[],
|
||||
nodeId: string,
|
||||
): AppAssetTreeView | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId)
|
||||
return node
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findNodeById(node.children, nodeId)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendant file IDs recursively (for tab cleanup on node delete)
|
||||
* @param nodeId - Target node ID (file or folder)
|
||||
* @param nodes - Tree nodes from API
|
||||
* @returns Array of file IDs to close (the node itself if file, or all descendants if folder)
|
||||
*/
|
||||
export function getAllDescendantFileIds(
|
||||
nodeId: string,
|
||||
nodes: AppAssetTreeView[],
|
||||
): string[] {
|
||||
const targetNode = findNodeById(nodes, nodeId)
|
||||
if (!targetNode)
|
||||
return []
|
||||
|
||||
// If deleting a file, return just that file's ID
|
||||
if (targetNode.node_type === 'file')
|
||||
return [targetNode.id]
|
||||
|
||||
// For folders, collect all descendant files
|
||||
const fileIds: string[] = []
|
||||
|
||||
function collectFileIds(nodeList: AppAssetTreeView[]) {
|
||||
for (const node of nodeList) {
|
||||
if (node.node_type === 'file')
|
||||
fileIds.push(node.id)
|
||||
if (node.children && node.children.length > 0)
|
||||
collectFileIds(node.children)
|
||||
}
|
||||
}
|
||||
|
||||
if (targetNode.children)
|
||||
collectFileIds(targetNode.children)
|
||||
|
||||
return fileIds
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ic
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'mov', 'webm', 'mpeg', 'mpg', 'm4v', 'avi']
|
||||
const OFFICE_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
|
||||
|
||||
export const getFileExtension = (name?: string, extension?: string) => {
|
||||
export function getFileExtension(name?: string, extension?: string): string {
|
||||
if (extension)
|
||||
return extension.toLowerCase()
|
||||
if (!name)
|
||||
@ -15,7 +15,7 @@ export const getFileExtension = (name?: string, extension?: string) => {
|
||||
return name.split('.').pop()?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
export const getFileIconType = (name: string) => {
|
||||
export function getFileIconType(name: string): FileAppearanceTypeEnum {
|
||||
const extension = name.split('.').pop()?.toLowerCase() ?? ''
|
||||
|
||||
if (MARKDOWN_EXTENSIONS.includes(extension))
|
||||
@ -27,37 +27,42 @@ export const getFileIconType = (name: string) => {
|
||||
return FileAppearanceTypeEnum.document
|
||||
}
|
||||
|
||||
export const isMarkdownFile = (extension: string) => MARKDOWN_EXTENSIONS.includes(extension)
|
||||
export const isCodeOrTextFile = (extension: string) => CODE_EXTENSIONS.includes(extension) || TEXT_EXTENSIONS.includes(extension)
|
||||
export const isImageFile = (extension: string) => IMAGE_EXTENSIONS.includes(extension)
|
||||
export const isVideoFile = (extension: string) => VIDEO_EXTENSIONS.includes(extension)
|
||||
export const isOfficeFile = (extension: string) => OFFICE_EXTENSIONS.includes(extension)
|
||||
export function isMarkdownFile(extension: string): boolean {
|
||||
return MARKDOWN_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Monaco editor language from file name extension
|
||||
*/
|
||||
export const getFileLanguage = (name: string): string => {
|
||||
export function isCodeOrTextFile(extension: string): boolean {
|
||||
return CODE_EXTENSIONS.includes(extension) || TEXT_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
export function isImageFile(extension: string): boolean {
|
||||
return IMAGE_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
export function isVideoFile(extension: string): boolean {
|
||||
return VIDEO_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
export function isOfficeFile(extension: string): boolean {
|
||||
return OFFICE_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
export function 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',
|
||||
86
web/app/components/workflow/skill/utils/tree-utils.ts
Normal file
86
web/app/components/workflow/skill/utils/tree-utils.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
|
||||
export function buildNodeMap(nodes: AppAssetTreeView[]): Map<string, AppAssetTreeView> {
|
||||
const map = new Map<string, AppAssetTreeView>()
|
||||
|
||||
function traverse(nodeList: AppAssetTreeView[]): void {
|
||||
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 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 function toOpensObject(expandedIds: Set<string>): Record<string, boolean> {
|
||||
return Object.fromEntries([...expandedIds].map(id => [id, true]))
|
||||
}
|
||||
|
||||
export function findNodeById(
|
||||
nodes: AppAssetTreeView[],
|
||||
nodeId: string,
|
||||
): AppAssetTreeView | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId)
|
||||
return node
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findNodeById(node.children, nodeId)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getAllDescendantFileIds(
|
||||
nodeId: string,
|
||||
nodes: AppAssetTreeView[],
|
||||
): string[] {
|
||||
const targetNode = findNodeById(nodes, nodeId)
|
||||
if (!targetNode)
|
||||
return []
|
||||
|
||||
if (targetNode.node_type === 'file')
|
||||
return [targetNode.id]
|
||||
|
||||
const fileIds: string[] = []
|
||||
|
||||
function collectFileIds(nodeList: AppAssetTreeView[]): void {
|
||||
for (const node of nodeList) {
|
||||
if (node.node_type === 'file')
|
||||
fileIds.push(node.id)
|
||||
if (node.children && node.children.length > 0)
|
||||
collectFileIds(node.children)
|
||||
}
|
||||
}
|
||||
|
||||
if (targetNode.children)
|
||||
collectFileIds(targetNode.children)
|
||||
|
||||
return fileIds
|
||||
}
|
||||
Reference in New Issue
Block a user