/** * @file CodeBlock.tsx * @description Reusable, syntax-highlighted code block with a chrome bar (language pill, * optional filename, copy-to-clipboard, line count) and optional gutter line numbers. * Used by MarkdownContent for fenced code blocks and by ToolCallBlock for tool I/O. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../../lib/highlight` * * ## Public surface * - `CodeBlock` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **CodeBlock** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useMemo, useState } from "react"; import { Check, Copy, FileCode } from "lucide-react"; import { canonicalLang, highlight, tokenClass, type Token } from "../../lib/highlight"; interface CodeBlockProps { code: string; lang?: string; /** Optional filename to display in the chrome bar. */ filename?: string; /** Render compact (no chrome bar). */ compact?: boolean; /** Override for the right-side label (e.g. "Output", "Error"). */ label?: string; /** Tone - "default" matches the surface, "danger" tints red, "success" tints emerald. */ tone?: "default" | "danger" | "success"; /** Cap the rendered height; pass null to disable. Default 24rem. */ maxHeight?: string | null; /** Show a left gutter with line numbers. Default true for >= 4 lines. */ showLineNumbers?: boolean; } const LANG_DISPLAY: Record = { js: "JavaScript", ts: "TypeScript", python: "Python", json: "JSON", bash: "Shell", html: "HTML", css: "CSS", sql: "SQL", yaml: "YAML", diff: "Diff", plain: "Text", }; function langDisplay(lang: string): string { const canon = canonicalLang(lang); return LANG_DISPLAY[canon] ?? (lang || "Text"); } /** * Split tokens that span multiple lines so we can render one line at a time * (necessary for the gutter line-number column to align). */ function splitTokensByLine(tokens: Token[]): Token[][] { const lines: Token[][] = [[]]; for (const t of tokens) { const parts = t.text.split("\n"); for (let i = 0; i < parts.length; i++) { if (i > 0) lines.push([]); const piece = parts[i]!; if (piece.length > 0) { lines[lines.length - 1]!.push({ type: t.type, text: piece }); } } } return lines; } export function CodeBlock({ code, lang = "", filename, compact = false, label, tone = "default", maxHeight = "24rem", showLineNumbers, }: CodeBlockProps) { const [copied, setCopied] = useState(false); const tokens = useMemo(() => highlight(code, lang), [code, lang]); const lineTokens = useMemo(() => splitTokensByLine(tokens), [tokens]); const totalLines = lineTokens.length; const gutter = showLineNumbers ?? totalLines >= 4; const handleCopy = async () => { try { await navigator.clipboard.writeText(code); setCopied(true); window.setTimeout(() => setCopied(false), 1500); } catch { // Clipboard may be unavailable in some contexts - fail silently. } }; const palette = tone === "danger" ? { wrapper: "border-status-danger/30 bg-status-danger/5", chrome: "bg-status-danger/10 border-b border-status-danger/20", label: "text-status-danger", } : tone === "success" ? { wrapper: "border-status-success/30 bg-status-success/5", chrome: "bg-status-success/10 border-b border-status-success/20", label: "text-status-success", } : { wrapper: "border-surface-3 bg-surface-4/50", chrome: "bg-surface-3/70 border-b border-surface-3", label: "text-fg-secondary", }; const preStyle: React.CSSProperties = {}; if (maxHeight) preStyle.maxHeight = maxHeight; return (
{!compact && (
{/* Language pill */} {filename ? : null} {filename ?? label ?? langDisplay(lang)} {/* Filename + lang together when both are set */} {filename && !label && ( {langDisplay(lang)} )} {filename && label && ( · {label} )} {/* Right side: line count + copy */}
{totalLines > 1 && ( {totalLines} {totalLines === 1 ? "line" : "lines"} )}
)}
          
            {gutter ? (
              
                  {lineTokens.map((line, i) => (
                    
                  ))}
                
{i + 1} {line.length === 0 ? (   ) : ( line.map((t, j) => ( {t.text} )) )}
) : (
{tokens.map((t, i) => ( {t.text} ))}
)}
); }