= {
js: 'JavaScript',
jsx: 'JSX',
ts: 'TypeScript',
tsx: 'TSX',
py: 'Python',
rb: 'Ruby',
rs: 'Rust',
go: 'Go',
sh: 'Shell',
bash: 'Bash',
zsh: 'Zsh',
yml: 'YAML',
yaml: 'YAML',
md: 'Markdown',
json: 'JSON',
html: 'HTML',
css: 'CSS',
sql: 'SQL',
dockerfile: 'Dockerfile',
toml: 'TOML',
};
function formatLanguage(lang: string): string {
return LANGUAGE_LABELS[lang] || lang;
}
/**
* Custom renderer for ReactMarkdown.
* Wraps code blocks with a language label and a floating copy button.
*/
const LINE_NUMBER_KEY = 'pinchchat-line-numbers';
const WRAP_KEY = 'pinchchat-code-wrap';
const LINE_THRESHOLD = 3; // Only show line numbers for blocks with more than this many lines
const COLLAPSE_THRESHOLD = 25; // Collapse code blocks longer than this
const COLLAPSE_PREVIEW_LINES = 10; // Lines to show when collapsed
export function CodeBlock(props: HTMLAttributes) {
const [copied, setCopied] = useState(false);
const [showLineNumbers, setShowLineNumbers] = useState(() => {
const stored = localStorage.getItem(LINE_NUMBER_KEY);
return stored === null ? true : stored === 'true';
});
const [wordWrap, setWordWrap] = useState(() => {
const stored = localStorage.getItem(WRAP_KEY);
return stored === 'true';
});
const [isCollapsed, setIsCollapsed] = useState(true);
const language = extractLanguage(props.children);
const code = (props.children as ReactElement<{ children?: string }> | undefined)?.props?.children;
const lines = typeof code === 'string' ? code.replace(/\n$/, '').split('\n') : [];
const hasEnoughLines = lines.length > LINE_THRESHOLD;
const isCollapsible = lines.length > COLLAPSE_THRESHOLD;
const handleCopy = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const text = typeof code === 'string' ? code : '';
if (!text) return;
copyToClipboard(text).then((ok) => {
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
});
}, [code]);
const toggleLineNumbers = useCallback(() => {
setShowLineNumbers(prev => {
const next = !prev;
localStorage.setItem(LINE_NUMBER_KEY, String(next));
return next;
});
}, []);
const toggleWrap = useCallback(() => {
setWordWrap(prev => {
const next = !prev;
localStorage.setItem(WRAP_KEY, String(next));
return next;
});
}, []);
const shouldShowNumbers = showLineNumbers && hasEnoughLines;
const collapsed = isCollapsible && isCollapsed;
const wrapStyle = wordWrap ? { whiteSpace: 'pre-wrap' as const, overflowWrap: 'break-word' as const, wordBreak: 'break-word' as const } : undefined;
const collapseStyle = collapsed ? { maxHeight: `${COLLAPSE_PREVIEW_LINES * 1.7142857}em`, overflow: 'hidden' as const } : undefined;
return (
{language && (
{formatLanguage(language)}{isCollapsible && ({lines.length} lines)}
{hasEnoughLines && (
)}
)}
{shouldShowNumbers ? (
{(collapsed ? lines.slice(0, COLLAPSE_PREVIEW_LINES) : lines).map((_, i) => (
{i + 1}
))}
) : (
)}
{collapsed && (
)}
{isCollapsible && (
)}
);
}