Skip to content
Source
Sign inSign up

PullRequestRouteScreen.tsx

1,408 lines60.4 KB

1import {
2 parsePullRequestAnchor,
3 pullRequestAnchorMatchesUrl,
4 pullRequestDiffPathMatches,
5 pullRequestLinkCommit,
6 resolvePullRequestReviewAnchor,
7} from "@t3tools/shared/pullRequestAnchor";
8import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
9import { Chip } from "../../components/ui/Chip";
10import { pullRequestTabOffset } from "./pullRequestTabScroll";
11import { guideFocusedRows, type GuideDiffFocus } from "./guide/guideDiffFocus";
12import { PullRequestGuideContent } from "./guide/PullRequestGuideScreen";
13import { useReviewGuidesEnabled } from "./guide/useReviewGuide";
14import {
15 filterPullRequestActivity,
16 DEFAULT_ACTIVITY_FILTER,
17 type ActivityFilter,
18} from "./pullRequestActivityFilter";
19import { PullRequestActivityFilters } from "./PullRequestActivityFilters";
20import type { LegendListRef } from "@legendapp/list/react-native";
21import { AnimatedLegendList } from "@legendapp/list/reanimated";
22import Animated from "react-native-reanimated";
23import {
24 PR_ROW_LAYOUT,
25 PR_ROW_ENTER,
26 PR_ROW_EXIT,
27 PR_POSITION_LAYOUT,
28} from "./pullRequestAnimations";
29import {
30 canPerformPullRequestAction,
31 openOnProviderLabel,
32 PR_ACTION_ICONS,
33 pullRequestActionLabel,
34} from "./pullRequestActions";
35import * as Clipboard from "expo-clipboard";
36import { Button } from "../../components/ui/Button";
37import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
38import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed";
39import { usePullRequestPermissions } from "./usePullRequestPermissions";
40import { usePullRequestDescription } from "./usePullRequestDescription";
41import { usePullRequestAction } from "./usePullRequestAction";
42import { annotatePullRequestDiff, reviewPosition } from "./pullRequestDiffComments";
43import { usePullRequestFileFilter } from "./usePullRequestFileFilter";
44import {
45 handlePullRequestDiffDebugEvent,
46 pullRequestDiffInitialPosition,
47} from "./pullRequestDiffDebug";
48import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles";
49import { isTestChangedFile } from "@t3tools/shared/changedFileKind";
50import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native";
51import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
52import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react";
53import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
54import {
55 pullRequestConversationThreads,
56 type PullRequestDetail,
57 type PullRequestListEntry,
58 type PullRequestSummary,
59} from "@t3tools/contracts";
60import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading";
61import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink";
62import { pullRequestStatus } from "./pullRequestStatus";
63import { use, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
64import { Linking, Platform, Pressable, StyleSheet, View } from "react-native";
65import { RefreshControl } from "../../components/ui/RefreshControl";
66import { NativeScrollView } from "../../native/NativeScrollViewMarker";
67import { useSafeAreaInsets } from "react-native-safe-area-context";
68​
69import { AppText as Text } from "../../components/ui/AppText";
70import { AppBar } from "../../components/ui/AppBar";
71import { useContentTopInset } from "../../components/ui/useBarInset";
72import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
73import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
74import { useAndroidWorkspaceBack } from "../layout/workspace/useAndroidWorkspaceNavigation";
75import { EmptyState } from "../../components/ui/EmptyState";
76import { SegmentedControl } from "../../components/ui/SegmentedControl";
77import { SymbolView } from "../../components/ui/AppSymbol";
78import { ScreenContent } from "../../components/ui/ScreenContent";
79import { RelativeTime } from "../../components/RelativeTime";
80import { usePullRequestConnected } from "./usePullRequestConnected";
81import { usePullRequestLiveChecks } from "./usePullRequestLiveChecks";
82import { pullRequestLabelColor } from "./pullRequestList";
83import { PullRequestReactions } from "./PullRequestReactions";
84import { composerPullRequests } from "../../state/pull-requests";
85import { useEnvironmentQuery } from "../../state/query";
86import { SourceControlAppAccessNotice } from "./SourceControlAppAccessNotice";
87import { usePullToRefresh } from "../../state/use-pull-to-refresh";
88import {
89 resolveNativeReviewDiffView,
90 type NativeReviewDiffViewHandle,
91 type NativeReviewDiffViewProps,
92} from "../diffs/nativeReviewDiffSurface";
93import {
94 getCachedNativeReviewDiffData,
95 NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
96} from "../review/nativeReviewDiffAdapter";
97import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
98import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
99import { useNativeReviewDiffBridge } from "../review/useNativeReviewDiffBridge";
100import { pullRequestTimelineRows } from "./PullRequestTimeline";
101import { PullRequestActionToolbar, PullRequestCardGroup } from "./PullRequestControls";
102import {
103 pullRequestTarget as requestTarget,
104 usePullRequestDiff,
105 usePullRequestDiffEntry,
106 type PullRequestDiffParams,
107 type PullRequestParams,
108} from "./usePullRequestDiff";
109import {
110 PullRequestAvatar,
111 PullRequestDiffStats,
112 PullRequestReadStatus,
113} from "./PullRequestPresentation";
114import { usePullRequestFilesSection } from "./PullRequestFiles";
115import { usePullRequestMaintainerEdits } from "./usePullRequestMaintainerEdits";
116import { PullRequestDescription } from "./PullRequestDescription";
117import { PullRequestMediaVisibleContext } from "./PullRequestMedia";
118import { PullRequestMarkdownRepository } from "./PullRequestMarkdown";
119import {
120 PullRequestStatusMenu,
121 PullRequestChecksRow,
122 PullRequestBranchRow,
123 PullRequestReviewersRow,
124} from "./PullRequestStatusRows";
125import { PullRequestMergeControls } from "./PullRequestMergeControls";
126import { PullRequestStackCard } from "./PullRequestStackCard";
127import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack";
128import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback";
129import { pullRequestFileJumpScope, subscribePullRequestFileJump } from "./pullRequestFileJump";
130import {
131 capitalizeTerm,
132 sourceControlClients,
133} from "@t3tools/client-runtime/source-control-clients";
134​
135const EMPTY_IDS: readonly string[] = [];
136​
137function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityFilter) {
138 const navigation = useNavigation();
139 const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));
140 const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(params)));
141 const permissions = usePullRequestPermissions(params, detail.data);
142 const filtered = result.data ? filterPullRequestActivity(result.data, filter) : null;
143 const rows = (() => {
144 if (!filtered) return [];
145 const rows = pullRequestTimelineRows(filtered.activity, params, navigation, {
146 permissions,
147 onChanged: result.refresh,
148 });
149 if (filter.newestFirst) {
150 const notice = rows.at(-1)?.key === "truncated" ? rows.pop() : undefined;
151 rows.reverse();
152 if (notice) rows.push(notice);
153 }
154 if (!rows.length)
155 rows.push({
156 key: "empty",
157 date: "",
158 content: (
159 <Text className="py-6 text-sm text-foreground-muted">
160 No activity matches these filters.
161 </Text>
162 ),
163 });
164 return rows;
165 })();
166 const location = parsePullRequestAnchor(params.navigationUrl);
167 const anchor =
168 location?.kind !== "timeline"
169 ? null
170 : result.data
171 ? resolvePullRequestReviewAnchor(
172 location.anchor,
173 result.data.comments,
174 result.data.reviewThreads,
175 )
176 : location.anchor;
177 const targetComment = anchor
178 ? result.data?.comments.find((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url))
179 : null;
180 const targetThread = anchor
181 ? result.data &&
182 pullRequestConversationThreads(result.data).find((thread) =>
183 thread.comments.some((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url)),
184 )
185 : null;
186 const targetKey = targetThread
187 ? `discussion:${targetThread.id}`
188 : targetComment
189 ? `comment:${targetComment.id}`
190 : null;
191 return {
192 rows,
193 targetKey,
194 targetThread,
195 hiddenCount: filtered?.hiddenCount ?? 0,
196 pending: result.isPending && !result.data,
197 error: result.error,
198 };
200​
201function PullRequestIdentity(props: {
202 readonly pr: PullRequestDetail | PullRequestListEntry | PullRequestSummary;
203 readonly params: PullRequestParams;
204}) {
205 const { pr } = props;
206 const status = pullRequestStatus({ state: pr.state, isDraft: pr.isDraft === true });
207 return (
208 <View className="gap-3">
209 <View className="flex-row flex-wrap items-center gap-2">
210 {"capabilities" in pr ? (
211 <PullRequestStatusMenu pr={pr} params={props.params} />
212 ) : (
213 <View className="min-h-8 flex-row items-center gap-1.5">
214 <SymbolView name={status.icon} size={14} tintColorClassName={status.tint} />
215 <Text className={`text-xs font-t3-medium ${status.text}`}>{status.label}</Text>
216 </View>
217 )}
218 <PullRequestDiffStats additions={pr.additions ?? 0} deletions={pr.deletions ?? 0} compact />
219 {"changedFiles" in pr && pr.changedFiles !== undefined ? (
220 <Text className="text-2xs text-foreground-muted">
221 {pr.changedFiles} {pr.changedFiles === 1 ? "file" : "files"}
222 </Text>
223 ) : (
224 <View className="h-3 w-12 rounded bg-subtle-strong" />
225 )}
226 </View>
227 <Text selectable className="text-xl font-t3-bold leading-7">
228 {pr.title}
229 </Text>
230 <View className="flex-row items-center gap-2">
231 <PullRequestAvatar actor={pr.author ?? null} size={20} />
232 <Text className="min-w-0 flex-1 text-xs font-t3-medium" numberOfLines={1}>
233 {pr.author?.login ?? "Unknown author"}
234 </Text>
235 <Text className="shrink text-right text-2xs text-foreground-muted" numberOfLines={1}>
236 Updated <RelativeTime timestamp={pr.updatedAt} /> ago
237 </Text>
238 </View>
239 <View className="flex-row items-start gap-1.5">
240 <Pressable
241 accessibilityRole="button"
242 accessibilityLabel={`Copy base branch ${pr.baseBranch}`}
243 onPress={() => void Clipboard.setStringAsync(pr.baseBranch)}
244 className="min-w-0 shrink active:opacity-70"
245 hitSlop={8}
246 >
247 <Text className="font-mono text-2xs text-foreground-muted">{pr.baseBranch}</Text>
248 </Pressable>
249 <View className="pt-0.5">
250 <SymbolView name="arrow.left" size={11} tintColorClassName="accent-icon-muted" />
251 </View>
252 <Pressable
253 accessibilityRole="button"
254 accessibilityLabel={`Copy head branch ${pr.headBranch}`}
255 onPress={() => void Clipboard.setStringAsync(pr.headBranch)}
256 className="min-w-0 shrink active:opacity-70"
257 hitSlop={8}
258 >
259 <Text className="font-mono text-2xs text-foreground-muted">{pr.headBranch}</Text>
260 </Pressable>
261 </View>
262 </View>
263 );
265​
266function PullRequestLabels(props: {
267 readonly pr: PullRequestDetail;
268 readonly params: PullRequestParams;
269}) {
270 const navigation = useNavigation();
271 const connected = usePullRequestConnected(props.params.environmentId);
272 const canWrite = useAtomValue(
273 composerPullRequests.setLabels.permissionAtom(requestTarget(props.params).environmentId),
274 );
275 const editable =
276 connected &&
277 canWrite &&
278 props.pr.capabilities.labels === true &&
279 props.pr.viewerPermissions.labels !== false;
280 if (!props.pr.labels.length && !editable) return null;
281 return (
282 <Animated.View layout={PR_ROW_LAYOUT} className="flex-row flex-wrap gap-2">
283 {props.pr.labels.map((label) => {
284 const color = pullRequestLabelColor(label.color);
285 // Tinted like the list row's label pills, at the overview's chip size.
286 return (
287 <View
288 key={label.name}
289 className="max-w-full flex-row items-center gap-1.5 rounded-2xl bg-grouped-card px-3 py-1"
290 style={color ? { backgroundColor: `${color}20` } : undefined}
291 >
292 {color ? (
293 <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: color }} />
294 ) : null}
295 <Text textRole="caption" className="shrink text-foreground-muted" numberOfLines={1}>
296 {label.name}
297 </Text>
298 </View>
299 );
300 })}
301 {editable ? (
302 <Chip
303 label={props.pr.labels.length ? "Edit" : "Add labels"}
304 icon="tag"
305 size="small"
306 accessibilityLabel="Edit labels"
307 onPress={() => navigation.navigate("PullRequestLabels", props.params)}
308 />
309 ) : null}
310 </Animated.View>
311 );
313​
314function PullRequestOverview(props: {
315 readonly pr: PullRequestDetail;
316 readonly description: ReturnType<typeof usePullRequestDescription>;
317 readonly params: PullRequestParams;
318 readonly stack: PullRequestStackState;
319 readonly onDescriptionLayout: (y: number) => void;
320 readonly onDescriptionCollapse: () => void;
321 readonly descriptionExpanded: boolean;
322 readonly onDescriptionExpandedChange: (expanded: boolean) => void;
323}) {
324 const { pr } = props;
325 return (
326 <View className="gap-5">
327 <PullRequestStackCard params={props.params} state={props.stack} />
328 <PullRequestCardGroup>
329 <PullRequestChecksRow pr={pr} params={props.params} />
330 <PullRequestReviewersRow pr={pr} params={props.params} />
331 <Animated.View layout={PR_ROW_LAYOUT}>
332 <PullRequestBranchRow pr={pr} params={props.params} />
333 </Animated.View>
334 <Animated.View layout={PR_ROW_LAYOUT}>
335 <PullRequestMergeControls pr={pr} params={props.params} stack={props.stack} />
336 </Animated.View>
337 </PullRequestCardGroup>
338 <PullRequestLabels pr={pr} params={props.params} />
339 <Animated.View
340 layout={PR_POSITION_LAYOUT}
341 onLayout={(event) => props.onDescriptionLayout(event.nativeEvent.layout.y)}
342 >
343 <PullRequestDescription
344 markdown={props.description.body}
345 onTaskListChange={props.description.toggleTask}
346 onCollapse={props.onDescriptionCollapse}
347 expanded={props.descriptionExpanded}
348 onExpandedChange={props.onDescriptionExpandedChange}
349 footer={<PullRequestReactions params={props.params} />}
350 />
351 </Animated.View>
352 </View>
353 );
355​
356function usePullRequestToolbarHeight(bottomInset: number) {
357 const { buttonSize } = useAndroidControlSizing();
358 return useState(() => (Platform.OS === "android" ? bottomInset + buttonSize + 40 : 0));
360​
361export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestParams>) {
362 const returnToList = useAndroidWorkspaceBack();
363 const { layout } = useAdaptiveWorkspaceLayout();
364 const navigation = useNavigation();
365 const insets = useSafeAreaInsets();
366 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
367 const contentTopInset = useContentTopInset();
368 // Only iOS lists run under the bottom safe area; Android's floating toolbar pads its own.
369 const contentBottomInset = Platform.OS === "ios" ? insets.bottom : 0;
370 const params = route.params;
371 usePullRequestEnvironmentFallback(params);
372 const connected = usePullRequestConnected(params.environmentId);
373 const [chosenSection, setSection] = useState<"overview" | "activity" | "guide">(() => {
374 const kind = parsePullRequestAnchor(params.navigationUrl)?.kind;
375 return kind === "timeline" || kind === "commits" ? "activity" : "overview";
376 });
377 const requestedSection = useRef(chosenSection);
378 const [listAnimationsReady, setListAnimationsReady] = useState(false);
379 const [descriptionExpanded, setDescriptionExpanded] = useState(false);
380 const filesContent = usePullRequestFilesSection(params);
381 const guidesEnabled = useReviewGuidesEnabled(params);
382 // The guide tab goes away while guides are off and overview stands in. The pick is kept, so
383 // guides coming back (a settings reload, a reconnect) return to the guide the user chose.
384 const shown = (tab: typeof chosenSection) =>
385 !guidesEnabled && tab === "guide" ? "overview" : tab;
386 const section = shown(chosenSection);
387 const [activityFilter, setActivityFilter] = useState<ActivityFilter>(() => {
388 const kind = parsePullRequestAnchor(params.navigationUrl)?.kind;
389 return kind === "timeline"
390 ? { ...DEFAULT_ACTIVITY_FILTER, bots: true, resolved: true }
391 : kind === "commits"
392 ? { ...DEFAULT_ACTIVITY_FILTER, kind: "commits" }
393 : DEFAULT_ACTIVITY_FILTER;
394 });
395 const activityContent = usePullRequestActivityRows(params, activityFilter);
396 const scrollOffsets = useRef<Record<string, number>>({});
397 // The offset of the tab last shown, and which tab that was: a tab standing in for a hidden guide
398 // has its own offset, which only its next scroll would record here.
399 const currentScrollOffset = useRef<{ tab: typeof section; offset: number }>({
400 tab: "overview",
401 offset: -contentTopInset,
402 });
403 const bodyY = useRef(0);
404 const [headerHeight, setHeaderHeight] = useState(0);
405 const [viewportHeight, setViewportHeight] = useState(0);
406 const scrollViews = useRef<Partial<Record<typeof section, LegendListRef | null>>>({});
407 // AnimatedLegendList re-attaches its handle and sets state whenever its ref changes,
408 // so an inline ref in the tab map loops the screen into "Maximum update depth exceeded".
409 const [listRefs] = useState(() => {
410 const listRef = (tab: typeof section) => (list: LegendListRef | null) => {
411 scrollViews.current[tab] = list;
412 };
413 return {
414 overview: listRef("overview"),
415 guide: listRef("guide"),
416 activity: listRef("activity"),
417 };
418 });
419 const selectSection = (next: typeof section) => {
420 if (next === section) return;
421 const from = shown(requestedSection.current);
422 const current =
423 currentScrollOffset.current.tab === from
424 ? currentScrollOffset.current.offset
425 : (scrollOffsets.current[from] ?? -contentTopInset);
426 scrollOffsets.current[section] = current;
427 const headerEnd = Math.max(-contentTopInset, bodyY.current - contentTopInset);
428 const offset = pullRequestTabOffset(current, scrollOffsets.current[next], headerEnd);
429 requestedSection.current = next;
430 scrollOffsets.current[next] = offset;
431 currentScrollOffset.current = { tab: next, offset };
432 scrollViews.current[next]?.scrollToOffset({ offset, animated: false });
433 setSection(next);
434 };
435 const followLink = useEffectEvent(() => {
436 const target = parsePullRequestAnchor(params.navigationUrl);
437 if (target?.kind === "diff" || target?.kind === "code") {
438 const commit = pullRequestLinkCommit(params.navigationUrl);
439 navigation.navigate("PullRequestFile", {
440 ...params,
441 path: "",
442 ...(commit ? { commit } : {}),
443 });
444 } else if (target?.kind === "timeline") {
445 selectSection("activity");
446 setActivityFilter((filter) => ({ ...filter, kind: "all", bots: true, resolved: true }));
447 } else if (target?.kind === "commits") {
448 selectSection("activity");
449 setActivityFilter((filter) => ({ ...filter, kind: "commits" }));
450 } else if (target?.kind === "checks") {
451 selectSection("overview");
452 // Checks head Overview, which a reused screen may have scrolled past.
453 scrollViews.current.overview?.scrollToOffset({ offset: -contentTopInset, animated: false });
454 } else if (target?.kind === "summary") {
455 selectSection("overview");
456 }
457 });
458 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a link selects its native PR section or diff even when the route is reused
459 useEffect(() => {
460 // oxlint-disable-next-line react/set-state-in-effect -- URL navigation selects the PR section and makes its target visible
461 followLink();
462 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- each clicked URL is a fresh navigation request
463 }, [params.navigationUrl, params.navigationId]);
464​
465 const activityTargetIndex = activityContent.rows.findIndex(
466 (row) => row.key === activityContent.targetKey,
467 );
468 const openedDiscussion = useRef<string | null>(null);
469 const positionedActivity = useRef<string | null>(null);
470 // Rows above the target measure in after the first jump, so it is held until the user scrolls.
471 const heldActivityRow = useRef<number | null>(null);
472 const positionActivityRow = (index: number) =>
473 void scrollViews.current.activity?.scrollToIndex({
474 index,
475 viewPosition: 0,
476 viewOffset: contentTopInset + 12,
477 animated: false,
478 });
479 const revealActivity = useEffectEvent(() => {
480 const key = `${params.navigationUrl}:${params.navigationId ?? 0}`;
481 if (activityContent.targetThread && openedDiscussion.current !== key) {
482 openedDiscussion.current = key;
483 navigation.navigate("PullRequestDiscussion", {
484 ...params,
485 discussionId: activityContent.targetThread.id,
486 });
487 return;
488 }
489 // Later filter or refresh changes move the row; they must not pull the user back to it.
490 if (activityTargetIndex >= 0 && positionedActivity.current !== key) {
491 positionedActivity.current = key;
492 // Its top sits under the app bar; centering a long comment pushed its start off screen.
493 heldActivityRow.current = activityTargetIndex + 1;
494 positionActivityRow(activityTargetIndex + 1);
495 }
496 });
497 const targetThreadId = activityContent.targetThread?.id;
498 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals the requested activity once its asynchronous rows arrive
499 useEffect(() => {
500 revealActivity();
501 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- reruns when activity resolves the URL target, even a thread the filter hides
502 }, [params.navigationUrl, params.navigationId, activityTargetIndex, targetThreadId]);
503 const tabContent = activityContent;
504 const filesY = useRef(0);
505 const descriptionY = useRef(0);
506 const pendingDescriptionJump = useRef(false);
507 const pendingFilesJump = useRef(false);
508 const detailAtom = composerPullRequests.detail(requestTarget(params));
509 const result = useEnvironmentQuery(detailAtom);
510 const pullRefresh = usePullToRefresh(detailAtom);
511 const { refresh: refreshDetail } = result;
512 useFocusEffect(refreshDetail);
513 const pr = usePullRequestLiveChecks(params, result.data);
514 const summary = params.initialSummary;
515 const identity = pr ?? summary;
516 const terminology = sourceControlClients.get(identity?.provider).changeRequest;
517 const permissions = usePullRequestPermissions(params, pr);
518 const description = usePullRequestDescription(params, pr, permissions.canEditChangeRequest);
519 const action = usePullRequestAction(params, pr);
520 const maintainerEdits = usePullRequestMaintainerEdits(
521 params,
522 pr,
523 permissions.canEditMaintainerEdits,
524 );
525 const showsDeleteBranch = pr !== null && canPerformPullRequestAction(pr, "delete-branch");
526 const stateAction = action.available("close")
527 ? "close"
528 : action.available("reopen")
529 ? "reopen"
530 : null;
531 const runStateAction = () =>
532 stateAction === "close" ? action.confirmClose() : void action.perform("reopen");
533 const confirmDeleteBranch = () =>
534 pr &&
535 action.confirm(
536 "delete-branch",
537 pullRequestActionLabel("delete-branch", terminology),
538 `Delete ${pr.headBranch} from ${pr.headRepositoryNameWithOwner ?? pr.repository}? Local branches and worktrees are not changed.`,
539 );
540 const stack = usePullRequestStack(params, pr);
541 const refreshActivity = useAtomRefresh(composerPullRequests.activity(requestTarget(params)));
542 const refreshGuide = useAtomRefresh(composerPullRequests.reviewGuide(requestTarget(params)));
543 const registry = use(RegistryContext);
544 const refresh = () =>
545 pullRefresh.onRefresh(async (signal) => {
546 const target = requestTarget(params);
547 await composerPullRequests.invalidate
548 .run(registry, {
549 environmentId: target.environmentId,
550 input: { reference: target.input },
551 })
552 .catch(() => undefined);
553 if (signal.aborted) return;
554 if (guidesEnabled) refreshGuide();
555 stack.refresh();
556 refreshActivity();
557 filesContent.refresh();
558 });
559 const jumpToFiles = () => {
560 scrollViews.current.overview?.scrollToIndex({
561 index: 0,
562 viewPosition: 0,
563 viewOffset: -filesY.current,
564 animated: true,
565 });
566 pendingFilesJump.current = false;
567 };
568 const openFiles = () => {
569 if (section === "overview") jumpToFiles();
570 else {
571 pendingFilesJump.current = true;
572 selectSection("overview");
573 if (filesY.current > 0) requestAnimationFrame(jumpToFiles);
574 }
575 };
576 return (
577 <PullRequestMarkdownRepository params={params} changeRequest={pr}>
578 <View className="flex-1 android:bg-header ios:bg-screen">
579 <AppBar
580 title={`#${params.number}`}
581 subtitle={connected ? params.repository : `${params.repository} · Offline`}
582 onBack={layout.usesSplitView ? undefined : returnToList}
583 menus={[
584 {
585 title: `${capitalizeTerm(terminology.singular)} actions`,
586 icon: "ellipsis",
587 items: [
588 ...(permissions.verdicts.length
589 ? [
590 {
591 id: "submit-review",
592 title: "Submit review",
593 icon: "text.bubble",
594 onPress: () =>
595 navigation.navigate("PullRequestReview", { ...params, mode: "review" }),
596 },
597 ]
598 : []),
599 ...(permissions.canComment
600 ? [
601 {
602 id: "add-comment",
603 title: "Add comment",
604 icon: "plus.bubble",
605 onPress: () =>
606 navigation.navigate("PullRequestReview", { ...params, mode: "comment" }),
607 },
608 ]
609 : []),
610 ...(permissions.canEditChangeRequest
611 ? [
612 {
613 id: "edit-title",
614 title: "Edit title",
615 icon: "pencil",
616 onPress: () =>
617 navigation.navigate("PullRequestEdit", { ...params, field: "title" }),
618 },
619 {
620 id: "edit-description",
621 title: "Edit description",
622 icon: "doc.text",
623 disabled: description.pending,
624 subtitle: description.pending ? "Saving…" : undefined,
625 onPress: () =>
626 navigation.navigate("PullRequestEdit", { ...params, field: "body" }),
627 },
628 ]
629 : []),
630 ...(maintainerEdits.visible
631 ? [
632 {
633 id: "maintainer-edits",
634 title: "Allow edits from maintainers",
635 icon: "pencil",
636 selected: maintainerEdits.value,
637 disabled: maintainerEdits.saving,
638 subtitle: maintainerEdits.subtitle,
639 onPress: maintainerEdits.toggle,
640 },
641 ]
642 : []),
643 ...(identity
644 ? [
645 {
646 id: "open-on-provider",
647 title: openOnProviderLabel(identity.provider),
648 icon: "arrow.up.right",
649 onPress: () => void Linking.openURL(identity.url),
650 },
651 {
652 id: "copy-link",
653 title: "Copy link",
654 icon: "link",
655 onPress: () => copyPullRequestLink(identity),
656 },
657 {
658 id: "share",
659 title: "Share…",
660 icon: "square.and.arrow.up",
661 onPress: () => sharePullRequestLink(identity),
662 },
663 ]
664 : []),
665 ...(stateAction
666 ? [
667 {
668 id: "state",
669 title: pullRequestActionLabel(stateAction, terminology),
670 icon: PR_ACTION_ICONS[stateAction],
671 destructive: stateAction === "close",
672 onPress: runStateAction,
673 },
674 ]
675 : []),
676 ...(showsDeleteBranch
677 ? [
678 {
679 id: "delete-branch",
680 title: pullRequestActionLabel("delete-branch", terminology),
681 icon: PR_ACTION_ICONS["delete-branch"],
682 destructive: true,
683 disabled: !action.available("delete-branch"),
684 subtitle: action.busy
685 ? "Deleting…"
686 : connected
687 ? pr.headBranch
688 : "Reconnect to delete this branch.",
689 onPress: confirmDeleteBranch,
690 },
691 ]
692 : []),
693 ],
694 },
695 ]}
696 />
697 <ScreenContent onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}>
698 {(["overview", "guide", "activity"] as const)
699 .filter((tab) => tab !== "guide" || guidesEnabled)
700 .map((tab) => (
701 <View
702 key={tab}
703 style={[StyleSheet.absoluteFill, { opacity: section === tab ? 1 : 0 }]}
704 pointerEvents={section === tab ? "auto" : "none"}
705 accessibilityElementsHidden={section !== tab}
706 importantForAccessibility={section === tab ? "auto" : "no-hide-descendants"}
707 >
708 <AnimatedLegendList
709 ref={listRefs[tab]}
710 // The scroll view owns its resting offset. LegendList keeps the first
711 // initialScrollOffset it gets, and the first render has the window's top
712 // inset (0 on the folded iPhone Duo), not the header's.
713 onScrollBeginDrag={
714 tab === "activity"
715 ? () => {
716 heldActivityRow.current = null;
717 }
718 : undefined
719 }
720 onScroll={(event) => {
721 const offset = event.nativeEvent.contentOffset.y;
722 scrollOffsets.current[tab] = offset;
723 if (shown(requestedSection.current) === tab)
724 currentScrollOffset.current = { tab, offset };
725 }}
726 className="flex-1"
727 renderScrollComponent={Platform.OS === "ios" ? NativeScrollView : undefined}
728 contentInsetAdjustmentBehavior="never"
729 contentInset={
730 Platform.OS === "ios"
731 ? { top: contentTopInset, bottom: contentBottomInset }
732 : undefined
733 }
734 refreshControl={
735 <RefreshControl
736 refreshing={section === tab && pullRefresh.refreshing}
737 onRefresh={refresh}
738 />
739 }
740 alwaysBounceVertical
741 scrollsToTop={section === tab}
742 onItemSizeChanged={({ itemKey }) => {
743 const held = heldActivityRow.current;
744 if (tab === "activity" && held !== null) {
745 // A filter or refresh that moved the row ends the hold.
746 if (held !== activityTargetIndex + 1) heldActivityRow.current = null;
747 else positionActivityRow(held);
748 return;
749 }
750 if (
751 tab !== "overview" ||
752 section !== "overview" ||
753 itemKey !== "overview:body" ||
754 !pendingDescriptionJump.current
755 )
756 return;
757 pendingDescriptionJump.current = false;
758 scrollViews.current.overview?.scrollToIndex({
759 index: 0,
760 viewPosition: 0,
761 viewOffset: -descriptionY.current,
762 animated: false,
763 });
764 }}
765 contentContainerStyle={{
766 paddingHorizontal: 16,
767 paddingTop: 8,
768 paddingBottom: 24 + (Platform.OS === "android" ? toolbarHeight : 0),
769 // Short tabs must still be tall enough to preserve the collapsed header.
770 minHeight: Math.max(
771 0,
772 viewportHeight + headerHeight - contentTopInset - contentBottomInset,
773 ),
774 }}
775 data={[
776 {
777 key: `${tab}:body`,
778 content: (
779 <View>
780 {tab === "overview" && identity ? (
781 <View>
782 <SourceControlAppAccessNotice
783 environmentId={params.environmentId}
784 provider={identity.provider}
785 host={params.host}
786 repository={params.repository}
787 />
788 {pr ? (
789 <PullRequestOverview
790 pr={pr}
791 description={description}
792 params={params}
793 stack={stack}
794 descriptionExpanded={descriptionExpanded}
795 onDescriptionExpandedChange={setDescriptionExpanded}
796 onDescriptionLayout={(y) => {
797 descriptionY.current = y;
798 }}
799 onDescriptionCollapse={() => {
800 pendingDescriptionJump.current = true;
801 }}
802 />
803 ) : summary ? (
804 <PullRequestOverviewLoading
805 summary={summary}
806 params={params}
807 stack={stack}
808 />
809 ) : null}
810 <View
811 className="mt-8 border-t border-border-subtle pt-3"
812 onLayout={(event) => {
813 filesY.current = event.nativeEvent.layout.y;
814 if (pendingFilesJump.current) {
815 requestAnimationFrame(() => {
816 if (shown(requestedSection.current) === "overview")
817 jumpToFiles();
818 });
819 }
820 }}
821 >
822 {filesContent.header}
823 </View>
824 </View>
825 ) : null}
826 {tab === "guide" ? (
827 <PullRequestGuideContent
828 params={params}
829 visible={section === "guide"}
830 />
831 ) : null}
832 {tab === "activity" ? (
833 <PullRequestActivityFilters
834 value={activityFilter}
835 onChange={setActivityFilter}
836 hiddenCount={tabContent.hiddenCount}
837 />
838 ) : null}
839 {tab === "activity" ? (
840 <PullRequestReadStatus
841 pending={tabContent.pending}
842 error={tabContent.error}
843 terminology={terminology}
844 />
845 ) : null}
846 </View>
847 ),
848 },
849 ...(tab === "overview"
850 ? identity
851 ? filesContent.rows
852 : []
853 : tab === "activity"
854 ? tabContent.rows
855 : []
856 ).map((row) => ({
857 ...row,
858 key: `${tab}:${row.key}`,
859 content: row.content,
860 })),
861 ]}
862 keyExtractor={(row) => row.key}
863 getItemType={(row) =>
864 row.key.endsWith(":body") ? row.key : row.key.split(":")[0]
865 }
866 // Animating overview fights clipped markdown and collapse scroll correction; file rows can still fade.
867 itemLayoutAnimation={tab === "overview" ? undefined : PR_ROW_LAYOUT}
868 onLoad={() => setListAnimationsReady(true)}
869 renderItem={({ item }) => (
870 <Animated.View
871 key={item.key}
872 entering={
873 listAnimationsReady && !item.key.endsWith(":body")
874 ? PR_ROW_ENTER
875 : undefined
876 }
877 exiting={
878 listAnimationsReady && !item.key.endsWith(":body") ? PR_ROW_EXIT : undefined
879 }
880 >
881 <PullRequestMediaVisibleContext value={section === tab}>
882 {item.content}
883 </PullRequestMediaVisibleContext>
884 </Animated.View>
885 )}
886 recycleItems={false}
887 estimatedItemSize={tab === "overview" ? 64 : 220}
888 maintainVisibleContentPosition={false}
889 ListHeaderComponent={
890 <>
891 <PullRequestReadStatus
892 pending={result.isPending && !identity}
893 error={result.error}
894 terminology={terminology}
895 />
896 {identity ? (
897 <View
898 className="gap-5 pb-5"
899 onLayout={(event) => {
900 bodyY.current = event.nativeEvent.layout.height;
901 setHeaderHeight(event.nativeEvent.layout.height);
902 }}
903 >
904 <PullRequestIdentity pr={identity} params={params} />
905 {/* UIKit draws the tapped control's glass lens outside the hidden
906 tab's opacity, so only the visible tab mounts the control. */}
907 {Platform.OS !== "ios" || section === tab ? (
908 <SegmentedControl
909 options={[
910 { value: "overview", label: "Overview" },
911 ...(guidesEnabled
912 ? [{ value: "guide" as const, label: "Guide" }]
913 : []),
914 { value: "activity", label: "Activity" },
915 ]}
916 selected={section}
917 onSelect={selectSection}
918 role="tab"
919 size="compact"
920 />
921 ) : (
922 <View style={{ height: 32 }} />
923 )}
924 </View>
925 ) : null}
926 </>
927 }
928 ListFooterComponent={tab === "overview" ? filesContent.footer : null}
929 />
930 </View>
931 ))}
932 </ScreenContent>
933 <PullRequestActionToolbar
934 pr={pr}
935 params={params}
936 onOpenFiles={openFiles}
937 onHeightChange={setToolbarHeight}
938 />
939 </View>
940 </PullRequestMarkdownRepository>
941 );
943​
944type PullRequestFileRouteProps = StaticScreenProps<
945 PullRequestDiffParams & {
946 readonly path: string;
947 readonly replacingDraftId?: string;
948 readonly focus?: GuideDiffFocus;
949 }
950>;
951​
952export function PullRequestFileRouteScreen(props: PullRequestFileRouteProps) {
953 const navigation =
954 useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "PullRequestFile">>();
955 const ready = usePullRequestDiffEntry(navigation);
956 if (ready) return <PullRequestFileRouteContent {...props} />;
957 const title = props.route.params.commit ? "Commit changes" : "Changes";
958 return (
959 <View className="flex-1 bg-screen">
960 <AppBar title={title} onBack={() => navigation.goBack()} />
961 <View className="flex-1 items-center justify-center">
962 <AppActivityIndicator accessibilityLabel="Loading changes" />
963 </View>
964 </View>
965 );
967​
968function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) {
969 const navigation = useNavigation();
970 const insets = useSafeAreaInsets();
971 const contentTopInset = useContentTopInset();
972 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
973 const commit = route.params.commit;
974 const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params)));
975 const terminology = sourceControlClients.get(
976 (detail.data ?? route.params.initialSummary)?.provider,
977 ).changeRequest;
978 const result = usePullRequestDiff(route.params, terminology);
979 // Other screens open files with the link's params spread in; only the link's own entry follows it.
980 const linkAnchor =
981 route.params.path === "" &&
982 (route.params.commit ?? null) === pullRequestLinkCommit(route.params.navigationUrl)
983 ? parsePullRequestAnchor(route.params.navigationUrl)
984 : null;
985 const linkPath =
986 linkAnchor?.kind === "diff" && result.parsed.kind === "files"
987 ? result.parsed.files.find((file) => pullRequestDiffPathMatches(linkAnchor.digest, file.path))
988 ?.path
989 : undefined;
990 const filter = usePullRequestFileFilter(
991 result.parsed,
992 linkPath ?? (route.params.focus ? route.params.path : undefined),
993 );
994 const { refresh: refreshDetail } = detail;
995 useFocusEffect(refreshDetail);
996 const { loadMore } = result;
997 const parsed = filter.parsed;
998 const reviewDraft = usePullRequestReviewDraft(route.params);
999 const viewed = usePullRequestFilesViewed(route.params, commit ? null : detail.data);
1000 const [commitViewedPaths, setCommitViewedPaths] = useState<ReadonlySet<string>>(() => new Set());
1001 const permissions = usePullRequestPermissions(route.params, detail.data);
1002 const scopeKey = pullRequestReviewKey(route.params);
1003 const [diffHead, setDiffHead] = useState({ scopeKey, sha: detail.data?.headSha ?? null });
1004 if (diffHead.scopeKey !== scopeKey || (!diffHead.sha && detail.data?.headSha))
1005 setDiffHead({ scopeKey, sha: detail.data?.headSha ?? null });
1006 const staleDiff = Boolean(
1007 !commit && diffHead.sha && detail.data?.headSha && diffHead.sha !== detail.data.headSha,
1008 );
1009 const canCommentOnDiff =
1010 permissions.canInlineComment &&
1011 !commit &&
1012 !staleDiff &&
1013 !detail.isPending &&
1014 !result.isPending &&
1015 !result.error;
1016 const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(route.params)));
1017 const baseData = useMemo(() => getCachedNativeReviewDiffData({ parsedDiff: parsed }), [parsed]);
1018 const highlightFiles = usePullRequestDiffHighlightFiles(route.params, parsed, baseData.files);
1019 const data = useMemo(
1020 () =>
1021 annotatePullRequestDiff(
1022 { ...baseData, files: highlightFiles },
1023 staleDiff || commit ? [] : (activity.data?.reviewThreads ?? []),
1024 commit ? [] : reviewDraft.draft.comments,
1025 diffHead.sha ?? undefined,
1026 ),
1028 baseData,
1029 highlightFiles,
1030 staleDiff,
1031 commit,
1032 activity.data?.reviewThreads,
1033 reviewDraft.draft.comments,
1034 diffHead.sha,
1035 ],
1036 );
1037 const viewedIds = data.files
1038 .filter((file) => (commit ? commitViewedPaths.has(file.path) : viewed.isViewed(file.path)))
1039 .map((file) => file.id);
1040 const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
1041 const selectedRow = data.rows.find((row) => row.id === selectedRowId);
1042 const selectedFile = data.files.find((file) => file.id === selectedRow?.fileId);
1043 const commentOnLine = (rowId = selectedRowId) => {
1044 const row = data.rows.find((row) => row.id === rowId);
1045 const file = data.files.find((file) => file.id === row?.fileId);
1046 if (!row || !file || !diffHead.sha || !canCommentOnDiff) return;
1047 const position = reviewPosition(row);
1048 if (!position) return;
1049 const oldPath =
1050 parsed.kind === "files"
1051 ? parsed.files.find((entry) => entry.id === file.id)?.previousPath
1052 : null;
1053 if (route.params.replacingDraftId) navigation.setParams({ replacingDraftId: undefined });
1054 navigation.navigate("PullRequestInlineComment", {
1055 ...route.params,
1056 ...(route.params.replacingDraftId ? { draftId: route.params.replacingDraftId } : {}),
1057 path: file.path,
1058 ...(oldPath ? { oldPath } : {}),
1059 position,
1060 headSha: diffHead.sha,
1061 code: row.content ?? "",
1062 });
1063 };
1064 const [pickedPath, setJumpPath] = useState<string | null>(null);
1065 const jumpPath = pickedPath ?? linkPath ?? route.params.path;
1066 const requestedFile =
1067 result.parsed.kind === "files"
1068 ? result.parsed.files.find((file) => file.path === jumpPath)
1069 : undefined;
1070 const targetFile =
1071 data.files.find((file) => file.path === jumpPath) ??
1072 (linkAnchor?.kind === "diff" && !linkPath
1073 ? undefined
1074 : !jumpPath || requestedFile
1075 ? data.files[0]
1076 : undefined);
1077 const range = pickedPath === null && linkAnchor?.kind === "diff" ? linkAnchor.range : null;
1078 const focus =
1079 route.params.focus ??
1080 (range
1081 ? {
1082 side: range.side === "left" ? ("old" as const) : ("new" as const),
1083 start: range.start,
1084 end: range.end,
1085 endSide: range.endSide === "left" ? ("old" as const) : ("new" as const),
1087 : undefined);
1088 const focusedRows = guideFocusedRows(data.rows, targetFile?.id, focus);
1089 const focusIndex = focusedRows.length
1090 ? data.rows.findIndex((row) => row.id === focusedRows[0]?.id)
1091 : -1;
1092 const [collapsedIds, setCollapsedIds] = useState<readonly string[]>(EMPTY_IDS);
1093 const [visibleFileId, setVisibleFileId] = useState<string | null>(null);
1094 const nativeView = useRef<NativeReviewDiffViewHandle>(null);
1095 // The filter the current jump was made under. Hiding or showing test files replaces the rows,
1096 // so a jump made under the other filter has to run again. Native applies the entry
1097 // destination before its first viewport, so the first one counts as made.
1098 const jumpedUnder = useRef<boolean | null>(targetFile ? filter.hideTestFiles : null);
1099 const hasTestFiles =
1100 result.parsed.kind === "files" &&
1101 result.parsed.files.some((file) => isTestChangedFile(file.path));
1102 const jumpStarted = () =>
1103 jumpedUnder.current !== null && (!hasTestFiles || jumpedUnder.current === filter.hideTestFiles);
1104 const scrollToFile = (fileId: string) => {
1105 if (!nativeView.current) return;
1106 jumpedUnder.current = filter.hideTestFiles;
1107 void nativeView.current.scrollToFile(fileId, false).catch(() => {
1108 jumpedUnder.current = null;
1109 });
1110 };
1111 const jumpScope = pullRequestFileJumpScope(route.params);
1112 const jumpToFile = useEffectEvent((path: string) => {
1113 setJumpPath(path);
1114 setSelectedRowId(null);
1115 jumpedUnder.current = null;
1116 const file = data.files.find((entry) => entry.path === path);
1117 if (!file) return;
1118 setCollapsedIds((ids) => ids.filter((id) => id !== file.id));
1119 scrollToFile(file.id);
1120 });
1121 // Subscribed for the whole screen, so a pick made while the diff is still loading is kept for
1122 // the first draw.
1123 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to pull request file jump requests for the screen's lifetime
1124 useEffect(() => subscribePullRequestFileJump(jumpScope, (path) => jumpToFile(path)), [jumpScope]);
1125 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
1126 const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences();
1127 const bridge = useNativeReviewDiffBridge({
1128 threadKey: null,
1129 sectionId: `pull-request:${route.params.repository}:${route.params.number}:${commit ?? "all"}`,
1130 diff: result.patch,
1131 data,
1132 collapsedFileIds: collapsedIds,
1133 viewedFileIds: viewedIds,
1134 selectedRowIds: selectedRowId ? [selectedRowId] : focusedRows.map((row) => row.id),
1135 canHighlight: true,
1136 });
1137 const [NativeDiffView] = useState(() => resolveNativeReviewDiffView());
1138 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- requests the next page of the paged diff query until the jump target file has loaded
1139 useEffect(() => {
1140 if (!targetFile) loadMore();
1141 }, [targetFile, loadMore]);
1142 const onDebug: NonNullable<NativeReviewDiffViewProps["onDebug"]> = (event) =>
1143 handlePullRequestDiffDebugEvent(event, {
1144 rowCount: data.rows.length,
1145 targetFileId: targetFile?.id,
1146 focusIndex,
1147 isIos: Platform.OS === "ios",
1148 nativeView,
1149 jumpStarted,
1150 setJumpStarted: (started) => {
1151 jumpedUnder.current = started ? filter.hideTestFiles : null;
1152 },
1153 onDebug: bridge.onDebug,
1154 loadMore,
1155 });
1156 const onToggleFile: NonNullable<NativeReviewDiffViewProps["onToggleFile"]> = (event) => {
1157 const id = event.nativeEvent.fileId;
1158 if (!id) return;
1159 setCollapsedIds((ids) =>
1160 ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id],
1161 );
1162 };
1163 const visibleFile = data.files.find((file) => file.id === visibleFileId) ?? targetFile;
1164 const toggleTestFiles = () => {
1165 setJumpPath(visibleFile?.path ?? route.params.path);
1166 setSelectedRowId(null);
1167 filter.toggle();
1168 };
1169 const title = commit ? "Commit changes" : "Changes";
1170 const seekingFile = !targetFile && result.nextCursor !== null;
1171 const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error);
1172 return (
1173 <View className="flex-1 android:bg-header ios:bg-screen">
1174 <AppBar
1175 title={title}
1176 subtitle={`${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` : ""}`}
1177 onBack={() => navigation.goBack()}
1178 menus={[
1180 title: "Diff options",
1181 icon: "ellipsis",
1182 items: [
1183 ...(permissions.verdicts.length
1184 ? [
1186 id: "submit-review",
1187 title: "Submit review",
1188 icon: "text.bubble",
1189 onPress: () =>
1190 navigation.navigate("PullRequestReview", {
1191 ...route.params,
1192 mode: "review",
1193 }),
1194 },
1196 : []),
1197 ...(permissions.canComment
1198 ? [
1200 id: "comment-on-pr",
1201 title: "Add comment",
1202 icon: "plus.bubble",
1203 onPress: () =>
1204 navigation.navigate("PullRequestReview", {
1205 ...route.params,
1206 mode: "comment",
1207 }),
1208 },
1210 : []),
1212 id: "word-wrap",
1213 title: "Word wrap",
1214 icon: "text.alignleft",
1215 selected: appearance.codeWordBreak,
1216 onPress: () => setCodeWordBreak(!appearance.codeWordBreak),
1217 },
1219 id: "hide-tests",
1220 title: "Hide test files",
1221 icon: "line.3.horizontal.decrease",
1222 selected: filter.hideTestFiles,
1223 onPress: toggleTestFiles,
1224 },
1225 ...(selectedRow && selectedFile && canCommentOnDiff
1226 ? [
1228 id: "comment",
1229 title: route.params.replacingDraftId
1230 ? "Move draft to this line"
1231 : "Add review comment",
1232 icon: "bubble.left",
1233 onPress: () => commentOnLine(),
1234 },
1236 : []),
1237 ...(selectedRow && selectedFile
1238 ? [
1240 id: "copy",
1241 title: "Copy line",
1242 icon: "document.on.document",
1243 onPress: () => void Clipboard.setStringAsync(selectedRow.content ?? ""),
1244 },
1246 : []),
1247 ],
1248 },
1249 ]}
1250 />
1251 {route.params.replacingDraftId ? (
1252 <Text className="px-4 py-2 text-xs text-foreground-muted">
1253 Select a current line, then choose “Move draft to this line” from its menu.
1254 </Text>
1255 ) : null}
1256 {viewed.error ? (
1257 <Text className="px-4 py-2 text-xs text-danger-foreground">{viewed.error}</Text>
1258 ) : null}
1259 {staleDiff ? (
1260 <View
1261 className="flex-row items-center gap-2 px-4 py-2"
1262 style={{ paddingTop: contentTopInset + 8 }}
1264 <Text className="min-w-0 flex-1 text-xs text-foreground-muted">
1265 The branch changed. Reload before adding line comments.
1266 </Text>
1267 <Button
1268 label="Reload changes"
1269 onPress={() => {
1270 result.refresh();
1271 setDiffHead({ scopeKey, sha: detail.data?.headSha ?? null });
1272 jumpedUnder.current = null;
1273 setSelectedRowId(null);
1274 setCollapsedIds(EMPTY_IDS);
1275 }}
1276 />
1277 </View>
1278 ) : null}
1279 <ScreenContent>
1280 <View className="flex-1" collapsable={false}>
1281 {loadingDiff || result.error ? (
1282 <View style={{ paddingTop: staleDiff ? 0 : contentTopInset }}>
1283 <PullRequestReadStatus
1284 pending={loadingDiff}
1285 error={result.error}
1286 terminology={terminology}
1287 />
1288 </View>
1289 ) : null}
1290 {NativeDiffView && data.rows.length && !seekingFile ? (
1291 <View className="flex-1" collapsable={false}>
1292 <NativeDiffView
1293 {...pullRequestDiffInitialPosition(targetFile?.id, focusIndex)}
1294 nativeViewRef={nativeView}
1295 refreshing={result.refreshing}
1296 onPullToRefresh={result.onPullRefresh}
1297 style={StyleSheet.absoluteFill}
1298 rowsJson={bridge.rowsJson}
1299 themeJson={bridge.themeJson}
1300 styleJson={bridge.styleJson}
1301 tokensPatchJson={bridge.tokensPatchJson}
1302 tokensResetKey={bridge.tokensResetKey}
1303 collapsedFileIdsJson={bridge.collapsedFileIdsJson}
1304 onDebug={onDebug}
1305 viewedFileIdsJson={bridge.viewedFileIdsJson}
1306 selectedRowIdsJson={bridge.selectedRowIdsJson}
1307 onPressLine={(event) => {
1308 const rowId = event.nativeEvent.rowId ?? null;
1309 setSelectedRowId(rowId);
1310 if (event.nativeEvent.gesture !== "longPress") commentOnLine(rowId);
1311 }}
1312 onToggleViewedFile={(event) => {
1313 const file = data.files.find((file) => file.id === event.nativeEvent.fileId);
1314 if (!file || (!commit && !viewed.canToggle)) return;
1315 const wasViewed = commit
1316 ? commitViewedPaths.has(file.path)
1317 : viewed.isViewed(file.path);
1318 setCollapsedIds((ids) =>
1319 wasViewed
1320 ? ids.filter((id) => id !== file.id)
1321 : ids.includes(file.id)
1322 ? ids
1323 : [...ids, file.id],
1324 );
1325 if (commit)
1326 setCommitViewedPaths((current) => {
1327 const next = new Set(current);
1328 if (next.has(file.path)) next.delete(file.path);
1329 else next.add(file.path);
1330 return next;
1331 });
1332 else void viewed.toggle(file.path);
1333 }}
1334 onToggleComment={(event) => {
1335 const id = event.nativeEvent.commentId;
1336 if (id?.startsWith("discussion:"))
1337 navigation.navigate("PullRequestDiscussion", {
1338 ...route.params,
1339 discussionId: id.slice("discussion:".length),
1340 });
1341 if (id?.startsWith("draft:")) {
1342 const draft = reviewDraft.draft.comments.find(
1343 (draft) => draft.id === id.slice("draft:".length),
1344 );
1345 if (draft)
1346 navigation.navigate("PullRequestInlineComment", {
1347 ...route.params,
1348 ...draft,
1349 draftId: draft.id,
1350 });
1352 }}
1353 onToggleFile={onToggleFile}
1354 onVisibleFileChange={(event) => setVisibleFileId(event.nativeEvent.fileId ?? null)}
1355 appearanceScheme={themeAppearance}
1356 rowHeight={nativeReviewDiffStyle.rowHeight}
1357 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
1358 contentInsetTop={staleDiff ? 0 : contentTopInset}
1359 contentInsetBottom={Platform.OS === "android" ? toolbarHeight : insets.bottom}
1360 contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`}
1361 />
1362 </View>
1363 ) : filter.hiddenCount > 0 &&
1364 !data.rows.length &&
1365 !result.isPending &&
1366 !result.error &&
1367 !seekingFile ? (
1368 <View style={{ paddingTop: contentTopInset }}>
1369 <EmptyState
1370 title="Only test files changed"
1371 detail="Test files are hidden from this review."
1372 action={<Button label="Show test files" tone="contrast" onPress={filter.showAll} />}
1373 variant="plain"
1374 />
1375 </View>
1376 ) : !result.isPending && !result.error && !seekingFile ? (
1377 <EmptyState
1378 title="Diff unavailable"
1379 detail={`Open this ${terminology.singular} on its provider to view the changes.`}
1380 variant="plain"
1381 />
1382 ) : null}
1383 {result.nextCursor && result.isPending && data.rows.length && !seekingFile ? (
1384 <View className="flex-row items-center justify-center gap-2 py-2">
1385 <AppActivityIndicator size="small" />
1386 <Text className="text-xs text-foreground-muted">Loading more files…</Text>
1387 </View>
1388 ) : null}
1389 {result.error && result.nextCursor ? (
1390 <View className="self-center">
1391 <Button label="Retry loading remaining files" tone="text" onPress={result.refresh} />
1392 </View>
1393 ) : null}
1394 {parsed.kind === "files" && parsed.notice ? (
1395 <Text className="px-4 py-2 text-2xs text-foreground-muted">{parsed.notice}</Text>
1396 ) : null}
1397 </View>
1398 </ScreenContent>
1399 <PullRequestActionToolbar
1400 pr={detail.data}
1401 params={route.params}
1402 filePath={visibleFile?.path}
1403 onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)}
1404 onHeightChange={setToolbarHeight}
1405 />
1406 </View>
1407 );