feat: migrate code blocks to shiki for VS Code-quality syntax highlighting

Replace prism-react-renderer with shiki/react-shiki in CodeViewer for
dramatically better tokenization (50+ TextMate scopes vs ~15 Prism tokens).
Custom warm-code theme with improved comment contrast (#5C6B7A, ~5:1 ratio)
and warm off-white background (#FDFCF9). Update DiffViewer to use matching
warm color palette with CSS variables instead of hardcoded GitHub colors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 21:24:14 +08:00
parent ad4ba88a62
commit f81fe41797
5 changed files with 3979 additions and 87 deletions

View File

@ -21,6 +21,8 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-diff-viewer-continued": "^4.2.0", "react-diff-viewer-continued": "^4.2.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-shiki": "^0.9.2",
"shiki": "^4.0.2",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {

3832
desktop/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Highlight, themes } from 'prism-react-renderer' import { ShikiHighlighter } from 'react-shiki'
import 'react-shiki/css'
import { CopyButton } from '../shared/CopyButton' import { CopyButton } from '../shared/CopyButton'
type Props = { type Props = {
@ -9,6 +10,41 @@ type Props = {
showLineNumbers?: boolean 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: 'light' as const,
fg: '#24201E',
bg: 'transparent',
tokenColors: [
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#5C6B7A', fontStyle: 'italic' } },
{ scope: ['string', 'string.quoted', 'string.template', 'string.other.link'], settings: { foreground: '#437220' } },
{ scope: ['string.regexp'], settings: { foreground: '#C15F3C' } },
{ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#B8533B' } },
{ scope: ['keyword.operator'], settings: { foreground: '#B8533B' } },
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#1D5A8C' } },
{ scope: ['entity.name.type', 'support.type', 'support.class', 'entity.name.class', 'entity.other.inherited-class'], settings: { foreground: '#7E5520' } },
{ scope: ['entity.name.type.parameter'], settings: { foreground: '#1B7A6A' } },
{ scope: ['variable', 'variable.other', 'variable.other.readwrite'], settings: { foreground: '#24201E' } },
{ scope: ['variable.parameter'], settings: { foreground: '#5C3D2E' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'], settings: { foreground: '#7A3E20' } },
{ scope: ['variable.other.constant', 'variable.other.enummember'], settings: { foreground: '#7E5520' } },
{ scope: ['constant.numeric', 'constant.language'], settings: { foreground: '#1B7A6A' } },
{ scope: ['punctuation', 'meta.brace', 'meta.bracket'], settings: { foreground: '#5C504A' } },
{ scope: ['entity.name.tag', 'punctuation.definition.tag'], settings: { foreground: '#B8533B' } },
{ scope: ['entity.other.attribute-name'], settings: { foreground: '#7A3E20' } },
{ scope: ['meta.decorator', 'punctuation.decorator'], settings: { foreground: '#7E5520' } },
{ scope: ['markup.inserted', 'punctuation.definition.inserted'], settings: { foreground: '#1A7F37' } },
{ scope: ['markup.deleted', 'punctuation.definition.deleted'], settings: { foreground: '#CF222E' } },
{ scope: ['markup.heading', 'entity.name.section'], settings: { foreground: '#1D5A8C', fontStyle: 'bold' } },
{ scope: ['markup.bold'], settings: { fontStyle: 'bold' } },
{ scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
],
}
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = true }: Props) { export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = true }: Props) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
@ -22,71 +58,44 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = tr
const showExpandToggle = allLines.length > maxLines const showExpandToggle = allLines.length > maxLines
return ( return (
<div className="overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]"> <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-1.5 text-[11px] text-[#57606a]"> {/* 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"> <div className="flex items-center gap-3">
<span className="font-semibold uppercase tracking-[0.14em] text-[#57606a]">{languageLabel}</span> <span className="font-semibold uppercase tracking-[0.14em]">{languageLabel}</span>
<span>{lineCountLabel}</span> <span>{lineCountLabel}</span>
</div> </div>
<CopyButton <CopyButton
text={code} text={code}
className="rounded-md border border-[#d0d7de] bg-white px-2 py-1 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]" 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> </div>
<div className="max-h-[420px] overflow-auto"> {/* Code area */}
<Highlight theme={themes.github} code={visibleCode} language={language || 'text'}> <div className="code-viewer-area max-h-[420px] overflow-auto bg-[#FDFCF9]">
{({ tokens, getLineProps, getTokenProps }) => ( <ShikiHighlighter
<pre className="m-0 bg-white p-0 font-[var(--font-mono)] text-[12px] leading-[1.3]"> language={language || 'text'}
{tokens.map((line, i) => { theme={warmCodeTheme}
const lineProps = getLineProps({ line }) showLineNumbers={effectiveShowLineNumbers}
const hasLanguage = !!language && language !== 'text' showLanguage={false}
return effectiveShowLineNumbers ? ( addDefaultStyles={false}
<div style={{
key={i} margin: 0,
{...lineProps} padding: '0.5rem 0',
className="grid grid-cols-[3rem,minmax(0,1fr)] gap-0 hover:bg-[#f6f8fa]/50" fontFamily: 'var(--font-mono)',
style={undefined} fontSize: '12px',
> lineHeight: '1.45',
<span className="select-none border-r border-[#eaeef2] bg-[#fafbfc] px-2 py-px text-right text-[11px] text-[#8b949e]"> }}
{i + 1} >
</span> {visibleCode}
<span className="overflow-hidden px-3 py-px whitespace-pre-wrap break-words"> </ShikiHighlighter>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
) : (
<div
key={i}
{...lineProps}
className="hover:bg-[#f6f8fa]/50"
style={undefined}
>
<span className={`block px-3 py-px whitespace-pre-wrap break-words ${!hasLanguage ? 'text-[#24292f]' : ''}`}>
{hasLanguage ? (
line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))
) : (
line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} style={{ color: '#24292f' }} />
))
)}
</span>
</div>
)
})}
</pre>
)}
</Highlight>
</div> </div>
{/* Expand/collapse toggle */}
{showExpandToggle && ( {showExpandToggle && (
<button <button
onClick={() => setExpanded((value) => !value)} onClick={() => setExpanded((value) => !value)}
className="w-full border-t border-[#d0d7de] bg-[#f6f8fa] py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#57606a] transition-colors hover:bg-[#eaeef2] hover:text-[#24292f]" 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`} {expanded ? 'Collapse' : `Show ${allLines.length - maxLines} more lines`}
</button> </button>

View File

@ -1,5 +1,5 @@
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued' import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
import { Highlight, themes } from 'prism-react-renderer' import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { CopyButton } from '../shared/CopyButton' import { CopyButton } from '../shared/CopyButton'
type Props = { type Props = {
@ -20,9 +20,33 @@ function inferLanguage(filePath: string): string {
return langMap[ext ?? ''] || 'text' return langMap[ext ?? ''] || 'text'
} }
/** Shared warm syntax theme — must stay in sync with CodeViewer */
const warmSyntaxTheme: PrismTheme = {
plain: {
color: '#24201E',
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: '#8C7E75', fontStyle: 'italic' as const } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: '#437220' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: '#B8533B' } },
{ types: ['function'], style: { color: '#1D5A8C' } },
{ types: ['tag'], style: { color: '#B8533B' } },
{ types: ['number', 'boolean'], style: { color: '#1B7A6A' } },
{ types: ['operator'], style: { color: '#24201E' } },
{ types: ['punctuation'], style: { color: '#5C504A' } },
{ types: ['variable', 'parameter'], style: { color: '#24201E' } },
{ types: ['property', 'attr-name'], style: { color: '#7A3E20' } },
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: '#7E5520' } },
{ types: ['regex'], style: { color: '#C15F3C' } },
{ types: ['inserted'], style: { color: '#1A7F37' } },
{ types: ['deleted'], style: { color: '#CF222E' } },
],
}
function highlightSyntax(str: string, language: string) { function highlightSyntax(str: string, language: string) {
return ( return (
<Highlight theme={themes.github} code={str} language={language}> <Highlight theme={warmSyntaxTheme} code={str} language={language}>
{({ tokens, getTokenProps }) => ( {({ tokens, getTokenProps }) => (
<> <>
{tokens.map((line, i) => ( {tokens.map((line, i) => (
@ -41,36 +65,36 @@ function highlightSyntax(str: string, language: string) {
const diffStyles = { const diffStyles = {
variables: { variables: {
light: { light: {
diffViewerBackground: '#ffffff', diffViewerBackground: '#FDFCF9',
diffViewerColor: '#24292f', diffViewerColor: '#3B3330',
addedBackground: '#dafbe1', addedBackground: '#E8F5E2',
addedColor: '#24292f', addedColor: '#3B3330',
removedBackground: '#ffebe9', removedBackground: '#FDECEA',
removedColor: '#24292f', removedColor: '#3B3330',
wordAddedBackground: '#abf2bc', wordAddedBackground: '#B8E4A8',
wordRemovedBackground: '#ff818266', wordRemovedBackground: '#F5B8B4',
addedGutterBackground: '#ccffd8', addedGutterBackground: '#D4EDCA',
removedGutterBackground: '#ffd7d5', removedGutterBackground: '#F9D4D0',
gutterBackground: '#f6f8fa', gutterBackground: '#F4F4F0',
gutterBackgroundDark: '#f0f1f3', gutterBackgroundDark: '#EFEEEA',
highlightBackground: '#fffbdd', highlightBackground: '#FFF5D6',
highlightGutterBackground: '#fff5b1', highlightGutterBackground: '#FFECB3',
codeFoldGutterBackground: '#dbedff', codeFoldGutterBackground: '#E4EDF6',
codeFoldBackground: '#f1f8ff', codeFoldBackground: '#EDF4FB',
emptyLineBackground: '#fafbfc', emptyLineBackground: '#F4F4F0',
gutterColor: '#8b949e', gutterColor: '#87736D',
addedGutterColor: '#1a7f37', addedGutterColor: '#1A7F37',
removedGutterColor: '#cf222e', removedGutterColor: '#CF222E',
codeFoldContentColor: '#57606a', codeFoldContentColor: '#87736D',
diffViewerTitleBackground: '#fafbfc', diffViewerTitleBackground: '#F4F4F0',
diffViewerTitleColor: '#57606a', diffViewerTitleColor: '#87736D',
diffViewerTitleBorderColor: '#d0d7de', diffViewerTitleBorderColor: '#DAC1BA',
}, },
}, },
diffContainer: { diffContainer: {
borderRadius: '0', borderRadius: '0',
fontSize: '12px', fontSize: '12px',
lineHeight: '1.3', lineHeight: '1.45',
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
}, },
line: { line: {
@ -96,24 +120,26 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
const deletions = oldLines.filter((l, i) => l !== (newLines[i] ?? null)).length const deletions = oldLines.filter((l, i) => l !== (newLines[i] ?? null)).length
return ( return (
<div className="overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]"> <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-1.5"> {/* 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">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-[var(--font-mono)] text-[11px] text-[#57606a]"> <div className="truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{filePath} {filePath}
</div> </div>
<div className="mt-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.14em]"> <div className="mt-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.14em]">
<span className="rounded-full bg-[#dafbe1] px-2 py-0.5 text-[#1a7f37]">+{additions}</span> <span className="rounded-full bg-[#E8F5E2] px-2 py-0.5 text-[#1A7F37]">+{additions}</span>
<span className="rounded-full bg-[#ffebe9] px-2 py-0.5 text-[#cf222e]">-{deletions}</span> <span className="rounded-full bg-[#FDECEA] px-2 py-0.5 text-[#CF222E]">-{deletions}</span>
</div> </div>
</div> </div>
<CopyButton <CopyButton
text={`--- ${filePath}\n+++ ${filePath}`} text={`--- ${filePath}\n+++ ${filePath}`}
label="Copy path" label="Copy path"
className="rounded-md border border-[#d0d7de] bg-white px-2 py-1 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]" 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> </div>
{/* Diff area */}
<div className="max-h-[400px] overflow-auto"> <div className="max-h-[400px] overflow-auto">
<ReactDiffViewer <ReactDiffViewer
oldValue={oldString} oldValue={oldString}

View File

@ -1,7 +1,30 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
/* Code highlighting is handled by prism-react-renderer (inline styles) */ /* Code highlighting is handled by shiki/react-shiki (inline styles + CSS line numbers) */
/* ─── Shiki code viewer ───────────────────────────────────────── */
.code-viewer-area .line {
display: inline-block;
width: 100%;
padding: 1px 12px;
}
.code-viewer-area .has-line-numbers .line {
padding-left: 0;
}
.code-viewer-area .line:hover {
background: rgba(0, 0, 0, 0.03);
}
.code-viewer-area .has-line-numbers {
--line-numbers-foreground: rgba(135, 115, 109, 0.5);
--line-numbers-width: 2.5ch;
--line-numbers-padding-left: 1ch;
--line-numbers-padding-right: 1.5ch;
--line-numbers-font-size: 11px;
}
/* ─── Tailwind Theme Override (matches prototype design system) ─── */ /* ─── Tailwind Theme Override (matches prototype design system) ─── */
@theme { @theme {