Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
279 lines
7.3 KiB
TypeScript
279 lines
7.3 KiB
TypeScript
import React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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 type { DiffLine } from "@/utils/tool-call-parsers";
|
|
import { diffLinePrefix } from "@/utils/diff-highlight";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
import { getCodeInsets } from "./code-insets";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
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,
|
|
fillAvailableHeight = false,
|
|
}: DiffViewerProps) {
|
|
const { t } = useTranslation();
|
|
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
|
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
|
|
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,
|
|
],
|
|
[maxHeight, fillAvailableHeight],
|
|
);
|
|
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}>{resolvedEmptyLabel}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const lines = (
|
|
<View style={linesContainerStyle} dataSet={CODE_SURFACE_DATASET}>
|
|
{keyedDiffLines.map(({ key, line }) => (
|
|
<DiffLineRow key={key} line={line} />
|
|
))}
|
|
</View>
|
|
);
|
|
|
|
const horizontalScroll = (
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
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: theme.fontFamily.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.base,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
};
|
|
});
|