Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 14 additions & 88 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";
import { CheckIcon, CopyIcon } from "lucide-react";
import React, {
Children,
Expand All @@ -16,60 +15,29 @@ import React, {
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeHighlightErrorBoundary } from "./CodeHighlightErrorBoundary";
import { useAppSettings } from "../appSettings";
import { openFileReference } from "../fileOpen";
import { useFileViewNavigation } from "~/hooks/useFileViewNavigation";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
import {
extractFenceLanguage,
getCachedHighlightedHtml,
getHighlighterPromise,
renderHighlightedCodeHtml,
setCachedHighlightedHtml,
} from "../lib/syntaxHighlighting";
import { useTheme } from "../hooks/useTheme";
import { resolveMarkdownFileLinkTarget } from "../markdown-links";
import { readNativeApi } from "../nativeApi";
import { toastManager } from "./ui/toast";

class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
{ hasError: boolean }
> {
constructor(props: { fallback: ReactNode; children: ReactNode }) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError() {
return { hasError: true };
}

override render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

interface ChatMarkdownProps {
text: string;
cwd: string | undefined;
isStreaming?: boolean;
}

const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
const highlightedCodeCache = new LRUCache<string>(
MAX_HIGHLIGHT_CACHE_ENTRIES,
MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
);
const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();

function extractFenceLanguage(className: string | undefined): string {
const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
const raw = match?.[1] ?? "text";
// Shiki doesn't bundle a gitignore grammar; ini is a close match (#685)
return raw === "gitignore" ? "ini" : raw;
}

function nodeToPlainText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
Expand Down Expand Up @@ -105,35 +73,6 @@ function extractCodeBlock(
};
}

function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string {
return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`;
}

function estimateHighlightedSize(html: string, code: string): number {
return Math.max(html.length * 2, code.length * 3);
}

function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
const cached = highlighterPromiseCache.get(language);
if (cached) return cached;

const promise = getSharedHighlighter({
themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],
langs: [language as SupportedLanguages],
preferredHighlighter: "shiki-js",
}).catch((err) => {
highlighterPromiseCache.delete(language);
if (language === "text") {
// "text" itself failed — Shiki cannot initialize at all, surface the error
throw err;
}
// Language not supported by Shiki — fall back to "text"
return getHighlighterPromise("text");
});
highlighterPromiseCache.set(language, promise);
return promise;
}

function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) {
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -196,8 +135,9 @@ function SuspenseShikiCodeBlock({
isStreaming,
}: SuspenseShikiCodeBlockProps) {
const language = extractFenceLanguage(className);
const cacheKey = createHighlightCacheKey(code, language, themeName);
const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null;
const cachedHighlightedHtml = !isStreaming
? getCachedHighlightedHtml(code, language, themeName, "chat-markdown")
: null;

if (cachedHighlightedHtml != null) {
return (
Expand All @@ -210,28 +150,14 @@ function SuspenseShikiCodeBlock({

const highlighter = use(getHighlighterPromise(language));
const highlightedHtml = useMemo(() => {
try {
return highlighter.codeToHtml(code, { lang: language, theme: themeName });
} catch (error) {
// Log highlighting failures for debugging while falling back to plain text
console.warn(
`Code highlighting failed for language "${language}", falling back to plain text.`,
error instanceof Error ? error.message : error,
);
// If highlighting fails for this language, render as plain text
return highlighter.codeToHtml(code, { lang: "text", theme: themeName });
}
return renderHighlightedCodeHtml(highlighter, code, language, themeName);
}, [code, highlighter, language, themeName]);

useEffect(() => {
if (!isStreaming) {
highlightedCodeCache.set(
cacheKey,
highlightedHtml,
estimateHighlightedSize(highlightedHtml, code),
);
setCachedHighlightedHtml(code, language, themeName, "chat-markdown", highlightedHtml);
}
}, [cacheKey, code, highlightedHtml, isStreaming]);
}, [code, highlightedHtml, isStreaming, language, themeName]);

return (
<div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/CodeHighlightErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { type ReactNode } from "react";

export class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
{ hasError: boolean }
> {
constructor(props: { fallback: ReactNode; children: ReactNode }) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError() {
return { hasError: true };
}

override render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
23 changes: 10 additions & 13 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import { resolvePathLinkTarget } from "../terminal-links";
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
import { Button } from "./ui/button";
import { Toggle, ToggleGroup } from "./ui/toggle-group";
import {
buildFileDiffRenderKey,
resolveFileDiffPath,
withInferredFileDiffLanguage,
} from "./pr-review/pr-review-utils";

type DiffRenderMode = "stacked" | "split";
type DiffThemeType = "light" | "dark";
Expand Down Expand Up @@ -127,18 +132,6 @@ function getRenderablePatch(
}
}

function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
const raw = fileDiff.name ?? fileDiff.prevName ?? "";
if (raw.startsWith("a/") || raw.startsWith("b/")) {
return raw.slice(2);
}
return raw;
}

function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
}

type FileDiffCategory = "all" | "added" | "modified" | "deleted" | "renamed";

const CATEGORY_ORDER: FileDiffCategory[] = ["all", "added", "modified", "deleted", "renamed"];
Expand Down Expand Up @@ -301,6 +294,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
if (selectedCategory === "all") return renderableFiles;
return renderableFiles.filter((fileDiff) => categorizeFileDiff(fileDiff) === selectedCategory);
}, [renderableFiles, selectedCategory]);
const renderedFiles = useMemo(
() => filteredFiles.map(withInferredFileDiffLanguage),
[filteredFiles],
);

useEffect(() => {
if (diffOpen && !previousDiffOpenRef.current) {
Expand Down Expand Up @@ -540,7 +537,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
intersectionObserverMargin: 1200,
}}
>
{filteredFiles.map((fileDiff) => {
{renderedFiles.map((fileDiff) => {
const filePath = resolveFileDiffPath(fileDiff);
const fileKey = buildFileDiffRenderKey(fileDiff);
const themedFileKey = `${fileKey}:${resolvedTheme}`;
Expand Down
Loading
Loading