1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import type { FileDiffMetadata } from "@pierre/diffs";
3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import { useEffect, useMemo } from "react";
5import { Platform, Pressable, View } from "react-native";
6import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import {
9 letterStopNotes,
10 stepTourPosition,
11 stopFilePaths,
12} from "@t3tools/client-runtime/review-guide-tour";
13import { SymbolView } from "../../../components/AppSymbol";
14import { GuideOutline } from "./GuideOutline";
15import { AppText as Text } from "../../../components/AppText";
16import { ScreenScrollView } from "../../../components/ScreenScrollView";
17import { ControlPillMenu } from "../../../components/ControlPillMenu";
18import { MaterialIconButton } from "../../../components/MaterialIconButton";
19import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider";
20import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../../native/StackHeader";
21import { PullRequestCardGroup, PullRequestCardRow } from "../PullRequestControls";
22import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown";
23import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff";
24import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt";
25import { reviewGuideState } from "./reviewGuideState";
26import { GuideNotice } from "./GuideNotice";
27import Animated from "react-native-reanimated";
28import { PR_ROW_ENTER, PR_ROW_EXIT, PR_ROW_LAYOUT } from "../pullRequestAnimations";
29import { useEnvironmentQuery } from "../../../state/query";
30import { composerPullRequests } from "../../../state/pull-requests";
31import { usePullRequestPermissions } from "../usePullRequestPermissions";
32import { pullRequestTarget } from "../usePullRequestDiff";
33import { useReviewGuide } from "./useReviewGuide";
34
35export type GuideStopParams = PullRequestParams & {
36 readonly position: number;
37 readonly guideHeadSha: string;
38 readonly guideGeneratedAt: string;
39};
40
41const 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
49export 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
93export 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}