mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
This introduces a persisted light/dark appearance setting, maps the desktop shell onto semantic theme tokens, and reworks the highest-traffic chat/settings surfaces so the new dark mode is usable without regressing the original light theme. The same pass tightens markdown rendering for chat replies by improving inline code, table overflow handling, and safe external-link behavior so dark-mode content stays legible in real conversations. Constraint: Preserve the existing light theme while adding a user-selectable dark theme in Settings > General Constraint: Avoid introducing new dependencies for styling or markdown handling Rejected: Replacing the light palette with a single dual-purpose palette | would risk broad visual regressions across the existing desktop UI Rejected: Implementing dark mode only for shell chrome | leaves chat markdown, diffs, and permission flows visually broken Confidence: high Scope-risk: moderate Reversibility: clean Directive: New desktop UI should use semantic theme variables instead of hard-coded color literals so both themes stay aligned Tested: bun run lint; bun run test; bun run build; browser review of theme switching, provider/permission states, and chat surfaces Not-tested: Prototype-style pages with remaining hard-coded colors (scheduled/session control mock surfaces) were not fully normalized in this change
162 lines
7.6 KiB
TypeScript
162 lines
7.6 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
|
import { ShikiHighlighter } 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' } },
|
|
],
|
|
}
|
|
|
|
/**
|
|
* 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} className="code-viewer-area max-h-[420px] overflow-auto bg-[var(--color-code-bg)]">
|
|
{/* Plain-text fallback shown until Shiki finishes highlighting */}
|
|
{!loaded && (
|
|
<pre
|
|
style={{
|
|
margin: 0,
|
|
padding: '0.5rem 12px',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '12px',
|
|
lineHeight: '1.45',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
color: 'var(--color-code-fg)',
|
|
}}
|
|
>
|
|
{code}
|
|
</pre>
|
|
)}
|
|
<div style={loaded ? undefined : { position: 'absolute', opacity: 0, pointerEvents: 'none' }}>
|
|
<ShikiHighlighter
|
|
language={language || 'text'}
|
|
theme={warmCodeTheme}
|
|
showLineNumbers={showLineNumbers}
|
|
showLanguage={false}
|
|
addDefaultStyles={false}
|
|
style={{
|
|
margin: 0,
|
|
padding: '0.5rem 0',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '12px',
|
|
lineHeight: '1.45',
|
|
}}
|
|
>
|
|
{code}
|
|
</ShikiHighlighter>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = true }: 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>
|
|
)
|
|
}
|