feat(app): syntax highlighting and copy button for chat code blocks (#1062)
Adds Lezer-based highlighting to fenced code blocks in assistant messages, with a hover/tap copy button. Colors move into the theme as `theme.colors.syntax`, so the chat blocks, file preview, and diff viewer share one StyleSheet path — token color updates flow through the Unistyles native ShadowRegistry with no React re-renders on the chat hot path.
This commit is contained in:
@@ -15,13 +15,8 @@ import { Fonts } from "@/constants/theme";
|
|||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
import {
|
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||||
highlightCode,
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||||
darkHighlightColors,
|
|
||||||
lightHighlightColors,
|
|
||||||
type HighlightToken,
|
|
||||||
type HighlightStyle,
|
|
||||||
} from "@getpaseo/highlight";
|
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -36,8 +31,6 @@ interface CodeLineProps {
|
|||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
colorMap: Record<HighlightStyle, string>;
|
|
||||||
baseColor: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilePreviewBodyProps {
|
interface FilePreviewBodyProps {
|
||||||
@@ -99,18 +92,8 @@ async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeLine = React.memo(function CodeLine({
|
const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, gutterWidth }: CodeLineProps) {
|
||||||
tokens,
|
|
||||||
lineNumber,
|
|
||||||
gutterWidth,
|
|
||||||
colorMap,
|
|
||||||
baseColor,
|
|
||||||
}: CodeLineProps) {
|
|
||||||
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
|
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
|
||||||
const gutterTextStyle = useMemo(
|
|
||||||
() => [codeLineStyles.gutterText, { color: baseColor }],
|
|
||||||
[baseColor],
|
|
||||||
);
|
|
||||||
const keyedTokens = useMemo(
|
const keyedTokens = useMemo(
|
||||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||||
[tokens],
|
[tokens],
|
||||||
@@ -118,17 +101,13 @@ const CodeLine = React.memo(function CodeLine({
|
|||||||
return (
|
return (
|
||||||
<View style={codeLineStyles.line}>
|
<View style={codeLineStyles.line}>
|
||||||
<View style={gutterStyle}>
|
<View style={gutterStyle}>
|
||||||
<Text numberOfLines={1} style={gutterTextStyle}>
|
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
||||||
{String(lineNumber)}
|
{String(lineNumber)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text selectable style={codeLineStyles.lineText}>
|
<Text selectable style={codeLineStyles.lineText}>
|
||||||
{keyedTokens.map(({ key, token }) => (
|
{keyedTokens.map(({ key, token }) => (
|
||||||
<CodeLineToken
|
<CodeLineToken key={key} token={token} />
|
||||||
key={key}
|
|
||||||
color={token.style ? (colorMap[token.style] ?? baseColor) : baseColor}
|
|
||||||
text={token.text}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -136,13 +115,11 @@ const CodeLine = React.memo(function CodeLine({
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface CodeLineTokenProps {
|
interface CodeLineTokenProps {
|
||||||
color: string;
|
token: HighlightToken;
|
||||||
text: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CodeLineToken({ color, text }: CodeLineTokenProps) {
|
function CodeLineToken({ token }: CodeLineTokenProps) {
|
||||||
const style = useMemo(() => ({ color }), [color]);
|
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||||
return <Text style={style}>{text}</Text>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
const codeLineStyles = StyleSheet.create((theme) => ({
|
||||||
@@ -155,6 +132,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
gutterText: {
|
gutterText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
lineHeight: theme.fontSize.sm * 1.45,
|
lineHeight: theme.fontSize.sm * 1.45,
|
||||||
@@ -178,9 +156,6 @@ function FilePreviewBody({
|
|||||||
imagePreviewUri,
|
imagePreviewUri,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isDark = theme.colorScheme === "dark";
|
|
||||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
|
||||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||||
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
||||||
@@ -258,14 +233,7 @@ function FilePreviewBody({
|
|||||||
const codeLines = (
|
const codeLines = (
|
||||||
<View>
|
<View>
|
||||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
||||||
<CodeLine
|
<CodeLine key={key} tokens={tokens} lineNumber={lineNumber} gutterWidth={gutterWidth} />
|
||||||
key={key}
|
|
||||||
tokens={tokens}
|
|
||||||
lineNumber={lineNumber}
|
|
||||||
gutterWidth={gutterWidth}
|
|
||||||
colorMap={colorMap}
|
|
||||||
baseColor={baseColor}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type StyleProp,
|
||||||
|
type TextStyle,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { Check, Copy } from "lucide-react-native";
|
||||||
|
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||||
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-instance cache for tokenized code blocks. Tokenization is
|
||||||
|
// theme-independent (colors are applied at render time), so the key is just
|
||||||
|
// (language, code). Bounded by entry count — 200 is generous for a chat
|
||||||
|
// transcript, code blocks rarely repeat beyond a handful of distinct shapes.
|
||||||
|
class LRUCache<K, V> {
|
||||||
|
private readonly map = new Map<K, V>();
|
||||||
|
constructor(private readonly max: number) {}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.map.get(key);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
this.map.delete(key);
|
||||||
|
this.map.set(key, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
if (this.map.has(key)) this.map.delete(key);
|
||||||
|
else if (this.map.size >= this.max) {
|
||||||
|
const oldest = this.map.keys().next().value;
|
||||||
|
if (oldest !== undefined) this.map.delete(oldest);
|
||||||
|
}
|
||||||
|
this.map.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenizationCache = new LRUCache<string, KeyedLine[]>(200);
|
||||||
|
|
||||||
|
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 keyedLines = useMemo<KeyedLine[] | null>(() => {
|
||||||
|
const ext = fenceLanguageToExtension(language);
|
||||||
|
if (!ext) return null;
|
||||||
|
const cacheKey = `${ext}:${code}`;
|
||||||
|
const cached = tokenizationCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
let tokenizedLines: HighlightToken[][];
|
||||||
|
try {
|
||||||
|
tokenizedLines = highlightCode(code, `x.${ext}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const result = tokenizedLines.map(toKeyedLine);
|
||||||
|
tokenizationCache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}, [code, 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 ? (
|
||||||
|
<Text style={innerTextStyle}>
|
||||||
|
{keyedLines.map((line, lineIndex) => (
|
||||||
|
<React.Fragment key={line.key}>
|
||||||
|
{lineIndex > 0 ? "\n" : null}
|
||||||
|
{line.tokens.map(({ key, token }) => (
|
||||||
|
<TokenSpan key={key} token={token} />
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={innerTextStyle}>{code}</Text>
|
||||||
|
)}
|
||||||
|
<CopyButton getCode={getCode} visible={controlsVisible} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface KeyedToken {
|
||||||
|
key: string;
|
||||||
|
token: HighlightToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyedLine {
|
||||||
|
key: string;
|
||||||
|
tokens: KeyedToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
|
||||||
|
return {
|
||||||
|
key: `line-${lineIndex}`,
|
||||||
|
tokens: tokens.map((token, tokenIndex) => ({
|
||||||
|
key: `${lineIndex}-${tokenIndex}`,
|
||||||
|
token,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenSpanProps {
|
||||||
|
token: HighlightToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
|
||||||
|
if (!token.style) return token.text;
|
||||||
|
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -74,6 +74,7 @@ import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markd
|
|||||||
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
||||||
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
|
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
|
||||||
import type { ToastApi } from "@/components/toast-host";
|
import type { ToastApi } from "@/components/toast-host";
|
||||||
|
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||||
import {
|
import {
|
||||||
@@ -1650,13 +1651,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
styles: MarkdownStyles,
|
styles: MarkdownStyles,
|
||||||
inheritedStyles: TextStyle = {},
|
inheritedStyles: TextStyle = {},
|
||||||
) => (
|
) => (
|
||||||
<MarkdownInheritedText
|
<HighlightedCodeBlock
|
||||||
key={node.key}
|
key={node.key}
|
||||||
|
code={node.content}
|
||||||
|
language={null}
|
||||||
inheritedStyles={inheritedStyles}
|
inheritedStyles={inheritedStyles}
|
||||||
textStyle={styles.code_block}
|
textStyle={styles.code_block}
|
||||||
>
|
/>
|
||||||
{node.content}
|
|
||||||
</MarkdownInheritedText>
|
|
||||||
),
|
),
|
||||||
fence: (
|
fence: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
@@ -1665,13 +1666,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
styles: MarkdownStyles,
|
styles: MarkdownStyles,
|
||||||
inheritedStyles: TextStyle = {},
|
inheritedStyles: TextStyle = {},
|
||||||
) => (
|
) => (
|
||||||
<MarkdownInheritedText
|
<HighlightedCodeBlock
|
||||||
key={node.key}
|
key={node.key}
|
||||||
|
code={node.content}
|
||||||
|
language={node.sourceInfo}
|
||||||
inheritedStyles={inheritedStyles}
|
inheritedStyles={inheritedStyles}
|
||||||
textStyle={styles.fence}
|
textStyle={styles.fence}
|
||||||
>
|
/>
|
||||||
{node.content}
|
|
||||||
</MarkdownInheritedText>
|
|
||||||
),
|
),
|
||||||
code_inline: (
|
code_inline: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
|||||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||||
import { DiffScroll } from "@/components/diff-scroll";
|
import { DiffScroll } from "@/components/diff-scroll";
|
||||||
import {
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||||
darkHighlightColors,
|
|
||||||
lightHighlightColors,
|
|
||||||
type HighlightStyle as HighlightStyleKey,
|
|
||||||
} from "@getpaseo/highlight";
|
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
|
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
|
||||||
@@ -136,8 +132,6 @@ function expandAllButtonStyle({
|
|||||||
return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
|
return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
|
||||||
|
|
||||||
interface HighlightedTextProps {
|
interface HighlightedTextProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
wrapLines?: boolean;
|
wrapLines?: boolean;
|
||||||
@@ -157,31 +151,14 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine
|
|||||||
: { whiteSpace: "pre", overflowWrap: "normal" };
|
: { whiteSpace: "pre", overflowWrap: "normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighlightedToken({
|
function HighlightedToken({ token }: { token: HighlightToken }) {
|
||||||
text,
|
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||||
color,
|
|
||||||
lineHeight,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
color: string;
|
|
||||||
lineHeight: number;
|
|
||||||
}) {
|
|
||||||
const tokenStyle = useMemo(() => ({ color, lineHeight }), [color, lineHeight]);
|
|
||||||
return <Text style={tokenStyle}>{text}</Text>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isDark = theme.colorScheme === "dark";
|
|
||||||
const lineHeight = theme.lineHeight.diff;
|
const lineHeight = theme.lineHeight.diff;
|
||||||
|
|
||||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
|
||||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
|
||||||
if (!style) return baseColor;
|
|
||||||
const colors = isDark ? darkHighlightColors : lightHighlightColors;
|
|
||||||
return colors[style as HighlightStyleKey] ?? baseColor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }],
|
() => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }],
|
||||||
[lineHeight, wrapLines],
|
[lineHeight, wrapLines],
|
||||||
@@ -195,12 +172,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
|||||||
return (
|
return (
|
||||||
<Text style={containerStyle}>
|
<Text style={containerStyle}>
|
||||||
{keyedTokens.map(({ key, token }) => (
|
{keyedTokens.map(({ key, token }) => (
|
||||||
<HighlightedToken
|
<HighlightedToken key={key} token={token} />
|
||||||
key={key}
|
|
||||||
text={token.text}
|
|
||||||
color={getTokenColor(token.style)}
|
|
||||||
lineHeight={lineHeight}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
// Per-token color styles for syntax-highlighted code. Each value reads a
|
||||||
|
// theme path so the Unistyles Babel plugin tracks the dependency and updates
|
||||||
|
// native style values on theme changes — no React re-render of consumers.
|
||||||
|
// Used by message code blocks (hot path), file preview, and the diff viewer.
|
||||||
|
export const syntaxTokenStyles = StyleSheet.create((theme) => ({
|
||||||
|
base: { color: theme.colors.foreground },
|
||||||
|
keyword: { color: theme.colors.syntax.keyword },
|
||||||
|
comment: { color: theme.colors.syntax.comment },
|
||||||
|
string: { color: theme.colors.syntax.string },
|
||||||
|
number: { color: theme.colors.syntax.number },
|
||||||
|
literal: { color: theme.colors.syntax.literal },
|
||||||
|
function: { color: theme.colors.syntax.function },
|
||||||
|
definition: { color: theme.colors.syntax.definition },
|
||||||
|
class: { color: theme.colors.syntax.class },
|
||||||
|
type: { color: theme.colors.syntax.type },
|
||||||
|
tag: { color: theme.colors.syntax.tag },
|
||||||
|
attribute: { color: theme.colors.syntax.attribute },
|
||||||
|
property: { color: theme.colors.syntax.property },
|
||||||
|
variable: { color: theme.colors.syntax.variable },
|
||||||
|
operator: { color: theme.colors.syntax.operator },
|
||||||
|
punctuation: { color: theme.colors.syntax.punctuation },
|
||||||
|
regexp: { color: theme.colors.syntax.regexp },
|
||||||
|
escape: { color: theme.colors.syntax.escape },
|
||||||
|
meta: { color: theme.colors.syntax.meta },
|
||||||
|
heading: { color: theme.colors.syntax.heading },
|
||||||
|
link: { color: theme.colors.syntax.link },
|
||||||
|
}));
|
||||||
|
|
||||||
|
type SyntaxTokenStyleValue = (typeof syntaxTokenStyles)["base"];
|
||||||
|
|
||||||
|
// Accepts a plain string so diff tokens (server-typed `string | null`) and
|
||||||
|
// @getpaseo/highlight tokens (typed `HighlightStyle | null`) share one path.
|
||||||
|
// Unknown styles fall back to the base color.
|
||||||
|
export function syntaxTokenStyleFor(style: string | null | undefined): SyntaxTokenStyleValue {
|
||||||
|
if (!style) return syntaxTokenStyles.base;
|
||||||
|
const indexed = syntaxTokenStyles as unknown as Record<string, SyntaxTokenStyleValue>;
|
||||||
|
return indexed[style] ?? syntaxTokenStyles.base;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight";
|
||||||
|
|
||||||
export const baseColors = {
|
export const baseColors = {
|
||||||
// Base colors
|
// Base colors
|
||||||
white: "#ffffff",
|
white: "#ffffff",
|
||||||
@@ -518,6 +520,7 @@ function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColor
|
|||||||
colors: {
|
colors: {
|
||||||
...semanticColors,
|
...semanticColors,
|
||||||
palette: baseColors,
|
palette: baseColors,
|
||||||
|
syntax: darkHighlightColors,
|
||||||
},
|
},
|
||||||
shadow: darkShadow,
|
shadow: darkShadow,
|
||||||
...commonTheme,
|
...commonTheme,
|
||||||
@@ -535,6 +538,7 @@ export const lightTheme = {
|
|||||||
colors: {
|
colors: {
|
||||||
...lightSemanticColors,
|
...lightSemanticColors,
|
||||||
palette: baseColors,
|
palette: baseColors,
|
||||||
|
syntax: lightHighlightColors,
|
||||||
},
|
},
|
||||||
shadow: {
|
shadow: {
|
||||||
sm: {
|
sm: {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// The runtime sets `sourceInfo` on fence/code nodes (tokensToAST.js sets
|
||||||
|
// `sourceInfo: token.info`), but the shipped .d.ts omits the field. The
|
||||||
|
// re-export is what marks this file as a module so the `declare module`
|
||||||
|
// below augments the package instead of shadowing it.
|
||||||
|
export type { ASTNode } from "react-native-markdown-display";
|
||||||
|
|
||||||
|
declare module "react-native-markdown-display" {
|
||||||
|
interface ASTNode {
|
||||||
|
sourceInfo?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user