diff --git a/desktop/src/components/chat/MermaidRenderer.integration.test.tsx b/desktop/src/components/chat/MermaidRenderer.integration.test.tsx index 00d1ef75..eafb81c8 100644 --- a/desktop/src/components/chat/MermaidRenderer.integration.test.tsx +++ b/desktop/src/components/chat/MermaidRenderer.integration.test.tsx @@ -57,4 +57,31 @@ describe('MermaidRenderer Mermaid integration', () => { expect(surface.innerHTML).not.toContain(' { + render( + Text / Map / Array]', + ' I[插入操作
{content, position, clock, clientID}]', + ' D[删除操作
{position, length, clock, clientID}]', + ' RM[Room Manager
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() + }) }) diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index d562f9d8..8c5151dd 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -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 = /|[{}[\]*]/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)) diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 2ea4f967..6d3a64d9 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -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[嵌入类型
Text / Map / Array]', + ' I[插入操作
{content, position, clock, clientID}]', + ' D[删除操作
{position, length, clock, clientID}]', + ' RM[Room Manager
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,