mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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
183 lines
8.2 KiB
TypeScript
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>
|
|
)
|
|
}
|