Skip to content
Source
Sign inSign up
Open History to see the last commit

MarkdownCodeBlock.tsx

370 lines10.8 KB

1import { PlayIcon, WrapTextIcon, CheckIcon, CopyIcon } from "lucide-react";
2import { toHtml } from "hast-util-to-html";
3import React, { use, useEffect, useState, type ReactNode } from "react";
4​
5import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
6import { getClientSettings } from "../../hooks/useSettings";
7import type { DiffThemeName } from "../../lib/diffRendering";
8import {
9 createIncrementalHighlightedDocument,
10 highlightCodeToHast,
11} from "../../lib/incrementalHighlighting";
12import { LRUCache } from "../../lib/lruCache";
13import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting";
14import { cn } from "../../lib/utils";
15import {
16 hasSpecificPierreIconForFileName,
17 syntheticFileNameForLanguageId,
18} from "../../pierre-icons";
19import { Button } from "../ui/button";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
21import { HighlightedCodeLines } from "./HighlightedCodeLines";
22import {
23 createHighlightCacheKey,
24 estimateHighlightedSize,
25 extractFenceLanguage,
26} from "./MarkdownCodeBlock.logic";
27import { reportMarkdownActionFailure } from "./markdownActionFailure";
28import { PierreEntryIcon } from "./PierreEntryIcon";
29​
30const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
31const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
32​
33const highlightedCodeCache = new LRUCache<string>(
34 MAX_HIGHLIGHT_CACHE_ENTRIES,
35 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
36);
37​
38const THREAD_FIND_IGNORE_PROPS = {
39 "data-thread-find-ignore": "",
40} as React.HTMLAttributes<HTMLDivElement>;
41​
42export function MarkdownCodeBlockFrame({
43 as: Wrapper = "div",
44 language,
45 fenceTitle,
46 theme,
47 wrapped = true,
48 title,
49 actions,
50 headerProps,
51 children,
52}: {
53 as?: React.ElementType;
54 language: string;
55 fenceTitle: string | null;
56 theme: "light" | "dark";
57 wrapped?: boolean;
58 title?: React.ReactNode;
59 actions?: React.ReactNode;
60 headerProps?: React.HTMLAttributes<HTMLDivElement>;
61 children: React.ReactNode;
62}) {
63 return (
64 <Wrapper
65 dir="ltr"
66 className="chat-markdown-codeblock my-[0.65rem] text-start overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"
67 data-language={language}
68 data-wrap={wrapped ? "true" : "false"}
69 >
70 <div
71 {...headerProps}
72 className={cn(
73 "chat-markdown-codeblock-header flex items-center justify-between gap-2 pt-1.5 pr-1.5 pb-0 pl-3 select-none",
74 headerProps?.className,
75 )}
76 >
77 {title ?? (
78 <span className="inline-flex min-w-0 items-center gap-1.5 font-mono text-2xs">
79 <MarkdownCodeBlockTitleContent
80 fenceTitle={fenceTitle}
81 language={language}
82 theme={theme}
83 />
84 </span>
85 )}
86 {actions}
87 </div>
88 {children}
89 </Wrapper>
90 );
92​
93function MarkdownCodeBlockTitleContent({
94 fenceTitle,
95 language,
96 theme,
97}: {
98 fenceTitle: string | null;
99 language: string;
100 theme: "light" | "dark";
101}) {
102 if (fenceTitle) {
103 return (
104 <>
105 <PierreEntryIcon pathValue={fenceTitle} kind="file" theme={theme} className="size-3.5" />
106 <span className="truncate">{fenceTitle}</span>
107 </>
108 );
109 }
110​
111 const fileName = syntheticFileNameForLanguageId(language);
112 if (!hasSpecificPierreIconForFileName(fileName)) {
113 return <span className="truncate">{language}</span>;
114 }
115 return (
116 <Tooltip>
117 <TooltipTrigger
118 render={
119 <span className="inline-flex shrink-0 rounded-sm" aria-label={`Language: ${language}`} />
120 }
121 >
122 <PierreEntryIcon pathValue={fileName} kind="file" theme={theme} className="size-3.5" />
123 </TooltipTrigger>
124 <TooltipPopup side="top">{language}</TooltipPopup>
125 </Tooltip>
126 );
128​
129export function MarkdownCodeBlock({
130 code,
131 language,
132 fenceTitle,
133 theme,
134 onRunShellCommand,
135 isStreaming,
136 viewToggle,
137 canWrap = true,
138 diagram = false,
139 children,
140}: {
141 code: string;
142 language: string;
143 fenceTitle: string | null;
144 theme: "light" | "dark";
145 onRunShellCommand?: ((command: string) => void) | undefined;
146 isStreaming: boolean;
147 viewToggle?: ReactNode;
148 canWrap?: boolean;
149 diagram?: boolean;
150 children: ReactNode;
151}) {
152 const { copyToClipboard, isCopied: copied } = useCopyToClipboard({
153 timeout: 1200,
154 onError: (cause) =>
155 reportMarkdownActionFailure(
156 {
157 operation: "copy-code-block",
158 language,
159 ...(fenceTitle ? { fenceTitle } : {}),
160 },
161 cause,
162 ),
163 });
164 const [wrapped, setWrapped] = useState(() => getClientSettings().wordWrap);
165 const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
166 const copyLabel = copied ? "Copied" : "Copy code";
167 const command = code.trim();
168 const canRun =
169 onRunShellCommand !== undefined &&
170 !isStreaming &&
171 /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) &&
172 code.endsWith("\n") &&
173 command.length > 0 &&
174 !command.endsWith("\\") &&
175 // Control and invisible format characters (bidi overrides, zero-width) can
176 // make the rendered command differ from what the terminal would receive.
177 !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1));
178​
179 const handleCopy = () => copyToClipboard(code);
180​
181 const copyButton = (
182 <Tooltip>
183 <TooltipTrigger
184 render={
185 <Button
186 type="button"
187 variant="ghost-muted"
188 size="icon-xs"
189 onClick={handleCopy}
190 aria-label={copyLabel}
191 />
192 }
193 >
194 {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
195 </TooltipTrigger>
196 <TooltipPopup side="top">{copyLabel}</TooltipPopup>
197 </Tooltip>
198 );
199​
200 if (diagram) {
201 return (
202 <div className="my-[0.65rem] border border-transparent" data-language={language}>
203 <div className="flex items-center justify-end gap-0.5 pt-1.5 pr-1.5 select-none">
204 {viewToggle}
205 {copyButton}
206 </div>
207 {children}
208 </div>
209 );
210 }
211​
212 return (
213 <MarkdownCodeBlockFrame
214 language={language}
215 fenceTitle={fenceTitle}
216 theme={theme}
217 wrapped={wrapped}
218 headerProps={THREAD_FIND_IGNORE_PROPS}
219 actions={
220 <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">
221 {canWrap ? (
222 <Tooltip>
223 <TooltipTrigger
224 render={
225 <Button
226 type="button"
227 variant={wrapped ? "secondary" : "ghost-muted"}
228 size="icon-xs"
229 aria-pressed={wrapped}
230 onClick={() => setWrapped((value) => !value)}
231 aria-label={wrapLabel}
232 />
233 }
234 >
235 <WrapTextIcon className="size-3" />
236 </TooltipTrigger>
237 <TooltipPopup side="top">{wrapLabel}</TooltipPopup>
238 </Tooltip>
239 ) : null}
240 {canRun ? (
241 <Tooltip>
242 <TooltipTrigger
243 render={
244 <Button
245 type="button"
246 variant="ghost-muted"
247 size="icon-xs"
248 onClick={() => onRunShellCommand(command)}
249 aria-label="Run in terminal"
250 />
251 }
252 >
253 <PlayIcon className="size-3" />
254 </TooltipTrigger>
255 <TooltipPopup side="top">Run in terminal</TooltipPopup>
256 </Tooltip>
257 ) : null}
258 {viewToggle}
259 {copyButton}
260 </span>
261 }
262 >
263 {children}
264 </MarkdownCodeBlockFrame>
265 );
267​
268interface SuspenseShikiCodeBlockProps {
269 className: string | undefined;
270 code: string;
271 themeName: DiffThemeName;
272 isStreaming: boolean;
274​
275export function SuspenseShikiCodeBlock({
276 className,
277 code,
278 themeName,
279 isStreaming,
280}: SuspenseShikiCodeBlockProps) {
281 const [hasStreamed, setHasStreamed] = useState(isStreaming);
282 if (isStreaming && !hasStreamed) setHasStreamed(true);
283 const language = extractFenceLanguage(className);
284 const cacheKey = createHighlightCacheKey(code, language, themeName);
285 // Once lines are mounted individually, keep that renderer when streaming
286 // finishes so switching to cached HTML cannot clear an existing selection.
287 const cachedHighlightedHtml =
288 !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null;
289​
290 if (cachedHighlightedHtml != null) {
291 return (
292 <div
293 className="chat-markdown-shiki"
294 dangerouslySetInnerHTML={{ __html: cachedHighlightedHtml }}
295 />
296 );
297 }
298​
299 return (
300 <UncachedShikiCodeBlock
301 code={code}
302 language={language}
303 themeName={themeName}
304 cacheKey={cacheKey}
305 isStreaming={isStreaming}
306 preserveLines={isStreaming || hasStreamed}
307 />
308 );
310​
311interface UncachedShikiCodeBlockProps {
312 code: string;
313 language: string;
314 themeName: DiffThemeName;
315 cacheKey: string;
316 isStreaming: boolean;
317 preserveLines: boolean;
319​
320function UncachedShikiCodeBlock({
321 code,
322 language,
323 themeName,
324 cacheKey,
325 isStreaming,
326 preserveLines,
327}: UncachedShikiCodeBlockProps) {
328 const highlighter = use(getSyntaxHighlighterPromise(language));
329 const incrementalHighlight = preserveLines
330 ? createIncrementalHighlightedDocument(highlighter, language, themeName)
331 : null;
332 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- disposes the incremental Shiki highlighter when it is replaced or the block unmounts
333 useEffect(() => () => incrementalHighlight?.dispose(), [incrementalHighlight]);
334 const highlighted = (() => {
335 const result = (() => {
336 try {
337 if (incrementalHighlight) return incrementalHighlight(code);
338 return preserveLines
339 ? highlightCodeToHast(highlighter, code, language, themeName)
340 : highlighter.codeToHtml(code, { lang: language, theme: themeName });
341 } catch (error) {
342 console.warn(
343 `Code highlighting failed for language "${language}", falling back to plain text.`,
344 error instanceof Error ? error.message : error,
345 );
346 return preserveLines
347 ? highlightCodeToHast(highlighter, code, "text", themeName)
348 : highlighter.codeToHtml(code, { lang: "text", theme: themeName });
349 }
350 })();
351 // A finished block is cached for the next mount; a streaming one is still changing.
352 if (!isStreaming) {
353 const highlightedHtml = typeof result === "string" ? result : toHtml(result);
354 highlightedCodeCache.set(
355 cacheKey,
356 highlightedHtml,
357 estimateHighlightedSize(highlightedHtml, cacheKey),
358 );
359 }
360 return result;
361 })();
362​
363 return typeof highlighted === "string" ? (
364 <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlighted }} />
365 ) : (
366 <div className="chat-markdown-shiki">
367 <HighlightedCodeLines root={highlighted} />
368 </div>
369 );