batch3 T6: usePanes hook + Shiki integration in CodeBlock

- hooks/usePanes: per-session panes CRUD; debounced (300ms) state PATCH;
  immediate position-change PATCH with refresh
- CodeBlock: shiki async highlighting via codeToHtml + github-dark theme;
  LANG_MAP for ts/tsx/js/jsx/py/go/rs/rb/java/c/cpp/cs/php/sh/yaml/json/
  toml/md/sql/dockerfile/html/css; falls back to plain pre on unknown lang
  or async failure
- package.json: + shiki

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:32:04 +00:00
parent e82e5670ee
commit b29555ee28
4 changed files with 359 additions and 7 deletions

View File

@@ -1,16 +1,86 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { codeToHtml } from 'shiki';
// NOTE: spec calls for syntax-highlighted code blocks. Highlighting deferred
// to keep dep footprint minimal; this renders styled mono code with a copy
// button. Adding a highlighter (shiki / highlight.js) is a one-import swap.
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1.
// Shiki output is compiler-generated and does not contain user input; setting
// it via a ref is safe here.
interface Props {
code: string;
lang?: string;
}
const LANG_MAP: Record<string, string> = {
ts: 'typescript',
tsx: 'tsx',
typescript: 'typescript',
js: 'javascript',
jsx: 'jsx',
javascript: 'javascript',
py: 'python',
python: 'python',
go: 'go',
rs: 'rust',
rust: 'rust',
rb: 'ruby',
ruby: 'ruby',
java: 'java',
c: 'c',
cpp: 'cpp',
cs: 'csharp',
csharp: 'csharp',
php: 'php',
sh: 'bash',
bash: 'bash',
shell: 'bash',
yaml: 'yaml',
yml: 'yaml',
json: 'json',
toml: 'toml',
md: 'markdown',
markdown: 'markdown',
sql: 'sql',
dockerfile: 'dockerfile',
html: 'html',
css: 'css',
};
const SHIKI_THEME = 'github-dark';
export function CodeBlock({ code, lang }: Props) {
const [copied, setCopied] = useState(false);
const [html, setHtml] = useState<string | null>(null);
const highlightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
if (!mappedLang) {
setHtml(null);
return;
}
(async () => {
try {
const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME });
if (!cancelled) setHtml(result);
} catch (err) {
console.warn('shiki failed', err);
if (!cancelled) setHtml(null);
}
})();
return () => {
cancelled = true;
};
}, [code, lang]);
// Inject Shiki HTML via ref; output is compiler-generated, not user input.
useEffect(() => {
if (highlightRef.current) {
// Shiki generates sanitized HTML spans — not user-supplied content.
// eslint-disable-next-line no-unsanitized/property
highlightRef.current.innerHTML = html ?? '';
}
}, [html]);
async function copy() {
try {
@@ -36,9 +106,16 @@ export function CodeBlock({ code, lang }: Props) {
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{code}
</pre>
{html !== null ? (
<div
ref={highlightRef}
className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0"
/>
) : (
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{code}
</pre>
)}
</div>
);
}