* feat(mermaid): 新增 mermaid 懒加载 chunk(构建链四处同步 + mermaid 依赖) tsdown CHUNKS / bundle-route CHUNK_NAMES / chunk-loader ChunkName / package.json files 同步注册 mermaid chunk;mermaid ^11.16.1 进依赖与 lockfile;chunk 构建注入 uuid 浏览器入口别名,绕开 node:crypto 纯度门。 * feat(mermaid): Markdown 预览安全渲染 mermaid 图表 + 点击放大弹窗 - splitMermaidBlocks 纯函数按 fence 切块,无 mermaid 文件零行为变化; - mermaid.initialize 走 securityLevel strict + htmlLabels:false,不应用 bindFunctions(静态图);渲染 SVG 经 mermaid-sanitize 二次清洗: 删 foreignObject/script/外来 HTML 元素、剥 @*/on* 属性、移除全部 href/xlink:href,仅接受 SVG 根,解析失败返回空串; - 点击图表弹出放大弹窗(滚轮缩放/拖拽平移/快捷键,借鉴 PR #75 交互, 基于已清洗 SVG 克隆重写,不新增攻击面); - 深浅色跟随重渲、失败回退错误条 + 原码、CodeBlock 同款 chrome。 * test(mermaid): splitter/sanitize 单测 + chunk 清单同步 + 挂载冒烟 diagram.md - mermaid-blocks.spec 10 例 fence 切块语义; - mermaid-sanitize.spec 8 例 XSS(foreignObject/on*/@*/href/xlink:href/ script/外来元素/畸形 XML/非 SVG 根); - bundle-route / chunk-artifact / manifest-consistency 清单加入 mermaid (chunk-artifact 补 addEventListener stub); - 挂载冒烟新增 diagram.md:client-mermaid.js 往返、SVG 节点文字、 点击弹窗 Esc 关闭、preview 模式编辑器隐藏。 * docs(mermaid): 安全渲染设计文档 + lazy-chunks 表 + README 双语更新 设计文档记录基线决策(PR #99 架构 + PR #75 点击放大)、安全模型 (strict/htmlLabels/清洗/href 剥离/无 bindFunctions)、验证与取舍。 * fix(mermaid): sanitizer 属性名大小写归一,堵住混合大小写属性绕过(CR P1) XML 属性名保留大小写,而输出会经 HTML 解析归一化(dangerouslySetInnerHTML), oNload/HREF/xlink:HREF 这类混合大小写属性此前会漏过清洗。比较前先 toLowerCase(),按原名删除;新增混合大小写事件/链接属性回归用例。 * fix(mermaid): sanitizer 元素黑名单大小写归一,堵住混合大小写元素绕过(CR P1) XML 元素名保留大小写,querySelectorAll('script') 等选择器匹配不到 <sCrIpT>/<foreignobject>,而输出经 HTML 解析会被归一化为 SVG script/foreignObject(可执行/可注入 HTML)。改为遍历全部元素按 localName.toLowerCase() 命中小写黑名单后整节点删除;新增混合大小写 script/foreignObject/外来 HTML 元素回归用例(jsdom 实证:旧选择器 命中 0,新逻辑命中 2)。 * fix(mermaid): 单次 Markdown 解析 + 占位交换(CR P1)+ CommonMark fence 规则(CR P2) P1(跨 fence 语义):弃用逐块 MarkdownText 拆分,改为整篇文档单次 MarkdownText 渲染,布局后把 language-mermaid 代码块交换为 MermaidDiagram (React 管理的 .md-code-block 宿主保留在树中、display:contents 抑制盒子, 协调安全;块不再 mermaid 时还原原 children)。新增 tests/mermaid-markdown.spec.tsx 与 e2e 跨 fence 引用链接断言。 P2(fence 匹配):支持 3+ 反引号与波浪号开 fence;闭 fence 须同字符且 长度不小于开 fence;反引号 info string 含反引号不视为开 fence;修复 带缩进开 fence 的 info 提取。blocks 单测 +3 例。
439 lines
17 KiB
TypeScript
439 lines
17 KiB
TypeScript
/**
|
|
* The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
|
|
* syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
|
|
* save, and a preview/edit toggle for markdown files. Registered as the
|
|
* `code` (catch-all) and `markdown` built-in viewers; the editor tab host
|
|
* fetches the content through the fsRead strategy and passes it in props,
|
|
* so this component never fetches or dispatches — it only edits.
|
|
*
|
|
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
|
|
* row below the host's title bar, VSCode-style — unless the host passes
|
|
* `toolbar: 'host'` (the merged editor-explorer mode), in which case this
|
|
* component skips the row and reports state + registers commands through
|
|
* the FileViewerProps toolbar callbacks so the host's path-input header
|
|
* renders the controls instead.
|
|
*/
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import type { ComponentType } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import clsx from 'clsx'
|
|
import { EditorState } from '@codemirror/state'
|
|
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
|
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import { api, htmlUrl } from './api.ts'
|
|
import { languageForPath } from './lang.ts'
|
|
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
|
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
|
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
|
import { appendToDraft } from './conversation-draft.ts'
|
|
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
|
|
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
|
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
|
|
import { t } from './locales.ts'
|
|
import type { EditorToolbarState, FileViewerProps } from './service.ts'
|
|
import css from './sidebar.module.css'
|
|
|
|
/** Previewable files (rendered output vs source editing). */
|
|
type ViewMode = 'preview' | 'edit'
|
|
|
|
/** The floating "add to conversation" action: payload + viewport anchor. */
|
|
interface SelectionPopup {
|
|
insert: string
|
|
left: number
|
|
top: number
|
|
}
|
|
|
|
/**
|
|
* The chunk-resident markdown preview renderer (mermaid lazy chunk): one
|
|
* MarkdownText pass over the whole source, with rendered mermaid fences
|
|
* swapped for diagrams. Module-level `pick` keeps the load effect stable.
|
|
*/
|
|
const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
|
|
'mermaid',
|
|
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
|
|
)
|
|
|
|
/**
|
|
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
|
|
* preview must stay in an opaque origin — with the route's own origin it
|
|
* could read session data) and NO allow-top-navigation (a previewed page
|
|
* must not hijack the GUI). The user can disable the sandbox per-feature
|
|
* in the side card settings (warned); the toggle below reflects it.
|
|
*/
|
|
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
|
|
|
export function TextEditor(props: FileViewerProps) {
|
|
const { ctx, scope, path, viewerId, content, truncated } = props
|
|
const [mode, setMode] = useState<ViewMode>('preview')
|
|
/** The editor's current text (null while clean); preview renders this. */
|
|
const [draft, setDraft] = useState<string | null>(null)
|
|
const [dirty, setDirty] = useState(false)
|
|
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
|
|
const hostRef = useRef<HTMLDivElement>(null)
|
|
const viewRef = useRef<CodeMirrorView | null>(null)
|
|
const savingRef = useRef(false)
|
|
/** The theme compartment of the current view (reconfigured on scheme flip). */
|
|
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
|
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
|
const [dark, setDark] = useState(() => isDarkScheme())
|
|
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
|
|
const [popup, setPopup] = useState<SelectionPopup | null>(null)
|
|
/** Live mirror of the popup state for click-time reads (no re-render race). */
|
|
const popupRef = useRef<SelectionPopup | null>(null)
|
|
/** The markdown preview container (selection-containment + line lookup). */
|
|
const mdRef = useRef<HTMLDivElement>(null)
|
|
|
|
const hidePopup = (): void => {
|
|
popupRef.current = null
|
|
setPopup(null)
|
|
}
|
|
|
|
/** Anchor the popup above the selection center; clamp inside the viewport. */
|
|
const showPopup = (insert: string, left: number, top: number): void => {
|
|
const next: SelectionPopup = {
|
|
insert,
|
|
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
|
|
top,
|
|
}
|
|
popupRef.current = next
|
|
setPopup(next)
|
|
}
|
|
|
|
/** The popup button's click: insert the stored payload into the draft. */
|
|
const commitPopup = (): void => {
|
|
const current = popupRef.current
|
|
if (current === null) return
|
|
appendToDraft(ctx, scope.sessionId, current.insert)
|
|
hidePopup()
|
|
}
|
|
|
|
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
|
|
|
// A new file (tab switch) starts clean: fresh preview mode, no draft.
|
|
useEffect(() => {
|
|
setMode('preview')
|
|
setDraft(null)
|
|
setDirty(false)
|
|
setSaveState('idle')
|
|
hidePopup()
|
|
}, [content])
|
|
|
|
// Create the CodeMirror editor once the content is loaded. The view owns
|
|
// the document; React only tracks dirty/draft state through the update
|
|
// listener. For markdown the view stays mounted while previewing (hidden),
|
|
// so unsaved edits survive the preview/edit toggle. The theme + syntax
|
|
// colors live in a compartment so a scheme flip reconfigures only that
|
|
// part — the document, undo history and scroll position survive.
|
|
useEffect(() => {
|
|
if (content === undefined) return
|
|
const host = hostRef.current
|
|
if (host === null) return
|
|
const language = languageForPath(path)
|
|
const themeComp = new CmThemeCompartment()
|
|
themeCompRef.current = themeComp
|
|
const state = EditorState.create({
|
|
doc: content,
|
|
extensions: [
|
|
CodeMirrorView.lineWrapping,
|
|
lineNumbers(),
|
|
history(),
|
|
EditorState.tabSize.of(2),
|
|
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
|
|
cmSurfaceTheme,
|
|
themeComp.of(dark),
|
|
...(language !== null ? [language] : []),
|
|
CodeMirrorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
setDraft(update.state.doc.toString())
|
|
setDirty(true)
|
|
}
|
|
}),
|
|
keymap.of([
|
|
{
|
|
key: 'Mod-s',
|
|
preventDefault: true,
|
|
run: () => { save(); return true },
|
|
},
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
]),
|
|
// Selection popup (the code and markdown editors): a non-empty
|
|
// selection anchors the floating "add to conversation" button above
|
|
// its head. Scrolling (geometry/viewport change) or losing focus
|
|
// hides it; typing collapses the selection and hides it too.
|
|
...(viewerId === 'code' || viewerId === 'markdown' ? [
|
|
CodeMirrorView.updateListener.of((update) => {
|
|
if (update.geometryChanged || update.viewportChanged) {
|
|
hidePopup()
|
|
return
|
|
}
|
|
if (!update.view.hasFocus) {
|
|
hidePopup()
|
|
return
|
|
}
|
|
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
|
|
const sel = update.state.selection.main
|
|
if (sel.empty) {
|
|
hidePopup()
|
|
return
|
|
}
|
|
const text = update.state.sliceDoc(sel.from, sel.to)
|
|
if (text.trim() === '') {
|
|
hidePopup()
|
|
return
|
|
}
|
|
// Page coordinates (the document root may scroll); the popup is
|
|
// position:fixed, so convert to viewport coordinates.
|
|
const rect = update.view.coordsAtPos(sel.head)
|
|
if (rect === null) {
|
|
hidePopup()
|
|
return
|
|
}
|
|
const doc = update.state.doc
|
|
showPopup(
|
|
buildSelectionInsert(path, scope.cwd, {
|
|
start: doc.lineAt(sel.from).number,
|
|
end: doc.lineAt(sel.to).number,
|
|
}, text),
|
|
rect.left - window.scrollX + (rect.right - rect.left) / 2,
|
|
rect.top - window.scrollY,
|
|
)
|
|
}),
|
|
] : []),
|
|
],
|
|
})
|
|
const view = new CodeMirrorView({ state, parent: host })
|
|
viewRef.current = view
|
|
return () => {
|
|
view.destroy()
|
|
viewRef.current = null
|
|
themeCompRef.current = null
|
|
}
|
|
// The keymap's save() reads live refs; scope/path are stable for a
|
|
// tab's lifetime, and the dark flip is handled by the reconfigure
|
|
// effect below (recreating the view here would drop the draft).
|
|
}, [content, path])
|
|
|
|
// Scheme flip: re-theme in place (the compartment holds only the
|
|
// scheme-dependent extensions; everything else is untouched).
|
|
useEffect(() => {
|
|
const view = viewRef.current
|
|
const themeComp = themeCompRef.current
|
|
if (view === null || themeComp === null) return
|
|
view.dispatch({ effects: themeComp.reconfigure(dark) })
|
|
}, [dark])
|
|
|
|
// The editor may have been display:none while previewing; re-measure when
|
|
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
|
|
// flip also invalidates any anchored selection popup.
|
|
useEffect(() => {
|
|
hidePopup()
|
|
if (mode === 'edit') viewRef.current?.requestMeasure()
|
|
}, [mode])
|
|
|
|
const save = (): void => {
|
|
const view = viewRef.current
|
|
if (view === null || savingRef.current) return
|
|
savingRef.current = true
|
|
setSaveState('saving')
|
|
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
|
|
savingRef.current = false
|
|
setDraft(null)
|
|
setDirty(false)
|
|
setSaveState('saved')
|
|
}).catch(() => {
|
|
savingRef.current = false
|
|
setSaveState('failed')
|
|
})
|
|
}
|
|
|
|
const markdown = viewerId === 'markdown'
|
|
const html = viewerId === 'html'
|
|
/** The markdown source the preview renders (draft wins over saved content). */
|
|
const mdText = draft ?? content ?? ''
|
|
/** md/mermaid block split for the preview (mermaid fences lift out). Split
|
|
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
|
|
const mdBlocks = useMemo(
|
|
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
|
|
[markdown, mode, mdText],
|
|
)
|
|
const hasMermaid = useMemo(
|
|
() => mdBlocks.some(block => block.kind === 'mermaid'),
|
|
[mdBlocks],
|
|
)
|
|
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
|
|
|
|
/**
|
|
* Selection popup for the markdown preview: a mouse-up inside the preview
|
|
* container anchors the floating "add to conversation" button above the
|
|
* selection. Line numbers come from a best-effort reverse-search of the
|
|
* selected text in the source ({@link linesOfSelection} — an ambiguous or
|
|
* missing hit omits them). The button's own mousedown preventDefaults so
|
|
* the selection survives until the click commits.
|
|
*/
|
|
const handlePreviewMouseUp = (): void => {
|
|
const sel = window.getSelection()
|
|
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
|
|
hidePopup()
|
|
return
|
|
}
|
|
const host = mdRef.current
|
|
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
|
|
hidePopup()
|
|
return
|
|
}
|
|
const text = sel.toString()
|
|
if (text.trim() === '') {
|
|
hidePopup()
|
|
return
|
|
}
|
|
const rect = sel.getRangeAt(0).getBoundingClientRect()
|
|
const lines = linesOfSelection(mdText, text)
|
|
showPopup(
|
|
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
|
|
rect.left + rect.width / 2,
|
|
rect.top,
|
|
)
|
|
}
|
|
const editable = content !== undefined
|
|
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
|
// Per-feature sandbox escape hatch: the global side card setting (warned)
|
|
// plus a per-surface temporary unlock. The unlock state starts at the
|
|
// "default unsafe" pref so a preview can open straight into the red
|
|
// unsandboxed state (still restorable from the status row). With the
|
|
// sandbox OFF the preview iframe drops its sandbox attribute entirely —
|
|
// the previewed page then runs on the GUI's own origin with full session
|
|
// access.
|
|
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
|
|
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
|
|
|
|
// Host-toolbar mode (the merged editor header renders the controls): skip
|
|
// the own toolbar row, report the state after every relevant render (the
|
|
// JSON key guards redundant calls), and register the commands on mount.
|
|
const hostToolbar = props.toolbar === 'host'
|
|
const lastToolbarRef = useRef('')
|
|
useEffect(() => {
|
|
if (!hostToolbar) return
|
|
const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState }
|
|
const key = JSON.stringify(state)
|
|
if (lastToolbarRef.current === key) return
|
|
lastToolbarRef.current = key
|
|
props.onToolbarState?.(state)
|
|
})
|
|
useEffect(() => {
|
|
if (!hostToolbar) return
|
|
// `save` reads live refs only, and `setMode` is the stable state setter —
|
|
// registering this render's closures is safe for the mount's lifetime.
|
|
props.onToolbarControls?.({ setMode, save })
|
|
return () => { props.onToolbarControls?.(null) }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [hostToolbar])
|
|
|
|
return (
|
|
<>
|
|
{!hostToolbar && (
|
|
<div className={css.editorHeader}>
|
|
{(markdown || html) && (
|
|
<div className={css.editorModeToggle}>
|
|
<button
|
|
type="button"
|
|
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
|
|
onClick={() => { setMode('preview') }}
|
|
>
|
|
{t('preview')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
|
|
onClick={() => { setMode('edit') }}
|
|
>
|
|
{t('edit')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
|
|
{editable && (
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('save')}
|
|
title={`${t('save')} (Ctrl/Cmd+S)`}
|
|
onClick={save}
|
|
>
|
|
<IconCheckOutline16 />
|
|
</button>
|
|
)}
|
|
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
|
</div>
|
|
)}
|
|
{editable && (
|
|
<>
|
|
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
|
<div
|
|
className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
|
|
ref={hostRef}
|
|
/>
|
|
</>
|
|
)}
|
|
{markdown && mode === 'preview' && (
|
|
<div
|
|
className={css.editorMd}
|
|
ref={mdRef}
|
|
onMouseUp={handlePreviewMouseUp}
|
|
onScroll={hidePopup}
|
|
>
|
|
{/* The fence copy-button labels must come from this plugin's own
|
|
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
|
|
fall back to hardcoded Chinese otherwise (same pattern as the
|
|
chat's AssistantMarkdown). Render-time t() keeps them following
|
|
the active locale on live switches. Mermaid fences hand the
|
|
whole document to the mermaid lazy chunk (single markdown
|
|
parse; cross-fence references/footnotes stay intact); files
|
|
without one render exactly as before. */}
|
|
{hasMermaid
|
|
? <LazyMermaidMarkdown text={mdText} codeLabels={codeLabels} />
|
|
: <MarkdownText text={mdText} codeLabels={codeLabels} />}
|
|
</div>
|
|
)}
|
|
{html && mode === 'preview' && (
|
|
<>
|
|
<SandboxStatusBar
|
|
sandboxed={!htmlNoSandbox}
|
|
local={localUnlock}
|
|
dangerCopy={t('htmlNoSandboxWarning')}
|
|
onUnlock={() => { setLocalUnlock(true) }}
|
|
onRestore={() => { setLocalUnlock(false) }}
|
|
/>
|
|
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
|
|
origin when unsandboxed; the route URL keeps the frame
|
|
cross-origin by construction). The preview shows the SAVED
|
|
file; the draft is only visible in edit mode. */}
|
|
<iframe
|
|
className={css.editorHtml}
|
|
src={htmlUrl(scope, path)}
|
|
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
|
|
referrerPolicy="no-referrer"
|
|
allow=""
|
|
title={path}
|
|
/>
|
|
</>
|
|
)}
|
|
{popup !== null && createPortal(
|
|
<button
|
|
type="button"
|
|
className={css.selectionPopup}
|
|
style={{ left: popup.left, top: popup.top }}
|
|
// Keep the selection (and CodeMirror focus) alive until the click
|
|
// commits — without this the popup unmounts before click lands.
|
|
onMouseDown={(event) => { event.preventDefault() }}
|
|
onClick={commitPopup}
|
|
>
|
|
{t('addToConversation')}
|
|
</button>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
)
|
|
}
|