Files
triggerdotdev--trigger.dev/apps/webapp/app/components/code/CodeBlock.tsx
T
Chris Arderne 7ea02716fc fix(webapp): avoid mutating render inputs (#4716)
## Summary

Keeps render inputs and shared regular expressions immutable. Grouped
selects now compute each section's shortcut offset directly from
preceding sections, which also makes numeric shortcuts follow the
displayed item order reliably.
2026-08-20 09:59:39 +01:00

603 lines
18 KiB
TypeScript

import { ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
import { Clipboard, ClipboardCheck } from "lucide-react";
import type { Language, PrismTheme } from "prism-react-renderer";
import { Highlight, Prism } from "prism-react-renderer";
import type { ReactNode } from "react";
import { forwardRef, useCallback, useEffect, useState } from "react";
import { TextWrapIcon } from "~/assets/icons/TextWrapIcon";
import { cn } from "~/utils/cn";
import { highlightSearchText } from "~/utils/logUtils";
import { Button } from "../primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { TextInlineIcon } from "~/assets/icons/TextInlineIcon";
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
async function setup() {
(typeof global !== "undefined" ? global : window).Prism = Prism;
//@ts-ignore
await import("prismjs/components/prism-json");
//@ts-ignore
await import("prismjs/components/prism-typescript");
//@ts-ignore
await import("prismjs/components/prism-sql.js");
}
setup();
type CodeBlockProps = {
/** Code which will be highlighted */
code: string;
/** Programming language that should be highlighted */
language?: Language;
/** Show copy to clipboard button */
showCopyButton?: boolean;
/** Show text wrapping button */
showTextWrapping?: boolean;
/** Display line numbers */
showLineNumbers?: boolean;
/** Highlight line at given line number with color from theme.colors */
highlightedRanges?: [number, number][];
/** Add/override classes on the overall element */
className?: string;
/** Add/override code theme */
theme?: PrismTheme;
/** Max lines */
maxLines?: number;
/** Whether to show the chrome, if you provide a string it will be used as the title, */
showChrome?: boolean;
/** filename */
fileName?: string;
/** title text for the Title row */
rowTitle?: ReactNode;
/** Whether to show the open in modal button */
showOpenInModal?: boolean;
/** Search term to highlight in the code */
searchTerm?: string;
/** Whether to wrap the code */
wrap?: boolean;
};
const dimAmount = 0.5;
const extraLinesWhenClipping = 0.35;
const defaultTheme: PrismTheme = {
plain: {
color: "var(--color-code-constant)",
backgroundColor: "rgba(0, 0, 0, 0)",
},
styles: [
{
types: ["comment", "prolog", "doctype", "cdata"],
style: {
color: "var(--color-code-muted)",
},
},
{
types: ["punctuation"],
style: {
color: "var(--color-code-foreground)",
},
},
{
types: ["property", "tag", "constant", "symbol", "deleted"],
style: {
color: "var(--color-code-language)",
},
},
{
types: ["boolean", "number"],
style: {
color: "var(--color-code-builtin)",
},
},
{
types: ["selector", "attr-name", "string", "char", "builtin", "inserted"],
style: {
color: "var(--color-code-string)",
},
},
{
types: ["operator", "entity", "url"],
style: {
color: "var(--color-code-plain)",
},
},
{
types: ["variable"],
style: {
color: "var(--color-code-variable)",
},
},
{
types: ["atrule", "attr-value", "keyword"],
style: {
color: "var(--color-code-keyword)",
},
},
{
types: ["function", "class-name"],
style: {
color: "var(--color-code-function)",
},
},
{
types: ["regex"],
style: {
color: "var(--color-code-regexp)",
},
},
{
types: ["important", "bold"],
style: {
fontWeight: "bold",
},
},
{
types: ["italic"],
style: {
fontStyle: "italic",
},
},
{
types: ["namespace"],
style: {
opacity: 0.7,
},
},
{
types: ["deleted"],
style: {
color: "var(--color-code-deleted)",
},
},
{
types: ["char"],
style: {
color: "var(--color-code-number)",
},
},
{
types: ["tag"],
style: {
color: "var(--color-code-escape)",
},
},
{
types: ["keyword.operator"],
style: {
color: "var(--color-code-storage)",
},
},
{
types: ["meta.template.expression"],
style: {
color: "var(--color-code-plain)",
},
},
],
};
export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
(
{
showCopyButton = true,
showTextWrapping = false,
showLineNumbers = true,
showOpenInModal = true,
highlightedRanges,
code,
className,
language = "typescript",
theme = defaultTheme,
maxLines,
showChrome = false,
fileName,
rowTitle,
searchTerm,
wrap = false,
...props
}: CodeBlockProps,
ref
) => {
const [mouseOver, setMouseOver] = useState(false);
const [copied, setCopied] = useState(false);
const [modalCopied, setModalCopied] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isWrapped, setIsWrapped] = useState(wrap);
const normalizedCode = code?.trim() ?? "";
const onCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(normalizedCode);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[normalizedCode]
);
const onModalCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(normalizedCode);
setModalCopied(true);
setTimeout(() => {
setModalCopied(false);
}, 1500);
},
[normalizedCode]
);
const lineCount = normalizedCode.split("\n").length;
const maxLineWidth = lineCount.toString().length;
let maxHeight: string | undefined = undefined;
if (maxLines && lineCount > maxLines) {
maxHeight = `calc(${(maxLines + extraLinesWhenClipping) * 0.75 * 1.625}rem + 1.5rem )`;
}
const highlightLines = highlightedRanges?.flatMap(([start, end]) =>
Array.from({ length: end - start + 1 }, (_, i) => start + i)
);
// if there are more than 1000 lines, don't highlight
const shouldHighlight = lineCount <= 1000;
return (
<>
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-md border border-grid-bright",
className
)}
style={{
backgroundColor: theme.plain.backgroundColor,
}}
ref={ref}
{...props}
translate="no"
>
{showChrome && <Chrome title={fileName} />}
{rowTitle && <TitleRow title={rowTitle} />}
<div
className={cn(
"absolute right-3 top-2.5 z-50 flex gap-3",
showChrome ? "right-1.5 top-1.5" : "top-2.5"
)}
>
{showTextWrapping && (
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger
onClick={() => setIsWrapped(!isWrapped)}
className="transition-colors focus-custom hover:cursor-pointer hover:text-text-bright"
>
{isWrapped ? (
<TextInlineIcon className="size-4" />
) : (
<TextWrapIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{isWrapped ? "Unwrap" : "Wrap"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{showCopyButton && (
<TooltipProvider>
<Tooltip open={copied || mouseOver} disableHoverableContent>
<TooltipTrigger
onClick={onCopied}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheck className="size-4" />
) : (
<Clipboard className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{copied ? "Copied" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{showOpenInModal && (
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger onClick={() => setIsModalOpen(true)}>
<ArrowsPointingOutIcon className="size-4 transition-colors hover:text-text-bright" />
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Expand
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{shouldHighlight ? (
<HighlightCode
theme={theme}
code={normalizedCode}
language={language}
showLineNumbers={showLineNumbers}
highlightLines={highlightLines}
maxLineWidth={maxLineWidth}
className="px-2 py-3"
preClassName="text-xs"
isWrapped={isWrapped}
searchTerm={searchTerm}
/>
) : (
<div
dir="ltr"
className={cn(
"min-h-0 flex-1 px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
"overflow-auto"
)}
style={{
maxHeight,
}}
>
<pre
className={cn(
"relative mr-2 p-2 font-mono text-xs leading-relaxed",
isWrapped && "[&_span]:whitespace-pre-wrap [&_span]:wrap-break-word"
)}
dir="ltr"
>
{highlightSearchText(normalizedCode, searchTerm)}
</pre>
</div>
)}
</div>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex flex-col gap-0 p-0 pt-[2.9rem] sm:h-[80vh] sm:max-h-[80vh] sm:max-w-[80vw]">
<DialogHeader className="h-fit">
<DialogTitle className="absolute left-3.5 top-2.5">
{fileName && fileName}
{rowTitle && rowTitle}
</DialogTitle>
<Button
variant="tertiary/small"
onClick={onModalCopied}
className="absolute right-4 top-16 z-50"
LeadingIcon={modalCopied ? undefined : Clipboard}
leadingIconClassName="size-3 -ml-1"
>
{modalCopied ? "Copied" : "Copy"}
</Button>
</DialogHeader>
{shouldHighlight ? (
<HighlightCode
theme={theme}
code={normalizedCode}
language={language}
showLineNumbers={showLineNumbers}
highlightLines={highlightLines}
maxLineWidth={maxLineWidth}
className="min-h-full"
preClassName="text-sm"
isWrapped={isWrapped}
/>
) : (
<div
dir="ltr"
className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<pre className="relative mr-2 p-2 font-mono text-base leading-relaxed" dir="ltr">
{highlightSearchText(normalizedCode, searchTerm)}
</pre>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
);
CodeBlock.displayName = "CodeBlock";
function Chrome({ title }: { title?: string }) {
return (
<div className="grid h-7 grid-cols-[100px_auto_100px] border-b border-background-bright bg-background-deep">
<div className="ml-2 flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-background-raised" />
<div className="h-3 w-3 rounded-full bg-background-raised" />
<div className="h-3 w-3 rounded-full bg-background-raised" />
</div>
<div className="flex items-center justify-center">
<div className={cn("rounded-sm px-3 py-0.5 text-xs text-text-faint")}>{title}</div>
</div>
<div />
</div>
);
}
function TitleRow({ title }: { title: ReactNode }) {
return (
<div className="flex items-center justify-between px-3">
<Paragraph variant="small/bright" className="w-full border-b border-grid-dimmed py-2">
{title}
</Paragraph>
</div>
);
}
type HighlightCodeProps = {
theme: PrismTheme;
code: string;
language: Language;
showLineNumbers: boolean;
highlightLines?: number[];
maxLineWidth?: number;
className?: string;
preClassName?: string;
isWrapped: boolean;
searchTerm?: string;
};
function HighlightCode({
theme,
code,
language,
showLineNumbers,
highlightLines,
maxLineWidth,
className,
preClassName,
isWrapped,
searchTerm,
}: HighlightCodeProps) {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
Promise.all([
//@ts-ignore
import("prismjs/components/prism-json"),
//@ts-ignore
import("prismjs/components/prism-typescript"),
//@ts-ignore
import("prismjs/components/prism-sql.js"),
]).then(() => setIsLoaded(true));
}, []);
const containerClasses = cn(
"min-h-0 flex-1 px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
!isWrapped && "overflow-auto",
isWrapped && "overflow-auto",
className
);
const preClasses = cn(
"relative mr-2 font-mono leading-relaxed",
preClassName,
isWrapped && "[&_span]:whitespace-pre-wrap [&_span]:wrap-break-word"
);
if (!isLoaded) {
return (
<div dir="ltr" className={containerClasses}>
<pre className={preClasses}>{code}</pre>
</div>
);
}
return (
<Highlight theme={theme} code={code} language={language}>
{({
className: inheritedClassName,
style: inheritedStyle,
tokens,
getLineProps,
getTokenProps,
}) => (
<div dir="ltr" className={containerClasses}>
<pre className={cn(preClasses, inheritedClassName)} style={inheritedStyle} dir="ltr">
{tokens
.map((line, index) => {
if (index === tokens.length - 1 && line.length === 1 && line[0].content === "\n") {
return null;
}
const lineNumber = index + 1;
const lineProps = getLineProps({ line });
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
let shouldDim = hasAnyHighlights;
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
shouldDim = false;
}
return (
<div
key={lineNumber}
{...lineProps}
className={cn(
"flex w-full justify-start transition-opacity duration-500",
lineProps.className,
isWrapped && "flex-wrap"
)}
style={{
opacity: shouldDim ? dimAmount : undefined,
...lineProps.style,
}}
>
{showLineNumbers && (
<div
className={cn(
"mr-2 flex-none select-none text-right text-text-faint transition-opacity duration-500",
isWrapped && "sticky left-0"
)}
style={{
width: `calc(8 * ${(maxLineWidth as number) / 16}rem)`,
}}
>
{lineNumber}
</div>
)}
<div className="flex-1">
{line.map((token, key) => {
const tokenProps = getTokenProps({ token });
// Highlight search term matches in token
const content = highlightSearchText(token.content, searchTerm);
return (
<span
key={key}
{...tokenProps}
style={{
color: tokenProps?.style?.color as string,
...tokenProps.style,
}}
>
{content}
</span>
);
})}
</div>
<div className="w-4 flex-none" />
</div>
);
})
.filter(Boolean)}
</pre>
</div>
)}
</Highlight>
);
}