cc-haha/desktop/src/components/chat/CodeViewer.tsx
程序员阿江(Relakkes) c70da2459d Ship desktop code blocks that render reliably in the macOS release build
The desktop app was still falling back to raw text in release builds for some
fenced code blocks, which left bash snippets visually wrong and too heavy for
chat use. This change switches the Shiki path to the JavaScript regex engine,
restores tighter code-block defaults for chat, and bumps the desktop app
version to 0.1.4 for the release artifacts.

Constraint: The packaged Tauri app must render markdown code blocks consistently without relying on WebView WASM behavior
Constraint: Chat code blocks should stay compact and should not show line numbers unless a caller explicitly requests them
Rejected: Keep tuning fallback-only CSS | did not address the packaged highlighter path failing to initialize
Rejected: Leave line numbers on by default | too noisy for assistant replies and bash snippets
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If code blocks regress again, inspect the highlighter engine path before adjusting chat spacing CSS
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test CodeViewer.test.tsx MarkdownRenderer.test.tsx Sidebar.test.tsx
Tested: cd desktop && bun run build:macos-arm64
Not-tested: Windows packaged build after the engine switch
2026-04-20 18:39:11 +08:00

183 lines
8.2 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { ShikiHighlighter, createJavaScriptRegexEngine } from 'react-shiki'
import 'react-shiki/css'
import { CopyButton } from '../shared/CopyButton'
type Props = {
code: string
language?: string
maxLines?: number
showLineNumbers?: boolean
}
/**
* Custom warm-toned TextMate theme — uses VS Code-quality tokenization
* while harmonizing with the app's cream/terra-cotta design system.
*/
const warmCodeTheme = {
name: 'warm-code',
type: 'dark' as const,
fg: 'var(--color-code-fg)',
bg: 'transparent',
tokenColors: [
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: 'var(--color-code-comment)', fontStyle: 'italic' } },
{ scope: ['string', 'string.quoted', 'string.template', 'string.other.link'], settings: { foreground: 'var(--color-code-string)' } },
{ scope: ['string.regexp'], settings: { foreground: 'var(--color-primary-container)' } },
{ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: 'var(--color-code-keyword)' } },
{ scope: ['keyword.operator'], settings: { foreground: 'var(--color-code-keyword)' } },
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: 'var(--color-code-function)' } },
{ scope: ['entity.name.type', 'support.type', 'support.class', 'entity.name.class', 'entity.other.inherited-class'], settings: { foreground: 'var(--color-code-type)' } },
{ scope: ['entity.name.type.parameter'], settings: { foreground: 'var(--color-code-number)' } },
{ scope: ['variable', 'variable.other', 'variable.other.readwrite'], settings: { foreground: 'var(--color-code-fg)' } },
{ scope: ['variable.parameter'], settings: { foreground: 'var(--color-code-parameter)' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'], settings: { foreground: 'var(--color-code-property)' } },
{ scope: ['variable.other.constant', 'variable.other.enummember'], settings: { foreground: 'var(--color-code-type)' } },
{ scope: ['constant.numeric', 'constant.language'], settings: { foreground: 'var(--color-code-number)' } },
{ scope: ['punctuation', 'meta.brace', 'meta.bracket'], settings: { foreground: 'var(--color-code-punctuation)' } },
{ scope: ['entity.name.tag', 'punctuation.definition.tag'], settings: { foreground: 'var(--color-code-keyword)' } },
{ scope: ['entity.other.attribute-name'], settings: { foreground: 'var(--color-code-property)' } },
{ scope: ['meta.decorator', 'punctuation.decorator'], settings: { foreground: 'var(--color-code-type)' } },
{ scope: ['markup.inserted', 'punctuation.definition.inserted'], settings: { foreground: 'var(--color-code-inserted)' } },
{ scope: ['markup.deleted', 'punctuation.definition.deleted'], settings: { foreground: 'var(--color-code-deleted)' } },
{ scope: ['markup.heading', 'entity.name.section'], settings: { foreground: 'var(--color-code-function)', fontStyle: 'bold' } },
{ scope: ['markup.bold'], settings: { fontStyle: 'bold' } },
{ scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
],
}
const CODE_AREA_PADDING = '0.5rem 12px'
const CODE_LINE_HEIGHT = 1.3
const shikiEngine = createJavaScriptRegexEngine({ forgiving: true })
/**
* Wraps ShikiHighlighter with a plain-text fallback so the code area
* is never empty while the async WASM / language-grammar load is in-flight,
* or if highlighting fails entirely.
*/
function CodeArea({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) {
const containerRef = useRef<HTMLDivElement>(null)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
// ShikiHighlighter renders `null` until the async highlight completes.
// Watch for real content appearing via MutationObserver so we can hide
// the plain-text fallback as soon as highlighted output is in the DOM.
const el = containerRef.current
if (!el) return
const check = () => {
const shikiContainer = el.querySelector('[data-testid="shiki-container"]')
// shiki renders a <code> element inside its container once highlighting is done
if (shikiContainer?.querySelector('code')) {
setLoaded(true)
}
}
check()
const observer = new MutationObserver(check)
observer.observe(el, { childList: true, subtree: true })
return () => observer.disconnect()
}, [code, language])
return (
<div
ref={containerRef}
data-has-line-numbers={showLineNumbers ? 'true' : 'false'}
className="code-viewer-area relative max-h-[420px] overflow-auto bg-[var(--color-code-bg)]"
>
{/* Plain-text fallback shown until Shiki finishes highlighting */}
{!loaded && (
<pre
style={{
margin: 0,
padding: CODE_AREA_PADDING,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--color-code-fg)',
}}
>
{code}
</pre>
)}
<div
data-code-viewer-content=""
style={
loaded
? { padding: CODE_AREA_PADDING }
: {
position: 'absolute',
inset: 0,
opacity: 0,
pointerEvents: 'none',
padding: CODE_AREA_PADDING,
}
}
>
<ShikiHighlighter
language={language || 'text'}
theme={warmCodeTheme}
engine={shikiEngine}
showLineNumbers={showLineNumbers}
showLanguage={false}
addDefaultStyles={false}
style={{
margin: 0,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
}}
>
{code}
</ShikiHighlighter>
</div>
</div>
)
}
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false }: Props) {
const [expanded, setExpanded] = useState(false)
const allLines = code.split('\n')
const isTruncated = !expanded && allLines.length > maxLines
const visibleCode = isTruncated ? allLines.slice(0, maxLines).join('\n') : code
const effectiveShowLineNumbers = showLineNumbers && !!language && language !== 'text'
const languageLabel = language || 'code'
const lineCountLabel = `${allLines.length} ${allLines.length === 1 ? 'line' : 'lines'}`
const showExpandToggle = allLines.length > maxLines
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-3">
<span className="font-semibold uppercase tracking-[0.14em]">{languageLabel}</span>
<span>{lineCountLabel}</span>
</div>
<CopyButton
text={code}
className="rounded-md border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
/>
</div>
{/* Code area */}
<CodeArea
code={visibleCode}
language={language}
showLineNumbers={effectiveShowLineNumbers}
/>
{/* Expand/collapse toggle */}
{showExpandToggle && (
<button
onClick={() => setExpanded((value) => !value)}
className="w-full border-t border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
>
{expanded ? 'Collapse' : `Show ${allLines.length - maxLines} more lines`}
</button>
)}
</div>
)
}