1import { pullRequestTabOffset } from "./pullRequestTabScroll";
2import { guideFocusedRows, type GuideDiffFocus } from "./guide/guideDiffFocus";
3import { PullRequestGuideContent } from "./guide/PullRequestGuideScreen";
4import { useReviewGuidesEnabled } from "./guide/useReviewGuide";
5import {
6 filterPullRequestActivity,
7 DEFAULT_ACTIVITY_FILTER,
8 type ActivityFilter,
9} from "./pullRequestActivityFilter";
10import { PullRequestActivityFilters } from "./PullRequestActivityFilters";
11import type { LegendListRef } from "@legendapp/list/react-native";
12import { AnimatedLegendList } from "@legendapp/list/reanimated";
13import Animated from "react-native-reanimated";
14import {
15 PR_ROW_LAYOUT,
16 PR_ROW_ENTER,
17 PR_ROW_EXIT,
18 PR_POSITION_LAYOUT,
19} from "./pullRequestAnimations";
20import {
21 canPerformPullRequestAction,
22 openOnProviderLabel,
23 PR_ACTION_ICONS,
24 PR_ACTION_LABELS,
25} from "./pullRequestActions";
26import * as Clipboard from "expo-clipboard";
27import { ControlPillMenu } from "../../components/ControlPillMenu";
28import { MaterialIconButton } from "../../components/MaterialIconButton";
29import { MaterialButton } from "../../components/MaterialButton";
30import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
31import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed";
32import { usePullRequestPermissions } from "./usePullRequestPermissions";
33import { usePullRequestAction } from "./usePullRequestAction";
34import { annotatePullRequestDiff, reviewPosition } from "./pullRequestDiffComments";
35import { usePullRequestFileFilter } from "./usePullRequestFileFilter";
36import { isPullRequestTestFile } from "./pullRequestFileFilters";
37import {
38 StackActions,
39 useFocusEffect,
40 useNavigation,
41 useRoute,
42 type StaticScreenProps,
43} from "@react-navigation/native";
44import { useAtomRefresh } from "@effect/atom-react";
45import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
46import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts";
47import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading";
48import { pullRequestStatus } from "./pullRequestStatus";
49import { useCallback, useEffect, useMemo, useRef, useState } from "react";
50import {
51 ActivityIndicator,
52 Linking,
53 Platform,
54 Pressable,
55 RefreshControl,
56 ScrollView,
57 type ScrollViewProps,
58 StyleSheet,
59 View,
60} from "react-native";
61import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker";
62import { useSafeAreaInsets } from "react-native-safe-area-context";
63
64import { AppText as Text } from "../../components/AppText";
65import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
66import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
67import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
68import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
69import { useAndroidWorkspaceBack } from "../workspace/useAndroidWorkspaceNavigation";
70import { EmptyState } from "../../components/EmptyState";
71import { SegmentedControl } from "../../components/SegmentedControl";
72import { SymbolView } from "../../components/AppSymbol";
73import { relativeTime } from "../../lib/time";
74import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
75import { usePullRequestConnected } from "./usePullRequestConnected";
76import { composerPullRequests } from "../../state/pull-requests";
77import { useEnvironmentQuery } from "../../state/query";
78import { usePullToRefresh } from "../../state/use-pull-to-refresh";
79import {
80 isNativeReviewDiffDrawEvent,
81 resolveNativeReviewDiffView,
82 type NativeReviewDiffViewHandle,
83 type NativeReviewDiffViewProps,
84} from "../diffs/nativeReviewDiffSurface";
85import {
86 buildNativeReviewDiffData,
87 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
88} from "../review/nativeReviewDiffAdapter";
89import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
90import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
91import { useNativeReviewDiffBridge } from "../review/useNativeReviewDiffBridge";
92import { pullRequestTimelineRows } from "./PullRequestTimeline";
93import { PullRequestActionToolbar, PullRequestCardGroup } from "./PullRequestControls";
94import {
95 pullRequestTarget as requestTarget,
96 usePullRequestDiff,
97 type PullRequestDiffParams,
98 type PullRequestParams,
99} from "./usePullRequestDiff";
100import {
101 PullRequestAvatar,
102 PullRequestDiffStats,
103 PullRequestReadStatus,
104} from "./PullRequestPresentation";
105import { PullRequestFiles, usePullRequestFilesContent } from "./PullRequestFiles";
106import { PullRequestSheetContent } from "./PullRequestSheetContent";
107import { PullRequestDescription } from "./PullRequestDescription";
108import { PullRequestMarkdownRepository } from "./PullRequestMarkdown";
109import {
110 PullRequestStatusMenu,
111 PullRequestChecksRow,
112 PullRequestBranchRow,
113 PullRequestReviewersRow,
114} from "./PullRequestStatusRows";
115import { PullRequestMergeControls } from "./PullRequestMergeControls";
116import { PullRequestStackCard } from "./PullRequestStackCard";
117import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack";
118import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback";
119
120const EMPTY_IDS: readonly string[] = [];
121
122function PullRequestTabScrollView(props: ScrollViewProps) {
123 const insets = useSafeAreaInsets();
124 return (
125 <ScrollViewMarker style={{ flex: 1 }}>
126 <ScrollView
127 {...props}
128 automaticallyAdjustContentInsets={false}
129 contentInsetAdjustmentBehavior="never"
130 contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }}
131 />
132 </ScrollViewMarker>
133 );
134}
135
136function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityFilter) {
137 const navigation = useNavigation();
138 const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));
139 const filtered = useMemo(
140 () => (result.data ? filterPullRequestActivity(result.data, filter) : null),
141 [result.data, filter],
142 );
143 const rows = useMemo(() => {
144 if (!filtered) return [];
145 const rows = pullRequestTimelineRows(filtered.activity, params, navigation);
146 if (filter.newestFirst) {
147 const notice = rows.at(-1)?.key === "truncated" ? rows.pop() : undefined;
148 rows.reverse();
149 if (notice) rows.push(notice);
150 }
151 if (!rows.length)
152 rows.push({
153 key: "empty",
154 date: "",
155 content: (
156 <Text className="py-6 text-sm text-foreground-muted">
157 No activity matches these filters.
158 </Text>
159 ),
160 });
161 return rows;
162 }, [filtered, filter.newestFirst, params, navigation]);
163 return {
164 rows,
165 hiddenCount: filtered?.hiddenCount ?? 0,
166 pending: result.isPending,
167 error: result.error,
168 };
169}
170
171function PullRequestIdentity(props: {
172 readonly pr: PullRequestDetail | PullRequestListEntry;
173 readonly params: PullRequestParams;
174}) {
175 const { pr } = props;
176 const status = pullRequestStatus(pr);
177 return (
178 <View className="gap-3">
179 <View className="flex-row flex-wrap items-center gap-2">
180 {"capabilities" in pr ? (
181 <PullRequestStatusMenu pr={pr} params={props.params} />
182 ) : (
183 <View className="min-h-8 flex-row items-center gap-1.5">
184 <SymbolView name={status.icon} size={14} tintColorClassName={status.tint} />
185 <Text className={`text-xs font-t3-medium ${status.text}`}>{status.label}</Text>
186 </View>
187 )}
188 <PullRequestDiffStats additions={pr.additions} deletions={pr.deletions} compact />
189 {"changedFiles" in pr ? (
190 <Text className="text-2xs text-foreground-muted">
191 {pr.changedFiles} {pr.changedFiles === 1 ? "file" : "files"}
192 </Text>
193 ) : (
194 <View className="h-3 w-12 rounded bg-subtle-strong" />
195 )}
196 </View>
197 <Text selectable className="text-xl font-t3-bold leading-7">
198 {pr.title}
199 </Text>
200 <View className="flex-row items-center gap-2">
201 <PullRequestAvatar actor={pr.author} size={20} />
202 <Text className="min-w-0 flex-1 text-xs font-t3-medium" numberOfLines={1}>
203 {pr.author?.login ?? "Unknown author"}
204 </Text>
205 <Text className="shrink text-right text-2xs text-foreground-muted" numberOfLines={1}>
206 Updated {relativeTime(pr.updatedAt)} ago
207 </Text>
208 </View>
209 <View className="flex-row items-start gap-1.5">
210 <Pressable
211 accessibilityRole="button"
212 accessibilityLabel={`Copy base branch ${pr.baseBranch}`}
213 onPress={() => void Clipboard.setStringAsync(pr.baseBranch)}
214 className="min-w-0 shrink active:opacity-70"
215 hitSlop={8}
216 >
217 <Text className="font-mono text-2xs text-foreground-muted">{pr.baseBranch}</Text>
218 </Pressable>
219 <View className="pt-0.5">
220 <SymbolView name="arrow.left" size={11} tintColorClassName="accent-icon-muted" />
221 </View>
222 <Pressable
223 accessibilityRole="button"
224 accessibilityLabel={`Copy head branch ${pr.headBranch}`}
225 onPress={() => void Clipboard.setStringAsync(pr.headBranch)}
226 className="min-w-0 shrink active:opacity-70"
227 hitSlop={8}
228 >
229 <Text className="font-mono text-2xs text-foreground-muted">{pr.headBranch}</Text>
230 </Pressable>
231 </View>
232 </View>
233 );
234}
235
236function PullRequestOverview(props: {
237 readonly pr: PullRequestDetail;
238 readonly params: PullRequestParams;
239 readonly stack: PullRequestStackState;
240 readonly onDescriptionLayout: (y: number) => void;
241 readonly onDescriptionCollapse: () => void;
242 readonly descriptionExpanded: boolean;
243 readonly onDescriptionExpandedChange: (expanded: boolean) => void;
244}) {
245 const { pr } = props;
246 return (
247 <View className="gap-5">
248 <PullRequestStackCard params={props.params} state={props.stack} />
249 <PullRequestCardGroup>
250 <PullRequestChecksRow pr={pr} params={props.params} />
251 <PullRequestReviewersRow pr={pr} params={props.params} />
252 <Animated.View layout={PR_ROW_LAYOUT}>
253 <PullRequestBranchRow pr={pr} params={props.params} />
254 </Animated.View>
255 <Animated.View layout={PR_ROW_LAYOUT}>
256 <PullRequestMergeControls pr={pr} params={props.params} stack={props.stack} />
257 </Animated.View>
258 </PullRequestCardGroup>
259 {pr.labels.length ? (
260 <Animated.View layout={PR_ROW_LAYOUT} className="flex-row flex-wrap gap-2">
261 {pr.labels.map((label) => (
262 <View key={label.name} className="rounded-full bg-grouped-card px-3 py-1">
263 <Text className="text-2xs text-foreground-muted">{label.name}</Text>
264 </View>
265 ))}
266 </Animated.View>
267 ) : null}
268 <Animated.View
269 layout={PR_POSITION_LAYOUT}
270 onLayout={(event) => props.onDescriptionLayout(event.nativeEvent.layout.y)}
271 >
272 <PullRequestDescription
273 markdown={pr.body}
274 onCollapse={props.onDescriptionCollapse}
275 expanded={props.descriptionExpanded}
276 onExpandedChange={props.onDescriptionExpandedChange}
277 />
278 </Animated.View>
279 </View>
280 );
281}
282
283function usePullRequestToolbarHeight(bottomInset: number) {
284 const { buttonSize } = useAndroidControlSizing();
285 // Reserve the controls, native toolbar padding, and outer gaps before onLayout measures them.
286 return useState(() => (Platform.OS === "android" ? bottomInset + buttonSize + 40 : 0));
287}
288
289export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestParams>) {
290 const returnToList = useAndroidWorkspaceBack("pullRequests");
291 const { layout } = useAdaptiveWorkspaceLayout();
292 const navigation = useNavigation();
293 const insets = useSafeAreaInsets();
294 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
295 const params = route.params;
296 usePullRequestEnvironmentFallback(params);
297 const connected = usePullRequestConnected(params.environmentId);
298 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
299 // Keep each tab's native list and measured rows alive when it is hidden.
300 const requestedSection = useRef(section);
301 const [listAnimationsReady, setListAnimationsReady] = useState(false);
302 const [descriptionExpanded, setDescriptionExpanded] = useState(false);
303 const filesContent = usePullRequestFilesContent({ params });
304 const guidesEnabled = useReviewGuidesEnabled(params);
305 const [activityFilter, setActivityFilter] = useState<ActivityFilter>(DEFAULT_ACTIVITY_FILTER);
306 const activityContent = usePullRequestActivityRows(params, activityFilter);
307 const scrollOffsets = useRef<Record<string, number>>({});
308 const currentScrollOffset = useRef(-insets.top);
309 const bodyY = useRef(0);
310 const [headerHeight, setHeaderHeight] = useState(0);
311 const [viewportHeight, setViewportHeight] = useState(0);
312 const scrollViews = useRef<Partial<Record<typeof section, LegendListRef | null>>>({});
313 const selectSection = (next: typeof section) => {
314 if (next === section) return;
315 scrollOffsets.current[section] = currentScrollOffset.current;
316 const headerEnd = Math.max(-insets.top, bodyY.current - insets.top);
317 // The shared header stays in place. Once collapsed, retain each tab's own reading position.
318 const offset = pullRequestTabOffset(
319 currentScrollOffset.current,
320 scrollOffsets.current[next],
321 headerEnd,
322 );
323 requestedSection.current = next;
324 scrollOffsets.current[next] = offset;
325 currentScrollOffset.current = offset;
326 scrollViews.current[next]?.scrollToOffset({ offset, animated: false });
327 setSection(next);
328 };
329 useEffect(() => {
330 if (!guidesEnabled && section === "guide") {
331 requestedSection.current = "overview";
332 currentScrollOffset.current = scrollOffsets.current.overview ?? -insets.top;
333 setSection("overview");
334 }
335 }, [guidesEnabled, section, insets.top]);
336 const tabContent = activityContent;
337 const filesY = useRef(0);
338 const descriptionY = useRef(0);
339 const pendingDescriptionJump = useRef(false);
340 const pendingFilesJump = useRef(false);
341 const detailAtom = composerPullRequests.detail(requestTarget(params));
342 const result = useEnvironmentQuery(detailAtom);
343 const pullRefresh = usePullToRefresh(detailAtom);
344 const { refresh: refreshDetail } = result;
345 useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail]));
346 const pr = result.data;
347 const summary = params.initialSummary;
348 const identity = pr ?? summary;
349 const permissions = usePullRequestPermissions(params, pr);
350 const action = usePullRequestAction(params, pr);
351 const showsDeleteBranch = pr !== null && canPerformPullRequestAction(pr, "delete-branch");
352 const confirmDeleteBranch = () =>
353 pr &&
354 action.confirm(
355 "delete-branch",
356 PR_ACTION_LABELS["delete-branch"],
357 `Delete ${pr.headBranch} from ${pr.headRepositoryNameWithOwner ?? pr.repository}? Local branches and worktrees are not changed.`,
358 );
359 const stack = usePullRequestStack(params, pr);
360 const refreshActivity = useAtomRefresh(composerPullRequests.activity(requestTarget(params)));
361 const refreshGuide = useAtomRefresh(composerPullRequests.reviewGuide(requestTarget(params)));
362 const refresh = () => {
363 pullRefresh.onRefresh();
364 if (guidesEnabled) refreshGuide();
365 stack.refresh();
366 refreshActivity();
367 filesContent.refresh();
368 };
369 const jumpToFiles = () => {
370 scrollViews.current.overview?.scrollToOffset({
371 offset: bodyY.current + filesY.current - insets.top,
372 animated: true,
373 });
374 pendingFilesJump.current = false;
375 };
376 const openFiles = () => {
377 if (section === "overview") jumpToFiles();
378 else {
379 pendingFilesJump.current = true;
380 selectSection("overview");
381 if (filesY.current > 0) requestAnimationFrame(jumpToFiles);
382 }
383 };
384 return (
385 <PullRequestMarkdownRepository
386 params={params}
387 value={pr?.provider === "github" ? pr.url : null}
388 >
389 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
390 <NativeStackScreenOptions
391 options={{
392 title: `#${params.number}`,
393 headerTitle: `#${params.number}`,
394 unstable_headerSubtitle: connected
395 ? params.repository
396 : `${params.repository} · Offline`,
397 }}
398 />
399 {Platform.OS === "android" ? (
400 <AndroidScreenHeader
401 title={`#${params.number}${connected ? "" : " · Offline"}`}
402 subtitle={params.repository}
403 onBack={layout.usesSplitView ? undefined : returnToList}
404 leading={layout.usesSplitView ? <AndroidWorkspaceSidebarButton /> : undefined}
405 actions={[
406 ...(permissions.verdicts.length
407 ? [
408 {
409 accessibilityLabel: "Submit review",
410 icon: "text.bubble" as const,
411 onPress: () =>
412 navigation.navigate("PullRequestReview", { ...params, mode: "review" }),
413 },
414 ]
415 : []),
416 ...(permissions.canComment
417 ? [
418 {
419 accessibilityLabel: "Add comment",
420 icon: "plus.bubble" as const,
421 onPress: () =>
422 navigation.navigate("PullRequestReview", { ...params, mode: "comment" }),
423 },
424 ]
425 : []),
426 ...(pr
427 ? [
428 {
429 accessibilityLabel: openOnProviderLabel(pr.provider),
430 icon: "arrow.up.right" as const,
431 onPress: () => void Linking.openURL(pr.url),
432 },
433 ]
434 : []),
435 ...(showsDeleteBranch
436 ? [
437 {
438 accessibilityLabel: PR_ACTION_LABELS["delete-branch"],
439 icon: PR_ACTION_ICONS["delete-branch"],
440 disabled: !action.available("delete-branch"),
441 destructive: true,
442 overflowOnly: true,
443 onPress: confirmDeleteBranch,
444 },
445 ]
446 : []),
447 ]}
448 />
449 ) : null}
450 <NativeHeaderToolbar placement="right">
451 <NativeHeaderToolbar.Menu
452 title="Pull request"
453 accessibilityLabel="Pull request actions"
454 icon="ellipsis"
455 >
456 {permissions.verdicts.length ? (
457 <NativeHeaderToolbar.MenuAction
458 icon="text.bubble"
459 onPress={() =>
460 navigation.navigate("PullRequestReview", { ...params, mode: "review" })
461 }
462 >
463 <NativeHeaderToolbar.Label>Submit review</NativeHeaderToolbar.Label>
464 </NativeHeaderToolbar.MenuAction>
465 ) : null}
466 {permissions.canComment ? (
467 <NativeHeaderToolbar.MenuAction
468 icon="plus.bubble"
469 onPress={() =>
470 navigation.navigate("PullRequestReview", { ...params, mode: "comment" })
471 }
472 >
473 <NativeHeaderToolbar.Label>Add comment</NativeHeaderToolbar.Label>
474 </NativeHeaderToolbar.MenuAction>
475 ) : null}
476 {pr ? (
477 <NativeHeaderToolbar.MenuAction
478 icon="arrow.up.right"
479 onPress={() => void Linking.openURL(pr.url)}
480 >
481 <NativeHeaderToolbar.Label>
482 {openOnProviderLabel(pr.provider)}
483 </NativeHeaderToolbar.Label>
484 </NativeHeaderToolbar.MenuAction>
485 ) : null}
486 {showsDeleteBranch ? (
487 <NativeHeaderToolbar.MenuAction
488 icon={PR_ACTION_ICONS["delete-branch"]}
489 destructive
490 disabled={!action.available("delete-branch")}
491 subtitle={
492 action.busy
493 ? "Deleting…"
494 : connected
495 ? pr.headBranch
496 : "Reconnect to delete this branch."
497 }
498 onPress={confirmDeleteBranch}
499 >
500 <NativeHeaderToolbar.Label>
501 {PR_ACTION_LABELS["delete-branch"]}
502 </NativeHeaderToolbar.Label>
503 </NativeHeaderToolbar.MenuAction>
504 ) : null}
505 </NativeHeaderToolbar.Menu>
506 </NativeHeaderToolbar>
507 <View
508 style={{
509 flex: 1,
510 marginHorizontal: Platform.OS === "android" && layout.usesSplitView ? 4 : 0,
511 }}
512 className={
513 Platform.OS === "android"
514 ? "overflow-hidden rounded-t-[28px] bg-sheet-solid"
515 : undefined
516 }
517 onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}
518 >
519 {(["overview", "guide", "activity"] as const)
520 .filter((tab) => tab !== "guide" || guidesEnabled)
521 .map((tab) => (
522 <View
523 key={tab}
524 style={[StyleSheet.absoluteFill, { opacity: section === tab ? 1 : 0 }]}
525 pointerEvents={section === tab ? "auto" : "none"}
526 accessibilityElementsHidden={section !== tab}
527 importantForAccessibility={section === tab ? "auto" : "no-hide-descendants"}
528 >
529 <AnimatedLegendList
530 ref={(list) => {
531 scrollViews.current[tab] = list;
532 }}
533 initialScrollOffset={-insets.top}
534 onScroll={(event) => {
535 const offset = event.nativeEvent.contentOffset.y;
536 scrollOffsets.current[tab] = offset;
537 if (requestedSection.current === tab) currentScrollOffset.current = offset;
538 }}
539 className="flex-1"
540 renderScrollComponent={
541 Platform.OS === "ios" ? PullRequestTabScrollView : undefined
542 }
543 contentInsetAdjustmentBehavior="never"
544 contentInset={
545 Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined
546 }
547 contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0}
548 refreshControl={
549 <RefreshControl
550 refreshing={section === tab && pullRefresh.refreshing}
551 onRefresh={refresh}
552 />
553 }
554 alwaysBounceVertical
555 scrollsToTop={section === tab}
556 onContentSizeChange={() => {
557 if (
558 tab !== "overview" ||
559 section !== "overview" ||
560 !pendingDescriptionJump.current
561 )
562 return;
563 pendingDescriptionJump.current = false;
564 // Collapse changes the scroll range; restore the heading after that layout commits.
565 scrollViews.current.overview?.scrollToOffset({
566 offset: bodyY.current + descriptionY.current - insets.top,
567 animated: false,
568 });
569 }}
570 contentContainerStyle={{
571 paddingHorizontal: 16,
572 paddingTop: 8,
573 paddingBottom: 24 + (Platform.OS === "android" ? toolbarHeight : 0),
574 // Short tabs must still be tall enough to preserve the collapsed header.
575 minHeight: Math.max(
576 0,
577 viewportHeight + headerHeight - insets.top - insets.bottom,
578 ),
579 }}
580 data={[
581 {
582 key: `${tab}:body`,
583 content: (
584 <View>
585 {tab === "overview" && identity ? (
586 <View>
587 {pr ? (
588 <PullRequestOverview
589 pr={pr}
590 params={params}
591 stack={stack}
592 descriptionExpanded={descriptionExpanded}
593 onDescriptionExpandedChange={setDescriptionExpanded}
594 onDescriptionLayout={(y) => {
595 descriptionY.current = y;
596 }}
597 onDescriptionCollapse={() => {
598 pendingDescriptionJump.current = true;
599 }}
600 />
601 ) : summary ? (
602 <PullRequestOverviewLoading
603 summary={summary}
604 params={params}
605 stack={stack}
606 />
607 ) : null}
608 <View
609 className="mt-8 border-t border-border-subtle pt-3"
610 onLayout={(event) => {
611 filesY.current = event.nativeEvent.layout.y;
612 if (pendingFilesJump.current) {
613 requestAnimationFrame(() => {
614 if (requestedSection.current === "overview") jumpToFiles();
615 });
616 }
617 }}
618 >
619 {filesContent.header}
620 </View>
621 </View>
622 ) : null}
623 {tab === "guide" ? <PullRequestGuideContent params={params} /> : null}
624 {tab === "activity" ? (
625 <PullRequestActivityFilters
626 value={activityFilter}
627 onChange={setActivityFilter}
628 hiddenCount={tabContent.hiddenCount}
629 />
630 ) : null}
631 {tab === "activity" ? (
632 <PullRequestReadStatus
633 pending={tabContent.pending}
634 error={tabContent.error}
635 />
636 ) : null}
637 </View>
638 ),
639 },
640 ...(tab === "overview"
641 ? identity
642 ? filesContent.rows
643 : []
644 : tab === "activity"
645 ? tabContent.rows
646 : []
647 ).map((row) => ({
648 ...row,
649 key: `${tab}:${row.key}`,
650 content: row.content,
651 })),
652 ]}
653 keyExtractor={(row) => row.key}
654 getItemType={(row) =>
655 row.key.endsWith(":body") ? row.key : row.key.split(":")[0]
656 }
657 // The overview body contains the clipped description and owns collapse scroll
658 // correction. Animating that cell fights both; file rows still fade individually.
659 itemLayoutAnimation={tab === "overview" ? undefined : PR_ROW_LAYOUT}
660 onLoad={() => setListAnimationsReady(true)}
661 renderItem={({ item }) => (
662 <Animated.View
663 key={item.key}
664 entering={
665 listAnimationsReady && !item.key.endsWith(":body")
666 ? PR_ROW_ENTER
667 : undefined
668 }
669 exiting={
670 listAnimationsReady && !item.key.endsWith(":body") ? PR_ROW_EXIT : undefined
671 }
672 >
673 {item.content}
674 </Animated.View>
675 )}
676 recycleItems={false}
677 estimatedItemSize={tab === "overview" ? 64 : 220}
678 maintainVisibleContentPosition={false}
679 ListHeaderComponent={
680 <>
681 <PullRequestReadStatus
682 pending={result.isPending && !identity}
683 error={result.error}
684 />
685 {identity ? (
686 <View
687 className="gap-5 pb-5"
688 onLayout={(event) => {
689 bodyY.current = event.nativeEvent.layout.height;
690 setHeaderHeight(event.nativeEvent.layout.height);
691 }}
692 >
693 <PullRequestIdentity pr={identity} params={params} />
694 <SegmentedControl
695 className={Platform.OS === "android" ? undefined : "bg-grouped-card"}
696 options={[
697 { value: "overview", label: "Overview" },
698 ...(guidesEnabled
699 ? [{ value: "guide" as const, label: "Guide" }]
700 : []),
701 { value: "activity", label: "Activity" },
702 ]}
703 selected={section}
704 onSelect={selectSection}
705 role="tab"
706 size="compact"
707 />
708 </View>
709 ) : null}
710 </>
711 }
712 ListFooterComponent={tab === "overview" ? filesContent.footer : null}
713 />
714 </View>
715 ))}
716 </View>
717 <PullRequestActionToolbar
718 pr={pr}
719 params={params}
720 onOpenFiles={openFiles}
721 onHeightChange={setToolbarHeight}
722 />
723 </View>
724 </PullRequestMarkdownRepository>
725 );
726}
727
728export function PullRequestFilesRouteScreen({ route }: StaticScreenProps<PullRequestDiffParams>) {
729 const navigation = useNavigation();
730 const fileSelectionId = useRoute().key;
731 return (
732 <PullRequestSheetContent
733 title="Changed files"
734 scrollable={false}
735 subtitle={`#${route.params.number}${route.params.commit ? ` · ${route.params.commit.slice(0, 7)}` : ` · ${route.params.repository}`}`}
736 >
737 <PullRequestFiles
738 params={route.params}
739 groupByDirectory
740 onSelectFile={(path) =>
741 navigation.dispatch(
742 StackActions.popTo("PullRequestFile", {
743 ...route.params,
744 path,
745 fileSelectionId,
746 }),
747 )
748 }
749 />
750 </PullRequestSheetContent>
751 );
752}
753
754export function PullRequestFileRouteScreen({
755 route,
756}: StaticScreenProps<
757 PullRequestDiffParams & {
758 readonly path: string;
759 readonly fileSelectionId?: string;
760 readonly replacingDraftId?: string;
761 readonly focus?: GuideDiffFocus;
762 }
763>) {
764 const { layout } = useAdaptiveWorkspaceLayout();
765 const navigation = useNavigation();
766 const insets = useSafeAreaInsets();
767 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
768 const commit = route.params.commit;
769 const result = usePullRequestDiff(route.params);
770 const filter = usePullRequestFileFilter(
771 result.parsed,
772 route.params.focus ? route.params.path : undefined,
773 );
774 const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params)));
775 const { refresh: refreshDetail } = detail;
776 useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail]));
777 const { loadMore } = result;
778 const parsed = filter.parsed;
779 const reviewDraft = usePullRequestReviewDraft(route.params);
780 const viewed = usePullRequestFilesViewed(route.params, commit ? null : detail.data);
781 const [commitViewedPaths, setCommitViewedPaths] = useState<ReadonlySet<string>>(() => new Set());
782 const permissions = usePullRequestPermissions(route.params, detail.data);
783 const scopeKey = pullRequestReviewKey(route.params);
784 const [diffHead, setDiffHead] = useState({ scopeKey, sha: detail.data?.headSha ?? null });
785 if (diffHead.scopeKey !== scopeKey || (!diffHead.sha && detail.data?.headSha))
786 setDiffHead({ scopeKey, sha: detail.data?.headSha ?? null });
787 const staleDiff = Boolean(
788 !commit && diffHead.sha && detail.data?.headSha && diffHead.sha !== detail.data.headSha,
789 );
790 const canCommentOnDiff =
791 permissions.canInlineComment &&
792 !commit &&
793 !staleDiff &&
794 !detail.isPending &&
795 !result.isPending &&
796 !result.error;
797 const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(route.params)));
798 const baseData = useMemo(() => buildNativeReviewDiffData(parsed), [parsed]);
799 const data = useMemo(
800 () =>
801 annotatePullRequestDiff(
802 baseData,
803 staleDiff || commit ? [] : (activity.data?.reviewThreads ?? []),
804 commit ? [] : reviewDraft.draft.comments,
805 diffHead.sha ?? undefined,
806 ),
807 [baseData, activity.data, reviewDraft.draft.comments, diffHead.sha, staleDiff, commit],
808 );
809 const viewedIds = data.files
810 .filter((file) => (commit ? commitViewedPaths.has(file.path) : viewed.isViewed(file.path)))
811 .map((file) => file.id);
812 const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
813 const selectedRow = data.rows.find((row) => row.id === selectedRowId);
814 const selectedFile = data.files.find((file) => file.id === selectedRow?.fileId);
815 const commentOnLine = (rowId = selectedRowId) => {
816 const row = data.rows.find((row) => row.id === rowId);
817 const file = data.files.find((file) => file.id === row?.fileId);
818 if (!row || !file || !diffHead.sha || !canCommentOnDiff) return;
819 const position = reviewPosition(row);
820 if (!position) return;
821 const oldPath =
822 parsed.kind === "files"
823 ? parsed.files.find((entry) => entry.id === file.id)?.previousPath
824 : null;
825 if (route.params.replacingDraftId) navigation.setParams({ replacingDraftId: undefined });
826 navigation.navigate("PullRequestInlineComment", {
827 ...route.params,
828 ...(route.params.replacingDraftId ? { draftId: route.params.replacingDraftId } : {}),
829 path: file.path,
830 ...(oldPath ? { oldPath } : {}),
831 position,
832 headSha: diffHead.sha,
833 code: row.content ?? "",
834 });
835 };
836 const [jumpPath, setJumpPath] = useState(route.params.path);
837 const requestedFile =
838 result.parsed.kind === "files"
839 ? result.parsed.files.find((file) => file.path === jumpPath)
840 : undefined;
841 const targetFile =
842 data.files.find((file) => file.path === jumpPath) ??
843 (!jumpPath || requestedFile ? data.files[0] : undefined);
844 const focus = route.params.focus;
845 const focusedRows = guideFocusedRows(data.rows, targetFile?.id, focus);
846 const focusIndex = focusedRows.length
847 ? data.rows.findIndex((row) => row.id === focusedRows[0]?.id)
848 : -1;
849 const [collapsedIds, setCollapsedIds] = useState<readonly string[]>(EMPTY_IDS);
850 const [visibleFileId, setVisibleFileId] = useState<string | null>(null);
851 const nativeView = useRef<NativeReviewDiffViewHandle>(null);
852 const jumpStarted = useRef(false);
853 const lastSelection = useRef({ path: route.params.path, id: route.params.fileSelectionId });
854 // Each sheet selection jumps in this reader, including reselecting its original file.
855 useEffect(() => {
856 if (
857 lastSelection.current.path === route.params.path &&
858 lastSelection.current.id === route.params.fileSelectionId
859 )
860 return;
861 lastSelection.current = { path: route.params.path, id: route.params.fileSelectionId };
862 if (route.params.focus) navigation.setParams({ focus: undefined });
863 setJumpPath(route.params.path);
864 setSelectedRowId(null);
865 jumpStarted.current = false;
866 const file = data.files.find((entry) => entry.path === route.params.path);
867 if (!file || !nativeView.current) return;
868 setCollapsedIds((ids) => ids.filter((id) => id !== file.id));
869 jumpStarted.current = true;
870 void nativeView.current.scrollToFile(file.id, false).catch(() => {
871 jumpStarted.current = false;
872 });
873 }, [route.params.path, route.params.fileSelectionId, route.params.focus, data.files, navigation]);
874 const appliedFilter = useRef(filter.hideTestFiles);
875 useEffect(() => {
876 if (appliedFilter.current === filter.hideTestFiles) return;
877 appliedFilter.current = filter.hideTestFiles;
878 if (
879 result.parsed.kind === "files" &&
880 result.parsed.files.some((file) => isPullRequestTestFile(file.path))
881 ) {
882 jumpStarted.current = false;
883 }
884 }, [filter.hideTestFiles, result.parsed]);
885 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
886 const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences();
887 const bridge = useNativeReviewDiffBridge({
888 threadKey: null,
889 sectionId: `pull-request:${route.params.repository}:${route.params.number}:${commit ?? "all"}`,
890 diff: result.patch,
891 data,
892 collapsedFileIds: collapsedIds,
893 viewedFileIds: viewedIds,
894 selectedRowIds: selectedRowId ? [selectedRowId] : focusedRows.map((row) => row.id),
895 canHighlight: true,
896 });
897 const [NativeDiffView] = useState(() => resolveNativeReviewDiffView());
898 // A file picked from a later page must be fetched before we can jump to its header.
899 useEffect(() => {
900 if (!targetFile) loadMore();
901 }, [targetFile, loadMore]);
902 const { onDebug: updateVisibleRange } = bridge;
903 const onDebug = useCallback<NonNullable<NativeReviewDiffViewProps["onDebug"]>>(
904 (event) => {
905 const drawn = isNativeReviewDiffDrawEvent(event.nativeEvent);
906 const decoded =
907 event.nativeEvent.message === "rows-decoded" && event.nativeEvent.rows === data.rows.length;
908 if (!drawn && !decoded) return;
909 // Filtering replaces the native row payload asynchronously. Old draw events
910 // must not consume the file jump or highlight the new payload's row indices.
911 if (
912 typeof event.nativeEvent.totalRows === "number" &&
913 event.nativeEvent.totalRows !== data.rows.length
914 )
915 return;
916 if (drawn) updateVisibleRange(event);
917 if (
918 targetFile &&
919 !jumpStarted.current &&
920 nativeView.current &&
921 (Platform.OS !== "ios" || decoded)
922 ) {
923 jumpStarted.current = true;
924 if (focusIndex >= 0) return;
925 void nativeView.current.scrollToFile(targetFile.id, false).catch(() => {
926 jumpStarted.current = false;
927 });
928 return;
929 }
930 if (!drawn) return;
931 const lastRow = event.nativeEvent.lastRowIndex;
932 if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore();
933 },
934 [updateVisibleRange, data.rows.length, loadMore, targetFile, focusIndex],
935 );
936 const onToggleFile = useCallback<NonNullable<NativeReviewDiffViewProps["onToggleFile"]>>(
937 (event) => {
938 const id = event.nativeEvent.fileId;
939 if (!id) return;
940 setCollapsedIds((ids) =>
941 ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id],
942 );
943 },
944 [],
945 );
946 const visibleFile = data.files.find((file) => file.id === visibleFileId) ?? targetFile;
947 const toggleTestFiles = () => {
948 setJumpPath(visibleFile?.path ?? route.params.path);
949 setSelectedRowId(null);
950 filter.toggle();
951 };
952 const title = commit ? "Commit changes" : "Changes";
953 const seekingFile = !targetFile && result.nextCursor !== null;
954 const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error);
955 return (
956 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
957 <NativeStackScreenOptions
958 options={{
959 title,
960 headerTitle: title,
961 unstable_headerSubtitle: `${commit ? commit.slice(0, 7) : `#${route.params.number}`} · ${data.files.length} ${data.files.length === 1 ? "file" : "files"}${filter.hideTestFiles && filter.hiddenCount ? ` · ${filter.hiddenCount} ${filter.hiddenCount === 1 ? "test" : "tests"} hidden` : ""}`,
962 }}
963 />
964 {Platform.OS === "android" ? (
965 <AndroidScreenHeader
966 title={title}
967 onBack={() => navigation.goBack()}
968 trailing={
969 <ControlPillMenu
970 actions={[
971 {
972 id: "word-wrap",
973 title: "Word wrap",
974 image: "text.alignleft",
975 state: appearance.codeWordBreak ? "on" : "off",
976 },
977 {
978 id: "hide-tests",
979 title: "Hide test files",
980 image: "line.3.horizontal.decrease",
981 state: filter.hideTestFiles ? "on" : "off",
982 },
983 ...(permissions.verdicts.length
984 ? [{ id: "submit-review", title: "Submit review", image: "text.bubble" }]
985 : []),
986 ...(permissions.canComment
987 ? [{ id: "comment-on-pr", title: "Add comment", image: "plus.bubble" }]
988 : []),
989 ...(selectedRow && selectedFile
990 ? [
991 ...(canCommentOnDiff ? [{ id: "comment", title: "Add review comment" }] : []),
992 { id: "copy", title: "Copy line" },
993 ]
994 : []),
995 ]}
996 onPressAction={({ nativeEvent }) => {
997 if (nativeEvent.event === "word-wrap") setCodeWordBreak(!appearance.codeWordBreak);
998 else if (nativeEvent.event === "hide-tests") toggleTestFiles();
999 else if (
1000 nativeEvent.event === "submit-review" ||
1001 nativeEvent.event === "comment-on-pr"
1002 )
1003 navigation.navigate("PullRequestReview", {
1004 ...route.params,
1005 mode: nativeEvent.event === "submit-review" ? "review" : "comment",
1006 });
1007 else if (nativeEvent.event === "comment") commentOnLine();
1008 else void Clipboard.setStringAsync(selectedRow?.content ?? "");
1009 }}
1010 >
1011 <View pointerEvents="none">
1012 <MaterialIconButton icon="ellipsis" accessibilityLabel="Diff options" />
1013 </View>
1014 </ControlPillMenu>
1015 }
1016 />
1017 ) : null}
1018 <NativeHeaderToolbar placement="right">
1019 <NativeHeaderToolbar.Menu
1020 title="Diff options"
1021 accessibilityLabel="Diff options"
1022 icon="ellipsis"
1023 >
1024 {permissions.verdicts.length ? (
1025 <NativeHeaderToolbar.MenuAction
1026 icon="text.bubble"
1027 onPress={() =>
1028 navigation.navigate("PullRequestReview", { ...route.params, mode: "review" })
1029 }
1030 >
1031 <NativeHeaderToolbar.Label>Submit review</NativeHeaderToolbar.Label>
1032 </NativeHeaderToolbar.MenuAction>
1033 ) : null}
1034 {permissions.canComment ? (
1035 <NativeHeaderToolbar.MenuAction
1036 icon="plus.bubble"
1037 onPress={() =>
1038 navigation.navigate("PullRequestReview", { ...route.params, mode: "comment" })
1039 }
1040 >
1041 <NativeHeaderToolbar.Label>Add comment</NativeHeaderToolbar.Label>
1042 </NativeHeaderToolbar.MenuAction>
1043 ) : null}
1044 <NativeHeaderToolbar.MenuAction
1045 icon="text.alignleft"
1046 isOn={appearance.codeWordBreak}
1047 onPress={() => setCodeWordBreak(!appearance.codeWordBreak)}
1048 >
1049 <NativeHeaderToolbar.Label>Word wrap</NativeHeaderToolbar.Label>
1050 </NativeHeaderToolbar.MenuAction>
1051 <NativeHeaderToolbar.MenuAction
1052 icon="line.3.horizontal.decrease"
1053 isOn={filter.hideTestFiles}
1054 onPress={toggleTestFiles}
1055 >
1056 <NativeHeaderToolbar.Label>Hide test files</NativeHeaderToolbar.Label>
1057 </NativeHeaderToolbar.MenuAction>
1058 {selectedRow && selectedFile && canCommentOnDiff ? (
1059 <NativeHeaderToolbar.MenuAction icon="bubble.left" onPress={() => commentOnLine()}>
1060 <NativeHeaderToolbar.Label>
1061 {route.params.replacingDraftId ? "Move draft to this line" : "Add review comment"}
1062 </NativeHeaderToolbar.Label>
1063 </NativeHeaderToolbar.MenuAction>
1064 ) : null}
1065 {selectedRow && selectedFile ? (
1066 <NativeHeaderToolbar.MenuAction
1067 icon="document.on.document"
1068 onPress={() => void Clipboard.setStringAsync(selectedRow.content ?? "")}
1069 >
1070 <NativeHeaderToolbar.Label>Copy line</NativeHeaderToolbar.Label>
1071 </NativeHeaderToolbar.MenuAction>
1072 ) : null}
1073 </NativeHeaderToolbar.Menu>
1074 </NativeHeaderToolbar>
1075 {route.params.replacingDraftId ? (
1076 <Text className="px-4 py-2 text-xs text-foreground-muted">
1077 Select a current line, then choose “Move draft to this line” from its menu.
1078 </Text>
1079 ) : null}
1080 {viewed.error ? (
1081 <Text className="px-4 py-2 text-xs text-danger-foreground">{viewed.error}</Text>
1082 ) : null}
1083 {staleDiff ? (
1084 <View
1085 className="flex-row items-center gap-2 px-4 py-2"
1086 style={{ paddingTop: Platform.OS === "ios" ? insets.top + 8 : 8 }}
1087 >
1088 <Text className="min-w-0 flex-1 text-xs text-foreground-muted">
1089 The branch changed. Reload before adding line comments.
1090 </Text>
1091 <MaterialButton
1092 label="Reload changes"
1093 onPress={() => {
1094 result.refresh();
1095 setDiffHead({ scopeKey, sha: detail.data?.headSha ?? null });
1096 jumpStarted.current = false;
1097 setSelectedRowId(null);
1098 setCollapsedIds(EMPTY_IDS);
1099 }}
1100 />
1101 </View>
1102 ) : null}
1103 <View
1104 className={
1105 Platform.OS === "android"
1106 ? "flex-1 overflow-hidden rounded-t-[28px] bg-sheet-solid"
1107 : "flex-1"
1108 }
1109 collapsable={false}
1110 style={{ marginHorizontal: Platform.OS === "android" && layout.usesSplitView ? 4 : 0 }}
1111 >
1112 {loadingDiff || result.error ? (
1113 <View style={{ paddingTop: Platform.OS === "ios" && !staleDiff ? insets.top : 0 }}>
1114 <PullRequestReadStatus pending={loadingDiff} error={result.error} />
1115 </View>
1116 ) : null}
1117 {NativeDiffView && data.rows.length && !seekingFile ? (
1118 <View className="flex-1" collapsable={false}>
1119 <NativeDiffView
1120 initialRowIndex={focusIndex >= 0 ? Math.max(0, focusIndex - 3) : undefined}
1121 nativeViewRef={nativeView}
1122 refreshing={result.refreshing}
1123 onPullToRefresh={result.onPullRefresh}
1124 style={StyleSheet.absoluteFill}
1125 rowsJson={bridge.rowsJson}
1126 themeJson={bridge.themeJson}
1127 styleJson={bridge.styleJson}
1128 tokensPatchJson={bridge.tokensPatchJson}
1129 tokensResetKey={bridge.tokensResetKey}
1130 collapsedFileIdsJson={bridge.collapsedFileIdsJson}
1131 onDebug={onDebug}
1132 viewedFileIdsJson={bridge.viewedFileIdsJson}
1133 selectedRowIdsJson={bridge.selectedRowIdsJson}
1134 onPressLine={(event) => {
1135 const rowId = event.nativeEvent.rowId ?? null;
1136 setSelectedRowId(rowId);
1137 if (event.nativeEvent.gesture !== "longPress") commentOnLine(rowId);
1138 }}
1139 onToggleViewedFile={(event) => {
1140 const file = data.files.find((file) => file.id === event.nativeEvent.fileId);
1141 if (!file || (!commit && !viewed.canToggle)) return;
1142 const wasViewed = commit
1143 ? commitViewedPaths.has(file.path)
1144 : viewed.isViewed(file.path);
1145 setCollapsedIds((ids) =>
1146 wasViewed
1147 ? ids.filter((id) => id !== file.id)
1148 : ids.includes(file.id)
1149 ? ids
1150 : [...ids, file.id],
1151 );
1152 if (commit)
1153 setCommitViewedPaths((current) => {
1154 const next = new Set(current);
1155 if (next.has(file.path)) next.delete(file.path);
1156 else next.add(file.path);
1157 return next;
1158 });
1159 else void viewed.toggle(file.path);
1160 }}
1161 onToggleComment={(event) => {
1162 const id = event.nativeEvent.commentId;
1163 if (id?.startsWith("discussion:"))
1164 navigation.navigate("PullRequestDiscussion", {
1165 ...route.params,
1166 discussionId: id.slice("discussion:".length),
1167 });
1168 if (id?.startsWith("draft:")) {
1169 const draft = reviewDraft.draft.comments.find(
1170 (draft) => draft.id === id.slice("draft:".length),
1171 );
1172 if (draft)
1173 navigation.navigate("PullRequestInlineComment", {
1174 ...route.params,
1175 ...draft,
1176 draftId: draft.id,
1177 });
1178 }
1179 }}
1180 onToggleFile={onToggleFile}
1181 onVisibleFileChange={(event) => setVisibleFileId(event.nativeEvent.fileId ?? null)}
1182 appearanceScheme={themeAppearance}
1183 rowHeight={nativeReviewDiffStyle.rowHeight}
1184 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
1185 contentInsetTop={Platform.OS === "ios" ? (staleDiff ? 0 : insets.top) : undefined}
1186 contentInsetBottom={Platform.OS === "android" ? toolbarHeight : insets.bottom}
1187 contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`}
1188 />
1189 </View>
1190 ) : filter.hiddenCount > 0 &&
1191 !data.rows.length &&
1192 !result.isPending &&
1193 !result.error &&
1194 !seekingFile ? (
1195 <View style={{ paddingTop: Platform.OS === "ios" ? insets.top : 0 }}>
1196 <EmptyState
1197 title="Only test files changed"
1198 detail="Test files are hidden from this review."
1199 action={
1200 <MaterialButton label="Show test files" tone="contrast" onPress={filter.showAll} />
1201 }
1202 variant="plain"
1203 />
1204 </View>
1205 ) : !result.isPending && !result.error && !seekingFile ? (
1206 <EmptyState
1207 title="Diff unavailable"
1208 detail="Open this pull request on its provider to view the changes."
1209 variant="plain"
1210 />
1211 ) : null}
1212 {result.nextCursor && result.isPending && data.rows.length && !seekingFile ? (
1213 <View className="flex-row items-center justify-center gap-2 py-2">
1214 <ActivityIndicator size="small" />
1215 <Text className="text-xs text-foreground-muted">Loading more files…</Text>
1216 </View>
1217 ) : null}
1218 {result.error && result.nextCursor ? (
1219 <Pressable
1220 accessibilityRole="button"
1221 onPress={result.refresh}
1222 className="min-h-11 items-center justify-center py-2"
1223 >
1224 <Text className="text-sm font-t3-medium">Retry loading remaining files</Text>
1225 </Pressable>
1226 ) : null}
1227 {parsed.kind === "files" && parsed.notice ? (
1228 <Text className="px-4 py-2 text-2xs text-foreground-muted">{parsed.notice}</Text>
1229 ) : null}
1230 </View>
1231 <PullRequestActionToolbar
1232 pr={detail.data}
1233 params={route.params}
1234 filePath={visibleFile?.path}
1235 onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)}
1236 onHeightChange={setToolbarHeight}
1237 />
1238 </View>
1239 );
1240}