'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 { noop } from 'es-toolkit/function' 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 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( null, ) const [referenceSelectedIndex, setReferenceSelectedIndex] = useState(0) const [saveStatus, setSaveStatus] = useState<'dirty' | 'error' | 'saved' | 'saving'>('saved') const [hasSaveConflict, setHasSaveConflict] = useState(false) const [saveConflictReloadContent, setSaveConflictReloadContent] = useState(null) const [saveConflictReloadDetail, setSaveConflictReloadDetail] = useState(null) const [fileObjectUrl, setFileObjectUrl] = useState() const [savedAt, setSavedAt] = useState(initialSavedAt) const [externalContentRevision, setExternalContentRevision] = useState(0) const draftContentRef = useRef(initialContent) const lastSavedContentRef = useRef(initialContent) const saveConflictContentRef = useRef(null) const saveConflictReloadContentRef = useRef(null) const handleSaveConflictReloadRef = useRef<() => void>(() => {}) const detailRef = useRef(detail) const fileRef = useRef(file) const pendingPublishAfterSaveRef = useRef(false) const metadataKeyInputRef = useRef(null) const metadataKeyDraftRef = useRef('') const metadataValueDraftRef = useRef('') const liveBodyTextareaRef = useRef(null) const liveBodyEditorRef = useRef(null) const sourceTextareaRef = useRef(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) => { 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) => { 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, 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.query({ ...consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({ input: { params: { skill_id: skillId, }, }, }), staleTime: 0, }) 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, ]) const handlePublishAction = useCallback(() => { void handlePublish().catch(noop) }, [handlePublish]) useEffect(() => { if (!pendingPublishAfterSaveRef.current || saveStatus === 'saving') return pendingPublishAfterSaveRef.current = false handlePublishAction() }, [handlePublishAction, 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 (

{t(($) => $['skillManagement.detail.noFileSelected'])}

) } if (!file) { return (

{t(($) => $['skillManagement.detail.fileMissing'])}

) } return (
$['skillManagement.detail.builder.open'])} className="flex h-8 w-33.25 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} > {t(($) => $['skillManagement.detail.builder.title'])} ) } files={openFiles} onClose={onCloseFile} onSelect={onSelectFile} previewPath={previewFilePath} selectedPath={selectedPath} />
{isTextContentPending ? (
) : isTextContentError ? (

{t(($) => $['skillManagement.detail.loadFailed'])}

) : isMarkdown && markdownMode === 'live' ? (
{showMarkdownMetadataPanel && (
{(markdownContent.name || !readonly) && ( $['skillManagement.detail.skillNamePlaceholder'], )} readOnly={readonly} onValueChange={ readonly ? undefined : (value) => updateDraftContent( setMarkdownFrontmatterField( draftContentRef.current, 'name', value, ), ) } /> )} {(markdownContent.description || !readonly) && ( $['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 ? ( ) : ( { updateDraftContent( updateMarkdownMetadata( draftContentRef.current, previousKey, nextKey, nextValue, ), ) }} onRemove={() => handleRemoveMetadata(entry.key)} /> ) })} {!readonly && metadataAdding && (
{ if (event.currentTarget.contains(event.relatedTarget as Node | null)) return handleAddMetadata( metadataKeyDraftRef.current, metadataValueDraftRef.current, ) }} >
$['skillManagement.detail.metadataKey'])} className={metadataKeyInputClassName} onChange={(event) => { metadataKeyDraftRef.current = event.target.value setMetadataKey(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Escape') handleCancelAddMetadata() }} />
$['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, ) }} />
)} {!readonly && !metadataAdding && (
)}
)}
{readonly ? ( $['skillManagement.detail.referenceFiles.livePlaceholder'], )} /> ) : (
$['skillManagement.detail.referenceFiles.livePlaceholder'], )} onInput={handleLiveBodyEditorInput} onKeyDown={(event) => handleTextEditorKeyDown(event, true)} />
)}
{referencePicker && ( $['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) }} /> )}
) : isMarkdown ? (
$['skillManagement.detail.referenceFiles.livePlaceholder'])} onChange={handleContentChange} onKeyDown={(event) => handleTextEditorKeyDown(event)} /> {referencePicker && ( $['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) }} /> )}
) : codeLanguage ? (
) : isCsv ? ( ) : isTextFile(file) ? (