diff --git a/client/scripts/highlight.test.mjs b/client/scripts/highlight.test.mjs new file mode 100644 index 0000000..c5693b6 --- /dev/null +++ b/client/scripts/highlight.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { cachedRender, highlightCode, languageForPath, splitHighlightedLines } from '../src/utils/highlight.js'; + +test('languageForPath maps common skill-bundle extensions and ignores unknown ones', () => { + assert.equal(languageForPath('scripts/run.sh'), 'bash'); + assert.equal(languageForPath('a/b/c.TSX'), 'typescript'); + assert.equal(languageForPath('config.toml'), 'ini'); + assert.equal(languageForPath('LICENSE'), null); + assert.equal(languageForPath('image.png'), null); +}); + +test('highlightCode escapes unknown languages instead of auto-detecting', () => { + assert.equal(highlightCode(' & b', null), '<a> & b'); + assert.equal(highlightCode('', 'bash'), ''); + assert.match(highlightCode('const x = 1;', 'javascript'), /const<\/span>/); +}); + +test('splitHighlightedLines closes and reopens spans that cross line breaks', () => { + const lines = splitHighlightedLines('"a\nb"\n\nend'); + assert.deepEqual(lines, [ + '"a', + 'b"', + ' ', + 'end', + ]); +}); + +test('cachedRender reuses results for identical source and kind', () => { + let calls = 0; + const render = (text) => { calls += 1; return text.toUpperCase(); }; + const source = `cache-test-${Date.now()}`; + assert.equal(cachedRender('t', source, render), source.toUpperCase()); + assert.equal(cachedRender('t', source, render), source.toUpperCase()); + assert.equal(calls, 1); + cachedRender('other', source, render); + assert.equal(calls, 2); +}); diff --git a/client/scripts/markdown-doc.test.mjs b/client/scripts/markdown-doc.test.mjs new file mode 100644 index 0000000..d9f69ac --- /dev/null +++ b/client/scripts/markdown-doc.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { extractHeadings, parseFrontmatterPairs, slugifyHeading, splitFrontmatter } from '../src/utils/markdownDoc.js'; + +test('splitFrontmatter separates the leading --- block from the body', () => { + assert.deepEqual(splitFrontmatter('---\nname: a\n---\n# Title'), { frontmatter: 'name: a', body: '# Title' }); + assert.deepEqual(splitFrontmatter('# No frontmatter'), { frontmatter: '', body: '# No frontmatter' }); + assert.deepEqual(splitFrontmatter('---\nunterminated'), { frontmatter: '', body: '---\nunterminated' }); + assert.deepEqual(splitFrontmatter(''), { frontmatter: '', body: '' }); +}); + +test('extractHeadings keeps h1-h4 and skips headings inside fenced code', () => { + const md = ['# One', '```bash', '# not a heading', '```', '## Two ##', '~~~', '### hidden', '~~~', '##### five', '#### Four'].join('\n'); + assert.deepEqual(extractHeadings(md), [ + { level: 1, text: 'One' }, + { level: 2, text: 'Two' }, + { level: 4, text: 'Four' }, + ]); +}); + +test('extractHeadings only closes a fence with the same marker', () => { + const md = ['```', '~~~', '# inside', '```', '# after'].join('\n'); + assert.deepEqual(extractHeadings(md), [{ level: 1, text: 'after' }]); +}); + +test('slugifyHeading keeps unicode letters and de-duplicates within one document', () => { + const used = new Set(); + assert.equal(slugifyHeading('Hello, World!', used), 'hello-world'); + assert.equal(slugifyHeading('Hello World', used), 'hello-world-2'); + assert.equal(slugifyHeading('使用 说明', used), '使用-说明'); + assert.equal(slugifyHeading('!!!', used), 'section'); +}); + +test('parseFrontmatterPairs reads key: value lines and strips surrounding quotes', () => { + assert.deepEqual(parseFrontmatterPairs('name: "demo"\ndescription: \'x y\'\n - list item\nversion: 1'), [ + { key: 'name', value: 'demo' }, + { key: 'description', value: 'x y' }, + { key: 'version', value: '1' }, + ]); + assert.deepEqual(parseFrontmatterPairs(''), []); +}); diff --git a/client/scripts/ui-regression.test.mjs b/client/scripts/ui-regression.test.mjs index 70665d5..a2c3b62 100644 --- a/client/scripts/ui-regression.test.mjs +++ b/client/scripts/ui-regression.test.mjs @@ -21,8 +21,9 @@ test('App wires every interactive component callback to an implementation', asyn test('detail breadcrumb navigates to the folder instead of moving the skill into it', async () => { const detail = await source('src/components/SkillDetail.jsx'); - assert.ok(!detail.includes('onMoveFolder'), 'breadcrumb must not trigger a move mutation'); - assert.match(detail, /crumb-link[\s\S]{0,80}?onSelectFolder\?\.\(arr\.slice\(0, i \+ 1\)\.join\('\/'\)\)/, 'each crumb segment navigates to its own path prefix'); + const toolbar = await source('src/components/detail/DetailToolbar.jsx'); + assert.ok(!detail.includes('onMoveFolder') && !toolbar.includes('onMoveFolder'), 'breadcrumb must not trigger a move mutation'); + assert.match(toolbar, /crumb-link[\s\S]{0,80}?onSelectFolder\?\.\(arr\.slice\(0, i \+ 1\)\.join\('\/'\)\)/, 'each crumb segment navigates to its own path prefix'); }); test('detail always fetches the full record so file_tree stays available', async () => { diff --git a/client/src/components/SkillDetail.jsx b/client/src/components/SkillDetail.jsx index aaa7718..d62a41b 100644 --- a/client/src/components/SkillDetail.jsx +++ b/client/src/components/SkillDetail.jsx @@ -1,322 +1,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { marked } from 'marked'; -import hljs from 'highlight.js/lib/core'; -import bash from 'highlight.js/lib/languages/bash'; -import c from 'highlight.js/lib/languages/c'; -import cpp from 'highlight.js/lib/languages/cpp'; -import css from 'highlight.js/lib/languages/css'; -import diff from 'highlight.js/lib/languages/diff'; -import go from 'highlight.js/lib/languages/go'; -import ini from 'highlight.js/lib/languages/ini'; -import java from 'highlight.js/lib/languages/java'; -import javascript from 'highlight.js/lib/languages/javascript'; -import json from 'highlight.js/lib/languages/json'; -import markdown from 'highlight.js/lib/languages/markdown'; -import python from 'highlight.js/lib/languages/python'; -import rust from 'highlight.js/lib/languages/rust'; -import scss from 'highlight.js/lib/languages/scss'; -import sql from 'highlight.js/lib/languages/sql'; -import typescript from 'highlight.js/lib/languages/typescript'; -import xml from 'highlight.js/lib/languages/xml'; -import yaml from 'highlight.js/lib/languages/yaml'; import { VersionHistoryModal } from './Modals'; import ReviewPanel from './ReviewPanel'; -import { installCommand, skillPrompt } from '../utils/agentPrompts'; +import DetailToolbar from './detail/DetailToolbar'; +import DocOutline from './detail/DocOutline'; +import FileTreeSidebar from './detail/FileTreeSidebar'; import { showToast } from './toastBus'; -import useResizableWidth from '../hooks/useResizableWidth'; import { requestJson } from '../utils/requestJson'; -import { formatDateTime, relativeTime } from '../utils/date'; -import { - Check, - ChevronDown, - ChevronRight, - Copy, - Download, - Edit3, - Eye, - EyeOff, - FileCode2, - FileText, - Folder, - List, - Terminal, - History, - MoreHorizontal, - PanelLeftClose, -} from 'lucide-react'; - -marked.setOptions({ breaks: true, gfm: true }); - -// 只注册文件查看器与代码块实际支持的语言。未知语言会安全地显示为纯文本。 -Object.entries({ bash, c, cpp, css, diff, go, ini, java, javascript, json, markdown, python, rust, scss, sql, typescript, xml, yaml }) - .forEach(([name, grammar]) => hljs.registerLanguage(name, grammar)); - -// 悬浮大纲的视口阈值:与 index.css 的 .doc-outline 收窄断点保持一致 -const OUTLINE_MIN_VIEWPORT = '(min-width: 1280px)'; - -const HEADING_SELECTOR = '.markdown-document h1, .markdown-document h2, .markdown-document h3, .markdown-document h4'; - -function splitFrontmatter(text) { - if (!text) return { frontmatter: '', body: '' }; - const trimmed = text.trim(); - if (!trimmed.startsWith('---')) return { frontmatter: '', body: trimmed }; - const end = trimmed.indexOf('---', 3); - if (end === -1) return { frontmatter: '', body: trimmed }; - return { - frontmatter: trimmed.slice(3, end).trim(), - body: trimmed.slice(end + 3).trim(), - }; -} - -function extractHeadings(markdown) { - const lines = markdown.split('\n'); - const headings = []; - let inFence = false; - let fenceMarker = ''; - for (const line of lines) { - const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); - if (fenceMatch) { - if (!inFence) { - inFence = true; - fenceMarker = fenceMatch[1][0]; - } else if (fenceMatch[1][0] === fenceMarker) { - inFence = false; - } - continue; - } - if (inFence) continue; - const match = line.match(/^(#{1,4})\s+(.+?)\s*#*\s*$/); - if (match) { - headings.push({ level: match[1].length, text: match[2].trim() }); - } - } - return headings; -} - -function slugifyHeading(text, used) { - const base = text - .toLowerCase() - .replace(/[^\p{L}\p{N}\s-]/gu, '') - .trim() - .replace(/\s+/g, '-') || 'section'; - let slug = base; - let index = 2; - while (used.has(slug)) { - slug = `${base}-${index}`; - index += 1; - } - used.add(slug); - return slug; -} - -// B5: frontmatter 解析为键值对表格 -function parseFrontmatterPairs(raw) { - if (!raw) return []; - return raw.split('\n') - .map((line) => line.match(/^([\w-]+)\s*:\s*(.*)$/)) - .filter(Boolean) - .map((match) => ({ key: match[1], value: match[2].replace(/^["']|["']$/g, '').trim() })) - .filter((pair) => pair.key); -} - -function buildFileTree(files) { - const root = { name: '', path: '', children: new Map(), file: null }; - for (const file of files) { - const parts = file.path.split('/'); - let node = root; - parts.forEach((part, depth) => { - const isLeaf = depth === parts.length - 1; - if (!node.children.has(part)) { - node.children.set(part, { name: part, path: parts.slice(0, depth + 1).join('/'), children: new Map(), file: null }); - } - node = node.children.get(part); - if (isLeaf) node.file = file; - }); - } - return root; -} - -function FileTreeNode({ node, depth, selectedFile, openFile, openDirs, toggleDir, hasSelectionInside, filterActive }) { - const dirEntries = [...node.children.values()].sort((a, b) => { - const aDir = a.children.size > 0; - const bDir = b.children.size > 0; - if (aDir !== bDir) return aDir ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - return ( - - ); -} - -// 扩展名 -> highlight.js 语言(覆盖技能包常见脚本/配置类型) -const EXT_LANGUAGES = { - sh: 'bash', bash: 'bash', zsh: 'bash', - py: 'python', - js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', - ts: 'typescript', tsx: 'typescript', - json: 'json', - yaml: 'yaml', yml: 'yaml', - md: 'markdown', - html: 'xml', xml: 'xml', svg: 'xml', - css: 'css', scss: 'scss', - sql: 'sql', - ini: 'ini', toml: 'ini', env: 'ini', - diff: 'diff', patch: 'diff', - go: 'go', rs: 'rust', java: 'java', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', -}; - -function languageForPath(path) { - const ext = path.split('.').pop()?.toLowerCase() || ''; - return EXT_LANGUAGES[ext] || null; -} - -function escapeHtml(text) { - return text.replace(/[&<>]/g, (ch) => ({ '&': '&', '<': '<', '>': '>' }[ch])); -} - -function highlightCode(code, language) { - if (!code) return ''; - try { - if (language && hljs.getLanguage(language)) { - return hljs.highlight(code, { language, ignoreIllegals: true }).value; - } - } catch { /* 语法包异常时退回纯文本 */ } - // 未知语言不再走 highlightAuto:它要拿每种已注册语言各试跑一遍,代价是定向高亮的 8~20 倍 - return escapeHtml(code); -} - -// 把整段高亮 HTML 按换行切开,跨行的 在行尾闭合、行首重开—— -// 这样既保住了跨行语法(多行字符串 / 块注释),又能沿用 CSS counter 渲染行号。 -function splitHighlightedLines(html) { - const lines = []; - const open = []; - let buffer = ''; - let index = 0; - while (index < html.length) { - if (html[index] === '<') { - const end = html.indexOf('>', index); - if (end === -1) { buffer += html.slice(index); break; } - const tag = html.slice(index, end + 1); - if (tag.startsWith('')) open.push(tag); - buffer += tag; - index = end + 1; - continue; - } - const next = html.indexOf('<', index); - const text = next === -1 ? html.slice(index) : html.slice(index, next); - const parts = text.split('\n'); - parts.forEach((part, i) => { - if (i > 0) { - buffer += ''.repeat(open.length); - lines.push(buffer || ' '); - buffer = open.join(''); - } - buffer += part; - }); - index = next === -1 ? html.length : next; - } - lines.push(buffer || ' '); - return lines; -} - -// 轻量字符串哈希(djb2):给 marked / hljs 的结果做缓存键 -function hashSource(text) { - let hash = 5381; - for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; - return `${hash.toString(36)}:${text.length}`; -} - -// 渲染结果按内容哈希缓存(小 LRU):来回切技能、预览↔编辑来回切都不必重跑 marked + hljs -const RENDER_CACHE = new Map(); -const RENDER_CACHE_MAX = 24; -function cachedRender(kind, source, render) { - const key = `${kind}:${hashSource(source)}`; - if (RENDER_CACHE.has(key)) { - const hit = RENDER_CACHE.get(key); - RENDER_CACHE.delete(key); - RENDER_CACHE.set(key, hit); // 命中后移到队尾 - return hit; - } - const value = render(source); - RENDER_CACHE.set(key, value); - if (RENDER_CACHE.size > RENDER_CACHE_MAX) RENDER_CACHE.delete(RENDER_CACHE.keys().next().value); - return value; -} - -// 代码块高亮 + 语言标签:对 marked 输出的 HTML 做后处理,比覆写 renderer 稳定(marked v18) -function highlightMarkdownHtml(html) { - if (!html) return ''; - const container = document.createElement('div'); - container.innerHTML = html; - container.querySelectorAll('pre > code').forEach((block) => { - const classMatch = block.className.match(/language-([\w-]+)/); - const lang = classMatch ? classMatch[1].toLowerCase() : null; - block.innerHTML = highlightCode(block.textContent || '', lang); - const pre = block.parentElement; - if (pre) { - pre.setAttribute('data-lang', lang || 'text'); - } - }); - return container.innerHTML; -} - -const renderMarkdown = (source) => highlightMarkdownHtml(marked.parse(source)); +import { extractHeadings, parseFrontmatterPairs, slugifyHeading, splitFrontmatter } from '../utils/markdownDoc'; +import useDocOutline, { HEADING_SELECTOR } from '../hooks/useDocOutline'; +import { cachedRender, highlightCode, languageForPath, renderMarkdown, splitHighlightedLines } from '../utils/highlight'; +import { FileCode2 } from 'lucide-react'; function readSkillDraft(skillId) { try { @@ -349,35 +42,12 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag const [detailError, setDetailError] = useState(''); const [fileError, setFileError] = useState(''); const [copied, setCopied] = useState(''); - const [openDirs, setOpenDirs] = useState(() => new Set()); - // D1: 面板宽度/大纲显隐持久化 - const [fileSidebarWidth, , fileSidebarResizer] = useResizableWidth('file-sidebar-width', { min: 160, max: 480, initial: 240, label: '调整技能文件栏宽度' }); const [showVersions, setShowVersions] = useState(false); - const [moreOpen, setMoreOpen] = useState(false); - const [outlineHidden, setOutlineHidden] = useState(() => { - try { return window.localStorage.getItem('ash:outline-hidden') === '1'; } - catch { return false; } - }); - // 大纲按「视口宽度」判定,而不是滚动区宽度:滚动区永远是视口减去左两栏, - // 用它做阈值会让大纲在 1684px 以下的视口里永远不出现。 - const [outlineFits, setOutlineFits] = useState(() => window.matchMedia(OUTLINE_MIN_VIEWPORT).matches); // 文件栏在滚动区 <640px 时自动收成细条(点击恢复),正文优先 const [fileBarCollapsed, setFileBarCollapsed] = useState(false); const fileBarManualRef = useRef(false); // 用户手动展开过则不再自动收起 - // A2: 文件树过滤 - const [fileFilter, setFileFilter] = useState(''); - // B1: 大纲当前高亮索引 - const [activeHeading, setActiveHeading] = useState(0); const scrollRef = useRef(null); - useEffect(() => { - const query = window.matchMedia(OUTLINE_MIN_VIEWPORT); - const sync = () => setOutlineFits(query.matches); - sync(); - query.addEventListener('change', sync); - return () => query.removeEventListener('change', sync); - }, []); - useEffect(() => { const el = scrollRef.current; if (!el || typeof ResizeObserver === 'undefined') return undefined; @@ -388,24 +58,6 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag return () => observer.disconnect(); }, []); - useEffect(() => { - try { window.localStorage.setItem('ash:outline-hidden', outlineHidden ? '1' : '0'); } - catch { /* 存储不可用时仍可在当前会话切换大纲 */ } - }, [outlineHidden]); - - // 溢出菜单:点外部或按 Esc 关闭 - useEffect(() => { - if (!moreOpen) return undefined; - const onPointerDown = (event) => { if (!event.target.closest('.detail-more')) setMoreOpen(false); }; - const onKeyDown = (event) => { if (event.key === 'Escape') setMoreOpen(false); }; - document.addEventListener('mousedown', onPointerDown); - document.addEventListener('keydown', onKeyDown); - return () => { - document.removeEventListener('mousedown', onPointerDown); - document.removeEventListener('keydown', onKeyDown); - }; - }, [moreOpen]); - useEffect(() => { let cancelled = false; requestJson(`/api/skills/${skill.id}`) @@ -535,116 +187,11 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag return undefined; }, [renderedMarkdown, auxRendered, selectedFile, mode]); - // B1: 大纲跟随滚动高亮(scroll-spy) - // 原实现每帧 querySelectorAll + 逐标题 getBoundingClientRect——每次滚动都强制同步布局。 - // 改成:标题位置只在内容/尺寸变化时量一次,滚动中只比 scrollTop,零 rect 读取。 - const headingOffsetsRef = useRef([]); - useEffect(() => { - const container = scrollRef.current; - if (!container || headings.length < 2) return undefined; - - const measure = () => { - const containerTop = container.getBoundingClientRect().top; - headingOffsetsRef.current = [...container.querySelectorAll(HEADING_SELECTOR)] - .map((node) => node.getBoundingClientRect().top - containerTop + container.scrollTop); - }; - - let ticking = false; - const onScroll = () => { - if (ticking) return; - ticking = true; - window.requestAnimationFrame(() => { - ticking = false; - const offsets = headingOffsetsRef.current; - if (!offsets.length) return; - const line = container.scrollTop + 60; - let current = 0; - for (let i = 0; i < offsets.length; i += 1) { - if (offsets[i] <= line) current = i; - } - // 滚动到底时高亮最后一个标题(末尾内容不足一屏时永远差一点) - if (container.scrollTop + container.clientHeight >= container.scrollHeight - 4) { - current = offsets.length - 1; - } - setActiveHeading(current); - }); - }; - - measure(); - onScroll(); - container.addEventListener('scroll', onScroll, { passive: true }); - // 字体/图片加载或面板改宽都会挪动标题:重量一次,仍然不碰滚动路径 - const content = container.querySelector('.detail-content'); - const observer = content && typeof ResizeObserver !== 'undefined' - ? new ResizeObserver(() => { measure(); onScroll(); }) - : null; - if (observer && content) observer.observe(content); - return () => { - container.removeEventListener('scroll', onScroll); - observer?.disconnect(); - }; - }, [headings.length, renderedMarkdown, auxRendered, selectedFile, mode]); - - const treeData = useMemo(() => buildFileTree(fileTree), [fileTree]); - - // A2: 文件树过滤(含路径子串匹配) - const filteredTreeData = useMemo(() => { - const q = fileFilter.trim().toLowerCase(); - if (!q) return treeData; - const filtered = fileTree.filter((f) => f.path.toLowerCase().includes(q)); - return buildFileTree(filtered); - }, [treeData, fileFilter, fileTree]); + const outline = useDocOutline({ scrollRef, headings, mode, renderedMarkdown, auxRendered, selectedFile }); // B5: frontmatter 键值对 const frontmatterPairs = useMemo(() => parseFrontmatterPairs(documentParts.frontmatter), [documentParts.frontmatter]); - const toggleDir = (dirPath) => { - setOpenDirs((prev) => { - const next = new Set(prev); - if (next.has(dirPath)) next.delete(dirPath); - else next.add(dirPath); - return next; - }); - }; - - const hasSelectionInside = (node) => { - if (!selectedFile) return false; - if (node.file && node.file.path === selectedFile) return true; - for (const child of node.children.values()) { - if (hasSelectionInside(child)) return true; - } - return false; - }; - - const jumpToHeading = (index) => { - const container = scrollRef.current; - if (!container) return; - const nodes = container.querySelectorAll(HEADING_SELECTOR); - const target = nodes[index]; - if (!target) return; - // offsetTop 的参照系是 offsetParent(.app-detail),不是滚动容器; - // 用 rect 相对差值计算真实滚动位置 - const delta = target.getBoundingClientRect().top - container.getBoundingClientRect().top; - // 末尾标题补位:确保即使内容不足也能把标题滚到视口上部 - const needed = container.scrollTop + delta - 12; - const maxScroll = container.scrollHeight - container.clientHeight; - if (needed > maxScroll) { - // 动态撑高底部 padding,让最后标题可达 - const content = container.querySelector('.detail-content'); - if (content) { - const extra = needed - maxScroll; - content.style.paddingBottom = `${Math.round(extra + 3 * 16)}px`; - window.requestAnimationFrame(() => container.scrollTo({ top: needed, behavior: 'smooth' })); - return; - } - } - container.scrollTo({ top: container.scrollTop + delta - 12, behavior: 'smooth' }); - }; - - const origin = window.location.origin; - const agentPrompt = skillPrompt(origin, { ...skill, file_count: fileTree.length || skill.file_count }); - const cliCommand = installCommand(origin, skill.slug); - const copyToClipboard = async (text, type, toastLabel) => { try { await navigator.clipboard.writeText(text); @@ -709,18 +256,6 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag } }, [skill.id]); - // 附属文件单独下载:/api/skills/:id/file 返回 JSON 包装,直接做成本地 Blob 更省事 - const downloadCurrentFile = () => { - const url = URL.createObjectURL(new Blob([auxFileContent], { type: 'text/plain;charset=utf-8' })); - const link = document.createElement('a'); - link.href = url; - link.download = selectedFile.split('/').pop(); - document.body.appendChild(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 0); - }; - const openFile = (path) => { setFileError(''); setAuxFileContent(''); @@ -730,169 +265,48 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag return (
-
-
- -

{skill.name}

-
- - {/* 头部中段原来是一大片空白:放只读身份信息(版本 / 更新时间 / 文件数 / 来源终端) */} -
- {skill.version && v{skill.version}} - {skill.updated_at && {relativeTime(skill.updated_at)}} - {fileTree.length > 1 && {fileTree.length} 个文件} - {skill.terminal_source && {skill.terminal_source}} -
- -
- - - - - - - {/* 溢出菜单:一键取值(slug / 路径 / 链接)与「复制技能」——后者后端早有 API,前端一直没入口 */} -
- - {moreOpen && ( -
- - - - - {selectedFile !== 'SKILL.md' && ( - <> - - - - )} -
- )} -
-
- - -
-
-
+ setShowVersions(true)} + mode={mode} + dirty={dirty} + saving={saving} + onSwitchMode={switchMode} + onSelectFolder={onSelectFolder} + onCopySkill={onCopySkill} + /> setShowVersions(false)} skillId={skill.id} onRestored={async () => { const fresh = await requestJson(`/api/skills/${skill.id}`).catch(() => null); if (fresh) { setContent(fresh.content || ''); setSavedContent(fresh.content || ''); } }} />
- {fileTree.length > 1 && fileBarCollapsed && ( - - )} - {fileTree.length > 1 && !fileBarCollapsed && ( - + {fileTree.length > 1 && ( + { fileBarManualRef.current = false; setFileBarCollapsed(true); }} + onExpand={() => { fileBarManualRef.current = true; setFileBarCollapsed(false); }} + /> )} -
1 ? ' has-outline' : ''}`} ref={scrollRef}> - {mode === 'preview' && !outlineHidden && outlineFits && headings.length > 1 && ( - - )} - {mode === 'preview' && outlineHidden && outlineFits && headings.length > 1 && ( - +
+ {outline.visible && ( +