| 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; |
| 2 | import type { FileDiffMetadata } from "@pierre/diffs"; |
| 3 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 4 | import { useEffect, useMemo } from "react"; |
| 5 | import { Platform, Pressable, View } from "react-native"; |
| 6 | import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | import { |
| 9 | letterStopNotes, |
| 10 | stepTourPosition, |
| 11 | stopFilePaths, |
| 12 | } from "@t3tools/client-runtime/review-guide-tour"; |
| 13 | import { SymbolView } from "../../../components/AppSymbol"; |
| 14 | import { GuideOutline } from "./GuideOutline"; |
| 15 | import { AppText as Text } from "../../../components/AppText"; |
| 16 | import { ScreenScrollView } from "../../../components/ScreenScrollView"; |
| 17 | import { ControlPillMenu } from "../../../components/ControlPillMenu"; |
| 18 | import { MaterialIconButton } from "../../../components/MaterialIconButton"; |
| 19 | import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; |
| 20 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../../native/StackHeader"; |
| 21 | import { PullRequestCardGroup, PullRequestCardRow } from "../PullRequestControls"; |
| 22 | import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown"; |
| 23 | import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff"; |
| 24 | import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt"; |
| 25 | import { reviewGuideState } from "./reviewGuideState"; |
| 26 | import { GuideNotice } from "./GuideNotice"; |
| 27 | import Animated from "react-native-reanimated"; |
| 28 | import { PR_ROW_ENTER, PR_ROW_EXIT, PR_ROW_LAYOUT } from "../pullRequestAnimations"; |
| 29 | import { useEnvironmentQuery } from "../../../state/query"; |
| 30 | import { composerPullRequests } from "../../../state/pull-requests"; |
| 31 | import { usePullRequestPermissions } from "../usePullRequestPermissions"; |
| 32 | import { pullRequestTarget } from "../usePullRequestDiff"; |
| 33 | import { useReviewGuide } from "./useReviewGuide"; |
| 34 | |
| 35 | export type GuideStopParams = PullRequestParams & { |
| 36 | readonly position: number; |
| 37 | readonly guideHeadSha: string; |
| 38 | readonly guideGeneratedAt: string; |
| 39 | }; |
| 40 | |
| 41 | const attentionLabels = { |
| 42 | tradeoff: "A tradeoff to weigh", |
| 43 | contract: "A contract to check", |
| 44 | product: "A product call", |
| 45 | risk: "A risk to judge", |
| 46 | accessibility: "An accessibility call", |
| 47 | }; |
| 48 | |
| 49 | export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) { |
| 50 | const navigation = useNavigation(); |
| 51 | const state = useReviewGuide(params); |
| 52 | const { guide, stale } = reviewGuideState(state.data); |
| 53 | return ( |
| 54 | <Animated.View layout={PR_ROW_LAYOUT} className="w-full max-w-[760px] self-center"> |
| 55 | {!state.enabled ? ( |
| 56 | <Text>Review guides are disabled for this server.</Text> |
| 57 | ) : ( |
| 58 | <> |
| 59 | <GuideNotice state={state} /> |
| 60 | {guide ? ( |
| 61 | <Animated.View |
| 62 | key={`outline:${guide.headSha}:${guide.generatedAt}`} |
| 63 | layout={PR_ROW_LAYOUT} |
| 64 | entering={PR_ROW_ENTER} |
| 65 | exiting={PR_ROW_EXIT} |
| 66 | > |
| 67 | <GuideOutline |
| 68 | guide={guide} |
| 69 | onOpen={(position) => |
| 70 | navigation.dispatch( |
| 71 | StackActions.push("PullRequestGuideStop", { |
| 72 | ...params, |
| 73 | position, |
| 74 | guideHeadSha: guide.headSha, |
| 75 | guideGeneratedAt: guide.generatedAt, |
| 76 | }), |
| 77 | ) |
| 78 | } |
| 79 | onRewrite={ |
| 80 | state.canWrite && !state.running && !stale && !state.startError && !state.error |
| 81 | ? () => void state.write() |
| 82 | : undefined |
| 83 | } |
| 84 | /> |
| 85 | </Animated.View> |
| 86 | ) : null} |
| 87 | </> |
| 88 | )} |
| 89 | </Animated.View> |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideStopParams>) { |
| 94 | const navigation = useNavigation(); |
| 95 | const state = useReviewGuide(route.params); |
| 96 | const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(route.params))); |
| 97 | const permissions = usePullRequestPermissions(route.params, detail.data); |
| 98 | const canReview = |
| 99 | permissions.verdicts.length > 0 && detail.data?.headSha === route.params.guideHeadSha; |
| 100 | const { guide, stale } = reviewGuideState(state.data); |
| 101 | const position = Math.max(0, Math.min(route.params.position, (guide?.stops.length ?? 1) - 1)); |
| 102 | const stop = guide?.stops[Math.max(0, position)]; |
| 103 | const diff = usePullRequestDiff(route.params); |
| 104 | const excerptFiles = useMemo(() => { |
| 105 | const files = new Map<string, FileDiffMetadata>(); |
| 106 | try { |
| 107 | for (const patch of parsePatchFiles(diff.patch)) { |
| 108 | for (const file of patch.files) { |
| 109 | files.set(file.name, file); |
| 110 | if (file.prevName) files.set(file.prevName, file); |
| 111 | } |
| 112 | } |
| 113 | } catch { |
| 114 | // An omitted or unparseable patch is shown as unavailable by the excerpt. |
| 115 | } |
| 116 | return files; |
| 117 | }, [diff.patch]); |
| 118 | const wanted = useMemo(() => (stop ? stopFilePaths(stop) : []), [stop]); |
| 119 | const loadedPaths = new Set( |
| 120 | diff.parsed.kind === "files" ? diff.parsed.files.map((file) => file.path) : [], |
| 121 | ); |
| 122 | const missing = wanted.some((path) => !loadedPaths.has(path)); |
| 123 | const { error: diffError, loadMore } = diff; |
| 124 | useEffect(() => { |
| 125 | if (state.enabled && missing && !diffError) loadMore(); |
| 126 | }, [state.enabled, missing, diffError, loadMore]); |
| 127 | const insets = useSafeAreaInsets(); |
| 128 | const changedGuide = |
| 129 | guide !== null && |
| 130 | (guide.headSha !== route.params.guideHeadSha || |
| 131 | guide.generatedAt !== route.params.guideGeneratedAt); |
| 132 | const { appearance, setCodeWordBreak } = useAppearancePreferences(); |
| 133 | const notes = stop ? letterStopNotes(stop).lettered : []; |
| 134 | return ( |
| 135 | <View className="flex-1 bg-screen"> |
| 136 | <NativeStackScreenOptions |
| 137 | options={{ |
| 138 | title: `Stop ${route.params.position + 1}`, |
| 139 | headerTitle: `Stop ${route.params.position + 1} of ${guide?.stops.length ?? "…"}`, |
| 140 | }} |
| 141 | /> |
| 142 | {Platform.OS === "android" ? ( |
| 143 | <AndroidScreenHeader |
| 144 | title={`Stop ${route.params.position + 1}`} |
| 145 | onBack={() => navigation.goBack()} |
| 146 | trailing={ |
| 147 | <ControlPillMenu |
| 148 | actions={[ |
| 149 | { |
| 150 | id: "word-wrap", |
| 151 | title: "Word wrap", |
| 152 | image: "text.alignleft", |
| 153 | state: appearance.codeWordBreak ? "on" : "off", |
| 154 | }, |
| 155 | ]} |
| 156 | onPressAction={() => setCodeWordBreak(!appearance.codeWordBreak)} |
| 157 | > |
| 158 | <View pointerEvents="none"> |
| 159 | <MaterialIconButton icon="ellipsis" accessibilityLabel="Guide options" /> |
| 160 | </View> |
| 161 | </ControlPillMenu> |
| 162 | } |
| 163 | /> |
| 164 | ) : null} |
| 165 | <NativeHeaderToolbar placement="right"> |
| 166 | <NativeHeaderToolbar.Menu |
| 167 | title="Guide options" |
| 168 | accessibilityLabel="Guide options" |
| 169 | icon="ellipsis" |
| 170 | > |
| 171 | <NativeHeaderToolbar.MenuAction |
| 172 | icon="text.alignleft" |
| 173 | isOn={appearance.codeWordBreak} |
| 174 | onPress={() => setCodeWordBreak(!appearance.codeWordBreak)} |
| 175 | > |
| 176 | <NativeHeaderToolbar.Label>Word wrap</NativeHeaderToolbar.Label> |
| 177 | </NativeHeaderToolbar.MenuAction> |
| 178 | </NativeHeaderToolbar.Menu> |
| 179 | </NativeHeaderToolbar> |
| 180 | <PullRequestMarkdownRepository |
| 181 | params={route.params} |
| 182 | value={`https://${route.params.host ?? "github.com"}/${route.params.repository}`} |
| 183 | > |
| 184 | <ScreenScrollView |
| 185 | contentInsetAdjustmentBehavior="automatic" |
| 186 | contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 24, gap: 20 }} |
| 187 | > |
| 188 | <View className="w-full max-w-[760px] self-center gap-5"> |
| 189 | {!state.enabled ? ( |
| 190 | <Text>Review guides are disabled for this server.</Text> |
| 191 | ) : changedGuide ? ( |
| 192 | <Text>This guide was rewritten. Go back to start the new tour.</Text> |
| 193 | ) : !stop ? ( |
| 194 | <Text>{state.error ?? "Loading stop…"}</Text> |
| 195 | ) : ( |
| 196 | <> |
| 197 | <GuideNotice state={state} /> |
| 198 | <Text className="text-2xl leading-8 font-t3-semibold">{stop.title}</Text> |
| 199 | <PullRequestMarkdown markdown={stop.summary} /> |
| 200 | {stop.attention ? ( |
| 201 | <View className="gap-3 rounded-xl border border-border bg-grouped-card p-4"> |
| 202 | <View className="flex-row items-center gap-2"> |
| 203 | <SymbolView name="eye" size={17} tintColorClassName="accent-icon-muted" /> |
| 204 | <Text className="text-sm font-t3-semibold"> |
| 205 | {attentionLabels[stop.attention.kind]} |
| 206 | </Text> |
| 207 | </View> |
| 208 | <PullRequestMarkdown markdown={stop.attention.note} /> |
| 209 | </View> |
| 210 | ) : null} |
| 211 | {stale ? ( |
| 212 | <Text className="text-sm text-foreground-muted"> |
| 213 | Excerpts are hidden until the guide is rewritten for the current head. |
| 214 | </Text> |
| 215 | ) : ( |
| 216 | stop.excerpts.map((excerpt) => ( |
| 217 | <ReviewGuideExcerpt |
| 218 | key={`${guide?.headSha}:${JSON.stringify(excerpt)}`} |
| 219 | onOpen={() => |
| 220 | navigation.dispatch( |
| 221 | StackActions.push("PullRequestFile", { |
| 222 | ...route.params, |
| 223 | path: excerpt.path, |
| 224 | focus: { side: excerpt.side, start: excerpt.start, end: excerpt.end }, |
| 225 | fileSelectionId: `guide:${position}:${excerpt.path}:${excerpt.start}`, |
| 226 | }), |
| 227 | ) |
| 228 | } |
| 229 | excerpt={excerpt} |
| 230 | file={excerptFiles.get(excerpt.path)} |
| 231 | loading={diff.isPending || (missing && diff.nextCursor !== null)} |
| 232 | /> |
| 233 | )) |
| 234 | )} |
| 235 | {diff.error ? <Text className="text-danger-foreground">{diff.error}</Text> : null} |
| 236 | {notes.length ? ( |
| 237 | <View className="gap-4"> |
| 238 | <Text className="font-t3-semibold">Notes on this stop</Text> |
| 239 | {notes.map(({ marker, note }) => ( |
| 240 | <View key={marker} className="flex-row gap-3"> |
| 241 | <View className="h-7 w-7 items-center justify-center rounded-lg bg-grouped-card"> |
| 242 | <Text className="text-xs font-t3-semibold">{marker}</Text> |
| 243 | </View> |
| 244 | <View className="min-w-0 flex-1 gap-2"> |
| 245 | <Text className="font-t3-semibold">{note.title}</Text> |
| 246 | <Text className="text-xs text-foreground-muted"> |
| 247 | {note.path.split("/").at(-1)}:{note.line} |
| 248 | </Text> |
| 249 | <PullRequestMarkdown markdown={note.body} /> |
| 250 | </View> |
| 251 | </View> |
| 252 | ))} |
| 253 | </View> |
| 254 | ) : null} |
| 255 | {stop.files.length ? ( |
| 256 | <PullRequestCardGroup> |
| 257 | {stop.files.map((file, index) => ( |
| 258 | <PullRequestCardRow |
| 259 | key={file.path} |
| 260 | title={file.path} |
| 261 | detail={file.note} |
| 262 | icon="doc" |
| 263 | last={index === stop.files.length - 1} |
| 264 | onPress={() => |
| 265 | navigation.navigate("PullRequestFile", { |
| 266 | ...route.params, |
| 267 | path: file.path, |
| 268 | }) |
| 269 | } |
| 270 | /> |
| 271 | ))} |
| 272 | </PullRequestCardGroup> |
| 273 | ) : null} |
| 274 | {stop.questions.length ? ( |
| 275 | <View className="gap-3"> |
| 276 | <Text className="font-t3-semibold">Questions to consider</Text> |
| 277 | {stop.questions.map((question) => ( |
| 278 | <PullRequestMarkdown key={question} markdown={question} /> |
| 279 | ))} |
| 280 | </View> |
| 281 | ) : null} |
| 282 | <Pressable |
| 283 | accessibilityRole="button" |
| 284 | accessibilityLabel={ |
| 285 | guide && position < guide.stops.length - 1 |
| 286 | ? "Next stop" |
| 287 | : canReview |
| 288 | ? "Finish review" |
| 289 | : "Finish tour" |
| 290 | } |
| 291 | className="mt-2 flex-row items-center gap-4 border-t border-border-subtle pt-5 pb-2 active:opacity-60" |
| 292 | onPress={() => |
| 293 | guide && position < guide.stops.length - 1 |
| 294 | ? navigation.dispatch( |
| 295 | StackActions.push("PullRequestGuideStop", { |
| 296 | ...route.params, |
| 297 | position: stepTourPosition(position, 1, guide.stops.length), |
| 298 | }), |
| 299 | ) |
| 300 | : canReview |
| 301 | ? navigation.navigate("PullRequestReview", { |
| 302 | ...route.params, |
| 303 | mode: "review", |
| 304 | }) |
| 305 | : navigation.dispatch(StackActions.popTo("PullRequest", route.params)) |
| 306 | } |
| 307 | > |
| 308 | <View className="min-w-0 flex-1 gap-1"> |
| 309 | <Text className="text-xs text-foreground-muted"> |
| 310 | {guide && position < guide.stops.length - 1 |
| 311 | ? `Next · ${position + 2} of ${guide.stops.length}` |
| 312 | : "End of guide"} |
| 313 | </Text> |
| 314 | <Text className="text-base font-t3-semibold"> |
| 315 | {guide && position < guide.stops.length - 1 |
| 316 | ? guide.stops[position + 1]?.title |
| 317 | : canReview |
| 318 | ? "Finish review" |
| 319 | : "Finish tour"} |
| 320 | </Text> |
| 321 | </View> |
| 322 | <View className="h-11 w-11 items-center justify-center rounded-full bg-foreground"> |
| 323 | <SymbolView |
| 324 | name={ |
| 325 | guide && position < guide.stops.length - 1 ? "arrow.right" : "checkmark" |
| 326 | } |
| 327 | size={20} |
| 328 | tintColorClassName="accent-screen" |
| 329 | /> |
| 330 | </View> |
| 331 | </Pressable> |
| 332 | </> |
| 333 | )} |
| 334 | </View> |
| 335 | </ScreenScrollView> |
| 336 | </PullRequestMarkdownRepository> |
| 337 | </View> |
| 338 | ); |
| 339 | } |