| 1 | import { useMemo, useState } from "react"; |
| 2 | import { Pressable, View } from "react-native"; |
| 3 | import type { FileDiffMetadata } from "@pierre/diffs"; |
| 4 | import type { PullRequestReviewGuideExcerpt } from "@t3tools/contracts"; |
| 5 | import { excerptRows, fileDiffRows, rowsToPatch } from "@t3tools/client-runtime/review-guide-tour"; |
| 6 | import { AppText as Text } from "../../../components/AppText"; |
| 7 | import { SymbolView } from "../../../components/AppSymbol"; |
| 8 | import { resolveNativeReviewDiffView } from "../../diffs/nativeReviewDiffSurface"; |
| 9 | import { buildNativeReviewDiffData } from "../../review/nativeReviewDiffAdapter"; |
| 10 | import { buildReviewParsedDiff } from "../../review/reviewModel"; |
| 11 | import { useNativeReviewDiffBridge } from "../../review/useNativeReviewDiffBridge"; |
| 12 | import { useAppearanceCodeSurface } from "../../settings/appearance/useAppearanceCodeSurface"; |
| 13 | import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; |
| 14 | import { PullRequestMarkdown } from "../PullRequestMarkdown"; |
| 15 | |
| 16 | const EMPTY_IDS: readonly string[] = []; |
| 17 | |
| 18 | export 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 | } |