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.
280 lines
7.3 KiB
TypeScript
280 lines
7.3 KiB
TypeScript
import React from "react";
|
|
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
|
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { Fonts } from "@/constants/theme";
|
|
import type { DiffLine } from "@/utils/tool-call-parsers";
|
|
import { diffLinePrefix } from "@/utils/diff-highlight";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
import { getCodeInsets } from "./code-insets";
|
|
import { isWeb } from "@/constants/platform";
|
|
|
|
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
|
|
|
interface DiffViewerProps {
|
|
diffLines: DiffLine[];
|
|
maxHeight?: number;
|
|
emptyLabel?: string;
|
|
fillAvailableHeight?: boolean;
|
|
}
|
|
|
|
function DiffLineRow({ line }: { line: DiffLine }) {
|
|
const lineContainerStyle = React.useMemo(
|
|
() => [
|
|
styles.line,
|
|
line.type === "header" && styles.headerLine,
|
|
line.type === "add" && styles.addLine,
|
|
line.type === "remove" && styles.removeLine,
|
|
line.type === "context" && styles.contextLine,
|
|
],
|
|
[line.type],
|
|
);
|
|
const plainLineTextStyle = React.useMemo(
|
|
() => [
|
|
styles.lineText,
|
|
line.type === "header" && styles.headerText,
|
|
line.type === "add" && styles.addText,
|
|
line.type === "remove" && styles.removeText,
|
|
line.type === "context" && styles.contextText,
|
|
],
|
|
[line.type],
|
|
);
|
|
|
|
const prefixStyle = React.useMemo(
|
|
() => [
|
|
line.type === "add" && styles.addText,
|
|
line.type === "remove" && styles.removeText,
|
|
line.type === "context" && styles.contextText,
|
|
],
|
|
[line.type],
|
|
);
|
|
|
|
if (line.tokens) {
|
|
return (
|
|
<View style={lineContainerStyle}>
|
|
<Text style={styles.lineText}>
|
|
<Text style={prefixStyle}>{diffLinePrefix(line)}</Text>
|
|
<DiffTokens tokens={line.tokens} />
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={lineContainerStyle}>
|
|
{line.segments ? (
|
|
<Text style={styles.lineText}>
|
|
<Text style={line.type === "add" ? styles.addText : styles.removeText}>
|
|
{line.content[0]}
|
|
</Text>
|
|
{line.segments.map((segment) => (
|
|
<DiffSegment
|
|
key={`${segment.changed ? "c" : "u"}:${segment.text}`}
|
|
segment={segment}
|
|
lineType={line.type}
|
|
/>
|
|
))}
|
|
</Text>
|
|
) : (
|
|
<Text style={plainLineTextStyle}>{line.content}</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function DiffTokens({ tokens }: { tokens: NonNullable<DiffLine["tokens"]> }) {
|
|
const keyed = React.useMemo(
|
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
[tokens],
|
|
);
|
|
return (
|
|
<>
|
|
{keyed.map(({ key, token }) => (
|
|
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
|
{token.text}
|
|
</Text>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DiffSegment({
|
|
segment,
|
|
lineType,
|
|
}: {
|
|
segment: NonNullable<DiffLine["segments"]>[number];
|
|
lineType: DiffLine["type"];
|
|
}) {
|
|
const segmentStyle = React.useMemo(
|
|
() => [
|
|
lineType === "add" ? styles.addText : styles.removeText,
|
|
segment.changed && (lineType === "add" ? styles.addHighlight : styles.removeHighlight),
|
|
],
|
|
[lineType, segment.changed],
|
|
);
|
|
return <Text style={segmentStyle}>{segment.text}</Text>;
|
|
}
|
|
|
|
export function DiffViewer({
|
|
diffLines,
|
|
maxHeight,
|
|
emptyLabel = "No changes to display",
|
|
fillAvailableHeight = false,
|
|
}: DiffViewerProps) {
|
|
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
|
const webScrollbarStyle = useWebScrollbarStyle();
|
|
const handleInnerLayout = React.useCallback(
|
|
(e: { nativeEvent: { layout: { width: number } } }) =>
|
|
setScrollViewWidth(e.nativeEvent.layout.width),
|
|
[],
|
|
);
|
|
|
|
const outerScrollStyle = React.useMemo(
|
|
() => [
|
|
styles.verticalScroll,
|
|
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
|
|
fillAvailableHeight && styles.fillHeight,
|
|
webScrollbarStyle,
|
|
],
|
|
[maxHeight, fillAvailableHeight, webScrollbarStyle],
|
|
);
|
|
const linesContainerStyle = React.useMemo(
|
|
() => [
|
|
styles.linesContainer,
|
|
scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
|
|
],
|
|
[scrollViewWidth],
|
|
);
|
|
const keyedDiffLines = React.useMemo(
|
|
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
|
|
[diffLines],
|
|
);
|
|
const webVerticalContentStyle = React.useMemo(
|
|
() => [styles.verticalContent, fillAvailableHeight && styles.fillHeight],
|
|
[fillAvailableHeight],
|
|
);
|
|
|
|
if (!diffLines.length) {
|
|
return (
|
|
<View style={styles.emptyState}>
|
|
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const lines = (
|
|
<View style={linesContainerStyle}>
|
|
{keyedDiffLines.map(({ key, line }) => (
|
|
<DiffLineRow key={key} line={line} />
|
|
))}
|
|
</View>
|
|
);
|
|
|
|
const horizontalScroll = (
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
style={webScrollbarStyle}
|
|
contentContainerStyle={styles.horizontalContent}
|
|
onLayout={handleInnerLayout}
|
|
>
|
|
{lines}
|
|
</ScrollView>
|
|
);
|
|
|
|
const content = (
|
|
<ScrollView
|
|
style={outerScrollStyle}
|
|
contentContainerStyle={webVerticalContentStyle}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator
|
|
>
|
|
{horizontalScroll}
|
|
</ScrollView>
|
|
);
|
|
|
|
return content;
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => {
|
|
const insets = getCodeInsets(theme);
|
|
|
|
return {
|
|
verticalScroll: {},
|
|
fillHeight: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
verticalContent: {
|
|
flexGrow: 1,
|
|
paddingBottom: insets.extraBottom,
|
|
},
|
|
horizontalContent: {
|
|
flexDirection: "column" as const,
|
|
paddingRight: insets.extraRight,
|
|
},
|
|
linesContainer: {
|
|
alignSelf: "flex-start",
|
|
padding: insets.padding,
|
|
},
|
|
line: {
|
|
minWidth: "100%",
|
|
paddingHorizontal: 0,
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
lineText: {
|
|
fontFamily: Fonts.mono,
|
|
fontSize: theme.fontSize.code,
|
|
color: theme.colors.foreground,
|
|
...(isWeb
|
|
? {
|
|
whiteSpace: "pre",
|
|
overflowWrap: "normal",
|
|
}
|
|
: null),
|
|
},
|
|
headerLine: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
headerText: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
addLine: {
|
|
backgroundColor: "rgba(46, 160, 67, 0.15)",
|
|
},
|
|
addText: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
removeLine: {
|
|
backgroundColor: "rgba(248, 81, 73, 0.1)",
|
|
},
|
|
removeText: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
addHighlight: {
|
|
backgroundColor: "rgba(46, 160, 67, 0.4)",
|
|
},
|
|
removeHighlight: {
|
|
backgroundColor: "rgba(248, 81, 73, 0.35)",
|
|
},
|
|
contextLine: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
contextText: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
emptyState: {
|
|
padding: theme.spacing[4],
|
|
alignItems: "center" as const,
|
|
justifyContent: "center" as const,
|
|
},
|
|
emptyText: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
};
|
|
});
|