1import { memo, useMemo, useState } from "react";
2import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName";
3import { ScrollView, StyleSheet, Text as NativeText, View, type ColorValue } from "react-native";
4import { AppText as Text } from "../../components/AppText";
5import { SymbolView } from "../../components/AppSymbol";
6import { useUniwindTheme } from "../../lib/useUniwindTheme";
7import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
8import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
9import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
10import {
11 buildNativeReviewDiffData,
12 buildNativeReviewSnippetRows,
13 createNativeReviewDiffTheme,
14 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
15} from "./nativeReviewDiffAdapter";
16import { buildReviewParsedDiff } from "./reviewModel";
17import { REVIEW_MONO_FONT_FAMILY } from "./reviewDiffRendering";
18import type { ReviewInlineComment } from "./reviewCommentSelection";
19import { useMarkdownCodeHighlight } from "../threads/markdownCodeHighlightState";
20
21export interface ReviewCommentColors {
22 readonly background: ColorValue;
23 readonly border: ColorValue;
24 readonly mutedBackground: ColorValue;
25 readonly text: ColorValue;
26 readonly mutedText: ColorValue;
27 readonly codeBackground: ColorValue;
28}
29
30export function useReviewCommentColors(): ReviewCommentColors {
31 const theme = useUniwindTheme();
32
33 return useMemo(
34 () => ({
35 background: theme["--color-card"],
36 border: theme["--color-border"],
37 mutedBackground: theme["--color-subtle"],
38 text: theme["--color-foreground"],
39 mutedText: theme["--color-foreground-muted"],
40 codeBackground: theme["--color-md-code-bg"],
41 }),
42 [theme],
43 );
44}
45
46export const ReviewCommentCard = memo(function ReviewCommentCard(props: {
47 readonly comment: ReviewInlineComment;
48 readonly colors: ReviewCommentColors;
49}) {
50 const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
51 const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences();
52 const appTheme = useUniwindTheme();
53 const [NativeReviewDiffView] = useState(() => resolveNativeReviewDiffView());
54 const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]);
55 const parsedDiff = useMemo(
56 () => buildReviewParsedDiff(patch, `thread-review-comment:${props.comment.id}`),
57 [patch, props.comment.id],
58 );
59 const nativeReviewDiffData = useMemo(() => buildNativeReviewDiffData(parsedDiff), [parsedDiff]);
60 const compactNativeRows = useMemo(() => {
61 const rows = nativeReviewDiffData.rows.filter((row) => row.kind !== "file");
62 return rows.length > 0 ? rows : buildNativeReviewSnippetRows(props.comment);
63 }, [nativeReviewDiffData.rows, props.comment]);
64 const language =
65 props.comment.fenceLanguage && props.comment.fenceLanguage !== "diff"
66 ? props.comment.fenceLanguage
67 : getFiletypeFromFileName(props.comment.filePath);
68 const addedRows = useMemo(
69 () => compactNativeRows.filter((row) => row.kind === "line" && row.change !== "delete"),
70 [compactNativeRows],
71 );
72 const deletedRows = useMemo(
73 () => compactNativeRows.filter((row) => row.kind === "line" && row.change === "delete"),
74 [compactNativeRows],
75 );
76 const addedTokens = useMarkdownCodeHighlight({
77 code: addedRows.map((row) => row.content ?? "").join("\n"),
78 language,
79 enabled: addedRows.length > 0,
80 theme: appearanceScheme,
81 });
82 const deletedTokens = useMarkdownCodeHighlight({
83 code: deletedRows.map((row) => row.content ?? "").join("\n"),
84 language,
85 enabled: deletedRows.length > 0,
86 theme: appearanceScheme,
87 });
88 const snippetTokens = useMarkdownCodeHighlight({
89 code: props.comment.diff.trim(),
90 language: props.comment.fenceLanguage ?? language,
91 enabled: compactNativeRows.length === 0,
92 theme: appearanceScheme,
93 });
94 const nativeTokensJson = useMemo(
95 () =>
96 JSON.stringify(
97 Object.fromEntries([
98 ...addedRows.map((row, index) => [row.id, addedTokens?.[index] ?? []]),
99 ...deletedRows.map((row, index) => [row.id, deletedTokens?.[index] ?? []]),
100 ]),
101 ),
102 [addedRows, deletedRows, addedTokens, deletedTokens],
103 );
104 const nativeReviewDiffTheme = useMemo(
105 () => createNativeReviewDiffTheme(appearanceScheme, themeId, appTheme),
106 [appearanceScheme, appTheme, themeId],
107 );
108 const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]);
109 const nativeThemeJson = useMemo(
110 () => JSON.stringify(nativeReviewDiffTheme),
111 [nativeReviewDiffTheme],
112 );
113 // The card's height is sized from its row count, so its snippet stays unwrapped.
114 const nativeStyleJson = useMemo(
115 () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }),
116 [nativeReviewDiffStyle],
117 );
118 const nativeDiffHeight = useMemo(
119 () =>
120 Math.min(
121 360,
122 Math.max(
123 48,
124 compactNativeRows.length * nativeReviewDiffStyle.rowHeight +
125 nativeReviewDiffStyle.fileHeaderVerticalMargin,
126 ),
127 ),
128 [compactNativeRows.length, nativeReviewDiffStyle],
129 );
130 const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0;
131
132 return (
133 <View
134 className="w-full overflow-hidden rounded-[16px] border border-continuous"
135 style={{
136 backgroundColor: props.colors.background,
137 borderColor: props.colors.border,
138 }}
139 >
140 <View
141 className="flex-row items-center gap-2 border-b px-3 py-2"
142 style={{ borderColor: props.colors.border }}
143 >
144 <View
145 className="size-6 items-center justify-center rounded-[7px] border-continuous"
146 style={{ backgroundColor: props.colors.mutedBackground }}
147 >
148 <SymbolView
149 name="doc.text"
150 size={13}
151 tintColor={props.colors.mutedText}
152 type="monochrome"
153 />
154 </View>
155 <View className="min-w-0 flex-1">
156 <Text
157 className="font-mono text-xs"
158 numberOfLines={1}
159 style={{ color: props.colors.text }}
160 >
161 {props.comment.filePath}
162 </Text>
163 <Text className="text-xs" style={{ color: props.colors.mutedText }}>
164 {props.comment.sectionTitle} · {props.comment.rangeLabel}
165 </Text>
166 </View>
167 </View>
168 {props.comment.text.length > 0 ? (
169 <View className="px-3 py-3">
170 <Text selectable className="text-base leading-snug" style={{ color: props.colors.text }}>
171 {props.comment.text}
172 </Text>
173 </View>
174 ) : null}
175 {shouldRenderNativeDiff ? (
176 <View
177 className="border-t"
178 collapsable={false}
179 style={{
180 backgroundColor: nativeReviewDiffTheme.background,
181 borderColor: props.colors.border,
182 height: nativeDiffHeight,
183 }}
184 >
185 <NativeReviewDiffView
186 collapsable={false}
187 style={StyleSheet.absoluteFill}
188 appearanceScheme={appearanceScheme}
189 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
190 rowHeight={nativeReviewDiffStyle.rowHeight}
191 rowsJson={nativeRowsJson}
192 tokensJson={nativeTokensJson}
193 styleJson={nativeStyleJson}
194 themeJson={nativeThemeJson}
195 />
196 </View>
197 ) : props.comment.diff.trim().length > 0 ? (
198 <ScrollView
199 horizontal
200 nestedScrollEnabled
201 directionalLockEnabled
202 showsHorizontalScrollIndicator={false}
203 bounces={false}
204 className="border-t"
205 style={{ backgroundColor: props.colors.codeBackground, borderColor: props.colors.border }}
206 contentContainerStyle={{ padding: 10 }}
207 >
208 <NativeText
209 selectable
210 className="font-mono"
211 style={{
212 color: props.colors.text,
213 fontFamily: REVIEW_MONO_FONT_FAMILY,
214 fontSize: codeSurface.fontSize,
215 lineHeight: codeSurface.rowHeight,
216 }}
217 >
218 {snippetTokens
219 ? snippetTokens.map((line, lineIndex) => (
220 <NativeText key={lineIndex}>
221 {lineIndex > 0 ? "\n" : ""}
222 {line.map((token, tokenIndex) => (
223 <NativeText
224 key={tokenIndex}
225 style={{
226 color: token.color ?? props.colors.text,
227 fontStyle: (token.fontStyle ?? 0) & 1 ? "italic" : "normal",
228 fontWeight: (token.fontStyle ?? 0) & 2 ? "700" : "400",
229 }}
230 >
231 {token.content}
232 </NativeText>
233 ))}
234 </NativeText>
235 ))
236 : props.comment.diff.trim()}
237 </NativeText>
238 </ScrollView>
239 ) : null}
240 </View>
241 );
242});
243
244function buildReviewCommentPatch(comment: ReviewInlineComment): string {
245 if ((comment.fenceLanguage ?? "diff") !== "diff") {
246 return "";
247 }
248 const diff = comment.diff.trim();
249 if (!diff) {
250 return "";
251 }
252
253 if (diff.startsWith("diff --git ")) {
254 return diff;
255 }
256
257 const normalizedPath = comment.filePath.replaceAll("\\", "/");
258 return [
259 `diff --git a/${normalizedPath} b/${normalizedPath}`,
260 `--- a/${normalizedPath}`,
261 `+++ b/${normalizedPath}`,
262 diff,
263 ].join("\n");
264}