| 1 | import { memo, useMemo, useState } from "react"; |
| 2 | import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; |
| 3 | import { ScrollView, StyleSheet, Text as NativeText, View, type ColorValue } from "react-native"; |
| 4 | import { AppText as Text } from "../../components/AppText"; |
| 5 | import { SymbolView } from "../../components/AppSymbol"; |
| 6 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 7 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 8 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 9 | import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; |
| 10 | import { |
| 11 | buildNativeReviewDiffData, |
| 12 | buildNativeReviewSnippetRows, |
| 13 | createNativeReviewDiffTheme, |
| 14 | NATIVE_REVIEW_DIFF_CONTENT_WIDTH, |
| 15 | } from "./nativeReviewDiffAdapter"; |
| 16 | import { buildReviewParsedDiff } from "./reviewModel"; |
| 17 | import { REVIEW_MONO_FONT_FAMILY } from "./reviewDiffRendering"; |
| 18 | import type { ReviewInlineComment } from "./reviewCommentSelection"; |
| 19 | import { useMarkdownCodeHighlight } from "../threads/markdownCodeHighlightState"; |
| 20 | |
| 21 | export 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 | |
| 30 | export 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 | |
| 46 | export 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 | |
| 244 | function 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 | } |