dify/web/features/skills/detail/file-editor.tsx
wangxiaolei 0024a845f7
feat: support agent skill (#39675)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: 起岚 <155608604+xiaoyaoqilan@users.noreply.github.com>
Co-authored-by: Parman Mohammadalizadeh <prmma23@gmail.com>
Co-authored-by: Escape0707 <tothesong@gmail.com>
Co-authored-by: Xin Zhang <zhangxin@dify.ai>
Co-authored-by: iridescentWen <66297467+iridescentWen@users.noreply.github.com>
Co-authored-by: Bond Zhu <37842169+MRZHUH@users.noreply.github.com>
Co-authored-by: KVOJJJin <jzongcode@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: Chester <superdiao6@gmail.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
Co-authored-by: Jony <619963502@qq.com>
Co-authored-by: Jony <13896935+zyz619963502zyz@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harsh.kashyap2001@gmail.com>
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: csurong <csurong1@gmail.com>
Co-authored-by: caosurong <surong.cao@thinkingdata.cn>
Co-authored-by: Taranum Wasu <81034301+Taranum01@users.noreply.github.com>
Co-authored-by: Taranum01 <50813317+Taranum01@users.noreply.github.com>
Co-authored-by: Pranav Agarwal <agarwalpranav0711@gmail.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: zl86790 <marshal_li_b@163.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-08-24 05:29:14 +00:00

1517 lines
58 KiB
TypeScript

'use client'
/* oxlint-disable eslint-react/set-state-in-effect -- Extracted editor owners intentionally mirror authoritative snapshots into local draft state. */
import type {
SkillDetailResponse,
SkillFileResponse,
SkillVersionResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ChangeEvent, FormEvent, KeyboardEvent } from 'react'
import type { SkillFileMutationCoordinator } from './shared'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import dynamic from '@/next/dynamic'
import { consoleQuery } from '@/service/client'
import { downloadBlob } from '@/utils/download'
import { fetchSkillFileBlob } from '../client'
import { FileTabs } from './file-tabs'
import {
CsvTablePreview,
EditableMetadataEntry,
EditableMetadataField,
MarkdownBodyReferencePreview,
MarkdownLiveBodyEditor,
MarkdownModeSwitch,
MarkdownSourceEditor,
ReferenceFilesPicker,
VersionActionBar,
} from './markdown-editor'
import { SkillPublishBar, SkillPublishBottomActions } from './publish-bar'
import {
addMarkdownMetadata,
findBrokenMarkdownReferenceRangeAtCaret,
findFileByPath,
findMarkdownReferenceRangeAtCaret,
getAsyncSkillErrorPayload,
getContentEditableCaretAnchor,
getErrorCode,
getErrorDetailNumber,
getErrorDetailString,
getMarkdownBodyPrefix,
getMarkdownLiveEditorSelectionOffset,
getPathBaseName,
getPathDirName,
getReferenceTargets,
getReferenceText,
getSkillCodeLanguage,
getSkillFileIconClass,
getTextareaCaretAnchor,
insertMarkdownLiveEditorLineBreak,
isCsvFile,
isDirectory,
isEditableMetadataKey,
isMarkdownFile,
isProtectedMarkdownMetadataKey,
isSkillImageFile,
isSkillPdfFile,
isTextFile,
metadataKeyInputClassName,
metadataValueInputClassName,
normalizeSkillDraftContentForEditing,
parseCsvRows,
parseMarkdownContent,
refreshSkillDetailAfterConflict,
removeMarkdownMetadata,
renderMarkdownLiveEditorContent,
replaceMarkdownBody,
runSkillFileMutation,
serializeMarkdownLiveEditorNode,
setMarkdownFrontmatterField,
setMarkdownLiveEditorSelectionOffset,
setSkillDetailCache,
stripSkillFrontmatterForDisplay,
updateMarkdownMetadata,
} from './shared'
import { SkillPublishConfirmPanel } from './skill-metadata'
const SkillPdfPreview = dynamic(
() => import('./skill-pdf-preview').then((module) => module.SkillPdfPreview),
{ ssr: false },
)
export function FileEditor({
canPublish,
detail,
file,
fileMutationCoordinator,
hasLocalUnpublishedChanges,
onLocalUnpublishedChangesChange,
onPromoteFile,
onOpenBuilder,
onOpenVersions,
onPublish,
onRestoreVersion,
onExitVersion,
onCloseFile,
onDraftDetailChange,
onSaveConflictConfirm,
onSelectFile,
openFiles,
previewFilePath,
publishing,
readonly,
selectedPath,
selectedVersion,
selectedVersionId,
skillId,
}: {
canPublish: boolean
detail: SkillDetailResponse | undefined
file: SkillFileResponse | undefined
fileMutationCoordinator: SkillFileMutationCoordinator
hasLocalUnpublishedChanges: boolean
onLocalUnpublishedChangesChange: (hasChanges: boolean) => void
onPromoteFile: (path: string) => void
onOpenBuilder?: () => void
onOpenVersions: () => void
onPublish: () => void
onRestoreVersion: () => void
onExitVersion: () => void
onCloseFile: (path: string) => void
onDraftDetailChange: (detail: SkillDetailResponse) => void
onSaveConflictConfirm: (onConfirm: () => void | Promise<void>) => void
onSelectFile: (path: string) => void
openFiles: SkillFileResponse[]
previewFilePath: string | undefined
publishing: boolean
readonly: boolean
selectedPath: string | undefined
selectedVersion: SkillVersionResponse | undefined
selectedVersionId: string | null
skillId: string
}) {
const { t } = useTranslation('skill')
const queryClient = useQueryClient()
const { formatTimeFromNow } = useFormatTimeFromNow()
const initialContent =
file && isTextFile(file) ? normalizeSkillDraftContentForEditing(file.content ?? '') : ''
const initialSavedAt = detail?.updated_at ? detail.updated_at * 1000 : undefined
const [draftContent, setDraftContent] = useState(initialContent)
const [markdownMode, setMarkdownMode] = useState<'live' | 'source'>('live')
const [metadataAdding, setMetadataAdding] = useState(false)
const [metadataKey, setMetadataKey] = useState('')
const [metadataValue, setMetadataValue] = useState('')
const [referencePicker, setReferencePicker] = useState<{
anchor: { x: number; y: number }
currentDirectory: string
query: string
slashIndex: number
} | null>(null)
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
const [publishReferenceCountOverride, setPublishReferenceCountOverride] = useState<number | null>(
null,
)
const [referenceSelectedIndex, setReferenceSelectedIndex] = useState(0)
const [saveStatus, setSaveStatus] = useState<'dirty' | 'error' | 'saved' | 'saving'>('saved')
const [hasSaveConflict, setHasSaveConflict] = useState(false)
const [saveConflictReloadContent, setSaveConflictReloadContent] = useState<string | null>(null)
const [saveConflictReloadDetail, setSaveConflictReloadDetail] =
useState<SkillDetailResponse | null>(null)
const [fileObjectUrl, setFileObjectUrl] = useState<string>()
const [savedAt, setSavedAt] = useState<number | undefined>(initialSavedAt)
const [externalContentRevision, setExternalContentRevision] = useState(0)
const draftContentRef = useRef(initialContent)
const lastSavedContentRef = useRef(initialContent)
const saveConflictContentRef = useRef<string | null>(null)
const saveConflictReloadContentRef = useRef<string | null>(null)
const handleSaveConflictReloadRef = useRef<() => void>(() => {})
const detailRef = useRef(detail)
const fileRef = useRef(file)
const pendingPublishAfterSaveRef = useRef(false)
const metadataKeyInputRef = useRef<HTMLInputElement>(null)
const metadataKeyDraftRef = useRef('')
const metadataValueDraftRef = useRef('')
const liveBodyTextareaRef = useRef<HTMLTextAreaElement>(null)
const liveBodyEditorRef = useRef<HTMLDivElement>(null)
const sourceTextareaRef = useRef<HTMLTextAreaElement>(null)
const { isPending: isSavingDraft, mutateAsync: saveDraftFile } = useMutation(
consoleQuery.workspaces.current.skills.bySkillId.files.patch.mutationOptions({
context: { silent: true },
}),
)
const filePath = file?.path
const codeLanguage = getSkillCodeLanguage(file)
const isMarkdown = isMarkdownFile(file)
const isSkillManifestFile = filePath === 'SKILL.md'
const isCsv = isCsvFile(file)
const editableDraftContent = useMemo(
() => normalizeSkillDraftContentForEditing(draftContent),
[draftContent],
)
const markdownContent = useMemo(() => {
if (!isSkillManifestFile) {
return {
body: stripSkillFrontmatterForDisplay(editableDraftContent),
description: '',
displayName: '',
metadata: [],
name: '',
}
}
const parsed = parseMarkdownContent(editableDraftContent)
return {
...parsed,
name: parsed.name.startsWith('untitled-skill-') ? '' : parsed.name,
}
}, [editableDraftContent, isSkillManifestFile])
const csvRows = useMemo(() => parseCsvRows(editableDraftContent), [editableDraftContent])
const hasPublishedVersion = !!detail?.latest_published_version_id
const latestPublishedAt = detail?.latest_published_at
const hasUnpublishedChanges =
saveStatus === 'dirty' ||
saveStatus === 'saving' ||
saveStatus === 'error' ||
hasSaveConflict ||
hasLocalUnpublishedChanges ||
!hasPublishedVersion ||
(typeof detail?.updated_at === 'number' &&
typeof latestPublishedAt === 'number' &&
detail.updated_at > latestPublishedAt)
const publishState = publishing
? 'publishing'
: !hasPublishedVersion
? 'draft'
: hasUnpublishedChanges
? 'unpublished'
: 'published'
const publishDisabled = publishState === 'publishing' || publishState === 'published'
const fileHash = file?.hash
const editorInstanceKey = `${selectedVersionId ?? 'draft'}:${filePath ?? 'empty'}:${readonly ? 'readonly' : 'draft'}`
const editorRenderKey = `${editorInstanceKey}:${externalContentRevision}`
const referenceTargets = useMemo(
() => getReferenceTargets(detail?.files ?? [], filePath),
[detail?.files, filePath],
)
const showMarkdownMetadataPanel =
isSkillManifestFile &&
(markdownContent.name ||
markdownContent.description ||
markdownContent.metadata.length > 0 ||
!readonly)
const referenceQuery = referencePicker?.query.trim().toLowerCase() ?? ''
const filteredReferenceFiles = useMemo(() => {
const currentDirectory = referencePicker?.currentDirectory ?? ''
const scopedTargets = referenceTargets.filter((referenceFile) => {
const parentDirectory = getPathDirName(referenceFile.path)
return parentDirectory === currentDirectory
})
if (!referenceQuery) return scopedTargets
return scopedTargets.filter((referenceFile) => {
const path = referenceFile.path.toLowerCase()
return path.includes(referenceQuery) || getPathBaseName(path).includes(referenceQuery)
})
}, [referencePicker?.currentDirectory, referenceQuery, referenceTargets])
useEffect(() => {
if (editableDraftContent === draftContent) return
draftContentRef.current = editableDraftContent
if (lastSavedContentRef.current === draftContent)
lastSavedContentRef.current = editableDraftContent
if (saveConflictContentRef.current === draftContent)
saveConflictContentRef.current = editableDraftContent
setDraftContent(editableDraftContent)
setSaveStatus(editableDraftContent === lastSavedContentRef.current ? 'saved' : 'dirty')
setExternalContentRevision((revision) => revision + 1)
}, [draftContent, editableDraftContent])
const shouldFetchTextFileContent = !!file && isTextFile(file) && file.content == null
const textContentQuery = useQuery({
queryKey: ['skill-file-text-content', skillId, selectedVersionId, filePath, fileHash],
queryFn: async () => {
if (!filePath) throw new Error('file path is required')
const blob = await fetchSkillFileBlob({
path: filePath,
skillId,
versionId: selectedVersionId,
})
return blob.text()
},
enabled: shouldFetchTextFileContent,
})
const isTextContentUnavailable = shouldFetchTextFileContent && textContentQuery.data == null
const isTextContentPending = isTextContentUnavailable && textContentQuery.isPending
const isTextContentError = isTextContentUnavailable && textContentQuery.isError
const canEdit = !!file && isTextFile(file) && !readonly && !isTextContentUnavailable
const canPreviewBinaryFile =
!!file && !isTextFile(file) && (isSkillImageFile(file) || isSkillPdfFile(file))
const binaryPreviewQuery = useQuery({
queryKey: ['skill-file-blob-preview', skillId, selectedVersionId, filePath, fileHash],
queryFn: () => {
if (!filePath) throw new Error('file path is required')
return fetchSkillFileBlob({
path: filePath,
skillId,
versionId: selectedVersionId,
})
},
enabled: canPreviewBinaryFile,
})
const downloadMutation = useMutation({
mutationFn: () => {
if (!filePath) throw new Error('file path is required')
return fetchSkillFileBlob({
download: true,
path: filePath,
skillId,
versionId: selectedVersionId,
})
},
onSuccess: (blob) => {
if (!file) return
downloadBlob({ data: blob, fileName: getPathBaseName(file.path) })
},
onError: () => {
toast.error(t(($) => $['skillManagement.detail.loadFailed']))
},
})
const saveDraftContent = useCallback(
async (content: string) => {
const currentDetail = detailRef.current
const currentFile = fileRef.current
if (!currentDetail || !currentFile || !canEdit || isSavingDraft) return false
if (saveConflictContentRef.current === content) {
setHasSaveConflict(true)
if (saveConflictReloadContentRef.current != null)
onSaveConflictConfirm(handleSaveConflictReloadRef.current)
return false
}
setHasSaveConflict(false)
setSaveStatus('saving')
try {
const nextCachedDetail = await runSkillFileMutation(
fileMutationCoordinator,
async (expectedUpdatedAt) =>
saveDraftFile({
params: {
skill_id: skillId,
},
body: {
content,
expected_updated_at: expectedUpdatedAt,
hash: currentFile.hash,
mime_type: currentFile.mime_type,
operation: 'upsert_text',
path: currentFile.path,
size: content.length,
},
}),
)
detailRef.current = nextCachedDetail
fileRef.current =
findFileByPath(nextCachedDetail.files ?? [], currentFile.path) ?? currentFile
lastSavedContentRef.current = content
saveConflictContentRef.current = null
setHasSaveConflict(false)
setSavedAt(nextCachedDetail.updated_at * 1000)
setSaveStatus(draftContentRef.current === content ? 'saved' : 'dirty')
setSkillDetailCache(queryClient, skillId, nextCachedDetail)
onDraftDetailChange(nextCachedDetail)
return true
} catch (error) {
const errorPayload = await getAsyncSkillErrorPayload(error)
if (getErrorCode(errorPayload ?? error) === 'skill_name_conflict') {
toast.error(
t(($) => $['skillManagement.errors.nameConflict'], {
name: getErrorDetailString(errorPayload ?? error, 'name') ?? '',
}),
)
setSaveStatus('error')
return false
}
if (getErrorCode(errorPayload ?? error) === 'skill_conflict') {
try {
const currentUpdatedAt = getErrorDetailNumber(
errorPayload ?? error,
'current_updated_at',
)
const currentFileContent = getErrorDetailString(
errorPayload ?? error,
'current_file_content',
)
let latestDetail: SkillDetailResponse | undefined
try {
latestDetail = await refreshSkillDetailAfterConflict(queryClient, skillId, {
updateCache: false,
})
} catch {
latestDetail = undefined
}
const refetchedDetail =
latestDetail?.updated_at != null &&
(currentUpdatedAt == null || latestDetail.updated_at >= currentUpdatedAt)
? latestDetail
: undefined
const latestUpdatedAt = refetchedDetail?.updated_at ?? currentUpdatedAt
if (latestUpdatedAt == null) throw error
const latestFile = refetchedDetail
? findFileByPath(refetchedDetail.files ?? [], currentFile.path)
: undefined
if (latestFile && isTextFile(latestFile) && latestFile.content != null)
lastSavedContentRef.current = normalizeSkillDraftContentForEditing(latestFile.content)
else if (currentFileContent != null)
lastSavedContentRef.current = normalizeSkillDraftContentForEditing(currentFileContent)
const recoveredDetail = refetchedDetail ?? {
...currentDetail,
updated_at: latestUpdatedAt,
}
fileMutationCoordinator.latestDetail = recoveredDetail
const latestContent =
latestFile?.content != null
? normalizeSkillDraftContentForEditing(latestFile.content)
: currentFileContent != null
? normalizeSkillDraftContentForEditing(currentFileContent)
: null
saveConflictReloadContentRef.current = latestContent
saveConflictContentRef.current =
draftContentRef.current === lastSavedContentRef.current
? null
: draftContentRef.current
setSaveConflictReloadContent(latestContent)
setSaveConflictReloadDetail(recoveredDetail)
setHasSaveConflict(saveConflictContentRef.current != null)
setSavedAt(latestUpdatedAt * 1000)
setSaveStatus('saved')
onSaveConflictConfirm(handleSaveConflictReloadRef.current)
return false
} catch {
setSaveStatus('error')
toast.error(t(($) => $['skillManagement.detail.saveFailed']))
return false
}
}
setSaveStatus('error')
toast.error(t(($) => $['skillManagement.detail.saveFailed']))
return false
}
},
[
canEdit,
fileMutationCoordinator,
isSavingDraft,
onDraftDetailChange,
onSaveConflictConfirm,
queryClient,
saveDraftFile,
skillId,
t,
],
)
const handleSaveConflictReload = useCallback(async () => {
let latestDetail = saveConflictReloadDetail
let useFetchedDetail = false
try {
const fetchedDetail = await refreshSkillDetailAfterConflict(queryClient, skillId, {
updateCache: false,
})
if (!latestDetail || fetchedDetail.updated_at >= latestDetail.updated_at) {
useFetchedDetail = true
latestDetail = fetchedDetail
}
} catch {
// Keep the snapshot fetched when the conflict was detected as a fallback.
}
const latestFile = latestDetail ? findFileByPath(latestDetail.files ?? [], filePath) : undefined
const fallbackContent = saveConflictReloadContentRef.current ?? saveConflictReloadContent
const latestContent =
useFetchedDetail && latestFile && isTextFile(latestFile) && latestFile.content != null
? normalizeSkillDraftContentForEditing(latestFile.content)
: fallbackContent
if (latestContent == null) return
if (latestDetail) {
const detailToApply = {
...latestDetail,
files: (latestDetail.files ?? []).map((candidateFile) =>
candidateFile.path === filePath && isTextFile(candidateFile)
? {
...candidateFile,
content: latestContent,
size: latestContent.length,
}
: candidateFile,
),
}
detailRef.current = detailToApply
fileMutationCoordinator.latestDetail = detailToApply
const appliedFile = findFileByPath(detailToApply.files ?? [], filePath)
if (appliedFile) fileRef.current = appliedFile
setSkillDetailCache(queryClient, skillId, detailToApply)
onDraftDetailChange(detailToApply)
}
draftContentRef.current = latestContent
lastSavedContentRef.current = latestContent
saveConflictContentRef.current = null
saveConflictReloadContentRef.current = null
setDraftContent(latestContent)
setHasSaveConflict(false)
setSaveConflictReloadContent(null)
setSaveConflictReloadDetail(null)
setSaveStatus('saved')
setExternalContentRevision((revision) => revision + 1)
}, [
fileMutationCoordinator,
filePath,
onDraftDetailChange,
queryClient,
saveConflictReloadContent,
saveConflictReloadDetail,
skillId,
])
useEffect(() => {
handleSaveConflictReloadRef.current = handleSaveConflictReload
}, [handleSaveConflictReload])
const canEditRef = useRef(canEdit)
const saveDraftContentRef = useRef(saveDraftContent)
detailRef.current = detail
fileRef.current = file
canEditRef.current = canEdit
useEffect(() => {
const currentFile = fileRef.current
const nextContent =
currentFile && isTextFile(currentFile)
? normalizeSkillDraftContentForEditing(currentFile.content ?? '')
: ''
draftContentRef.current = nextContent
lastSavedContentRef.current = nextContent
saveConflictContentRef.current = null
setHasSaveConflict(false)
setDraftContent(nextContent)
setSaveStatus('saved')
setMetadataAdding(false)
setMetadataKey('')
setMetadataValue('')
metadataKeyDraftRef.current = ''
metadataValueDraftRef.current = ''
setReferencePicker(null)
setExternalContentRevision(0)
}, [editorInstanceKey])
useEffect(() => {
if (!file || !isTextFile(file) || file.content == null) return
if (draftContentRef.current !== lastSavedContentRef.current) return
const nextContent = normalizeSkillDraftContentForEditing(file.content)
if (nextContent === lastSavedContentRef.current) return
draftContentRef.current = nextContent
lastSavedContentRef.current = nextContent
saveConflictContentRef.current = null
setHasSaveConflict(false)
setDraftContent(nextContent)
setSavedAt(detail?.updated_at ? detail.updated_at * 1000 : undefined)
setSaveStatus('saved')
setExternalContentRevision((revision) => revision + 1)
}, [detail?.updated_at, file, fileHash])
useEffect(() => {
if (!shouldFetchTextFileContent || textContentQuery.data == null) return
if (draftContentRef.current !== lastSavedContentRef.current) return
const nextContent = normalizeSkillDraftContentForEditing(textContentQuery.data)
draftContentRef.current = nextContent
lastSavedContentRef.current = nextContent
saveConflictContentRef.current = null
setHasSaveConflict(false)
setDraftContent(nextContent)
setSaveStatus('saved')
setExternalContentRevision((revision) => revision + 1)
}, [shouldFetchTextFileContent, textContentQuery.data])
useEffect(() => {
if (!canEdit) return
if (draftContent === lastSavedContentRef.current) return
if (draftContent === saveConflictContentRef.current) return
if (saveStatus === 'saving' || saveStatus === 'error') return
const timer = window.setTimeout(() => {
void saveDraftContent(draftContent)
}, 1000)
return () => window.clearTimeout(timer)
}, [canEdit, draftContent, saveDraftContent, saveStatus])
useEffect(() => {
saveDraftContentRef.current = saveDraftContent
}, [saveDraftContent])
useEffect(() => {
return () => {
if (!canEditRef.current) return
if (draftContentRef.current === lastSavedContentRef.current) return
void saveDraftContentRef.current(draftContentRef.current)
}
}, [])
useEffect(() => {
if (!binaryPreviewQuery.data) {
setFileObjectUrl(undefined)
return
}
const objectUrl = URL.createObjectURL(binaryPreviewQuery.data)
setFileObjectUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [binaryPreviewQuery.data])
useEffect(() => {
if (referencePicker?.query == null) return
setReferenceSelectedIndex(0)
}, [referencePicker?.currentDirectory, referencePicker?.query])
useEffect(() => {
if (referenceSelectedIndex < filteredReferenceFiles.length) return
setReferenceSelectedIndex(Math.max(filteredReferenceFiles.length - 1, 0))
}, [filteredReferenceFiles.length, referenceSelectedIndex])
const updateDraftContent = useCallback(
(nextContent: string) => {
draftContentRef.current = nextContent
const isConflictContent = nextContent === saveConflictContentRef.current
if (!isConflictContent) {
saveConflictContentRef.current = null
setHasSaveConflict(false)
}
setDraftContent(nextContent)
setSaveStatus(nextContent === lastSavedContentRef.current ? 'saved' : 'dirty')
if (nextContent !== lastSavedContentRef.current) {
onLocalUnpublishedChangesChange(true)
if (filePath) onPromoteFile(filePath)
}
},
[filePath, onLocalUnpublishedChangesChange, onPromoteFile],
)
const handleContentChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
const nextContent = event.target.value
updateDraftContent(nextContent)
if (!referencePicker) return
const caretIndex = event.target.selectionStart
if (
caretIndex <= referencePicker.slashIndex ||
nextContent[referencePicker.slashIndex] !== '/' ||
nextContent.slice(referencePicker.slashIndex + 1, caretIndex).includes('\n')
) {
setReferencePicker(null)
return
}
setReferencePicker({
anchor: getTextareaCaretAnchor(event.target, caretIndex),
currentDirectory: referencePicker.currentDirectory,
slashIndex: referencePicker.slashIndex,
query: nextContent.slice(referencePicker.slashIndex + 1, caretIndex),
})
}
const handleLiveBodyEditorInput = (event: FormEvent<HTMLDivElement>) => {
const nextBody = serializeMarkdownLiveEditorNode(event.currentTarget).replace(/\u00A0/g, ' ')
const nextCaretOffset = getMarkdownLiveEditorSelectionOffset(event.currentTarget)
const nextContent = replaceMarkdownBody(draftContentRef.current, nextBody)
updateDraftContent(nextContent)
if (nextCaretOffset != null) {
window.requestAnimationFrame(() => {
if (!liveBodyEditorRef.current) return
liveBodyEditorRef.current.focus()
setMarkdownLiveEditorSelectionOffset(liveBodyEditorRef.current, nextCaretOffset)
})
}
if (!referencePicker) return
const bodyPrefixLength = getMarkdownBodyPrefix(nextContent).length
const bodyCaretIndex = getMarkdownLiveEditorSelectionOffset(event.currentTarget) ?? 0
const caretIndex = bodyPrefixLength + bodyCaretIndex
if (
caretIndex <= referencePicker.slashIndex ||
nextContent[referencePicker.slashIndex] !== '/' ||
nextContent.slice(referencePicker.slashIndex + 1, caretIndex).includes('\n')
) {
setReferencePicker(null)
return
}
setReferencePicker({
anchor: getContentEditableCaretAnchor(event.currentTarget),
currentDirectory: referencePicker.currentDirectory,
slashIndex: referencePicker.slashIndex,
query: nextContent.slice(referencePicker.slashIndex + 1, caretIndex),
})
}
const handleReferenceDirectoryBack = () => {
if (!referencePicker?.currentDirectory) return
setReferencePicker({
anchor: referencePicker.anchor,
currentDirectory: getPathDirName(referencePicker.currentDirectory),
slashIndex: referencePicker.slashIndex,
query: '',
})
}
const handleReferenceDirectoryOpen = (directory: SkillFileResponse) => {
if (!referencePicker || !isDirectory(directory)) return
setReferencePicker({
anchor: referencePicker.anchor,
currentDirectory: directory.path,
slashIndex: referencePicker.slashIndex,
query: '',
})
}
const handleTextEditorKeyDown = (
event: KeyboardEvent<HTMLDivElement | HTMLTextAreaElement>,
bodyMode = false,
) => {
if (!isMarkdown || readonly) return
if (
bodyMode &&
event.currentTarget instanceof HTMLTextAreaElement &&
event.key === 'Backspace' &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
event.currentTarget.selectionStart === event.currentTarget.selectionEnd
) {
const caretIndex = event.currentTarget.selectionStart
if (event.currentTarget.value[caretIndex - 1] === '\n') {
event.preventDefault()
const nextCaretIndex = caretIndex - 1
const nextBody = `${event.currentTarget.value.slice(0, nextCaretIndex)}${event.currentTarget.value.slice(caretIndex)}`
const nextContent = replaceMarkdownBody(draftContentRef.current, nextBody)
updateDraftContent(nextContent)
window.requestAnimationFrame(() => {
liveBodyTextareaRef.current?.focus()
liveBodyTextareaRef.current?.setSelectionRange(nextCaretIndex, nextCaretIndex)
})
return
}
const referenceRange =
findMarkdownReferenceRangeAtCaret(event.currentTarget.value, caretIndex) ??
findBrokenMarkdownReferenceRangeAtCaret(event.currentTarget.value, caretIndex)
if (referenceRange) {
event.preventDefault()
const nextBody = `${event.currentTarget.value.slice(0, referenceRange.start)}${event.currentTarget.value.slice(referenceRange.end)}`
const nextContent = replaceMarkdownBody(draftContentRef.current, nextBody)
updateDraftContent(nextContent)
window.requestAnimationFrame(() => {
liveBodyTextareaRef.current?.focus()
liveBodyTextareaRef.current?.setSelectionRange(referenceRange.start, referenceRange.start)
})
return
}
}
if (referencePicker) {
if (event.key === 'Escape') {
event.preventDefault()
setReferencePicker(null)
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
setReferenceSelectedIndex((index) =>
Math.min(index + 1, Math.max(filteredReferenceFiles.length - 1, 0)),
)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (referenceSelectedIndex === 0 && referencePicker.currentDirectory) {
handleReferenceDirectoryBack()
return
}
setReferenceSelectedIndex((index) => Math.max(index - 1, 0))
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
handleReferenceDirectoryBack()
return
}
if (event.key === 'ArrowRight') {
const selectedFile = filteredReferenceFiles[referenceSelectedIndex]
if (!selectedFile || !isDirectory(selectedFile)) return
event.preventDefault()
handleReferenceDirectoryOpen(selectedFile)
return
}
if (event.key === 'Enter') {
const selectedFile = filteredReferenceFiles[referenceSelectedIndex]
if (!selectedFile) return
event.preventDefault()
if (isDirectory(selectedFile)) {
handleReferenceDirectoryOpen(selectedFile)
return
}
// oxlint-disable-next-line typescript/no-use-before-define -- Reference insertion reads current picker state and is kept beside its write logic.
handleInsertReferenceFile(selectedFile, bodyMode)
return
}
}
if (
bodyMode &&
event.currentTarget instanceof HTMLDivElement &&
event.key === 'Enter' &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
event.preventDefault()
const nextCaretOffset = insertMarkdownLiveEditorLineBreak(event.currentTarget)
if (nextCaretOffset == null) return
const nextBody = serializeMarkdownLiveEditorNode(event.currentTarget).replace(/\u00A0/g, ' ')
const nextContent = replaceMarkdownBody(draftContentRef.current, nextBody)
updateDraftContent(nextContent)
window.requestAnimationFrame(() => {
if (!liveBodyEditorRef.current) return
liveBodyEditorRef.current.focus()
setMarkdownLiveEditorSelectionOffset(liveBodyEditorRef.current, nextCaretOffset)
})
return
}
if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return
const bodyPrefixLength = bodyMode ? getMarkdownBodyPrefix(draftContentRef.current).length : 0
const bodySelectionStart =
event.currentTarget instanceof HTMLTextAreaElement
? event.currentTarget.selectionStart
: getMarkdownLiveEditorSelectionOffset(event.currentTarget)
if (bodySelectionStart == null) return
setReferencePicker({
anchor:
event.currentTarget instanceof HTMLTextAreaElement
? getTextareaCaretAnchor(event.currentTarget, bodySelectionStart)
: getContentEditableCaretAnchor(event.currentTarget),
currentDirectory: '',
slashIndex: bodyPrefixLength + bodySelectionStart,
query: '',
})
setReferenceSelectedIndex(0)
}
const handleInsertReferenceFile = (referenceFile: SkillFileResponse, bodyMode = false) => {
if (!referencePicker) return
if (bodyMode && liveBodyEditorRef.current) {
const bodyPrefixLength = getMarkdownBodyPrefix(draftContentRef.current).length
const bodyCaretIndex =
getMarkdownLiveEditorSelectionOffset(liveBodyEditorRef.current) ??
referencePicker.slashIndex - bodyPrefixLength + referencePicker.query.length + 1
const replaceEnd = bodyPrefixLength + bodyCaretIndex
const referenceText = `${getReferenceText(referenceFile)}\n`
const nextContent = `${draftContentRef.current.slice(0, referencePicker.slashIndex)}${referenceText}${draftContentRef.current.slice(replaceEnd)}`
const nextBody = nextContent.slice(bodyPrefixLength)
updateDraftContent(nextContent)
setReferencePicker(null)
window.requestAnimationFrame(() => {
const editor = liveBodyEditorRef.current
if (!editor) return
renderMarkdownLiveEditorContent(editor, nextBody)
editor.focus()
setMarkdownLiveEditorSelectionOffset(
editor,
referencePicker.slashIndex - bodyPrefixLength + referenceText.length,
)
})
return
}
const textarea = bodyMode ? liveBodyTextareaRef.current : sourceTextareaRef.current
const bodyPrefixLength = bodyMode ? getMarkdownBodyPrefix(draftContentRef.current).length : 0
const replaceEnd =
(textarea?.selectionStart ?? referencePicker.slashIndex + referencePicker.query.length + 1) +
bodyPrefixLength
const referenceText = bodyMode
? `${getReferenceText(referenceFile)}\n`
: getReferenceText(referenceFile)
const nextContent = `${draftContentRef.current.slice(0, referencePicker.slashIndex)}${referenceText}${draftContentRef.current.slice(replaceEnd)}`
const nextCaretIndex = referencePicker.slashIndex + referenceText.length
const textareaCaretIndex = bodyMode ? nextCaretIndex - bodyPrefixLength : nextCaretIndex
updateDraftContent(nextContent)
setReferencePicker(null)
window.requestAnimationFrame(() => {
textarea?.focus()
textarea?.setSelectionRange(textareaCaretIndex, textareaCaretIndex)
})
}
const handleAddMetadata = (keyOverride?: string, valueOverride?: string) => {
const nextKey = (keyOverride ?? metadataKey).trim()
if (
!isSkillManifestFile ||
!isEditableMetadataKey(nextKey) ||
isProtectedMarkdownMetadataKey(nextKey)
)
return
updateDraftContent(
addMarkdownMetadata(draftContentRef.current, nextKey, valueOverride ?? metadataValue),
)
metadataKeyDraftRef.current = ''
metadataValueDraftRef.current = ''
setMetadataKey('')
setMetadataValue('')
setMetadataAdding(false)
}
const handleRemoveMetadata = (key: string) => {
if (!isSkillManifestFile) return
updateDraftContent(removeMarkdownMetadata(draftContentRef.current, key))
}
const handleCancelAddMetadata = () => {
metadataKeyDraftRef.current = ''
metadataValueDraftRef.current = ''
setMetadataKey('')
setMetadataValue('')
setMetadataAdding(false)
}
const handlePublish = useCallback(async () => {
if (publishDisabled) return
if (saveStatus === 'saving') {
pendingPublishAfterSaveRef.current = true
return
}
const contentToPublish = draftContentRef.current
if (canEdit && contentToPublish !== lastSavedContentRef.current) {
const saved = await saveDraftContent(contentToPublish)
if (!saved) return
}
const referenceCount = detail?.reference_count ?? 0
if (referenceCount > 0) {
setPublishReferenceCountOverride(null)
setPublishConfirmOpen(true)
return
}
const references = await queryClient.fetchQuery(
consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({
input: {
params: {
skill_id: skillId,
},
},
}),
)
const fetchedReferenceCount = references.data?.length ?? 0
if (fetchedReferenceCount > 0) {
if (detail && detail.reference_count !== fetchedReferenceCount)
setSkillDetailCache(queryClient, skillId, {
...detail,
reference_count: fetchedReferenceCount,
})
setPublishReferenceCountOverride(fetchedReferenceCount)
setPublishConfirmOpen(true)
return
}
onPublish()
}, [
canEdit,
detail,
onPublish,
publishDisabled,
queryClient,
saveDraftContent,
saveStatus,
skillId,
])
useEffect(() => {
if (!pendingPublishAfterSaveRef.current || saveStatus === 'saving') return
pendingPublishAfterSaveRef.current = false
void handlePublish()
}, [handlePublish, saveStatus])
const saveStateText =
saveStatus === 'saving'
? t(($) => $['skillManagement.detail.saving'])
: hasSaveConflict
? t(($) => $['skillManagement.detail.saveConflictStatus'])
: saveStatus === 'dirty'
? t(($) => $['skillManagement.detail.unsavedChanges'])
: saveStatus === 'error'
? t(($) => $['skillManagement.detail.saveFailed'])
: savedAt
? t(($) => $['skillManagement.detail.savedAt'], { time: formatTimeFromNow(savedAt) })
: t(($) => $['skillManagement.detail.saved'])
const publishMetaText =
publishState === 'published'
? latestPublishedAt
? t(($) => $['skillManagement.detail.publishedAt'], {
time: formatTimeFromNow(latestPublishedAt * 1000),
})
: t(($) => $['skillManagement.detail.published'])
: saveStateText
if (!selectedPath) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<p className="system-sm-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.noFileSelected'])}
</p>
</div>
)
}
if (!file) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<p className="system-sm-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.fileMissing'])}
</p>
</div>
)
}
return (
<main className="relative my-1 mr-1 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background-default inset-ring-[0.5px] inset-ring-divider-subtle">
<FileTabs
endAction={
onOpenBuilder && (
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.builder.open'])}
className="flex h-8 w-[133px] cursor-pointer items-center justify-center gap-0.5 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 system-xs-semibold-uppercase text-text-accent shadow-xs outline-hidden backdrop-blur-[5px] hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onOpenBuilder}
>
<span aria-hidden className="i-ri-box-3-line size-4" />
{t(($) => $['skillManagement.detail.builder.title'])}
</button>
)
}
files={openFiles}
onClose={onCloseFile}
onSelect={onSelectFile}
previewPath={previewFilePath}
selectedPath={selectedPath}
/>
<div className="mt-px min-h-0 flex-1 overflow-hidden">
{isTextContentPending ? (
<div aria-busy="true" className="h-full cursor-not-allowed" />
) : isTextContentError ? (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-divider-regular bg-background-default">
<p className="system-sm-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.loadFailed'])}
</p>
</div>
) : isMarkdown && markdownMode === 'live' ? (
<div className="relative h-full overflow-hidden bg-background-default">
<MarkdownModeSwitch mode={markdownMode} onChange={setMarkdownMode} />
<div className="h-full scrollbar-none overflow-y-auto px-12 py-8">
<div className="mx-auto max-w-[768px]">
{showMarkdownMetadataPanel && (
<div className="mb-3 flex flex-col gap-3 p-2">
{(markdownContent.name || !readonly) && (
<EditableMetadataField
label="name"
value={markdownContent.name}
valuePlaceholder={t(
($) => $['skillManagement.detail.skillNamePlaceholder'],
)}
readOnly={readonly}
onValueChange={
readonly
? undefined
: (value) =>
updateDraftContent(
setMarkdownFrontmatterField(
draftContentRef.current,
'name',
value,
),
)
}
/>
)}
{(markdownContent.description || !readonly) && (
<EditableMetadataField
label="description"
value={markdownContent.description}
valuePlaceholder={t(
($) => $['skillManagement.detail.skillDescriptionPlaceholder'],
)}
multiline
readOnly={readonly}
onValueChange={
readonly
? undefined
: (value) =>
updateDraftContent(
setMarkdownFrontmatterField(
draftContentRef.current,
'description',
value,
),
)
}
/>
)}
{markdownContent.metadata.map((entry) => {
const removable = !readonly && !isProtectedMarkdownMetadataKey(entry.key)
return readonly || !removable ? (
<EditableMetadataField
key={entry.key}
label={entry.key}
value={entry.value}
multiline
readOnly
/>
) : (
<EditableMetadataEntry
key={entry.key}
entryKey={entry.key}
value={entry.value}
onCommit={(previousKey, nextKey, nextValue) => {
updateDraftContent(
updateMarkdownMetadata(
draftContentRef.current,
previousKey,
nextKey,
nextValue,
),
)
}}
onRemove={() => handleRemoveMetadata(entry.key)}
/>
)
})}
{!readonly && metadataAdding && (
<div
className="w-full space-y-0.5"
onBlurCapture={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null))
return
handleAddMetadata(
metadataKeyDraftRef.current,
metadataValueDraftRef.current,
)
}}
>
<div className="flex h-6 items-center gap-1">
<input
ref={metadataKeyInputRef}
value={metadataKey}
placeholder={t(($) => $['skillManagement.detail.metadataKey'])}
className={metadataKeyInputClassName}
onChange={(event) => {
metadataKeyDraftRef.current = event.target.value
setMetadataKey(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === 'Escape') handleCancelAddMetadata()
}}
/>
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.cancelAddMetadata'])}
className="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-text-quaternary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={handleCancelAddMetadata}
>
<span aria-hidden className="i-ri-delete-bin-line size-3.5" />
</button>
</div>
<input
value={metadataValue}
placeholder={t(($) => $['skillManagement.detail.metadataValue'])}
className={metadataValueInputClassName}
onChange={(event) => {
metadataValueDraftRef.current = event.target.value
setMetadataValue(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
handleCancelAddMetadata()
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
handleAddMetadata(
metadataKeyInputRef.current?.value,
event.currentTarget.value,
)
}
}}
onKeyUp={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
event.stopPropagation()
handleAddMetadata(
metadataKeyInputRef.current?.value,
event.currentTarget.value,
)
}}
/>
</div>
)}
{!readonly && !metadataAdding && (
<div className="flex flex-col items-start px-1">
<button
type="button"
className="flex h-6 shrink-0 cursor-pointer items-center justify-center gap-px overflow-hidden rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 system-xs-medium text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => {
setMetadataAdding(true)
window.requestAnimationFrame(() => metadataKeyInputRef.current?.focus())
}}
>
<span aria-hidden className="i-ri-add-line size-3.5" />
<span className="px-[3px]">
{t(($) => $['skillManagement.detail.addMetadata'])}
</span>
</button>
</div>
)}
</div>
)}
<div
className={cn(
'px-3 py-2',
showMarkdownMetadataPanel && 'border-t border-divider-subtle pt-5',
)}
>
{readonly ? (
<MarkdownBodyReferencePreview
body={markdownContent.body}
className="min-h-[360px]"
onOpenReference={onSelectFile}
placeholder={t(
($) => $['skillManagement.detail.referenceFiles.livePlaceholder'],
)}
/>
) : (
<div className="relative min-h-[360px]">
<MarkdownLiveBodyEditor
key={editorRenderKey}
body={markdownContent.body}
contentRevision={externalContentRevision}
editorRef={liveBodyEditorRef}
onOpenReference={onSelectFile}
placeholder={t(
($) => $['skillManagement.detail.referenceFiles.livePlaceholder'],
)}
onInput={handleLiveBodyEditorInput}
onKeyDown={(event) => handleTextEditorKeyDown(event, true)}
/>
</div>
)}
</div>
</div>
</div>
{referencePicker && (
<ReferenceFilesPicker
files={filteredReferenceFiles}
query={referenceQuery}
selectedIndex={referenceSelectedIndex}
title={t(($) => $['skillManagement.detail.referenceFiles.title'])}
emptyText={t(($) => $['skillManagement.detail.referenceFiles.empty'])}
navigateText={t(($) => $['skillManagement.detail.referenceFiles.navigate'])}
confirmText={t(($) => $['skillManagement.detail.referenceFiles.confirm'])}
currentDirectory={referencePicker.currentDirectory}
anchor={referencePicker.anchor}
onBack={handleReferenceDirectoryBack}
onSelectIndex={setReferenceSelectedIndex}
onSelect={(referenceFile) => {
if (isDirectory(referenceFile)) {
handleReferenceDirectoryOpen(referenceFile)
return
}
handleInsertReferenceFile(referenceFile, true)
}}
/>
)}
</div>
) : isMarkdown ? (
<div className="relative h-full overflow-hidden bg-background-default">
<MarkdownModeSwitch mode={markdownMode} onChange={setMarkdownMode} />
<MarkdownSourceEditor
key={editorRenderKey}
editorRef={sourceTextareaRef}
readOnly={readonly}
value={editableDraftContent}
placeholder={t(($) => $['skillManagement.detail.referenceFiles.livePlaceholder'])}
onChange={handleContentChange}
onKeyDown={(event) => handleTextEditorKeyDown(event)}
/>
{referencePicker && (
<ReferenceFilesPicker
files={filteredReferenceFiles}
query={referenceQuery}
selectedIndex={referenceSelectedIndex}
title={t(($) => $['skillManagement.detail.referenceFiles.title'])}
emptyText={t(($) => $['skillManagement.detail.referenceFiles.empty'])}
navigateText={t(($) => $['skillManagement.detail.referenceFiles.navigate'])}
confirmText={t(($) => $['skillManagement.detail.referenceFiles.confirm'])}
currentDirectory={referencePicker.currentDirectory}
anchor={referencePicker.anchor}
onBack={handleReferenceDirectoryBack}
onSelectIndex={setReferenceSelectedIndex}
onSelect={(referenceFile) => {
if (isDirectory(referenceFile)) {
handleReferenceDirectoryOpen(referenceFile)
return
}
handleInsertReferenceFile(referenceFile)
}}
/>
)}
</div>
) : codeLanguage ? (
<div className="h-full overflow-hidden bg-background-default">
<CodeEditor
key={editorRenderKey}
language={codeLanguage}
value={draftContent}
readOnly={readonly}
noWrapper
isExpand
className="h-full"
onChange={updateDraftContent}
/>
</div>
) : isCsv ? (
<CsvTablePreview rows={csvRows} />
) : isTextFile(file) ? (
<div className="relative h-full">
<textarea
ref={sourceTextareaRef}
key={editorRenderKey}
readOnly={readonly}
value={editableDraftContent}
spellCheck={false}
className={cn(
'h-full w-full resize-none bg-background-default p-4 font-mono text-[13px]/[20px] text-text-secondary outline-hidden read-only:bg-background-section',
)}
onChange={handleContentChange}
onKeyDown={(event) => handleTextEditorKeyDown(event)}
/>
{referencePicker && (
<ReferenceFilesPicker
files={filteredReferenceFiles}
query={referenceQuery}
selectedIndex={referenceSelectedIndex}
title={t(($) => $['skillManagement.detail.referenceFiles.title'])}
emptyText={t(($) => $['skillManagement.detail.referenceFiles.empty'])}
navigateText={t(($) => $['skillManagement.detail.referenceFiles.navigate'])}
confirmText={t(($) => $['skillManagement.detail.referenceFiles.confirm'])}
currentDirectory={referencePicker.currentDirectory}
anchor={referencePicker.anchor}
onBack={handleReferenceDirectoryBack}
onSelectIndex={setReferenceSelectedIndex}
onSelect={(referenceFile) => {
if (isDirectory(referenceFile)) {
handleReferenceDirectoryOpen(referenceFile)
return
}
handleInsertReferenceFile(referenceFile)
}}
/>
)}
</div>
) : canPreviewBinaryFile && binaryPreviewQuery.isPending ? (
<div className="flex h-full items-center justify-center bg-background-default">
<SkeletonRectangle className="h-full w-full" />
</div>
) : canPreviewBinaryFile && binaryPreviewQuery.isError ? (
<div className="flex h-full items-center justify-center bg-background-default">
<p className="system-sm-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.loadFailed'])}
</p>
</div>
) : isSkillImageFile(file) && fileObjectUrl ? (
<div className="flex h-full items-center justify-center overflow-hidden bg-background-default pt-1 pr-4 pb-2 pl-4">
<img
src={fileObjectUrl}
alt={file.path}
className="max-h-full max-w-full object-contain"
/>
</div>
) : isSkillPdfFile(file) && fileObjectUrl ? (
<div className="h-full overflow-hidden bg-background-default pt-1 pl-3">
<SkillPdfPreview fileName={file.path} url={fileObjectUrl} />
</div>
) : (
<div className="flex h-full items-center justify-center bg-background-default">
<div className="flex w-64 flex-col items-center text-center">
<span aria-hidden className={cn('size-16', getSkillFileIconClass(file))} />
<p className="mt-1 max-w-full truncate system-sm-medium text-text-secondary">
{getPathBaseName(file.path)}
</p>
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.fileMeta'], {
size: file.size ?? 0,
type: file.mime_type ?? file.kind,
})}
</p>
<div className="mt-3 h-px w-full bg-divider-subtle" />
<p className="mt-3 system-xs-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.previewUnsupported'])}
</p>
<Button
variant="secondary"
className="mt-2 h-8 px-3"
loading={downloadMutation.isPending}
onClick={() => downloadMutation.mutate()}
>
<span aria-hidden className="i-ri-download-line size-4" />
{t(($) => $['skillManagement.detail.downloadFile'])}
</Button>
</div>
</div>
)}
</div>
{!readonly && (
<SkillPublishBottomActions>
<div className="flex max-w-[calc(100%-2rem)] flex-col items-center justify-end">
<SkillPublishConfirmPanel
loading={publishing}
onCancel={() => {
setPublishConfirmOpen(false)
setPublishReferenceCountOverride(null)
}}
onConfirm={() => {
setPublishConfirmOpen(false)
setPublishReferenceCountOverride(null)
onPublish()
}}
open={publishConfirmOpen}
referenceCount={publishReferenceCountOverride ?? detail?.reference_count ?? 0}
skillId={skillId}
/>
<div
className={cn(publishConfirmOpen && 'hidden')}
aria-hidden={publishConfirmOpen || undefined}
hidden={publishConfirmOpen}
>
<SkillPublishBar
canPublish={canPublish}
metaLabel={publishMetaText}
onOpenVersions={onOpenVersions}
onPublish={handlePublish}
state={publishState}
/>
</div>
</div>
</SkillPublishBottomActions>
)}
{readonly && selectedVersion && (
<VersionActionBar
version={selectedVersion}
restoring={publishing}
onRestore={onRestoreVersion}
onExit={onExitVersion}
/>
)}
</main>
)
}