| import { useState, useRef, useEffect } from 'react' |
| import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' |
| import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' |
| import { useCopyToClipboard } from '../hooks/useCopyToClipboard' |
|
|
| const LANG_ALIASES = { |
| jsx: 'jsx', tsx: 'tsx', js: 'javascript', javascript: 'javascript', |
| ts: 'typescript', typescript: 'typescript', |
| py: 'python', python: 'python', css: 'css', json: 'json', |
| html: 'markup', sh: 'bash', bash: 'bash', |
| } |
|
|
| function ChevronIcon({ open }) { |
| return ( |
| <svg |
| width="11" |
| height="11" |
| viewBox="0 0 16 16" |
| fill="none" |
| stroke="currentColor" |
| strokeWidth="2" |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| style={{ |
| transform: open ? 'rotate(90deg)' : 'rotate(0deg)', |
| transition: 'transform 120ms ease', |
| }} |
| > |
| <path d="M6 4l4 4-4 4" /> |
| </svg> |
| ) |
| } |
|
|
| function StreamingDots() { |
| return ( |
| <span className="inline-flex items-center gap-1 ml-1"> |
| {[0, 150, 300].map(d => ( |
| <span |
| key={d} |
| className="w-1 h-1 rounded-full animate-bounce" |
| style={{ background: 'currentColor', animationDelay: `${d}ms` }} |
| /> |
| ))} |
| </span> |
| ) |
| } |
|
|
| export default function CollapsibleCode({ filename, lang, code, streaming }) { |
| |
| const [expanded, setExpanded] = useState(false) |
| const wasStreaming = useRef(streaming) |
| const codeRef = useRef(null) |
|
|
| |
| useEffect(() => { |
| if (wasStreaming.current && !streaming) { |
| setExpanded(false) |
| } |
| wasStreaming.current = streaming |
| }, [streaming]) |
|
|
| |
| useEffect(() => { |
| if (streaming && expanded && codeRef.current) { |
| codeRef.current.scrollTop = codeRef.current.scrollHeight |
| } |
| }, [code, streaming, expanded]) |
|
|
| const { copied, copy } = useCopyToClipboard() |
| const language = LANG_ALIASES[(lang || '').toLowerCase()] || 'text' |
| const label = streaming ? 'Writing' : 'Wrote' |
| const accent = streaming ? 'var(--accent-hi)' : 'var(--text-dim)' |
|
|
| return ( |
| <div |
| className="my-2 rounded-lg overflow-hidden border" |
| style={{ borderColor: 'var(--border)', background: 'var(--surface-2)' }} |
| > |
| <button |
| onClick={() => setExpanded(e => !e)} |
| className="w-full flex items-center gap-2 px-3 py-2 text-left transition-colors" |
| style={{ background: 'var(--surface-3)', color: 'var(--text-dim)' }} |
| > |
| <span style={{ color: accent }}> |
| <ChevronIcon open={expanded} /> |
| </span> |
| <span className="text-[12px]" style={{ color: accent }}>{label}</span> |
| <span className="mono text-[12px]" style={{ color: 'var(--text)' }}>{filename}</span> |
| {streaming && <span style={{ color: accent }}><StreamingDots /></span>} |
| <span className="flex-1" /> |
| {!streaming && ( |
| <span |
| onClick={(e) => { e.stopPropagation(); copy(code, 'Code copied') }} |
| className="text-[11px] mono px-2 py-0.5 rounded hover:bg-[var(--surface)] transition-colors" |
| style={{ color: copied ? 'var(--accent-hi)' : 'var(--text-faint)' }} |
| > |
| {copied ? 'copied' : 'copy'} |
| </span> |
| )} |
| </button> |
| |
| {expanded && ( |
| <div |
| ref={codeRef} |
| style={{ maxHeight: 360, overflow: 'auto', background: 'var(--surface-2)' }} |
| > |
| <SyntaxHighlighter |
| language={language} |
| style={oneDark} |
| customStyle={{ |
| margin: 0, |
| padding: '12px 14px', |
| background: 'var(--surface-2)', |
| fontSize: 12.5, |
| lineHeight: 1.55, |
| fontFamily: "'JetBrains Mono', ui-monospace, monospace", |
| }} |
| codeTagProps={{ style: { fontFamily: "'JetBrains Mono', ui-monospace, monospace" } }} |
| wrapLongLines |
| > |
| {code || ''} |
| </SyntaxHighlighter> |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|