Highlighting reuses the existing code-block tokenizer and LRU cache, runs lazily on expand, and falls back to plain text above a size cap so a large Read can't stall the main thread. The diff highlighter reconstructs the old and new file text by position rather than from @@ line ranges, so diffs that arrive without real hunk headers still highlight. Read content is normalized server-side: the cat -n line-number gutter some providers emit is stripped so content is uniformly raw source, with the first line number surfaced as offset for the client to rebuild the gutter.
227 lines
7.0 KiB
TypeScript
227 lines
7.0 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { MarkdownTextSpan } from "@/components/markdown-text";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { Check, Copy } from "lucide-react-native";
|
|
import type { HighlightToken } from "@getpaseo/highlight";
|
|
import { isNative, isWeb } from "@/constants/platform";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
|
|
|
interface HighlightedCodeBlockProps {
|
|
code: string;
|
|
language: string | null | undefined;
|
|
inheritedStyles: TextStyle;
|
|
textStyle: TextStyle;
|
|
}
|
|
|
|
// Fence info strings ("```ts", "```typescript", "```ts {1,3}") map to the
|
|
// extension-based parser table in @getpaseo/highlight. Aliases here only
|
|
// cover names that don't already match an extension key in parsers.ts.
|
|
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
typescript: "ts",
|
|
javascript: "js",
|
|
python: "py",
|
|
rust: "rs",
|
|
golang: "go",
|
|
"c++": "cpp",
|
|
objc: "m",
|
|
"objective-c": "m",
|
|
markdown: "md",
|
|
elixir: "ex",
|
|
};
|
|
|
|
function fenceLanguageToExtension(info: string | null | undefined): string | null {
|
|
if (!info) return null;
|
|
const first = info.trim().split(/\s+/)[0]?.toLowerCase();
|
|
if (!first) return null;
|
|
const normalized = first.replace(/^\./, "");
|
|
return LANGUAGE_ALIASES[normalized] ?? normalized;
|
|
}
|
|
|
|
function stripTerminalFenceNewline(code: string): string {
|
|
return code.endsWith("\n") ? code.slice(0, -1) : code;
|
|
}
|
|
|
|
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
|
code,
|
|
language,
|
|
inheritedStyles,
|
|
textStyle,
|
|
}: HighlightedCodeBlockProps) {
|
|
// Box styles (bg / padding / border / radius / margin) go on the wrapper View
|
|
// so the absolute copy button positions relative to the visible code area,
|
|
// not to a parent that includes the Text's own marginVertical.
|
|
const { containerStyle, innerTextStyle } = useMemo(
|
|
() => splitFenceStyle(inheritedStyles, textStyle),
|
|
[inheritedStyles, textStyle],
|
|
);
|
|
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
|
|
|
|
const keyedLines = useMemo<KeyedLine[] | null>(
|
|
() => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)),
|
|
[renderedCode, language],
|
|
);
|
|
|
|
const isCompact = useIsCompactFormFactor();
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
|
const controlsVisible = isHovered || isNative || isCompact;
|
|
const getCode = useCallback(() => code, [code]);
|
|
|
|
return (
|
|
<View
|
|
style={containerStyle}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
{keyedLines ? (
|
|
<MarkdownTextSpan style={innerTextStyle}>{renderCodeSegments(keyedLines)}</MarkdownTextSpan>
|
|
) : (
|
|
<MarkdownTextSpan style={innerTextStyle}>{renderedCode}</MarkdownTextSpan>
|
|
)}
|
|
<CopyButton getCode={getCode} visible={controlsVisible} />
|
|
</View>
|
|
);
|
|
});
|
|
|
|
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
|
|
const segments: React.ReactNode[] = [];
|
|
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {
|
|
const line = keyedLines[lineIndex];
|
|
if (lineIndex > 0) {
|
|
segments.push(<CodeTextSpan key={`${line.key}-newline`} text={"\n"} />);
|
|
}
|
|
for (const { key, token } of line.tokens) {
|
|
segments.push(<TokenSpan key={`${line.key}-${key}`} token={token} />);
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
interface TokenSpanProps {
|
|
token: HighlightToken;
|
|
}
|
|
|
|
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
|
|
return (
|
|
<MarkdownTextSpan style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
|
{token.text}
|
|
</MarkdownTextSpan>
|
|
);
|
|
});
|
|
|
|
interface CodeTextSpanProps {
|
|
text: string;
|
|
}
|
|
|
|
const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) {
|
|
return <MarkdownTextSpan>{text}</MarkdownTextSpan>;
|
|
});
|
|
|
|
interface SplitStyles {
|
|
containerStyle: StyleProp<ViewStyle>;
|
|
innerTextStyle: StyleProp<TextStyle>;
|
|
}
|
|
|
|
const CONTAINER_BASE: ViewStyle = { position: "relative" };
|
|
const WEB_SELECTABLE: TextStyle = isWeb ? ({ userSelect: "text" } as TextStyle) : {};
|
|
|
|
function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): SplitStyles {
|
|
const { fontFamily, fontSize, color, ...box } = textStyle;
|
|
const textOnly: TextStyle = { ...WEB_SELECTABLE };
|
|
if (fontFamily !== undefined) textOnly.fontFamily = fontFamily;
|
|
if (fontSize !== undefined) textOnly.fontSize = fontSize;
|
|
if (fontSize !== undefined) textOnly.lineHeight = Math.round(fontSize * 1.45);
|
|
if (color !== undefined) textOnly.color = color;
|
|
return {
|
|
containerStyle: [box as ViewStyle, CONTAINER_BASE],
|
|
innerTextStyle: [inheritedStyles, textOnly],
|
|
};
|
|
}
|
|
|
|
interface CopyButtonProps {
|
|
getCode: () => string;
|
|
visible: boolean;
|
|
}
|
|
|
|
const COPIED_RESET_MS = 1500;
|
|
|
|
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
|
|
const [copied, setCopied] = useState(false);
|
|
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (resetRef.current) clearTimeout(resetRef.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handlePress = useCallback(async () => {
|
|
const content = getCode();
|
|
if (!content) return;
|
|
await Clipboard.setStringAsync(content);
|
|
setCopied(true);
|
|
if (resetRef.current) clearTimeout(resetRef.current);
|
|
resetRef.current = setTimeout(() => {
|
|
setCopied(false);
|
|
resetRef.current = null;
|
|
}, COPIED_RESET_MS);
|
|
}, [getCode]);
|
|
|
|
const visibilityStyle = visible
|
|
? copyButtonStyles.containerVisible
|
|
: copyButtonStyles.containerHidden;
|
|
const wrapperStyle = useMemo(
|
|
() => [copyButtonStyles.container, visibilityStyle],
|
|
[visibilityStyle],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={handlePress}
|
|
style={wrapperStyle}
|
|
pointerEvents={visible ? "auto" : "none"}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={copied ? "Copied" : "Copy code"}
|
|
hitSlop={8}
|
|
>
|
|
{({ hovered }) => {
|
|
const iconColor = hovered
|
|
? copyButtonStyles.iconHoveredColor.color
|
|
: copyButtonStyles.iconColor.color;
|
|
return copied ? (
|
|
<Check size={14} color={iconColor} />
|
|
) : (
|
|
<Copy size={14} color={iconColor} />
|
|
);
|
|
}}
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
const copyButtonStyles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
position: "absolute",
|
|
top: theme.spacing[2],
|
|
right: theme.spacing[2],
|
|
padding: theme.spacing[1],
|
|
},
|
|
containerVisible: {
|
|
opacity: 1,
|
|
},
|
|
containerHidden: {
|
|
opacity: 0,
|
|
},
|
|
iconColor: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
iconHoveredColor: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
}));
|