1import { useMemo, useState } from "react";
2import { Pressable, View } from "react-native";
3import type { FileDiffMetadata } from "@pierre/diffs";
4import type { PullRequestReviewGuideExcerpt } from "@t3tools/contracts";
5import { excerptRows, fileDiffRows, rowsToPatch } from "@t3tools/client-runtime/review-guide-tour";
6import { AppText as Text } from "../../../components/AppText";
7import { SymbolView } from "../../../components/AppSymbol";
8import { resolveNativeReviewDiffView } from "../../diffs/nativeReviewDiffSurface";
9import { buildNativeReviewDiffData } from "../../review/nativeReviewDiffAdapter";
10import { buildReviewParsedDiff } from "../../review/reviewModel";
11import { useNativeReviewDiffBridge } from "../../review/useNativeReviewDiffBridge";
12import { useAppearanceCodeSurface } from "../../settings/appearance/useAppearanceCodeSurface";
13import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider";
14import { PullRequestMarkdown } from "../PullRequestMarkdown";
15
16const EMPTY_IDS: readonly string[] = [];
17
18export function ReviewGuideExcerpt({
19 excerpt,
20 file,
21 loading,
22 onOpen,
23}: {
24 readonly excerpt: PullRequestReviewGuideExcerpt;
25 readonly file: FileDiffMetadata | undefined;
26 readonly loading: boolean;
27 readonly onOpen: () => void;
28}) {
29 const { nativeReviewDiffStyle, codeWordBreak } = useAppearanceCodeSurface();
30 const { themeAppearance } = useAppearancePreferences();
31 const slice = useMemo(() => {
32 try {
33 if (!file) return "";
34 return rowsToPatch(
35 excerpt.path,
36 file.prevName ?? excerpt.path,
37 excerptRows(fileDiffRows(file), excerpt),
38 );
39 } catch {
40 return "";
41 }
42 }, [file, excerpt]);
43 const data = useMemo(() => {
44 const parsed = buildNativeReviewDiffData(
45 buildReviewParsedDiff(slice, `guide:${excerpt.path}:${excerpt.start}`),
46 );
47 // The compact header and real line numbers provide context without file/hunk chrome.
48 return {
49 ...parsed,
50 rows: parsed.rows.filter((row) => row.kind !== "file" && row.kind !== "hunk"),
51 };
52 }, [slice, excerpt.path, excerpt.start]);
53 const bridge = useNativeReviewDiffBridge({
54 threadKey: null,
55 sectionId: `guide:${excerpt.path}:${excerpt.start}`,
56 diff: slice,
57 data,
58 collapsedFileIds: EMPTY_IDS,
59 viewedFileIds: EMPTY_IDS,
60 selectedRowIds: EMPTY_IDS,
61 canHighlight: true,
62 });
63 const [measuredHeight, setMeasuredHeight] = useState<{ key: string; height: number } | null>(
64 null,
65 );
66 const measurementKey = `${slice}:${codeWordBreak}:${nativeReviewDiffStyle.rowHeight}`;
67 const [NativeDiff] = useState(() => resolveNativeReviewDiffView());
68 return (
69 <View className="gap-3">
70 {excerpt.caption.trim() ? <PullRequestMarkdown markdown={excerpt.caption} /> : null}
71 <View className="overflow-hidden rounded-lg border border-border-subtle">
72 <Pressable
73 accessibilityRole="button"
74 accessibilityLabel={`Open ${excerpt.path} at line ${excerpt.start} in full diff`}
75 onPress={onOpen}
76 className="min-h-11 flex-row items-center gap-3 border-b border-border-subtle px-3 py-2 active:opacity-60"
77 >
78 <Text
79 className="min-w-0 flex-1 text-xs text-foreground-muted"
80 numberOfLines={1}
81 ellipsizeMode="middle"
82 >
83 {excerpt.path}
84 </Text>
85 <Text className="text-2xs text-foreground-muted">
86 {excerpt.start}–{excerpt.end}
87 </Text>
88 <SymbolView name="arrow.up.right" size={13} tintColorClassName="accent-icon-muted" />
89 </Pressable>
90 {NativeDiff && data.rows.length ? (
91 <View
92 style={{
93 height:
94 measuredHeight?.key === measurementKey
95 ? measuredHeight.height
96 : data.rows.length * nativeReviewDiffStyle.rowHeight,
97 }}
98 >
99 <NativeDiff
100 scrollEnabled={false}
101 bounces={false}
102 appearanceScheme={themeAppearance}
103 rowHeight={nativeReviewDiffStyle.rowHeight}
104 contentWidth={2800}
105 style={{ flex: 1 }}
106 rowsJson={bridge.rowsJson}
107 themeJson={bridge.themeJson}
108 styleJson={bridge.styleJson}
109 tokensPatchJson={bridge.tokensPatchJson}
110 tokensResetKey={bridge.tokensResetKey}
111 onDebug={(event) => {
112 bridge.onDebug(event);
113 const { message, contentHeight } = event.nativeEvent;
114 if (message === "metrics" && typeof contentHeight === "number") {
115 setMeasuredHeight((current) =>
116 current?.key === measurementKey && current.height === contentHeight
117 ? current
118 : { key: measurementKey, height: contentHeight },
119 );
120 }
121 }}
122 onPressLine={onOpen}
123 />
124 </View>
125 ) : (
126 <Text className="p-3 text-xs text-foreground-muted">
127 {loading
128 ? `Loading ${excerpt.path}…`
129 : `${excerpt.path}:${excerpt.start}–${excerpt.end} is not in the loaded diff.`}
130 </Text>
131 )}
132 </View>
133 </View>
134 );
135}