dify/web/features/skills/detail/file-tree.tsx

1608 lines
59 KiB
TypeScript

'use client'
import type {
SkillDetailResponse,
SkillFileCheckResponse,
SkillFileResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type {
DragEvent,
MouseEvent,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from 'react'
import type { SkillDropOperation, SkillDropTarget } from './file-tree-dnd'
import type {
FileTreeInlineAction,
FileTreeNode,
SkillFileClipboard,
SkillFileMutationCoordinator,
SkillUploadQueueItem,
} from './shared'
import type { SkillUploadDecision, SkillUploadReviewItem } from './upload-workflow'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
ContextMenu,
ContextMenuContent,
ContextMenuTrigger,
} from '@langgenius/dify-ui/context-menu'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay, matchesKeyboardEvent, useHotkey } from '@tanstack/react-hotkeys'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import copy from 'copy-to-clipboard'
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import AccountSection from '@/app/components/main-nav/components/account-section'
import HelpMenu from '@/app/components/main-nav/components/help-menu'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { fetchSkillFileBlob, uploadSkillFile } from '../client'
import { SkillDropDestinationHint, SkillUploadStatusPanel } from './file-tree-dnd'
import { FileTreeItem, FileTreeNameInput, RootFileActionMenuItems } from './file-tree-items'
import {
createUploadItemId,
findFileByPath,
flattenFileTree,
getAsyncSkillErrorMessage,
getAsyncSkillErrorPayload,
getCopyTargetPath,
getCreatedSkillFileMimeType,
getDraggedSkillPaths,
getErrorCode,
getPathBaseName,
getPathDirName,
getUploadFileName,
getUploadPath,
invalidateSkillDetail,
isDirectory,
isEditableKeyboardTarget,
isNestedPath,
joinSkillPath,
refreshSkillDetailAfterConflict,
runSkillFileMutation,
setSkillDetailCache,
showSkillErrorToast,
skillFileHotkeys,
skillFileMenuPopupClassName,
toFileTree,
} from './shared'
import { SkillDetailSidebarActions } from './sidebar-actions'
import { SkillDisplayNameEditor } from './skill-display-name-editor'
import { SkillReferencesPanel, SkillTagsEditor } from './skill-metadata'
import { SkillUploadFailuresDialog, SkillUploadReviewDialog } from './upload-dialogs'
import {
buildUploadReviewItems,
createAvailableUploadPath,
isUploadReviewItemSkipped,
resolveUploadReviewItem,
} from './upload-workflow'
const skillSidebarMinWidth = 240
const skillSidebarMaxWidth = 420
const skillSidebarKeyboardStep = 8
const skillSidebarHelpTriggerIcon = (
<span aria-hidden className="i-ri-question-line size-4 shrink-0" />
)
function SkillSidebarAccountFooter({ compact = false }: { compact?: boolean }) {
return (
<div
className={cn(
'shrink-0 border-t border-divider-subtle',
compact
? 'flex w-full flex-col items-center gap-0.5 px-2 pt-1 pb-3'
: 'flex h-14 w-full items-center justify-between p-3',
)}
>
<div
className={cn(
'flex min-w-0 items-center gap-1 overflow-hidden',
compact && 'w-full justify-center',
)}
>
<AccountSection compact={compact} />
</div>
<HelpMenu
triggerIcon={skillSidebarHelpTriggerIcon}
triggerClassName={cn(
'size-8 border-0 bg-transparent shadow-none hover:bg-state-base-hover hover:text-text-secondary',
compact && 'mt-2',
)}
/>
</div>
)
}
const clampSkillSidebarWidth = (width: number) =>
Math.min(skillSidebarMaxWidth, Math.max(skillSidebarMinWidth, width))
export function FileTree({
canEdit,
canDelete,
collapsed,
detail,
fileMutationCoordinator,
files,
onCollapsedChange,
onSelect,
readonly,
selectedPath,
skillId,
}: {
canEdit: boolean
canDelete: boolean
collapsed: boolean
detail: SkillDetailResponse | undefined
fileMutationCoordinator: SkillFileMutationCoordinator
files: SkillFileResponse[]
onCollapsedChange: (collapsed: boolean) => void
onSelect: (path: string, files?: SkillFileResponse[], mode?: 'pinned' | 'preview') => void
readonly: boolean
selectedPath: string | undefined
skillId: string
}) {
const { t } = useTranslation('skill')
const { t: tApp } = useTranslation('app')
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const sidebarRef = useRef<HTMLElement>(null)
const uploadInputRef = useRef<HTMLInputElement>(null)
const [inlineAction, setInlineAction] = useState<FileTreeInlineAction>()
const [draggingPaths, setDraggingPaths] = useState<string[]>([])
const [dropTarget, setDropTarget] = useState<SkillDropTarget>()
const [collapsedFolderPaths, setCollapsedFolderPaths] = useState<string[]>([])
const [skillRenameEditing, setSkillRenameEditing] = useState(false)
const [selectedPaths, setSelectedPaths] = useState<string[]>([])
const [selectionAnchorPath, setSelectionAnchorPath] = useState<string>()
const [clipboard, setClipboard] = useState<SkillFileClipboard>()
const [uploadItems, setUploadItems] = useState<SkillUploadQueueItem[]>([])
const [uploadReviewItems, setUploadReviewItems] = useState<SkillUploadReviewItem[]>([])
const [uploadReviewOpen, setUploadReviewOpen] = useState(false)
const [uploadFailuresOpen, setUploadFailuresOpen] = useState(false)
const [deleteNode, setDeleteNode] = useState<FileTreeNode>()
const [sidebarWidth, setSidebarWidth] = useState(skillSidebarMinWidth)
const [sidebarFloating, setSidebarFloating] = useState(false)
const [sidebarResizing, setSidebarResizing] = useState(false)
const referencesQuery = useQuery({
...consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({
input: {
params: {
skill_id: skillId,
},
},
}),
refetchOnMount: 'always',
})
const referenceCount = referencesQuery.data?.data?.length ?? detail?.reference_count ?? 0
const referenceCountLabel = t(
($) =>
referenceCount === 1
? $['skillManagement.detail.referencedBy_one']
: $['skillManagement.detail.referencedBy_other'],
{ count: referenceCount },
)
const activeUploadXhrRef = useRef<XMLHttpRequest | undefined>(undefined)
const cancelUploadRef = useRef(false)
const stopSidebarResizeRef = useRef<() => void>(() => undefined)
const closeSidebarFloatingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const fetchedReferenceCount = referencesQuery.data?.data?.length
if (
fetchedReferenceCount == null ||
!detail ||
detail.reference_count === fetchedReferenceCount
)
return
setSkillDetailCache(queryClient, skillId, {
...detail,
reference_count: fetchedReferenceCount,
})
}, [detail, queryClient, referencesQuery.data?.data?.length, skillId])
useEffect(
() => () => {
stopSidebarResizeRef.current()
if (closeSidebarFloatingTimerRef.current) clearTimeout(closeSidebarFloatingTimerRef.current)
},
[],
)
const openSidebarFloatingPreview = () => {
if (!collapsed) return
if (closeSidebarFloatingTimerRef.current) clearTimeout(closeSidebarFloatingTimerRef.current)
setSidebarFloating(true)
}
const closeSidebarFloatingPreview = () => {
if (closeSidebarFloatingTimerRef.current) clearTimeout(closeSidebarFloatingTimerRef.current)
closeSidebarFloatingTimerRef.current = setTimeout(() => setSidebarFloating(false), 120)
}
const handleSidebarResizePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.preventDefault()
stopSidebarResizeRef.current()
const startX = event.clientX
const startWidth = sidebarWidth
const previousUserSelect = document.body.style.userSelect
const previousCursor = document.body.style.cursor
const handlePointerMove = (moveEvent: PointerEvent) => {
setSidebarWidth(clampSkillSidebarWidth(startWidth + moveEvent.clientX - startX))
}
const stopResize = () => {
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', stopResize)
document.removeEventListener('pointercancel', stopResize)
window.removeEventListener('blur', stopResize)
document.body.style.userSelect = previousUserSelect
document.body.style.cursor = previousCursor
setSidebarResizing(false)
stopSidebarResizeRef.current = () => undefined
}
setSidebarResizing(true)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', stopResize)
document.addEventListener('pointercancel', stopResize)
window.addEventListener('blur', stopResize)
stopSidebarResizeRef.current = stopResize
},
[sidebarWidth],
)
const handleSidebarResizeKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
let nextWidth: number | undefined
if (event.key === 'ArrowLeft') nextWidth = sidebarWidth - skillSidebarKeyboardStep
if (event.key === 'ArrowRight') nextWidth = sidebarWidth + skillSidebarKeyboardStep
if (event.key === 'Home') nextWidth = skillSidebarMinWidth
if (event.key === 'End') nextWidth = skillSidebarMaxWidth
if (nextWidth === undefined) return
event.preventDefault()
setSidebarWidth(clampSkillSidebarWidth(nextWidth))
}
const fileMutation = useMutation(
consoleQuery.workspaces.current.skills.bySkillId.files.patch.mutationOptions({
context: { silent: true },
}),
)
const fileCheckMutation = useMutation(
consoleQuery.workspaces.current.skills.bySkillId.files.check.post.mutationOptions({
context: { silent: true },
}),
)
const tree = toFileTree(files)
const flatTree = flattenFileTree(tree)
const isUploading = uploadItems.some(
(item) => item.status === 'uploading' || item.status === 'saving',
)
const isMutating = fileMutation.isPending || fileCheckMutation.isPending || isUploading
const fileCount = files.filter((file) => !isDirectory(file)).length
const mutateFile = (
body: Parameters<typeof fileMutation.mutate>[0]['body'],
options: {
onSuccess?: (detail: SkillDetailResponse) => void
successMessage: string
},
) => {
if (!detail || fileMutation.isPending) return
void runSkillFileMutation(fileMutationCoordinator, (expectedUpdatedAt) =>
fileMutation.mutateAsync({
params: {
skill_id: skillId,
},
body: {
...body,
expected_updated_at: expectedUpdatedAt,
},
}),
)
.then((nextDetail) => {
toast.success(options.successMessage)
setSkillDetailCache(queryClient, skillId, nextDetail)
invalidateSkillDetail(queryClient, skillId)
options.onSuccess?.(nextDetail)
})
.catch((error) => {
showSkillErrorToast(
error,
t(($) => $['skillManagement.detail.fileOperationFailed']),
)
})
}
const handleSubmitInlineAction = (name: string) => {
if (!inlineAction) return
if (inlineAction.kind === 'rename') {
const targetPath = joinSkillPath(getPathDirName(inlineAction.path), name)
if (targetPath === inlineAction.path) {
setInlineAction(undefined)
return
}
mutateFile(
{
operation: 'rename',
path: inlineAction.path,
target_path: targetPath,
},
{
successMessage: t(($) => $['skillManagement.detail.renameFileSuccess']),
onSuccess: (nextDetail) => {
setInlineAction(undefined)
onSelect(targetPath, nextDetail.files)
},
},
)
return
}
const path = joinSkillPath(inlineAction.parentPath, name)
if (files.some((file) => file.path === path)) {
toast.error(t(($) => $['skillManagement.detail.fileAlreadyExists']))
return
}
if (inlineAction.nodeType === 'directory') {
mutateFile(
{
operation: 'mkdir',
path,
},
{
successMessage: t(($) => $['skillManagement.detail.createFolderSuccess']),
onSuccess: () => {
setInlineAction(undefined)
},
},
)
return
}
mutateFile(
{
content: '',
mime_type: getCreatedSkillFileMimeType(path),
operation: 'upsert_text',
path,
size: 0,
},
{
successMessage: t(($) => $['skillManagement.detail.createFileSuccess']),
onSuccess: (nextDetail) => {
setInlineAction(undefined)
onSelect(path, nextDetail.files)
},
},
)
}
const handleDelete = () => {
if (!deleteNode) return
mutateFile(
{
operation: 'delete',
path: deleteNode.path,
},
{
successMessage: t(($) => $['skillManagement.detail.deleteFileSuccess']),
onSuccess: () => {
setDeleteNode(undefined)
},
},
)
}
const patchUploadItem = (id: string, patch: Partial<SkillUploadQueueItem>) => {
setUploadItems((currentItems) =>
currentItems.map((item) => (item.id === id ? { ...item, ...patch } : item)),
)
}
const handleUploadFiles = async (filesToUpload: File[], targetDirectory: string | undefined) => {
if (!detail || filesToUpload.length === 0 || isMutating) return
const itemIds = filesToUpload.map((file, index) => createUploadItemId(file, index))
const paths = filesToUpload.map((file) => getUploadPath(file, targetDirectory))
try {
const response = (await fileCheckMutation.mutateAsync({
params: { skill_id: skillId },
body: {
files: filesToUpload.map((file, index) => ({
filename: file.name,
mime_type: file.type || null,
path: paths[index],
size: file.size,
})),
},
})) as SkillFileCheckResponse
const reviewItems = buildUploadReviewItems({
checks: response.data ?? {},
existingFiles: files,
files: filesToUpload,
itemIds,
paths,
})
setUploadReviewItems(reviewItems)
setUploadReviewOpen(true)
} catch {
toast.error(t(($) => $['skillManagement.detail.uploadCheckFailed']))
}
}
const startUpload = async (reviewItems: SkillUploadReviewItem[], replaceQueue = true) => {
if (!detail || isMutating) return
const uploadableItems = reviewItems.filter(
(item) => !isUploadReviewItemSkipped(item) && item.resolvedPath,
)
const nextUploadItems = uploadableItems.map((item) => ({
file: item.file,
id: item.id,
name: getUploadFileName(item.file),
path: item.resolvedPath!,
progress: 0,
status: 'uploading' as const,
}))
if (replaceQueue) setUploadItems(nextUploadItems)
else {
setUploadItems((currentItems) => {
const replacements = new Map(nextUploadItems.map((item) => [item.id, item]))
const mergedItems = currentItems.map((item) => replacements.get(item.id) ?? item)
const currentIds = new Set(currentItems.map((item) => item.id))
return [...mergedItems, ...nextUploadItems.filter((item) => !currentIds.has(item.id))]
})
}
setUploadFailuresOpen(false)
cancelUploadRef.current = false
let latestDetail = detail
let lastUploadedPath = ''
let successCount = 0
let failedCount = 0
for (const [index, file] of uploadableItems.map((item) => item.file).entries()) {
const item = nextUploadItems[index]
if (!item) continue
if (cancelUploadRef.current) {
failedCount += 1
patchUploadItem(item.id, {
error: tCommon(($) => $['operation.cancel']),
progress: 100,
status: 'failed',
})
continue
}
try {
patchUploadItem(item.id, { progress: 0, status: 'uploading' })
const xhr = new XMLHttpRequest()
activeUploadXhrRef.current = xhr
const uploadedFile = await uploadSkillFile(file, {
onProgress: (progress) => {
patchUploadItem(item.id, { progress: Math.min(progress, 99) })
},
xhr,
})
activeUploadXhrRef.current = undefined
patchUploadItem(item.id, { progress: 100, status: 'saving' })
const path = item.path
const nextDetail = await runSkillFileMutation(
fileMutationCoordinator,
(expectedUpdatedAt) =>
fileMutation.mutateAsync({
params: {
skill_id: skillId,
},
body: {
expected_updated_at: expectedUpdatedAt,
mime_type: uploadedFile.mime_type ?? file.type,
operation: 'upsert_tool_file',
path,
size: uploadedFile.size,
tool_file_id: uploadedFile.id,
},
}),
)
latestDetail = nextDetail
lastUploadedPath = path
successCount += 1
patchUploadItem(item.id, { progress: 100, status: 'uploaded' })
} catch (error) {
activeUploadXhrRef.current = undefined
failedCount += 1
const errorPayload = await getAsyncSkillErrorPayload(error)
const errorCode = getErrorCode(errorPayload ?? error)
let failureKind: SkillUploadQueueItem['failureKind'] = 'network'
let errorMessage =
(await getAsyncSkillErrorMessage(error)) ??
t(($) => $['skillManagement.detail.uploadNetworkFailure'])
let suggestedPath: string | undefined
if (errorCode === 'skill_conflict') {
failureKind = 'conflict'
errorMessage = t(($) => $['skillManagement.detail.uploadLateConflict'])
try {
const refreshedDetail = await refreshSkillDetailAfterConflict(queryClient, skillId)
fileMutationCoordinator.latestDetail = refreshedDetail
latestDetail = refreshedDetail
suggestedPath = createAvailableUploadPath(
item.path,
(refreshedDetail.files ?? []).map((file) => file.path),
)
} catch {
// Replace remains available even if refreshing the latest paths fails.
}
}
patchUploadItem(item.id, {
error: errorMessage,
failureKind,
progress: 100,
status: 'failed',
suggestedPath,
})
}
}
if (successCount > 0) {
setSkillDetailCache(queryClient, skillId, latestDetail)
invalidateSkillDetail(queryClient, skillId)
if (lastUploadedPath) onSelect(lastUploadedPath, latestDetail.files)
}
if (failedCount > 0) {
toast.error(
t(($) => $['skillManagement.detail.uploadFilesFailedStatus'], { count: failedCount }),
)
return
}
toast.success(t(($) => $['skillManagement.detail.uploadFileSuccess']))
}
const handleCancelUpload = () => {
cancelUploadRef.current = true
activeUploadXhrRef.current?.abort()
}
const handleRetryUpload = () => {
const failedItems = uploadItems.filter(
(item) => item.status === 'failed' && item.failureKind !== 'conflict',
)
void startUpload(
failedItems.map((item) => ({
check: {
errors: [],
extension: '',
filename: item.file.name,
mime_type: item.file.type,
path: item.path,
size: item.file.size,
},
file: item.file,
id: item.id,
kind: 'ready',
originalPath: item.path,
resolvedPath: item.path,
})),
false,
)
}
const handleRetryUploadItem = (id: string, path?: string) => {
const item = uploadItems.find((uploadItem) => uploadItem.id === id)
if (!item) return
const resolvedPath = path ?? item.path
setUploadFailuresOpen(false)
void startUpload(
[
{
check: {
errors: [],
extension: '',
filename: item.file.name,
mime_type: item.file.type,
path: resolvedPath,
size: item.file.size,
},
file: item.file,
id: item.id,
kind: 'ready',
originalPath: item.path,
resolvedPath,
},
],
false,
)
}
const handleFailedUploadDecision = (id: string, decision: SkillUploadDecision) => {
const item = uploadItems.find((uploadItem) => uploadItem.id === id)
if (!item) return
if (decision === 'skip') {
setUploadItems((currentItems) => currentItems.filter((uploadItem) => uploadItem.id !== id))
if (uploadItems.filter((uploadItem) => uploadItem.status === 'failed').length === 1)
setUploadFailuresOpen(false)
return
}
handleRetryUploadItem(id, decision === 'keep-both' ? item.suggestedPath : item.path)
}
const handleUploadDecision = (id: string, decision: SkillUploadDecision) => {
setUploadReviewItems((currentItems) =>
currentItems.map((item) => (item.id === id ? resolveUploadReviewItem(item, decision) : item)),
)
}
const handleConfirmUpload = () => {
setUploadReviewOpen(false)
void startUpload(uploadReviewItems)
}
const handleItemSelect = (node: FileTreeNode, event: MouseEvent<HTMLElement>) => {
const currentPath = node.path
const isAdditive = event.metaKey || event.ctrlKey
if (event.shiftKey && selectionAnchorPath) {
const anchorIndex = flatTree.findIndex((item) => item.path === selectionAnchorPath)
const currentIndex = flatTree.findIndex((item) => item.path === currentPath)
if (anchorIndex >= 0 && currentIndex >= 0) {
const [startIndex, endIndex] =
anchorIndex < currentIndex ? [anchorIndex, currentIndex] : [currentIndex, anchorIndex]
const rangePaths = flatTree.slice(startIndex, endIndex + 1).map((item) => item.path)
setSelectedPaths(
isAdditive ? Array.from(new Set([...selectedPaths, ...rangePaths])) : rangePaths,
)
return
}
}
if (isAdditive) {
setSelectedPaths((currentPaths) =>
currentPaths.includes(currentPath)
? currentPaths.filter((path) => path !== currentPath)
: [...currentPaths, currentPath],
)
setSelectionAnchorPath(currentPath)
return
}
setSelectedPaths([currentPath])
setSelectionAnchorPath(currentPath)
}
const getMovablePaths = (sourcePaths: string[], targetDirectory: string | undefined) => {
const uniquePaths = Array.from(new Set(sourcePaths))
return uniquePaths.filter((sourcePath) => {
if (
targetDirectory &&
(sourcePath === targetDirectory || isNestedPath(sourcePath, targetDirectory))
)
return false
return !uniquePaths.some(
(candidatePath) => candidatePath !== sourcePath && isNestedPath(candidatePath, sourcePath),
)
})
}
const getActionPaths = (path: string) => (selectedPaths.includes(path) ? selectedPaths : [path])
const getFileActionPaths = (path: string) =>
getActionPaths(path).filter((actionPath) => {
const file = findFileByPath(files, actionPath)
return file && !isDirectory(file)
})
const handleCut = (path: string) => {
const filePaths = getFileActionPaths(path)
if (filePaths.length === 0) return
setClipboard({ mode: 'cut', paths: filePaths })
toast.success(t(($) => $['skillManagement.detail.cutFileSuccess']))
}
const handleCopy = (path: string) => {
const filePaths = getFileActionPaths(path)
if (filePaths.length === 0) return
setClipboard({ mode: 'copy', paths: filePaths })
const copiedFile = filePaths.length === 1 ? findFileByPath(files, filePaths[0]) : undefined
if (copiedFile && typeof copiedFile.content === 'string') {
copy(copiedFile.content)
toast.success(t(($) => $['skillManagement.detail.copyContentSuccess']))
return
}
toast.success(t(($) => $['skillManagement.detail.copyFileSuccess']))
}
const handleMove = async (sourcePaths: string[], targetDirectory: string | undefined) => {
if (!detail || fileMutation.isPending) return
const movablePaths = getMovablePaths(sourcePaths, targetDirectory)
if (movablePaths.length === 0) return
try {
let lastTargetPath = ''
let latestDetail = detail
const movedTargetPaths: string[] = []
for (const sourcePath of movablePaths) {
const targetPath = joinSkillPath(targetDirectory, getPathBaseName(sourcePath))
if (sourcePath === targetPath) continue
const nextDetail = await runSkillFileMutation(
fileMutationCoordinator,
(expectedUpdatedAt) =>
fileMutation.mutateAsync({
params: {
skill_id: skillId,
},
body: {
expected_updated_at: expectedUpdatedAt,
operation: 'rename',
path: sourcePath,
target_path: targetPath,
},
}),
)
latestDetail = nextDetail
lastTargetPath = targetPath
movedTargetPaths.push(targetPath)
}
if (movedTargetPaths.length === 0) return
toast.success(
movedTargetPaths.length > 1
? t(($) => $['skillManagement.detail.moveFilesSuccess'])
: t(($) => $['skillManagement.detail.moveFileSuccess']),
)
setSkillDetailCache(queryClient, skillId, latestDetail)
invalidateSkillDetail(queryClient, skillId)
setSelectedPaths(movedTargetPaths)
if (lastTargetPath) onSelect(lastTargetPath, latestDetail.files)
} catch (error) {
showSkillErrorToast(
error,
t(($) => $['skillManagement.detail.fileOperationFailed']),
)
}
}
const handleCopyFiles = async (sourcePaths: string[], targetDirectory: string | undefined) => {
if (!detail || fileMutation.isPending) return
try {
let latestDetail = fileMutationCoordinator.latestDetail ?? detail
const copyablePaths = getMovablePaths(sourcePaths, targetDirectory).filter((sourcePath) => {
const file = findFileByPath(latestDetail.files ?? [], sourcePath)
return file && !isDirectory(file)
})
let lastTargetPath = ''
const copiedTargetPaths: string[] = []
let hasRetriedConflict = false
for (const sourcePath of copyablePaths) {
while (true) {
const operationFiles = latestDetail.files ?? []
const sourceFile = findFileByPath(operationFiles, sourcePath)
if (!sourceFile || isDirectory(sourceFile)) break
const targetPath = getCopyTargetPath(
operationFiles,
targetDirectory,
sourcePath,
copiedTargetPaths,
)
if (!targetPath) throw new Error('target path already exists')
try {
let nextDetail: SkillDetailResponse
if (sourceFile.tool_file_id) {
nextDetail = await runSkillFileMutation(
fileMutationCoordinator,
(expectedUpdatedAt) =>
fileMutation.mutateAsync({
params: {
skill_id: skillId,
},
body: {
expected_updated_at: expectedUpdatedAt,
hash: sourceFile.hash,
mime_type: sourceFile.mime_type,
operation: 'upsert_tool_file',
path: targetPath,
size: sourceFile.size,
tool_file_id: sourceFile.tool_file_id,
},
}),
)
} else {
const content =
sourceFile.content ??
(await (
await fetchSkillFileBlob({
path: sourcePath,
skillId,
versionId: null,
})
).text())
nextDetail = await runSkillFileMutation(
fileMutationCoordinator,
(expectedUpdatedAt) =>
fileMutation.mutateAsync({
params: {
skill_id: skillId,
},
body: {
content,
expected_updated_at: expectedUpdatedAt,
hash: sourceFile.hash,
mime_type: sourceFile.mime_type,
operation: 'upsert_text',
path: targetPath,
size: new Blob([content]).size,
},
}),
)
}
latestDetail = nextDetail
lastTargetPath = targetPath
copiedTargetPaths.push(targetPath)
break
} catch (error) {
const errorPayload = await getAsyncSkillErrorPayload(error)
if (hasRetriedConflict || getErrorCode(errorPayload ?? error) !== 'skill_conflict')
throw error
latestDetail = await refreshSkillDetailAfterConflict(queryClient, skillId)
fileMutationCoordinator.latestDetail = latestDetail
hasRetriedConflict = true
}
}
}
if (copiedTargetPaths.length === 0) return
toast.success(t(($) => $['skillManagement.detail.pasteFileSuccess']))
setSkillDetailCache(queryClient, skillId, latestDetail)
invalidateSkillDetail(queryClient, skillId)
setSelectedPaths(copiedTargetPaths)
if (lastTargetPath) onSelect(lastTargetPath, latestDetail.files)
} catch (error) {
showSkillErrorToast(
error,
t(($) => $['skillManagement.detail.fileOperationFailed']),
)
}
}
const handlePaste = (targetDirectory: string | undefined) => {
if (!clipboard) return
if (clipboard.mode === 'cut') {
void handleMove(clipboard.paths, targetDirectory)
setClipboard(undefined)
return
}
void handleCopyFiles(clipboard.paths, targetDirectory)
}
const getPasteTargetDirectory = () => {
if (selectedPaths.length !== 1) return undefined
const selectedFile = findFileByPath(files, selectedPaths[0])
if (!selectedFile) return undefined
if (isDirectory(selectedFile)) return selectedFile.path
return getPathDirName(selectedFile.path) || undefined
}
const shortcutTargetPath = selectedPaths[0] ?? selectedPath
const fileShortcutEnabled =
!readonly && !!shortcutTargetPath && !fileMutation.isPending && !inlineAction
const isInSidebar = (target: EventTarget | null) =>
target instanceof Node && !!sidebarRef.current?.contains(target)
const handleOpenMenuHotkey = useEffectEvent((event: globalThis.KeyboardEvent) => {
if (readonly || fileMutation.isPending || inlineAction) return
if (!(event.target instanceof Element) || !event.target.closest('[role="menu"]')) return
if (
shortcutTargetPath &&
(matchesKeyboardEvent(event, 'Meta+X') || matchesKeyboardEvent(event, 'Control+X'))
) {
event.preventDefault()
event.stopPropagation()
handleCut(shortcutTargetPath)
return
}
if (
shortcutTargetPath &&
(matchesKeyboardEvent(event, 'Meta+C') || matchesKeyboardEvent(event, 'Control+C'))
) {
event.preventDefault()
event.stopPropagation()
handleCopy(shortcutTargetPath)
return
}
if (
clipboard &&
(matchesKeyboardEvent(event, 'Meta+V') || matchesKeyboardEvent(event, 'Control+V'))
) {
event.preventDefault()
event.stopPropagation()
handlePaste(getPasteTargetDirectory())
}
})
useEffect(() => {
document.addEventListener('keydown', handleOpenMenuHotkey, true)
return () => document.removeEventListener('keydown', handleOpenMenuHotkey, true)
}, [])
useHotkey(
skillFileHotkeys.cut.command,
(event) => {
if (!shortcutTargetPath) return
if (!isInSidebar(event.target)) return
event.preventDefault()
event.stopPropagation()
handleCut(shortcutTargetPath)
},
{
enabled: fileShortcutEnabled,
ignoreInputs: true,
preventDefault: false,
stopPropagation: false,
},
)
useHotkey(
skillFileHotkeys.copy.command,
(event) => {
if (!shortcutTargetPath) return
if (!isInSidebar(event.target)) return
event.preventDefault()
event.stopPropagation()
handleCopy(shortcutTargetPath)
},
{
enabled: fileShortcutEnabled,
ignoreInputs: true,
preventDefault: false,
stopPropagation: false,
},
)
const handleNativeCopy = useEffectEvent((event: ClipboardEvent) => {
if (!fileShortcutEnabled || !shortcutTargetPath) return
if (!isInSidebar(event.target)) return
if (isEditableKeyboardTarget(event.target)) return
event.preventDefault()
handleCopy(shortcutTargetPath)
})
const handleNativeCut = useEffectEvent((event: ClipboardEvent) => {
if (!fileShortcutEnabled || !shortcutTargetPath) return
if (!isInSidebar(event.target)) return
if (isEditableKeyboardTarget(event.target)) return
event.preventDefault()
handleCut(shortcutTargetPath)
})
useEffect(() => {
document.addEventListener('copy', handleNativeCopy, true)
document.addEventListener('cut', handleNativeCut, true)
return () => {
document.removeEventListener('copy', handleNativeCopy, true)
document.removeEventListener('cut', handleNativeCut, true)
}
}, [])
useEffect(() => {
const handlePasteEvent = (event: ClipboardEvent) => {
if (readonly || !clipboard || fileMutation.isPending) return
if (!isInSidebar(event.target)) return
if (isEditableKeyboardTarget(event.target)) return
event.preventDefault()
requestAnimationFrame(() => {
handlePaste(getPasteTargetDirectory())
})
}
document.addEventListener('paste', handlePasteEvent)
return () => document.removeEventListener('paste', handlePasteEvent)
})
const handleRootDragOver = (event: DragEvent<HTMLElement>) => {
if (readonly) return
event.preventDefault()
const operation: SkillDropOperation = Array.from(event.dataTransfer.types).includes('Files')
? 'upload'
: 'move'
event.dataTransfer.dropEffect = operation === 'upload' ? 'copy' : 'move'
setDropTarget({ operation, path: '' })
}
const handleRootDrop = (event: DragEvent<HTMLElement>) => {
if (readonly) return
event.preventDefault()
setDropTarget(undefined)
const droppedFiles = Array.from(event.dataTransfer.files)
if (droppedFiles.length > 0) {
void handleUploadFiles(droppedFiles, undefined)
return
}
const sourcePaths = getDraggedSkillPaths(event.dataTransfer)
if (sourcePaths.length > 0) void handleMove(sourcePaths, undefined)
}
const handleRootDragLeave = (event: DragEvent<HTMLElement>) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))
return
setDropTarget(undefined)
}
const handleRootClick = (event: MouseEvent<HTMLElement>) => {
const target = event.target
if (target instanceof Element && target.closest('[data-skill-file-tree-item]')) return
setSelectedPaths([])
setSelectionAnchorPath(undefined)
}
const creatorName = detail?.created_by_name ?? detail?.created_by ?? '-'
if (collapsed && !sidebarFloating) {
return (
<aside
data-testid="skill-detail-sidebar-shell"
className="relative flex h-full w-16 shrink-0 bg-background-body p-1"
onMouseEnter={openSidebarFloatingPreview}
onMouseLeave={closeSidebarFloatingPreview}
>
<div className="flex min-h-0 w-14 flex-1 flex-col items-center overflow-hidden rounded-lg bg-components-panel-bg">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.expandSidebar'])}
className="mt-2 flex size-8 cursor-pointer items-center justify-center rounded-[10px] border-0 bg-transparent text-text-tertiary shadow-none outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => onCollapsedChange(false)}
>
<SidebarLeftArrowIcon aria-hidden className="size-4" />
</button>
}
/>
<TooltipContent placement="right">
{t(($) => $['skillManagement.detail.expandSidebar'])}
</TooltipContent>
</Tooltip>
<div aria-hidden className="my-1 h-px w-7 bg-divider-subtle" />
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-divider-regular bg-background-default text-text-secondary">
<span aria-hidden className="i-custom-vender-main-nav-skill size-5" />
</div>
<div className="mt-auto w-full">
<SkillSidebarAccountFooter compact />
</div>
</div>
</aside>
)
}
return (
<>
<aside
ref={sidebarRef}
data-testid="skill-detail-sidebar-shell"
className={cn(
'relative flex h-full shrink-0 bg-background-body p-1',
collapsed ? 'w-16' : 'overflow-visible',
)}
style={collapsed ? undefined : { width: sidebarWidth + 8 }}
onMouseEnter={collapsed ? openSidebarFloatingPreview : undefined}
onMouseLeave={collapsed ? closeSidebarFloatingPreview : undefined}
>
<div
data-testid="skill-detail-sidebar"
className={cn(
'group/sidebar relative flex min-h-0 flex-col rounded-lg bg-components-panel-bg',
collapsed && sidebarFloating
? 'absolute top-1 bottom-1 left-1 z-40 w-60 overflow-hidden border border-divider-subtle shadow-lg'
: 'flex-1 overflow-visible',
)}
style={{ width: collapsed ? skillSidebarMinWidth : sidebarWidth }}
>
{!collapsed && (
<div
role="separator"
aria-label={t(($) => $['skillManagement.detail.resizeSidebar'])}
aria-orientation="vertical"
aria-valuemax={skillSidebarMaxWidth}
aria-valuemin={skillSidebarMinWidth}
aria-valuenow={sidebarWidth}
tabIndex={0}
className="group/resize absolute top-0 -right-2 z-40 flex h-full w-4 cursor-col-resize touch-none items-center justify-center outline-hidden"
onKeyDown={handleSidebarResizeKeyDown}
onPointerDown={handleSidebarResizePointerDown}
>
<span
aria-hidden
className={cn(
'absolute right-1.25 h-10 w-0.5 rounded-full bg-state-base-handle opacity-0 transition-[height,background-color,opacity] group-hover/resize:opacity-100 group-focus-visible/resize:opacity-100',
sidebarResizing && 'h-full bg-state-accent-solid opacity-100',
)}
/>
</div>
)}
<div
data-testid="skill-detail-sidebar-header"
className="flex h-12 shrink-0 items-center py-2 pr-2 pl-1"
>
<div className="flex min-w-0 flex-1 items-center gap-px">
<Link
href="/skills"
className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary outline-hidden hover:bg-background-default-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
aria-label={t(($) => $['skillManagement.detail.back'])}
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
<span className="shrink-0 system-md-regular text-text-quaternary">/</span>
<Link
href="/skills"
className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
SKILLS
</Link>
</div>
<Tooltip>
<TooltipTrigger
render={
<DialogTrigger
handle={gotoAnythingDialogHandle}
render={
<button
type="button"
aria-label={tApp(($) => $['gotoAnything.searchTitle'])}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span
aria-hidden
className="i-custom-vender-main-nav-quick-search size-4"
/>
</button>
}
/>
}
/>
<TooltipContent
placement="bottom"
className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]"
>
<span className="px-0.5">{tApp(($) => $['gotoAnything.quickAction'])}</span>
<KbdGroup>
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.collapseSidebar'])}
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-[10px] border-0 bg-transparent text-text-tertiary shadow-none outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => {
if (collapsed) {
setSidebarFloating(false)
onCollapsedChange(false)
} else {
onCollapsedChange(true)
}
}}
>
<SidebarLeftArrowIcon aria-hidden className="size-4" />
</button>
}
/>
<TooltipContent placement="bottom">
{t(($) => $['skillManagement.detail.collapseSidebar'])}
</TooltipContent>
</Tooltip>
</div>
<div className="p-3">
<div className="flex min-h-10 items-start gap-2">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-divider-regular bg-background-default">
<span
aria-hidden
className="i-custom-vender-main-nav-skill size-5 text-text-secondary"
/>
</div>
<div className="min-w-0 flex-1">
{detail ? (
<SkillDisplayNameEditor
detail={detail}
editing={skillRenameEditing}
fileMutationCoordinator={fileMutationCoordinator}
onEditingChange={setSkillRenameEditing}
readonly={readonly}
skillId={skillId}
/>
) : (
<div className="w-full truncate rounded-md px-1 py-0.5 system-md-semibold text-text-secondary">
{skillId}
</div>
)}
<p className="truncate px-1 system-xs-regular text-text-tertiary">
{detail?.name ?? skillId}
</p>
</div>
{!readonly &&
detail &&
(canEdit || canDelete || !!detail.latest_published_version_id) && (
<SkillDetailSidebarActions
canDelete={canDelete}
canEdit={canEdit}
detail={detail}
onRename={() => setSkillRenameEditing(true)}
/>
)}
</div>
<SkillTagsEditor
detail={detail}
fileMutationCoordinator={fileMutationCoordinator}
readonly={readonly}
skillId={skillId}
/>
</div>
<div className="flex h-4.25 shrink-0 items-center px-3">
<div className="h-px w-full bg-linear-to-r from-divider-subtle to-transparent" />
</div>
<div className="flex h-8 shrink-0 items-center gap-1 px-3">
<h2 className="min-w-0 flex-1 system-xs-medium-uppercase text-text-tertiary">
{t(
($) =>
fileCount === 1
? $['skillManagement.detail.fileCount_one']
: $['skillManagement.detail.fileCount_other'],
{ count: fileCount },
)}
</h2>
{!readonly && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
disabled={!detail || isMutating}
>
<span aria-hidden className="i-ri-add-line size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" className={skillFileMenuPopupClassName}>
<RootFileActionMenuItems
kind="dropdown"
onCreateFile={() =>
setInlineAction({
kind: 'create',
nodeType: 'file',
})
}
onCreateFolder={() =>
setInlineAction({
kind: 'create',
nodeType: 'directory',
})
}
onUploadFiles={() => uploadInputRef.current?.click()}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
<input
ref={uploadInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
void handleUploadFiles(Array.from(event.target.files ?? []), undefined)
event.target.value = ''
}}
/>
</div>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<ScrollAreaViewport tabIndex={-1}>
<ScrollAreaContent
className={cn(
'relative flex min-h-full flex-col rounded-lg px-1 pt-1 pb-3',
dropTarget?.path === '' &&
'bg-components-dropzone-bg-accent before:pointer-events-none before:absolute before:inset-0.5 before:z-10 before:rounded-lg before:border-[1.5px] before:border-dashed before:border-components-dropzone-border-accent',
)}
style={{ minWidth: 0 }}
onDragLeave={handleRootDragLeave}
onDragOver={handleRootDragOver}
onDrop={handleRootDrop}
onClick={handleRootClick}
>
<ContextMenu>
<ContextMenuTrigger
data-skill-file-tree-context-region
className="block w-full flex-1"
onContextMenuCapture={(event) => {
const target = event.target
if (
!(target instanceof Element) ||
!target.closest('[data-skill-file-tree-item]')
) {
setSelectedPaths([])
setSelectionAnchorPath(undefined)
}
if (readonly || !detail || isMutating) {
event.preventDefault()
event.stopPropagation()
}
}}
>
{tree.length === 0 && inlineAction?.kind !== 'create' ? (
<p className="px-2 py-3 system-xs-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.noFiles'])}
</p>
) : (
<ul className="min-w-0 space-y-px">
{inlineAction?.kind === 'create' &&
inlineAction.parentPath === undefined && (
<FileTreeNameInput
loading={fileMutation.isPending}
nodeType={inlineAction.nodeType}
onCancel={() => setInlineAction(undefined)}
onSubmit={handleSubmitInlineAction}
placeholder={
inlineAction.nodeType === 'file'
? t(($) => $['skillManagement.detail.createFile'])
: t(($) => $['skillManagement.detail.createFolder'])
}
/>
)}
{tree.map((node) => (
<FileTreeItem
collapsedFolderPaths={collapsedFolderPaths}
detail={detail}
draggingPaths={draggingPaths}
dropTarget={dropTarget}
inlineAction={inlineAction}
inlineActionLoading={fileMutation.isPending}
key={node.id}
node={node}
onCancelInlineAction={() => setInlineAction(undefined)}
onCopy={handleCopy}
onCreate={(nodeType, parentPath) =>
setInlineAction({
kind: 'create',
nodeType,
parentPath,
})
}
onCut={handleCut}
onDelete={setDeleteNode}
onDropFiles={(filesToUpload, targetDirectory) => {
void handleUploadFiles(filesToUpload, targetDirectory)
}}
onItemSelect={handleItemSelect}
onMove={handleMove}
onRename={(nodeToRename) =>
setInlineAction({
kind: 'rename',
nodeType: nodeToRename.type,
path: nodeToRename.path,
})
}
onSelect={(path, mode) => onSelect(path, undefined, mode)}
onExpandFolder={(path) =>
setCollapsedFolderPaths((paths) =>
paths.filter((folderPath) => folderPath !== path),
)
}
onSetDraggingPaths={setDraggingPaths}
onSetDropTarget={setDropTarget}
onSubmitInlineAction={handleSubmitInlineAction}
onToggleFolder={(path) =>
setCollapsedFolderPaths((paths) =>
paths.includes(path)
? paths.filter((folderPath) => folderPath !== path)
: [...paths, path],
)
}
onUploadFiles={(filesToUpload, targetDirectory) => {
void handleUploadFiles(filesToUpload, targetDirectory)
}}
readonly={readonly}
selectedPaths={selectedPaths}
selectedPath={selectedPath}
/>
))}
</ul>
)}
</ContextMenuTrigger>
<ContextMenuContent className={skillFileMenuPopupClassName}>
<RootFileActionMenuItems
kind="context"
onCreateFile={() =>
setInlineAction({
kind: 'create',
nodeType: 'file',
})
}
onCreateFolder={() =>
setInlineAction({
kind: 'create',
nodeType: 'directory',
})
}
onUploadFiles={() => uploadInputRef.current?.click()}
/>
</ContextMenuContent>
</ContextMenu>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
{dropTarget ? (
<SkillDropDestinationHint target={dropTarget} />
) : (
<SkillUploadStatusPanel
items={uploadItems}
onCancel={handleCancelUpload}
onDismiss={() => setUploadItems([])}
onViewErrors={() => setUploadFailuresOpen(true)}
/>
)}
</ScrollArea>
<SkillUploadReviewDialog
items={uploadReviewItems}
open={uploadReviewOpen}
onDecision={handleUploadDecision}
onOpenChange={setUploadReviewOpen}
onUpload={handleConfirmUpload}
/>
<SkillUploadFailuresDialog
items={uploadItems}
open={uploadFailuresOpen}
onDecision={handleFailedUploadDecision}
onDismiss={() => setUploadFailuresOpen(false)}
onRetry={handleRetryUpload}
onRetryItem={handleRetryUploadItem}
/>
<AlertDialog
open={!!deleteNode}
onOpenChange={(open) => !open && setDeleteNode(undefined)}
>
<AlertDialogContent className="p-6">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['skillManagement.detail.deleteFileConfirm'])}
</AlertDialogTitle>
<AlertDialogDescription className="mt-2 system-md-regular text-text-tertiary">
{deleteNode?.path}
</AlertDialogDescription>
<AlertDialogActions className="p-0 pt-6">
<AlertDialogCancelButton disabled={fileMutation.isPending}>
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
loading={fileMutation.isPending}
onClick={handleDelete}
>
{tCommon(($) => $['operation.delete'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<div className="mx-3 border-t border-divider-subtle pt-2 pb-3">
{referenceCount > 0 ? (
<Popover>
<PopoverTrigger
render={
<button
type="button"
className="-mx-2 flex h-6 w-[calc(100%+16px)] cursor-pointer items-center gap-2 rounded-md px-2.5 text-left system-xs-regular text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary"
>
<span aria-hidden className="i-ri-apps-2-line size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{referenceCountLabel}</span>
<span
aria-hidden
className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary"
/>
</button>
}
/>
<PopoverContent
placement="top-start"
sideOffset={4}
className="w-(--anchor-width) max-w-(--available-width) bg-components-panel-bg-blur p-1 shadow-shadow-shadow-5 backdrop-blur-[5px]"
aria-label={referenceCountLabel}
>
<div className="px-1 pt-1.5 pb-1 system-xs-medium text-text-tertiary">
{referenceCountLabel}
</div>
<SkillReferencesPanel
compact
embedded
maxHeight="max-h-[240px]"
skillId={skillId}
/>
</PopoverContent>
</Popover>
) : (
<div className="-mx-2 flex h-6 w-[calc(100%+16px)] items-center gap-2 px-2.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-apps-2-line size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{referenceCountLabel}</span>
</div>
)}
<div className="-mx-2 flex h-6 w-[calc(100%+16px)] items-center gap-2 px-2.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-account-circle-line size-4 shrink-0" />
<span className="min-w-0 truncate">
{t(($) => $['skillManagement.detail.createdBy'], {
name: creatorName,
})}
</span>
</div>
</div>
<SkillSidebarAccountFooter />
</div>
</aside>
</>
)
}