mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Code blocks should keep the richer Shiki rendering in modern WebUI while avoiding the old WebKit startup crash path by loading Shiki lazily and falling back to Prism only when the runtime cannot safely parse or execute it. Constraint: Issue #461 is triggered by older Safari/WebKit parsing modern RegExp syntax during startup. Rejected: Replace Shiki entirely with Prism | visual quality regressed for normal WebUI usage. Confidence: high Scope-risk: moderate Directive: Keep Shiki out of the startup path unless macOS 12 WebView compatibility is re-verified. Tested: cd desktop && bun run test -- --run src/components/chat/CodeViewer.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: Real WebUI smoke on http://127.0.0.1:1420/ verified six code blocks rendered with data-highlight-engine=shiki. Not-tested: Physical macOS 12 WebView runtime. Related: https://github.com/NanmiCoder/cc-haha/issues/461
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import { useEffect, useRef, useState, type ComponentType, type CSSProperties } from 'react'
|
|
import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
|
import { CopyButton } from '../shared/CopyButton'
|
|
|
|
type Props = {
|
|
code: string
|
|
language?: string
|
|
maxLines?: number
|
|
showLineNumbers?: boolean
|
|
}
|
|
|
|
const warmPrismTheme: PrismTheme = {
|
|
plain: {
|
|
color: 'var(--color-code-fg)',
|
|
backgroundColor: 'transparent',
|
|
},
|
|
styles: [
|
|
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } },
|
|
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
|
|
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
|
|
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
|
|
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
|
|
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
|
|
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
|
|
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
|
|
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
|
|
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
|
|
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
|
|
{ types: ['regex'], style: { color: 'var(--color-primary-container)' } },
|
|
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
|
|
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
|
|
],
|
|
}
|
|
|
|
const warmShikiTheme = {
|
|
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
|
|
|
|
type ShikiHighlighterProps = {
|
|
language: string
|
|
theme: typeof warmShikiTheme
|
|
engine: unknown
|
|
showLineNumbers: boolean
|
|
showLanguage: boolean
|
|
addDefaultStyles: boolean
|
|
style: CSSProperties
|
|
children: string
|
|
}
|
|
|
|
type ReactShikiModule = {
|
|
ShikiHighlighter: ComponentType<any>
|
|
createJavaScriptRegexEngine: (options: { forgiving: boolean }) => unknown
|
|
}
|
|
|
|
type ShikiRuntime = {
|
|
Highlighter: ComponentType<ShikiHighlighterProps>
|
|
engine: unknown
|
|
}
|
|
|
|
let shikiRuntimePromise: Promise<ShikiRuntime | null> | null = null
|
|
|
|
function canUseShikiRuntime(): boolean {
|
|
if (import.meta.env.MODE === 'test') return false
|
|
if (typeof window === 'undefined') return false
|
|
|
|
try {
|
|
new RegExp('(?<name>a)')
|
|
new RegExp('(?<=a)b')
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
const ua = window.navigator.userAgent
|
|
const chromiumLike = /\b(Chrome|Chromium|CriOS|Edg|OPR|Firefox)\b/.test(ua)
|
|
const safariVersion = /\bVersion\/(\d+)(?:\.\d+)?\b.*\bSafari\//.exec(ua)
|
|
if (!chromiumLike && safariVersion && Number(safariVersion[1]) <= 15) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
function loadShikiRuntime(): Promise<ShikiRuntime | null> {
|
|
if (!canUseShikiRuntime()) return Promise.resolve(null)
|
|
shikiRuntimePromise ??= import('react-shiki')
|
|
.then((mod) => {
|
|
const shiki = mod as unknown as ReactShikiModule
|
|
return {
|
|
Highlighter: shiki.ShikiHighlighter as ComponentType<ShikiHighlighterProps>,
|
|
engine: shiki.createJavaScriptRegexEngine({ forgiving: true }),
|
|
}
|
|
})
|
|
.catch(() => null)
|
|
return shikiRuntimePromise
|
|
}
|
|
|
|
function PrismCodeContent({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) {
|
|
return (
|
|
<Highlight
|
|
theme={warmPrismTheme}
|
|
code={code}
|
|
language={language || 'text'}
|
|
>
|
|
{({ tokens, getLineProps, getTokenProps }) => (
|
|
<pre
|
|
data-code-viewer-content=""
|
|
data-highlight-engine="prism"
|
|
style={{
|
|
margin: 0,
|
|
padding: CODE_AREA_PADDING,
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '12px',
|
|
lineHeight: String(CODE_LINE_HEIGHT),
|
|
whiteSpace: 'pre',
|
|
wordBreak: 'normal',
|
|
color: 'var(--color-code-fg)',
|
|
}}
|
|
>
|
|
{tokens.map((line, index) => (
|
|
<span
|
|
key={index}
|
|
{...getLineProps({ line })}
|
|
data-line-number={showLineNumbers ? index + 1 : undefined}
|
|
>
|
|
{showLineNumbers && (
|
|
<span className="mr-3 inline-block min-w-[2.5ch] select-none text-right text-[var(--color-text-tertiary)]">
|
|
{index + 1}
|
|
</span>
|
|
)}
|
|
{line.map((token, key) => (
|
|
<span key={key} {...getTokenProps({ token })} />
|
|
))}
|
|
</span>
|
|
))}
|
|
</pre>
|
|
)}
|
|
</Highlight>
|
|
)
|
|
}
|
|
|
|
function CodeArea({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [runtime, setRuntime] = useState<ShikiRuntime | null>(null)
|
|
const [loaded, setLoaded] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoaded(false)
|
|
loadShikiRuntime().then((nextRuntime) => {
|
|
if (!cancelled) setRuntime(nextRuntime)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
setLoaded(false)
|
|
}, [code, language])
|
|
|
|
useEffect(() => {
|
|
if (!runtime) return
|
|
const el = containerRef.current
|
|
if (!el) return
|
|
const check = () => {
|
|
const shikiContainer = el.querySelector('[data-testid="shiki-container"]')
|
|
if (shikiContainer?.querySelector('code')) {
|
|
setLoaded(true)
|
|
}
|
|
}
|
|
check()
|
|
const observer = new MutationObserver(check)
|
|
observer.observe(el, { childList: true, subtree: true })
|
|
return () => observer.disconnect()
|
|
}, [runtime, code, language])
|
|
|
|
const ShikiHighlighter = runtime?.Highlighter
|
|
|
|
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)]"
|
|
>
|
|
{(!ShikiHighlighter || !loaded) && (
|
|
<PrismCodeContent
|
|
code={code}
|
|
language={language}
|
|
showLineNumbers={showLineNumbers}
|
|
/>
|
|
)}
|
|
{ShikiHighlighter && (
|
|
<div
|
|
data-code-viewer-content=""
|
|
data-highlight-engine="shiki"
|
|
style={
|
|
loaded
|
|
? { padding: CODE_AREA_PADDING }
|
|
: {
|
|
position: 'absolute',
|
|
inset: 0,
|
|
opacity: 0,
|
|
pointerEvents: 'none',
|
|
padding: CODE_AREA_PADDING,
|
|
}
|
|
}
|
|
>
|
|
<ShikiHighlighter
|
|
language={language || 'text'}
|
|
theme={warmShikiTheme}
|
|
engine={runtime.engine}
|
|
showLineNumbers={showLineNumbers}
|
|
showLanguage={false}
|
|
addDefaultStyles={false}
|
|
style={{
|
|
margin: 0,
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '12px',
|
|
lineHeight: String(CODE_LINE_HEIGHT),
|
|
whiteSpace: 'pre',
|
|
}}
|
|
>
|
|
{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>
|
|
)
|
|
}
|