fix(desktop): render generated Mermaid labels in previews (#803)

Normalize generated flowchart labels before Mermaid rendering so Markdown previews handle labels with HTML breaks, braces, and bracketed type text.

Tested: bun run check:desktop
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 09:52:30 +08:00
parent ae32599ba8
commit dd72901ba3
3 changed files with 216 additions and 1 deletions

View File

@ -57,4 +57,31 @@ describe('MermaidRenderer Mermaid integration', () => {
expect(surface.innerHTML).not.toContain('<script')
expect(surface.innerHTML).not.toContain('onerror')
})
it('renders generated flowchart labels with HTML breaks and structural characters', async () => {
render(
<MermaidRenderer
code={[
'graph LR',
' subgraph "Yjs CRDT 核心"',
' Y[Yjs Document]',
' A[嵌入类型<br/>Text / Map / Array]',
' I[插入操作<br/>{content, position, clock, clientID}]',
' D[删除操作<br/>{position, length, clock, clientID}]',
' RM[Room Manager<br/>map[string]*Room]',
' end',
' I --> Y',
' D --> Y',
' RM --> Y',
].join('\n')}
/>,
)
const surface = await screen.findByTestId('mermaid-diagram-surface')
expect(surface).toHaveTextContent('插入操作')
expect(surface).toHaveTextContent('{content, position, clock, clientID}')
expect(surface).toHaveTextContent('map[string]*Room')
expect(screen.queryByText('Mermaid Error')).not.toBeInTheDocument()
})
})

View File

@ -38,6 +38,98 @@ type MermaidThemeColors = {
isDark: boolean
}
const FLOWCHART_START = /^\s*(?:graph|flowchart)\b/i
const FLOWCHART_NODE_START = /^([A-Za-z][\w-]*)\[/
const UNQUOTED_FLOWCHART_LABEL_UNSAFE = /<br\s*\/?>|[{}[\]*]/i
function isFlowchartDiagram(code: string) {
const firstMeaningfulLine = code
.split('\n')
.map((line) => line.trim())
.find(Boolean)
return firstMeaningfulLine ? FLOWCHART_START.test(firstMeaningfulLine) : false
}
function isQuotedFlowchartLabel(label: string) {
const trimmed = label.trim()
return (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('`') && trimmed.endsWith('`'))
)
}
function shouldQuoteFlowchartLabel(label: string) {
return !isQuotedFlowchartLabel(label) && UNQUOTED_FLOWCHART_LABEL_UNSAFE.test(label)
}
function escapeFlowchartLabel(label: string) {
return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function isLikelyFlowchartLabelClose(line: string, closeIndex: number) {
const after = line.slice(closeIndex + 1).trimStart()
return (
after.length === 0 ||
after.startsWith('--') ||
after.startsWith('-.') ||
after.startsWith('==') ||
after.startsWith('~~~') ||
after.startsWith(':::') ||
after.startsWith('&') ||
after.startsWith('@') ||
/^[;,)]/.test(after)
)
}
function findFlowchartLabelClose(line: string, openIndex: number) {
for (let index = openIndex + 1; index < line.length; index += 1) {
if (line[index] === ']' && isLikelyFlowchartLabelClose(line, index)) {
return index
}
}
return -1
}
function normalizeFlowchartLine(line: string) {
let output = ''
let index = 0
while (index < line.length) {
const match = FLOWCHART_NODE_START.exec(line.slice(index))
if (!match) {
output += line[index]
index += 1
continue
}
const nodeId = match[1] ?? ''
const openIndex = index + nodeId.length
const closeIndex = findFlowchartLabelClose(line, openIndex)
if (closeIndex < 0) {
output += line[index]
index += 1
continue
}
const label = line.slice(openIndex + 1, closeIndex)
if (!shouldQuoteFlowchartLabel(label)) {
output += line.slice(index, closeIndex + 1)
} else {
output += `${nodeId}["${escapeFlowchartLabel(label)}"]`
}
index = closeIndex + 1
}
return output
}
function normalizeGeneratedFlowchartSyntax(code: string) {
if (!isFlowchartDiagram(code)) return code
return code.split('\n').map(normalizeFlowchartLine).join('\n')
}
function rgbToHex(color: string, fallback: string) {
const trimmed = color.trim()
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed
@ -329,8 +421,9 @@ export function MermaidRenderer({ code }: Props) {
const { lineColor } = initMermaid(theme)
const id = `mermaid-${++mermaidIdCounter}`
const renderCode = normalizeGeneratedFlowchartSyntax(code)
mermaid.render(id, code).then(
mermaid.render(id, renderCode).then(
({ svg: renderedSvg }) => {
if (!cancelled) {
setSvg(normalizeMermaidSvg(renderedSvg, lineColor))

View File

@ -221,6 +221,34 @@ function classNameContains(element: Element | null, needle: string) {
return false
}
type SvgMeasurementPrototype = SVGElement & {
getBBox?: () => { x: number; y: number; width: number; height: number }
getComputedTextLength?: () => number
}
function ensureMermaidSvgMeasurementStubs() {
const svgPrototype = SVGElement.prototype as SvgMeasurementPrototype
if (!svgPrototype.getBBox) {
Object.defineProperty(svgPrototype, 'getBBox', {
configurable: true,
value: () => ({
x: 0,
y: 0,
width: 120,
height: 24,
}),
})
}
if (!svgPrototype.getComputedTextLength) {
Object.defineProperty(svgPrototype, 'getComputedTextLength', {
configurable: true,
value: () => 96,
})
}
}
vi.mock('../../api/sessions', () => ({
sessionsApi: (() => {
if (!mocks) {
@ -265,6 +293,7 @@ describe('WorkspacePanel', () => {
beforeEach(async () => {
vi.clearAllMocks()
ensureMermaidSvgMeasurementStubs()
await setWorkspaceState(workspaceInitialState)
useChatStore.setState(chatInitialState, true)
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
@ -1069,6 +1098,72 @@ describe('WorkspacePanel', () => {
expect(view.queryByTestId('workspace-code')).toBeNull()
})
it('renders Mermaid diagrams in markdown file previews when labels contain HTML breaks and braces', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-markdown-mermaid-preview': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-markdown-mermaid-preview': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-markdown-mermaid-preview': [{
id: 'file:architecture.md',
path: 'architecture.md',
kind: 'file',
title: 'architecture.md',
language: 'markdown',
content: [
'# Architecture',
'',
'```mermaid',
'graph LR',
' subgraph "Yjs CRDT 核心"',
' Y[Yjs Document]',
' A[嵌入类型<br/>Text / Map / Array]',
' I[插入操作<br/>{content, position, clock, clientID}]',
' D[删除操作<br/>{position, length, clock, clientID}]',
' RM[Room Manager<br/>map[string]*Room]',
' end',
' I --> Y',
' D --> Y',
' RM --> Y',
'```',
].join('\n'),
state: 'ok',
size: 256,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-markdown-mermaid-preview': 'file:architecture.md',
},
}))
const view = await renderPanel('session-markdown-mermaid-preview')
const surface = await view.findByTestId('mermaid-diagram-surface')
expect(surface.textContent).toContain('插入操作')
expect(surface.textContent).toContain('{content, position, clock, clientID}')
expect(surface.textContent).toContain('map[string]*Room')
expect(view.queryByText('Mermaid Error')).toBeNull()
expect(view.queryByTestId('workspace-code')).toBeNull()
})
it('opens a context menu for preview tabs and closes tabs to the right', async () => {
await setWorkspaceState((state) => ({
...state,