Python 64.6%
TypeScript 33.7%
CSS 0.8%
1import { useEffect, useState } from 'react';2import { Check, Copy, Pencil, Play, Square } from 'lucide-react';3import { api } from '@/lib/api';4import { fmtDuration } from '@/lib/format';5import { Button } from '@/components/ui/button';67let highlighterPromise: Promise<{ codeToHtml: (code: string, o: { lang: string; theme: string }) => string }> | null = null;8/** Fine-grained shiki bundle: only the languages students actually meet (keeps the PWA small). */9async function getHighlighter() {10 if (!highlighterPromise) {11 highlighterPromise = (async () => {12 const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([13 import('shiki/core'),14 import('shiki/engine/javascript'),15 ]);16 const hl = await createHighlighterCore({17 themes: [import('shiki/themes/github-dark.mjs')],18 langs: [import('shiki/langs/python.mjs'), import('shiki/langs/json.mjs'), import('shiki/langs/bash.mjs'), import('shiki/langs/sql.mjs'), import('shiki/langs/javascript.mjs')],19 engine: createJavaScriptRegexEngine(),20 });21 const loaded = new Set(hl.getLoadedLanguages());22 return { codeToHtml: (code: string, o: { lang: string; theme: string }) => hl.codeToHtml(code, { lang: loaded.has(o.lang) ? o.lang : 'text', theme: o.theme }) };23 })();24 }25 return highlighterPromise;26}2728interface RunOut { stdout?: string; stderr?: string; exit_code?: number; duration_ms?: number; artifacts?: { file_id: string; filename: string; type: string }[] }2930export function CodeBlock({ code, lang, conversationId, streaming }: { code: string; lang: string; conversationId?: string; streaming?: boolean }) {31 const [html, setHtml] = useState<string>('');32 const [copied, setCopied] = useState(false);33 const [editing, setEditing] = useState(false);34 const [draft, setDraft] = useState(code);35 const [running, setRunning] = useState(false);36 const [out, setOut] = useState<RunOut | null>(null);37 const runnable = lang === 'python' || lang === 'py';3839 useEffect(() => {40 if (streaming) return;41 let alive = true;42 getHighlighter().then((h) => alive && setHtml(h.codeToHtml(editing ? draft : code, { lang: lang || 'text', theme: 'github-dark' }))).catch(() => undefined);43 return () => {44 alive = false;45 };46 }, [code, draft, editing, lang, streaming]);4748 const copy = async () => {49 await navigator.clipboard.writeText(editing ? draft : code);50 setCopied(true);51 setTimeout(() => setCopied(false), 1200);52 };5354 const run = async () => {55 setRunning(true);56 setOut(null);57 try {58 const r = await api<RunOut>('/tools/python/run', { method: 'POST', body: JSON.stringify({ code: editing ? draft : code, conversation_id: conversationId }) });59 setOut(r);60 } catch (e) {61 setOut({ stderr: e instanceof Error ? e.message : 'Erreur', exit_code: 1 });62 } finally {63 setRunning(false);64 }65 };6667 return (68 <div className="my-2 rounded-lg overflow-hidden border border-[#1f2d3d] bg-[#0f1b2a] text-[13px]">69 <div className="flex items-center justify-between px-3 py-1.5 bg-[#16233a] text-[#b7c4d1]">70 <span className="font-mono text-xs uppercase tracking-wide">{lang || 'code'}</span>71 <div className="flex items-center gap-1">72 {runnable && !streaming && (73 <>74 <button onClick={() => setEditing((v) => !v)} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1" title="Modifier">75 <Pencil size={14} /> <span className="hidden sm:inline">Modifier</span>76 </button>77 <button onClick={run} disabled={running} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1 text-uqo-green" title="Exécuter">78 {running ? <Square size={14} className="animate-pulse" /> : <Play size={14} />} <span className="hidden sm:inline">Exécuter</span>79 </button>80 </>81 )}82 <button onClick={copy} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1" title="Copier">83 {copied ? <Check size={14} className="text-uqo-green" /> : <Copy size={14} />} <span className="hidden sm:inline">{copied ? 'Copié' : 'Copier'}</span>84 </button>85 </div>86 </div>87 {editing ? (88 <textarea value={draft} onChange={(e) => setDraft(e.target.value)} spellCheck={false}89 className="w-full min-h-[160px] bg-[#0f1b2a] text-[#e6edf3] font-mono text-[13px] p-3 outline-none resize-y" />90 ) : html ? (91 <div className="overflow-x-auto scroll-thin [&_pre]:!bg-transparent [&_pre]:p-3 [&_pre]:m-0" dangerouslySetInnerHTML={{ __html: html }} />92 ) : (93 <pre className="p-3 m-0 text-[#e6edf3] font-mono overflow-x-auto scroll-thin"><code>{code}</code></pre>94 )}95 {out && (96 <div className="border-t border-[#1f2d3d] bg-[#0b1522] p-3 font-mono text-xs text-[#d5dde5] space-y-2">97 <div className="text-[#8aa0b5]">Sortie {out.exit_code === 0 ? '✓' : `(code ${out.exit_code})`} {out.duration_ms ? `· ${fmtDuration(out.duration_ms)}` : ''}</div>98 {out.stdout && <pre className="whitespace-pre-wrap">{out.stdout}</pre>}99 {out.stderr && <pre className="whitespace-pre-wrap text-[#ff9aa2]">{out.stderr}</pre>}100 {out.artifacts?.filter((a) => a.type === 'image').map((a) => (101 <img key={a.file_id} src={`/api/v1/files/${a.file_id}`} alt={a.filename} className="rounded-md max-h-[360px] bg-white" />102 ))}103 {out.artifacts?.filter((a) => a.type !== 'image').map((a) => (104 <Button key={a.file_id} size="sm" variant="secondary" onClick={() => window.open(`/api/v1/files/${a.file_id}?download=1`, '_blank')}>{a.filename}</Button>105 ))}106 </div>107 )}108 </div>109 );110}111