/**
* CodeBlock — a fenced snippet, syntax-coloured and scrolled sideways.
*
* Code does not wrap. Wrapping a line of code puts the continuation under the
* indentation and the two stop meaning different things, so the body scrolls
* horizontally instead — which on a phone is the only honest way to show a
* ninety-column line.
*
* ```tsx
*
*
* calendar.tsx
*
*
*
* ```
*
* ## Colouring
*
* The highlighter is a single pass of regex, shipped in the component, over the
* languages a chat actually streams. There is no grammar and no worker, because
* the alternative costs megabytes and has to run again on every frame of a
* stream. It gets keywords, strings, comments and numbers right in a twelve-line
* snippet and does not pretend to more; an unrecognised language renders as
* clean monospace rather than as a guess. Colours come from the theme's
* `--color-code-*` tokens, so a snippet follows light and dark like everything
* else.
*
* ## Where the props come from
*
* With the AI SDK, `code` and `language` are what you pull out of a fenced
* block in a text part, or the fields of a tool call that wrote a file.
*/
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { ScrollView, View, type TextProps, type ViewProps } from 'react-native';
import { tv } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard';
import { CheckIcon, CopyIcon } from '../../icons';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { AnimatedPressable } from '../../primitives/animated-pressable';
import { highlight, resolveLanguage, type Token, type TokenKind } from './highlight';
const codeBlockVariants = tv({
slots: {
root: 'w-full overflow-hidden rounded-2xl border border-border bg-muted/40',
header: 'flex-row items-center justify-between gap-2 border-b border-border px-3 py-2',
filename: 'font-mono text-xs text-muted-foreground',
language: 'text-xs uppercase tracking-wide text-muted-foreground',
actions: 'flex-row items-center gap-1',
action: 'h-7 w-7 items-center justify-center rounded-lg active:bg-accent',
body: 'p-3',
line: 'flex-row',
/*
* `leading-*` on the row rather than on each token: the tokens of a line
* are separate Text nodes, and a line height set on some of them and not
* others makes the baseline jog wherever a keyword ends.
*/
gutter: 'w-8 pe-3 text-end font-mono text-xs text-muted-foreground/50',
code: 'font-mono text-xs leading-5',
},
});
interface CodeBlockContextValue {
code: string;
}
const CodeBlockContext = createContext(null);
function useCodeBlock(component: string): CodeBlockContextValue {
const context = useContext(CodeBlockContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
/** Which theme token paints each kind of token. */
const TOKEN_COLORS: Record = {
plain: '--color-foreground',
keyword: '--color-code-keyword',
string: '--color-code-string',
number: '--color-code-number',
comment: '--color-code-comment',
function: '--color-code-function',
property: '--color-code-property',
punctuation: '--color-code-punctuation',
inserted: '--color-code-inserted',
deleted: '--color-code-deleted',
};
/**
* The palette, resolved once for the whole block.
*
* `useCSSVariable` is a hook, so it cannot be called per token — and a snippet
* has hundreds. Ten lookups at the top and a lookup table underneath is the
* same answer at a fraction of the cost.
*/
function useTokenColors(): Record {
const plain = useCSSVariable(TOKEN_COLORS.plain);
const keyword = useCSSVariable(TOKEN_COLORS.keyword);
const string = useCSSVariable(TOKEN_COLORS.string);
const number = useCSSVariable(TOKEN_COLORS.number);
const comment = useCSSVariable(TOKEN_COLORS.comment);
const fn = useCSSVariable(TOKEN_COLORS.function);
const property = useCSSVariable(TOKEN_COLORS.property);
const punctuation = useCSSVariable(TOKEN_COLORS.punctuation);
const inserted = useCSSVariable(TOKEN_COLORS.inserted);
const deleted = useCSSVariable(TOKEN_COLORS.deleted);
const only = (value: unknown) => (typeof value === 'string' ? value : undefined);
return useMemo(
() => ({
plain: only(plain),
keyword: only(keyword),
string: only(string),
number: only(number),
comment: only(comment),
function: only(fn),
property: only(property),
punctuation: only(punctuation),
inserted: only(inserted),
deleted: only(deleted),
}),
[plain, keyword, string, number, comment, fn, property, punctuation, inserted, deleted]
);
}
export interface CodeBlockProps extends Omit {
className?: string;
/** The snippet. */
code: string;
/**
* What it is written in. `ts`, `tsx`, `js`, `jsx`, `json`, `bash`, `python`,
* `css`, `html`, `sql`, `markdown` and `diff` are coloured, along with the
* usual spellings of each; anything else renders as plain monospace.
*/
language?: string;
/** Number the lines in a gutter down the leading edge. */
showLineNumbers?: boolean;
/** A header, and anything else that goes above the code. */
children?: ReactNode;
}
function CodeBlockRoot({
className,
code,
language,
showLineNumbers = false,
children,
...props
}: CodeBlockProps) {
const slots = codeBlockVariants();
const colors = useTokenColors();
const lines = useMemo(() => highlight(code, language), [code, language]);
const context = useMemo(() => ({ code }), [code]);
const gutterWidth = String(lines.length).length;
return (
{children}
{/*
Horizontal only. The rows are laid out at their natural width inside
it, so the longest line sets the scrollable width and every row shares
it — otherwise each row would scroll to its own end and the gutter
would drift away from its code.
*/}
{lines.map((tokens, index) => (
{showLineNumbers ? (
{index + 1}
) : null}
))}
);
}
CodeBlockRoot.displayName = 'CodeBlock';
/**
* One line, as a single Text with a run of coloured children.
*
* Nested Text rather than a row of Views: only inside one Text do the runs
* share a baseline and keep their spaces. A row of Views gives every token its
* own box and the line comes apart at the seams.
*/
function CodeLine({
tokens,
colors,
className,
}: {
tokens: Token[];
colors: Record;
className: string;
}) {
return (
{/* An empty line still needs a glyph's worth of height. */}
{tokens.length === 0 ? ' ' : null}
{tokens.map((token, index) => (
{token.text}
))}
);
}
export interface CodeBlockHeaderProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** The bar above the code: what the file is, and what can be done with it. */
function CodeBlockHeader({ className, children, ...props }: CodeBlockHeaderProps) {
const { header } = codeBlockVariants();
return (
{children}
);
}
export interface CodeBlockFilenameProps extends TextProps {
className?: string;
children?: ReactNode;
}
function CodeBlockFilename({ className, children, ...props }: CodeBlockFilenameProps) {
const { filename } = codeBlockVariants();
return (
{children}
);
}
export interface CodeBlockLanguageProps extends TextProps {
className?: string;
children?: ReactNode;
}
/** The language, for a block with no filename to name it by. */
function CodeBlockLanguage({ className, children, ...props }: CodeBlockLanguageProps) {
const { language } = codeBlockVariants();
return (
{children}
);
}
export interface CodeBlockActionsProps extends ViewProps {
className?: string;
children?: ReactNode;
}
function CodeBlockActions({ className, children, ...props }: CodeBlockActionsProps) {
const { actions } = codeBlockVariants();
return (
{children}
);
}
export interface CodeBlockCopyButtonProps {
className?: string;
/** How long the tick stays up before turning back into the copy glyph. */
timeout?: number;
onCopy?: () => void;
}
/**
* Copies the block's code.
*
* The tick is the whole feedback: there is no toast, because a snippet in a
* transcript is copied often and a notification for each one is noise.
*/
function CodeBlockCopyButton({ className, timeout = 2000, onCopy }: CodeBlockCopyButtonProps) {
const { code } = useCodeBlock('CodeBlock.CopyButton');
const { action } = codeBlockVariants();
const { copy, copied } = useCopyToClipboard({ timeout });
// Both glyphs are given a colour: the check's own fallback is white, which is
// invisible on this header in a light theme.
const rawTick = useCSSVariable('--color-success');
const rawIdle = useCSSVariable('--color-muted-foreground');
const tick = typeof rawTick === 'string' ? rawTick : undefined;
const idle = typeof rawIdle === 'string' ? rawIdle : undefined;
return (
{
void copy(code);
onCopy?.();
}}
className={cn(action(), className)}
>
{copied ? : }
);
}
CodeBlockHeader.displayName = 'CodeBlock.Header';
CodeBlockFilename.displayName = 'CodeBlock.Filename';
CodeBlockLanguage.displayName = 'CodeBlock.Language';
CodeBlockActions.displayName = 'CodeBlock.Actions';
CodeBlockCopyButton.displayName = 'CodeBlock.CopyButton';
export { resolveLanguage, type CodeLanguage, type Token, type TokenKind } from './highlight';
export const CodeBlock = Object.assign(CodeBlockRoot, {
Header: CodeBlockHeader,
Filename: CodeBlockFilename,
Language: CodeBlockLanguage,
Actions: CodeBlockActions,
CopyButton: CodeBlockCopyButton,
});