Spaces:
Running
Running
| import { useEffect, useMemo, useRef } from "react"; | |
| import { Badge } from "@/components/ui/badge"; | |
| export type CodeFileName = "index.html" | "styles.css" | "script.js"; | |
| interface CodeStreamPanelProps { | |
| streamedHtml: string; | |
| activeFile: CodeFileName; | |
| onActiveFileChange: (next: CodeFileName) => void; | |
| generationLoading: boolean; | |
| generationStatus?: string | null; | |
| generationLogs: string[]; | |
| generationError?: string | null; | |
| } | |
| const FILE_ORDER: CodeFileName[] = ["index.html", "styles.css", "script.js"]; | |
| type HighlightTokenType = | |
| | "plain" | |
| | "comment" | |
| | "keyword" | |
| | "string" | |
| | "number" | |
| | "property" | |
| | "tag"; | |
| interface HighlightSegment { | |
| text: string; | |
| type: HighlightTokenType; | |
| } | |
| const TOKEN_CLASS: Record<HighlightTokenType, string> = { | |
| plain: "text-[#c9d1d9]", | |
| comment: "text-[#8b949e]", | |
| keyword: "text-[#ff7b72]", | |
| string: "text-[#a5d6ff]", | |
| number: "text-[#79c0ff]", | |
| property: "text-[#d2a8ff]", | |
| tag: "text-[#7ee787]", | |
| }; | |
| function tokenizeCode( | |
| code: string, | |
| pattern: RegExp, | |
| classify: (token: string) => HighlightSegment[], | |
| ): HighlightSegment[] { | |
| const segments: HighlightSegment[] = []; | |
| let lastIndex = 0; | |
| for (const match of code.matchAll(pattern)) { | |
| const matchIndex = match.index ?? 0; | |
| const token = match[0] ?? ""; | |
| if (matchIndex > lastIndex) { | |
| segments.push({ | |
| text: code.slice(lastIndex, matchIndex), | |
| type: "plain", | |
| }); | |
| } | |
| segments.push(...classify(token)); | |
| lastIndex = matchIndex + token.length; | |
| } | |
| if (lastIndex < code.length) { | |
| segments.push({ | |
| text: code.slice(lastIndex), | |
| type: "plain", | |
| }); | |
| } | |
| return segments; | |
| } | |
| function highlightHtmlTag(tagToken: string): HighlightSegment[] { | |
| const segments: HighlightSegment[] = []; | |
| let lastIndex = 0; | |
| for (const match of tagToken.matchAll(/"[^"]*"|'[^']*'/g)) { | |
| const matchIndex = match.index ?? 0; | |
| const token = match[0] ?? ""; | |
| if (matchIndex > lastIndex) { | |
| segments.push({ | |
| text: tagToken.slice(lastIndex, matchIndex), | |
| type: "tag", | |
| }); | |
| } | |
| segments.push({ | |
| text: token, | |
| type: "string", | |
| }); | |
| lastIndex = matchIndex + token.length; | |
| } | |
| if (lastIndex < tagToken.length) { | |
| segments.push({ | |
| text: tagToken.slice(lastIndex), | |
| type: "tag", | |
| }); | |
| } | |
| return segments; | |
| } | |
| function highlightHtml(code: string): HighlightSegment[] { | |
| return tokenizeCode( | |
| code, | |
| /<!--[\s\S]*?-->|<\/?[a-zA-Z!][^>]*>/g, | |
| (token): HighlightSegment[] => { | |
| if (token.startsWith("<!--")) { | |
| return [{ text: token, type: "comment" }]; | |
| } | |
| return highlightHtmlTag(token); | |
| }, | |
| ); | |
| } | |
| function highlightCss(code: string): HighlightSegment[] { | |
| return tokenizeCode( | |
| code, | |
| /\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[\da-fA-F]{3,8}\b|\b(?:@media|@keyframes|@supports|from|to)\b|[a-zA-Z-]+(?=\s*:)|\b\d+(?:\.\d+)?(?:px|rem|em|vh|vw|%|s|ms)?\b/g, | |
| (token): HighlightSegment[] => { | |
| if (token.startsWith("/*")) { | |
| return [{ text: token, type: "comment" }]; | |
| } | |
| if (token.startsWith('"') || token.startsWith("'")) { | |
| return [{ text: token, type: "string" }]; | |
| } | |
| if (token.startsWith("@")) { | |
| return [{ text: token, type: "keyword" }]; | |
| } | |
| if (/^[a-zA-Z-]+$/.test(token)) { | |
| return [{ text: token, type: "property" }]; | |
| } | |
| if (/^#/.test(token) || /^\d/.test(token)) { | |
| return [{ text: token, type: "number" }]; | |
| } | |
| return [{ text: token, type: "plain" }]; | |
| }, | |
| ); | |
| } | |
| function highlightJavaScript(code: string): HighlightSegment[] { | |
| const jsKeywordPattern = | |
| /^(?:const|let|var|function|return|if|else|for|while|switch|case|break|continue|class|new|try|catch|finally|throw|await|async|import|from|export|default|typeof|instanceof)$/; | |
| return tokenizeCode( | |
| code, | |
| /\/\/.*|\/\*[\s\S]*?\*\/|`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:const|let|var|function|return|if|else|for|while|switch|case|break|continue|class|new|try|catch|finally|throw|await|async|import|from|export|default|typeof|instanceof)\b|\b\d+(?:\.\d+)?\b/g, | |
| (token): HighlightSegment[] => { | |
| if (token.startsWith("//") || token.startsWith("/*")) { | |
| return [{ text: token, type: "comment" }]; | |
| } | |
| if (token.startsWith('"') || token.startsWith("'") || token.startsWith("`")) { | |
| return [{ text: token, type: "string" }]; | |
| } | |
| if (jsKeywordPattern.test(token)) { | |
| return [{ text: token, type: "keyword" }]; | |
| } | |
| if (/^\d/.test(token)) { | |
| return [{ text: token, type: "number" }]; | |
| } | |
| return [{ text: token, type: "plain" }]; | |
| }, | |
| ); | |
| } | |
| function highlightCode(file: CodeFileName, code: string): HighlightSegment[] { | |
| if (file === "styles.css") { | |
| return highlightCss(code); | |
| } | |
| if (file === "script.js") { | |
| return highlightJavaScript(code); | |
| } | |
| return highlightHtml(code); | |
| } | |
| function splitSegmentsByLine(segments: HighlightSegment[]): HighlightSegment[][] { | |
| const lines: HighlightSegment[][] = [[]]; | |
| for (const segment of segments) { | |
| const rawText = segment.text; | |
| if (!rawText) { | |
| continue; | |
| } | |
| const parts = rawText.split("\n"); | |
| parts.forEach((part, index) => { | |
| if (part.length > 0) { | |
| lines[lines.length - 1]?.push({ | |
| text: part, | |
| type: segment.type, | |
| }); | |
| } | |
| if (index < parts.length - 1) { | |
| lines.push([]); | |
| } | |
| }); | |
| } | |
| return lines; | |
| } | |
| function extractStyleCode(html: string): string { | |
| const blocks = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)]; | |
| return blocks.map((block) => block[1]?.trim() ?? "").filter(Boolean).join("\n\n"); | |
| } | |
| function extractInlineScriptCode(html: string): string { | |
| const blocks = [ | |
| ...html.matchAll(/<script\b(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi), | |
| ]; | |
| return blocks.map((block) => block[1]?.trim() ?? "").filter(Boolean).join("\n\n"); | |
| } | |
| function getDisplayCode(file: CodeFileName, html: string): string { | |
| if (file === "styles.css") { | |
| return extractStyleCode(html); | |
| } | |
| if (file === "script.js") { | |
| return extractInlineScriptCode(html); | |
| } | |
| return html; | |
| } | |
| function getEmptyState(file: CodeFileName): string { | |
| if (file === "styles.css") { | |
| return "No closed <style> blocks streamed yet."; | |
| } | |
| if (file === "script.js") { | |
| return "No closed inline <script> blocks streamed yet."; | |
| } | |
| return "Streaming output will appear here."; | |
| } | |
| export function CodeStreamPanel({ | |
| streamedHtml, | |
| activeFile, | |
| onActiveFileChange, | |
| generationLoading, | |
| generationStatus, | |
| generationLogs, | |
| generationError, | |
| }: CodeStreamPanelProps) { | |
| const scrollContainerRef = useRef<HTMLDivElement | null>(null); | |
| const displayCode = getDisplayCode(activeFile, streamedHtml).trim(); | |
| const showPlaceholder = displayCode.length === 0; | |
| const highlightedCode = useMemo( | |
| () => highlightCode(activeFile, displayCode), | |
| [activeFile, displayCode], | |
| ); | |
| const highlightedLines = useMemo( | |
| () => splitSegmentsByLine(highlightedCode), | |
| [highlightedCode], | |
| ); | |
| const lineNumberWidth = useMemo( | |
| () => Math.max(2, String(highlightedLines.length).length), | |
| [highlightedLines.length], | |
| ); | |
| useEffect(() => { | |
| if (!generationLoading || showPlaceholder) { | |
| return; | |
| } | |
| const container = scrollContainerRef.current; | |
| if (!container) { | |
| return; | |
| } | |
| container.scrollTop = container.scrollHeight; | |
| }, [displayCode, generationLoading, showPlaceholder]); | |
| return ( | |
| <div className="flex h-full min-h-[62vh] flex-col lg:min-h-0"> | |
| <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-sidebar px-4 py-2"> | |
| <div className="inline-flex rounded-lg bg-muted p-0.5"> | |
| {FILE_ORDER.map((file) => { | |
| const isActive = activeFile === file; | |
| return ( | |
| <button | |
| key={file} | |
| type="button" | |
| onClick={() => onActiveFileChange(file)} | |
| className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${ | |
| isActive | |
| ? "bg-background text-foreground shadow-sm" | |
| : "text-muted-foreground hover:text-foreground" | |
| }`} | |
| > | |
| {file} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {generationLoading ? <Badge variant="default">streaming</Badge> : null} | |
| {generationError ? <Badge variant="destructive">error</Badge> : null} | |
| {generationStatus ? ( | |
| <p className="text-xs text-muted-foreground">{generationStatus}</p> | |
| ) : null} | |
| </div> | |
| </div> | |
| <div | |
| ref={scrollContainerRef} | |
| data-testid="code-stream-scroll" | |
| className="min-h-0 flex-1 overflow-auto bg-[#0d1117] dark:bg-[#0d1117]" | |
| > | |
| <div className="min-h-full px-4 py-4 font-mono text-xs leading-6 text-[#c9d1d9]"> | |
| {showPlaceholder ? ( | |
| <span className="text-[#484f58]">{getEmptyState(activeFile)}</span> | |
| ) : ( | |
| <div className="min-w-full whitespace-pre-wrap"> | |
| {highlightedLines.map((lineSegments, lineIndex) => ( | |
| <div | |
| key={`line-${lineIndex + 1}`} | |
| className="grid grid-cols-[auto_1fr] gap-4" | |
| > | |
| <span | |
| data-testid="code-line-number" | |
| className="select-none text-right text-[#6e7681]" | |
| style={{ minWidth: `${lineNumberWidth}ch` }} | |
| > | |
| {lineIndex + 1} | |
| </span> | |
| <span className="whitespace-pre-wrap"> | |
| {lineSegments.length === 0 ? "\u00A0" : null} | |
| {lineSegments.map((segment, segmentIndex) => ( | |
| <span | |
| key={`${lineIndex + 1}-${segment.type}-${segmentIndex}`} | |
| className={TOKEN_CLASS[segment.type]} | |
| > | |
| {segment.text} | |
| </span> | |
| ))} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="border-t border-border bg-sidebar px-4 py-2.5"> | |
| <p className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"> | |
| Activity | |
| </p> | |
| {generationLogs.length === 0 ? ( | |
| <p className="text-xs text-muted-foreground/50">No events yet.</p> | |
| ) : ( | |
| <ul className="space-y-0.5 font-mono text-[11px] text-muted-foreground"> | |
| {generationLogs.map((entry, index) => ( | |
| <li key={`${entry}-${index}`}>{entry}</li> | |
| ))} | |
| </ul> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |