From a8616345e660edc651877749dd9c7542c79403ac Mon Sep 17 00:00:00 2001 From: ningzimu Date: Sat, 28 Mar 2026 04:17:25 +0800 Subject: [PATCH] feat: add Shiki syntax highlighting to file preview panel --- src/components/ai-elements/code-block.tsx | 2 +- src/components/layout/panels/PreviewPanel.tsx | 124 ++++++++++++------ 2 files changed, 86 insertions(+), 40 deletions(-) diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index a23fceee..3880cab1 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -444,7 +444,7 @@ export const CodeBlockActions = ({ ); /** Resolve Shiki theme pair from the current theme family. */ -function useShikiThemes(): { light: BundledTheme; dark: BundledTheme } { +export function useShikiThemes(): { light: BundledTheme; dark: BundledTheme } { const { family, families } = useThemeFamily(); const shikiTheme = resolveShikiTheme(families, family); return resolveShikiThemes(shikiTheme); diff --git a/src/components/layout/panels/PreviewPanel.tsx b/src/components/layout/panels/PreviewPanel.tsx index ec0e48f3..379c80e9 100644 --- a/src/components/layout/panels/PreviewPanel.tsx +++ b/src/components/layout/panels/PreviewPanel.tsx @@ -1,12 +1,11 @@ "use client"; import { useState, useEffect, useMemo, useCallback } from "react"; -import { useTheme } from "next-themes"; import { X, Copy, Check, SpinnerGap } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; -import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; -import { useThemeFamily } from "@/lib/theme/context"; -import { resolveCodeTheme, resolveHljsStyle } from "@/lib/theme/code-themes"; +import { highlightCode, useShikiThemes } from "@/components/ai-elements/code-block"; +import type { BundledLanguage } from "shiki"; +import { bundledLanguages } from "shiki"; import { Streamdown } from "streamdown"; import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; @@ -64,9 +63,7 @@ const PREVIEW_MAX_WIDTH = 800; const PREVIEW_DEFAULT_WIDTH = 480; export function PreviewPanel() { - const { resolvedTheme } = useTheme(); const { workingDirectory, sessionId, previewFile, setPreviewFile, previewViewMode, setPreviewViewMode, setPreviewOpen } = usePanel(); - const isDark = resolvedTheme === "dark"; const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -211,7 +208,7 @@ export function PreviewPanel() { previewViewMode === "rendered" && canRender ? ( ) : ( - + ) ) : null} @@ -258,41 +255,90 @@ function ViewModeToggle({ ); } -/** Resolve hljs style from the current theme family + mode. */ -function useDocCodeTheme(isDark: boolean) { - const { family, families } = useThemeFamily(); - const codeTheme = resolveCodeTheme(families, family); - return resolveHljsStyle(codeTheme, isDark); +/** Check if a language string is a known Shiki bundled language */ +const isBundledLanguage = (lang: string): lang is BundledLanguage => + lang in bundledLanguages || lang === "text" || lang === "plaintext"; + +/** Tokenized code result type */ +interface TokenizedCode { + tokens: import("shiki").ThemedToken[][]; + fg: string; + bg: string; } -/** Source code view using react-syntax-highlighter */ -function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boolean }) { - const hljsStyle = useDocCodeTheme(isDark); +/** Create raw (unhighlighted) tokens for immediate display */ +const createRawTokens = (code: string): TokenizedCode => ({ + bg: "transparent", + fg: "inherit", + tokens: code.split("\n").map((line) => + line === "" + ? [] + : [{ color: "inherit", content: line } as import("shiki").ThemedToken] + ), +}); + +/** Source code view using Shiki syntax highlighting */ +function SourceView({ preview }: { preview: FilePreviewType }) { + const { light: lightTheme, dark: darkTheme } = useShikiThemes(); + const language = isBundledLanguage(preview.language) + ? preview.language + : ("text" as BundledLanguage); + + const rawTokens = useMemo(() => createRawTokens(preview.content), [preview.content]); + + const syncTokenized = useMemo( + () => highlightCode(preview.content, language, undefined, lightTheme, darkTheme) ?? rawTokens, + [preview.content, language, rawTokens, lightTheme, darkTheme] + ); + + const [asyncResult, setAsyncResult] = useState<{ key: string; tokens: TokenizedCode } | null>(null); + const resultKey = `${preview.content}:${language}:${lightTheme}:${darkTheme}`; + + useEffect(() => { + let cancelled = false; + highlightCode(preview.content, language, (result) => { + if (!cancelled) { + setAsyncResult({ key: `${preview.content}:${language}:${lightTheme}:${darkTheme}`, tokens: result }); + } + }, lightTheme, darkTheme); + return () => { cancelled = true; }; + }, [preview.content, language, lightTheme, darkTheme]); + + const tokenized = (asyncResult && asyncResult.key === resultKey) ? asyncResult.tokens : syncTokenized; + return ( -
- - {preview.content} - -
+
+      
+        {tokenized.tokens.map((line, lineIdx) => (
+          
+            {line.length === 0
+              ? "\n"
+              : line.map((token, tokenIdx) => (
+                  
+                    {token.content}
+                  
+                ))}
+          
+        ))}
+      
+    
); }