import { PullRequestComposerNotice } from "./PullRequestComposerNotice";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { AppBar as AndroidScreenHeader } from "../../components/ui/AppBar";
import { Menu } from "../../components/ui/Menu";
import { IconButton } from "../../components/ui/IconButton";
import {
parsePullRequestAnchor,
pullRequestAnchorMatchesUrl,
pullRequestDiffPathMatches,
pullRequestLinkCommit,
resolvePullRequestReviewAnchor,
} from "@t3tools/shared/pullRequestAnchor";
import { isNativeReviewDiffDrawEvent } from "../diffs/nativeReviewDiffSurface";
import { pullRequestFileChapters } from "./pullRequestFileChapters";
import { Chip } from "../../components/ui/Chip";
import { Toolbar } from "../../components/ui/Toolbar";
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { readPullRequestView, rememberPullRequestView } from "./pullRequestViewMemory";
import { PullRequestFilesSheet } from "./PullRequestFilesSheet";
import { ReviewDisplayOptions } from "../review/ReviewDisplayOptions";
import { useDiffWhitespacePreference } from "../review/useDiffWhitespacePreference";
import { PullRequestCommentsSheet } from "./PullRequestCommentsSheet";
import { PullRequestCommitPicker } from "./PullRequestCommitPicker";
import { ReviewChapterNavigation } from "../review/ReviewChapterNavigation";
import { reviewFileGroup, type ReviewFileGroup } from "@t3tools/shared/reviewFileClassification";
import { reviewCommentRange, reviewPositionLabel } from "@t3tools/client-runtime/review-selection";
import {
orderReviewFiles,
isReviewFileVisible,
reviewCommentCounts,
latestReviewedCommit,
reviewCommitSelection,
} from "@t3tools/client-runtime/review-workspace";
import { pullRequestTabOffset } from "./pullRequestTabScroll";
import { guideFocusedRows, type GuideDiffFocus } from "./guide/guideDiffFocus";
import { PullRequestGuideContent } from "./guide/PullRequestGuideScreen";
import { useReviewGuidesEnabled } from "./guide/useReviewGuide";
import {
filterPullRequestActivity,
DEFAULT_ACTIVITY_FILTER,
type ActivityFilter,
} from "@t3tools/client-runtime/review-activity";
import { PullRequestActivityFilters } from "./PullRequestActivityFilters";
import type { LegendListRef } from "@legendapp/list/react-native";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
import Animated from "react-native-reanimated";
import {
PR_ROW_LAYOUT,
PR_ROW_ENTER,
PR_ROW_EXIT,
PR_POSITION_LAYOUT,
} from "./pullRequestAnimations";
import {
canPerformPullRequestAction,
openOnProviderLabel,
PR_ACTION_ICONS,
pullRequestActionLabel,
} from "./pullRequestActions";
import * as Clipboard from "expo-clipboard";
import { Button } from "../../components/ui/Button";
import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed";
import { usePullRequestPermissions } from "./usePullRequestPermissions";
import { usePullRequestDescription } from "./usePullRequestDescription";
import { usePullRequestAction } from "./usePullRequestAction";
import { annotatePullRequestDiff, reviewPosition } from "./pullRequestDiffComments";
import { usePullRequestFileFilter } from "./usePullRequestFileFilter";
import { pullRequestDiffInitialPosition } from "./pullRequestDiffDebug";
import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles";
import { usePullRequestViewingPreferences } from "./usePullRequestViewingPreferences";
import { isPullRequestTestFile } from "./pullRequestFileFilters";
import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react";
import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
import {
pullRequestConversationThreads,
type PullRequestDetail,
type PullRequestListEntry,
type PullRequestSummary,
} from "@t3tools/contracts";
import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading";
import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink";
import { pullRequestStatus } from "./pullRequestStatus";
import {
use,
useEffectEvent,
useCallback,
useMemo,
useLayoutEffect,
useEffect,
useRef,
useState,
} from "react";
import { Linking, Platform, Pressable, RefreshControl, StyleSheet, View } from "react-native";
import { NativeScrollView } from "../../native/NativeScrollViewMarker";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/ui/AppText";
import { AppBar } from "../../components/ui/AppBar";
import { useContentTopInset } from "../../components/ui/useBarInset";
import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { useAndroidWorkspaceBack } from "../layout/workspace/useAndroidWorkspaceNavigation";
import { EmptyState } from "../../components/ui/EmptyState";
import { Tabs } from "../../components/ui/Tabs";
import { SymbolView } from "../../components/ui/AppSymbol";
import { RelativeTime } from "../../components/RelativeTime";
import { usePullRequestConnected } from "./usePullRequestConnected";
import { usePullRequestLiveChecks } from "./usePullRequestLiveChecks";
import { pullRequestLabelColor } from "./pullRequestList";
import { PullRequestReactions } from "./PullRequestReactions";
import { composerPullRequests } from "../../state/pull-requests";
import { useEnvironmentQuery } from "../../state/query";
import { SourceControlAppAccessNotice } from "./SourceControlAppAccessNotice";
import { usePullToRefresh } from "../../state/use-pull-to-refresh";
import {
resolveNativeReviewDiffView,
type NativeReviewDiffViewHandle,
type NativeReviewDiffViewProps,
} from "../diffs/nativeReviewDiffSurface";
import {
getCachedNativeReviewDiffData,
NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
} from "../review/nativeReviewDiffAdapter";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
import { useNativeReviewDiffBridge } from "../review/useNativeReviewDiffBridge";
import { pullRequestTimelineRows } from "./PullRequestTimeline";
import {
PullRequestActionToolbar,
PullRequestCardGroup,
PullRequestHeaderToolbar,
} from "./PullRequestControls";
import {
pullRequestTarget as requestTarget,
usePullRequestDiff,
usePullRequestDiffEntry,
type PullRequestDiffParams,
type PullRequestParams,
} from "./usePullRequestDiff";
import {
PullRequestAvatar,
PullRequestDiffStats,
PullRequestReadStatus,
} from "./PullRequestPresentation";
import { usePullRequestFilesSection } from "./PullRequestFiles";
import { usePullRequestMaintainerEdits } from "./usePullRequestMaintainerEdits";
import { PullRequestDescription } from "./PullRequestDescription";
import { PullRequestMediaVisibleContext } from "./PullRequestMedia";
import { PullRequestMarkdownRepository } from "./PullRequestMarkdown";
import {
PullRequestStatusMenu,
PullRequestChecksRow,
PullRequestBranchRow,
PullRequestReviewersRow,
} from "./PullRequestStatusRows";
import { PullRequestMergeControls } from "./PullRequestMergeControls";
import { PullRequestStackCard } from "./PullRequestStackCard";
import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack";
import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback";
import { pullRequestFileJumpScope, subscribePullRequestFileJump } from "./pullRequestFileJump";
import {
capitalizeTerm,
sourceControlClients,
} from "@t3tools/client-runtime/source-control-clients";
const EMPTY_IDS: readonly string[] = [];
function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityFilter) {
const navigation = useNavigation();
const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));
const filtered = result.data ? filterPullRequestActivity(result.data, filter) : null;
const rows = (() => {
if (!filtered) return [];
const rows = pullRequestTimelineRows(filtered.activity, params, navigation, filter.newestFirst);
if (filter.newestFirst) {
const notice = rows.at(-1)?.key === "truncated" ? rows.pop() : undefined;
rows.reverse();
if (notice) rows.push(notice);
}
if (!rows.length)
rows.push({
key: "empty",
date: "",
content: (
No activity matches these filters.
),
});
return rows;
})();
const location = parsePullRequestAnchor(params.navigationUrl);
const anchor =
location?.kind !== "timeline"
? null
: result.data
? resolvePullRequestReviewAnchor(
location.anchor,
result.data.comments,
result.data.reviewThreads,
)
: location.anchor;
const targetComment = anchor
? result.data?.comments.find((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url))
: null;
const targetThread = anchor
? result.data &&
pullRequestConversationThreads(result.data).find((thread) =>
thread.comments.some((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url)),
)
: null;
const targetKey = targetThread
? `discussion:${targetThread.id}`
: targetComment
? `comment:${targetComment.id}`
: null;
return {
rows,
targetKey,
targetThread,
hiddenCount: filtered?.hiddenCount ?? 0,
pending: result.isPending && !result.data,
error: result.error,
};
}
function PullRequestIdentity(props: {
readonly pr: PullRequestDetail | PullRequestListEntry | PullRequestSummary;
readonly params: PullRequestParams;
}) {
const { pr } = props;
const status = pullRequestStatus({ state: pr.state, isDraft: pr.isDraft === true });
return (
{"capabilities" in pr ? (
) : (
{status.label}
)}
{"changedFiles" in pr && pr.changedFiles !== undefined ? (
{pr.changedFiles} {pr.changedFiles === 1 ? "file" : "files"}
) : (
)}
{pr.title}
{pr.author?.login ?? "Unknown author"}
Updated ago
void Clipboard.setStringAsync(pr.baseBranch)}
className="min-w-0 shrink active:opacity-70"
hitSlop={8}
>
{pr.baseBranch}
void Clipboard.setStringAsync(pr.headBranch)}
className="min-w-0 shrink active:opacity-70"
hitSlop={8}
>
{pr.headBranch}
);
}
function PullRequestLabels(props: {
readonly pr: PullRequestDetail;
readonly params: PullRequestParams;
}) {
const navigation = useNavigation();
const connected = usePullRequestConnected(props.params.environmentId);
const canWrite = useAtomValue(
composerPullRequests.setLabels.permissionAtom(requestTarget(props.params).environmentId),
);
const editable =
connected &&
canWrite &&
props.pr.capabilities.labels === true &&
props.pr.viewerPermissions.labels !== false;
if (!props.pr.labels.length && !editable) return null;
return (
{props.pr.labels.map((label) => {
const color = pullRequestLabelColor(label.color);
// Tinted like the list row's label pills, at the overview's chip size.
return (
{color ? (
) : null}
{label.name}
);
})}
{editable ? (
navigation.navigate("PullRequestLabels", props.params)}
/>
) : null}
);
}
function PullRequestOverview(props: {
readonly pr: PullRequestDetail;
readonly description: ReturnType;
readonly params: PullRequestParams;
readonly stack: PullRequestStackState;
readonly onDescriptionLayout: (y: number) => void;
readonly onDescriptionCollapse: () => void;
readonly descriptionExpanded: boolean;
readonly onDescriptionExpandedChange: (expanded: boolean) => void;
}) {
const { pr } = props;
return (
props.onDescriptionLayout(event.nativeEvent.layout.y)}
>
}
/>
);
}
function usePullRequestToolbarHeight(bottomInset: number) {
const { buttonSize } = useAndroidControlSizing();
return useState(() => (Platform.OS === "android" ? bottomInset + buttonSize + 40 : 0));
}
export function PullRequestRouteScreen(props: StaticScreenProps) {
return ;
}
function PullRequestRouteContent({ route }: StaticScreenProps) {
const returnToList = useAndroidWorkspaceBack("pullRequests");
const { layout } = useAdaptiveWorkspaceLayout();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
const contentTopInset = useContentTopInset();
// Only iOS lists run under the bottom safe area; Android's floating toolbar pads its own.
const contentBottomInset = Platform.OS === "ios" ? insets.bottom : 0;
const params = route.params;
usePullRequestEnvironmentFallback(params);
const connected = usePullRequestConnected(params.environmentId);
const guidesEnabled = useReviewGuidesEnabled(params);
const viewMemoryKey = pullRequestReviewKey(params);
const [chosenSection, setSection] = useState<"overview" | "diff" | "guide" | "activity">(() => {
const kind = parsePullRequestAnchor(params.navigationUrl)?.kind;
return kind === "timeline" || kind === "commits"
? "activity"
: (readPullRequestView(viewMemoryKey).section ?? "overview");
});
const shown = (tab: typeof chosenSection) =>
!guidesEnabled && tab === "guide" ? "overview" : tab;
const section = shown(chosenSection);
const [visitedSections, setVisitedSections] = useState>(
() => new Set([section]),
);
// Keep each tab's native list and measured rows alive when it is hidden.
const requestedSection = useRef(chosenSection);
const [listAnimationsReady, setListAnimationsReady] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const filesContent = usePullRequestFilesSection(params);
const [activityFilter, setActivityFilter] = useState(() => {
const kind = parsePullRequestAnchor(params.navigationUrl)?.kind;
return kind === "timeline"
? { ...DEFAULT_ACTIVITY_FILTER, bots: true, resolved: true }
: kind === "commits"
? { ...DEFAULT_ACTIVITY_FILTER, kind: "commits" }
: DEFAULT_ACTIVITY_FILTER;
});
const [commentsOpen, setCommentsOpen] = useState(false);
const activityContent = usePullRequestActivityRows(params, activityFilter);
const scrollOffsets = useRef>({});
// The offset of the tab last shown, and which tab that was: a tab standing in for a hidden guide
// has its own offset, which only its next scroll would record here.
const currentScrollOffset = useRef<{ tab: typeof section; offset: number }>({
tab: "overview",
offset: -contentTopInset,
});
const bodyY = useRef(0);
const [headerHeight, setHeaderHeight] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollViews = useRef>>({});
// AnimatedLegendList re-attaches its handle and sets state whenever its ref changes,
// so an inline ref in the tab map loops the screen into "Maximum update depth exceeded".
const [listRefs] = useState(() => {
const listRef = (tab: typeof section) => (list: LegendListRef | null) => {
scrollViews.current[tab] = list;
};
return {
overview: listRef("overview"),
guide: listRef("guide"),
activity: listRef("activity"),
};
});
const selectSection = (next: typeof section) => {
if (next === section) return;
const from = shown(requestedSection.current);
const current =
currentScrollOffset.current.tab === from
? currentScrollOffset.current.offset
: (scrollOffsets.current[from] ?? -contentTopInset);
scrollOffsets.current[section] = current;
const headerEnd = Math.max(-contentTopInset, bodyY.current - contentTopInset);
const offset = pullRequestTabOffset(current, scrollOffsets.current[next], headerEnd);
requestedSection.current = next;
scrollOffsets.current[next] = offset;
currentScrollOffset.current = { tab: next, offset };
scrollViews.current[next]?.scrollToOffset({ offset, animated: false });
setVisitedSections((current) => new Set(current).add(next));
setSection(next);
if (next !== "activity") rememberPullRequestView(viewMemoryKey, { section: next });
};
const followLink = useEffectEvent(() => {
const target = parsePullRequestAnchor(params.navigationUrl);
if (target?.kind === "diff" || target?.kind === "code") {
const commit = pullRequestLinkCommit(params.navigationUrl);
navigation.navigate("PullRequestFile", {
...params,
path: "",
...(commit ? { commit } : {}),
});
} else if (target?.kind === "timeline") {
selectSection("activity");
setActivityFilter((filter) => ({ ...filter, kind: "all", bots: true, resolved: true }));
} else if (target?.kind === "commits") {
selectSection("activity");
setActivityFilter((filter) => ({ ...filter, kind: "commits" }));
} else if (target?.kind === "checks") {
selectSection("overview");
// Checks head Overview, which a reused screen may have scrolled past.
scrollViews.current.overview?.scrollToOffset({ offset: -contentTopInset, animated: false });
} else if (target?.kind === "summary") {
selectSection("overview");
}
});
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a link selects its native PR section or diff even when the route is reused
useEffect(() => {
// oxlint-disable-next-line react/set-state-in-effect -- URL navigation selects the PR section and makes its target visible
followLink();
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- each clicked URL is a fresh navigation request
}, [params.navigationUrl, params.navigationId]);
const activityTargetIndex = activityContent.rows.findIndex(
(row) =>
row.key === activityContent.targetKey ||
(activityContent.targetKey !== null && row.memberKeys?.includes(activityContent.targetKey)),
);
const openedDiscussion = useRef(null);
const positionedActivity = useRef(null);
// Rows above the target measure in after the first jump, so it is held until the user scrolls.
const heldActivityRow = useRef(null);
const positionActivityRow = (index: number) =>
void scrollViews.current.activity?.scrollToIndex({
index,
viewPosition: 0,
viewOffset: contentTopInset + 12,
animated: false,
});
const revealActivity = useEffectEvent(() => {
const key = `${params.navigationUrl}:${params.navigationId ?? 0}`;
if (activityContent.targetThread && openedDiscussion.current !== key) {
openedDiscussion.current = key;
navigation.navigate("PullRequestDiscussion", {
...params,
discussionId: activityContent.targetThread.id,
});
return;
}
// Later filter or refresh changes move the row; they must not pull the user back to it.
if (activityTargetIndex >= 0 && positionedActivity.current !== key) {
positionedActivity.current = key;
// Its top sits under the app bar; centering a long comment pushed its start off screen.
heldActivityRow.current = activityTargetIndex + 1;
positionActivityRow(activityTargetIndex + 1);
}
});
const targetThreadId = activityContent.targetThread?.id;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals the requested activity once its asynchronous rows arrive
useEffect(() => {
revealActivity();
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- reruns when activity resolves the URL target, even a thread the filter hides
}, [params.navigationUrl, params.navigationId, activityTargetIndex, targetThreadId]);
const tabContent = activityContent;
const filesY = useRef(0);
const descriptionY = useRef(0);
const pendingDescriptionJump = useRef(false);
const pendingFilesJump = useRef(false);
const detailAtom = composerPullRequests.detail(requestTarget(params));
const result = useEnvironmentQuery(detailAtom);
const pullRefresh = usePullToRefresh(detailAtom);
const { refresh: refreshDetail } = result;
useFocusEffect(refreshDetail);
const pr = usePullRequestLiveChecks(params, result.data);
const summary = params.initialSummary;
const identity = pr ?? summary;
const terminology = sourceControlClients.get(identity?.provider).changeRequest;
const permissions = usePullRequestPermissions(params, pr);
const description = usePullRequestDescription(params, pr, permissions.canEditChangeRequest);
const action = usePullRequestAction(params, pr);
const maintainerEdits = usePullRequestMaintainerEdits(
params,
pr,
permissions.canEditMaintainerEdits,
);
const showsDeleteBranch = pr !== null && canPerformPullRequestAction(pr, "delete-branch");
const stateAction = action.available("close")
? "close"
: action.available("reopen")
? "reopen"
: null;
const runStateAction = () =>
stateAction === "close" ? action.confirmClose() : void action.perform("reopen");
const confirmDeleteBranch = () =>
pr &&
action.confirm(
"delete-branch",
pullRequestActionLabel("delete-branch", terminology),
`Delete ${pr.headBranch} from ${pr.headRepositoryNameWithOwner ?? pr.repository}? Local branches and worktrees are not changed.`,
);
const stack = usePullRequestStack(params, pr);
const refreshActivity = useAtomRefresh(composerPullRequests.activity(requestTarget(params)));
const refreshGuide = useAtomRefresh(composerPullRequests.reviewGuide(requestTarget(params)));
const registry = use(RegistryContext);
const refresh = () =>
pullRefresh.onRefresh(async (signal) => {
const target = requestTarget(params);
await composerPullRequests.invalidate
.run(registry, {
environmentId: target.environmentId,
input: { reference: target.input },
})
.catch(() => undefined);
if (signal.aborted) return;
if (guidesEnabled) refreshGuide();
stack.refresh();
refreshActivity();
filesContent.refresh();
});
const jumpToFiles = () => {
scrollViews.current.overview?.scrollToIndex({
index: 0,
viewPosition: 0,
viewOffset: -filesY.current,
animated: true,
});
pendingFilesJump.current = false;
};
const openFiles = () => {
selectSection("diff");
};
return (
setCommentsOpen(true),
},
...(permissions.verdicts.length
? [
{
id: "submit-review",
title: "Submit review",
icon: "text.bubble",
onPress: () =>
navigation.navigate("PullRequestReview", { ...params, mode: "review" }),
},
]
: []),
...(permissions.canComment
? [
{
id: "add-comment",
title: "Add comment",
icon: "plus.bubble",
onPress: () =>
navigation.navigate("PullRequestReview", { ...params, mode: "comment" }),
},
]
: []),
...(permissions.canEditChangeRequest
? [
{
id: "edit-title",
title: "Edit title",
icon: "pencil",
onPress: () =>
navigation.navigate("PullRequestEdit", { ...params, field: "title" }),
},
{
id: "edit-description",
title: "Edit description",
icon: "doc.text",
disabled: description.pending,
subtitle: description.pending ? "Saving…" : undefined,
onPress: () =>
navigation.navigate("PullRequestEdit", { ...params, field: "body" }),
},
]
: []),
...(maintainerEdits.visible
? [
{
id: "maintainer-edits",
title: "Allow edits from maintainers",
icon: "pencil",
selected: maintainerEdits.value,
disabled: maintainerEdits.saving,
subtitle: maintainerEdits.subtitle,
onPress: maintainerEdits.toggle,
},
]
: []),
...(identity
? [
{
id: "open-on-provider",
title: openOnProviderLabel(identity.provider),
icon: "arrow.up.right",
onPress: () => void Linking.openURL(identity.url),
},
{
id: "copy-link",
title: "Copy link",
icon: "link",
onPress: () => copyPullRequestLink(identity),
},
{
id: "share",
title: "Share…",
icon: "square.and.arrow.up",
onPress: () => sharePullRequestLink(identity),
},
]
: []),
...(stateAction
? [
{
id: "state",
title: pullRequestActionLabel(stateAction, terminology),
icon: PR_ACTION_ICONS[stateAction],
destructive: stateAction === "close",
onPress: runStateAction,
},
]
: []),
...(showsDeleteBranch
? [
{
id: "delete-branch",
title: pullRequestActionLabel("delete-branch", terminology),
icon: PR_ACTION_ICONS["delete-branch"],
destructive: true,
disabled: !action.available("delete-branch"),
subtitle: action.busy
? "Deleting…"
: connected
? pr.headBranch
: "Reconnect to delete this branch.",
onPress: confirmDeleteBranch,
},
]
: []),
],
},
]}
/>
setCommentsOpen(false)}
/>
setViewportHeight(event.nativeEvent.layout.height)}
>
{(["overview", "guide", "activity", "diff"] as const)
.filter(
(tab) =>
(visitedSections.has(tab) || section === tab) && (tab !== "guide" || guidesEnabled),
)
.map((tab) => (
{tab === "diff" ? (
{Platform.OS !== "ios" || section === tab ? (
) : (
)}
) : (
{
heldActivityRow.current = null;
}
: undefined
}
onScroll={(event) => {
const offset = event.nativeEvent.contentOffset.y;
scrollOffsets.current[tab] = offset;
if (shown(requestedSection.current) === tab)
currentScrollOffset.current = { tab, offset };
}}
className="flex-1"
renderScrollComponent={Platform.OS === "ios" ? NativeScrollView : undefined}
contentInsetAdjustmentBehavior="never"
contentInset={{ top: contentTopInset, bottom: contentBottomInset }}
scrollToOverflowEnabled
refreshControl={
}
alwaysBounceVertical
scrollsToTop={section === tab}
onItemSizeChanged={({ itemKey }) => {
const held = heldActivityRow.current;
if (tab === "activity" && held !== null) {
// A filter or refresh that moved the row ends the hold.
if (held !== activityTargetIndex + 1) heldActivityRow.current = null;
else positionActivityRow(held);
return;
}
if (
tab !== "overview" ||
section !== "overview" ||
itemKey !== "overview:body" ||
!pendingDescriptionJump.current
)
return;
pendingDescriptionJump.current = false;
scrollViews.current.overview?.scrollToIndex({
index: 0,
viewPosition: 0,
viewOffset: -descriptionY.current,
animated: false,
});
}}
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 24 + (Platform.OS === "android" ? toolbarHeight : 0),
// Short tabs must still be tall enough to preserve the collapsed header.
minHeight: Math.max(
0,
viewportHeight + headerHeight - contentTopInset - contentBottomInset,
),
}}
data={[
{
key: `${tab}:body`,
content: (
{tab === "overview" && identity ? (
{pr ? (
{
descriptionY.current = y;
}}
onDescriptionCollapse={() => {
pendingDescriptionJump.current = true;
}}
/>
) : summary ? (
) : null}
{
filesY.current = event.nativeEvent.layout.y;
if (pendingFilesJump.current) {
requestAnimationFrame(() => {
if (requestedSection.current === "overview")
jumpToFiles();
});
}
}}
>
{filesContent.header}
) : null}
{tab === "guide" ? (
) : null}
{tab === "activity" ? (
<>
>
) : null}
),
},
...(tab === "overview"
? identity
? filesContent.rows
: []
: tab === "activity"
? tabContent.rows
: []
).map((row) => ({
...row,
key: `${tab}:${row.key}`,
content: row.content,
})),
]}
keyExtractor={(row) => row.key}
getItemType={(row) =>
row.key.endsWith(":body") ? row.key : row.key.split(":")[0]
}
// The overview body contains the clipped description and owns collapse scroll
// correction. Animating that cell fights both; file rows still fade individually.
itemLayoutAnimation={tab === "overview" ? undefined : PR_ROW_LAYOUT}
onLoad={() => setListAnimationsReady(true)}
renderItem={({ item }) => (
{item.content}
)}
recycleItems={false}
estimatedItemSize={tab === "overview" ? 64 : 220}
maintainVisibleContentPosition={false}
ListHeaderComponent={
<>
{identity ? (
{
bodyY.current = event.nativeEvent.layout.height;
setHeaderHeight(event.nativeEvent.layout.height);
}}
>
{Platform.OS !== "ios" || section === tab ? (
) : (
)}
) : null}
>
}
ListFooterComponent={tab === "overview" ? filesContent.footer : null}
/>
)}
))}
);
}
type PullRequestFileRouteProps = StaticScreenProps<
PullRequestDiffParams & {
readonly path: string;
readonly replacingDraftId?: string;
readonly focus?: GuideDiffFocus;
}
>;
export function PullRequestFileRouteScreen(props: PullRequestFileRouteProps) {
const navigation =
useNavigation>();
const ready = usePullRequestDiffEntry(navigation);
if (ready)
return ;
const title = props.route.params.commit ? "Commit changes" : "Changes";
return (
navigation.goBack()} />
);
}
function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) {
return ;
}
type PullRequestFileParams = PullRequestDiffParams & {
readonly path: string;
readonly fileSelectionId?: string;
readonly replacingDraftId?: string;
readonly focus?: GuideDiffFocus;
};
export function PullRequestDiffContent({
params,
embedded = false,
onlyPaths,
parentToolbarHeight = 0,
}: {
params: PullRequestFileParams;
embedded?: boolean;
onlyPaths?: ReadonlyArray;
parentToolbarHeight?: number;
}) {
const { layout } = useAdaptiveWorkspaceLayout();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const contentTopInset = useContentTopInset();
const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
const [commit, setCommit] = useState(params.commit ?? null);
const { commit: _requestedCommit, ...reviewParams } = params;
const { ignoreWhitespace, setIgnoreWhitespace } = useDiffWhitespacePreference();
const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(params)));
const terminology = sourceControlClients.get(
(detail.data ?? params.initialSummary)?.provider,
).changeRequest;
const result = usePullRequestDiff(
{ ...reviewParams, ...(commit ? { commit } : {}) },
terminology,
ignoreWhitespace,
);
// Other screens open files with the link's params spread in; only the link's own entry follows it.
const linkAnchor =
params.path === "" && (params.commit ?? null) === pullRequestLinkCommit(params.navigationUrl)
? parsePullRequestAnchor(params.navigationUrl)
: null;
const matchedLinkPath =
linkAnchor?.kind === "diff" && result.parsed.kind === "files"
? result.parsed.files.find((file) => pullRequestDiffPathMatches(linkAnchor.digest, file.path))
?.path
: undefined;
// String conversion stops the compiler aliasing this path to mutable parsed data.
const linkPath = matchedLinkPath === undefined ? undefined : `${matchedLinkPath}`;
const [filesOpen, setFilesOpen] = useState(false);
const [optionsOpen, setOptionsOpen] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(false);
const filter = usePullRequestFileFilter(
result.parsed,
linkPath ?? (params.focus ? params.path : undefined),
);
const { refresh: refreshDetail } = detail;
useFocusEffect(refreshDetail);
const { loadMore } = result;
const reviewDraft = usePullRequestReviewDraft(params);
const viewed = usePullRequestFilesViewed(params, detail.data);
const permissions = usePullRequestPermissions(params, detail.data);
const scopeKey = pullRequestReviewKey(params);
const [diffHead, setDiffHead] = useState({ scopeKey, sha: detail.data?.headSha ?? null });
if (diffHead.scopeKey !== scopeKey || (!diffHead.sha && detail.data?.headSha))
setDiffHead({ scopeKey, sha: detail.data?.headSha ?? null });
const staleDiff = Boolean(
!commit && diffHead.sha && detail.data?.headSha && diffHead.sha !== detail.data.headSha,
);
const canCommentOnDiff =
permissions.canInlineComment &&
!commit &&
!staleDiff &&
!detail.isPending &&
!result.isPending &&
!result.error;
const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params)));
const { preferences: viewingPreferences, update: updateViewingPreferences } =
usePullRequestViewingPreferences();
const { commentsOnly, hideReviewed, groupByType } = viewingPreferences;
const isGuideChapter = onlyPaths !== undefined;
const grouped = groupByType && !isGuideChapter;
const rememberedPath =
params.path || (onlyPaths === undefined ? (readPullRequestView(scopeKey).path ?? "") : "");
const [pickedPath, setJumpPath] = useState(null);
const [chapterId, setChapterId] = useState(() =>
reviewFileGroup(linkPath ?? rememberedPath),
);
const chapterRequest = `${params.navigationUrl ?? ""}:${params.navigationId ?? ""}`;
const [chapterTarget, setChapterTarget] = useState({ request: chapterRequest, path: linkPath });
const newChapterTarget =
chapterTarget.request !== chapterRequest ||
(linkPath !== undefined && chapterTarget.path !== linkPath);
if (newChapterTarget) {
setChapterTarget({ request: chapterRequest, path: linkPath });
setChapterId(reviewFileGroup(linkPath ?? rememberedPath));
setJumpPath(null);
}
const { isViewed } = viewed;
const visibleParsed = useMemo(() => {
const original = isGuideChapter ? result.parsed : filter.parsed;
if (original.kind !== "files") return original;
const counts = reviewCommentCounts(
original.files.map((file) => ({ path: file.path, previousPath: file.previousPath })),
activity.data?.reviewThreads ?? [],
reviewDraft.comments,
);
const files = orderReviewFiles(
original.files.filter(
(file) =>
(onlyPaths === undefined || onlyPaths.includes(file.path)) &&
(isGuideChapter ||
isReviewFileVisible(
{ commentsOnly, hideReviewed },
isViewed(file.path),
(counts.get(file.path)?.unresolved ?? 0) > 0,
)),
),
(file) => file.path,
grouped,
);
return {
...original,
files,
fileCount: files.length,
additions: files.reduce((sum, file) => sum + file.additions, 0),
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
};
}, [
filter.parsed,
result.parsed,
isGuideChapter,
activity.data,
reviewDraft.comments,
commentsOnly,
hideReviewed,
grouped,
isViewed,
onlyPaths,
]);
const chapterIndex = useMemo(
() =>
pullRequestFileChapters({
completePaths:
!detail.data || (viewed.supported && (!viewed.ready || viewed.truncated))
? null
: !commit && viewed.filePaths !== null
? viewed.filePaths
: !result.isPending &&
!result.error &&
result.nextCursor === null &&
result.parsed.kind === "files"
? result.parsed.files.map((file) => file.path)
: null,
loadedPaths:
filter.parsed.kind === "files" ? filter.parsed.files.map((file) => file.path) : [],
visiblePaths:
visibleParsed.kind === "files" ? visibleParsed.files.map((file) => file.path) : [],
hideTestFiles: filter.hideTestFiles,
includePath: linkPath ?? (params.focus ? params.path : undefined),
}),
[
commit,
detail.data,
viewed.supported,
viewed.ready,
viewed.truncated,
viewed.filePaths,
result.isPending,
result.error,
result.nextCursor,
result.parsed,
filter.parsed,
filter.hideTestFiles,
visibleParsed,
linkPath,
params.focus,
params.path,
],
);
const chapters = grouped ? chapterIndex.chapters : [];
const requestedChapterId =
linkPath !== undefined && (pickedPath === null || newChapterTarget)
? reviewFileGroup(linkPath)
: chapterId;
const activeChapter =
chapters.find((chapter) => chapter.id === requestedChapterId) ?? chapters[0];
const chapterFiles = activeChapter?.files ?? EMPTY_IDS;
const reviewedChapterFiles = chapterFiles.filter(viewed.isViewed).length;
const parsed = useMemo(() => {
if (!activeChapter || visibleParsed.kind !== "files") return visibleParsed;
const files = visibleParsed.files.filter(
(file) => reviewFileGroup(file.path) === activeChapter.id,
);
return {
...visibleParsed,
files,
fileCount: files.length,
additions: files.reduce((sum, file) => sum + file.additions, 0),
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
};
}, [activeChapter, visibleParsed]);
const baseData = useMemo(() => getCachedNativeReviewDiffData({ parsedDiff: parsed }), [parsed]);
const highlightFiles = usePullRequestDiffHighlightFiles(params, parsed, baseData.files);
const data = useMemo(
() =>
annotatePullRequestDiff(
{ ...baseData, files: highlightFiles },
staleDiff || commit ? [] : reviewDraft.threads,
commit ? [] : reviewDraft.comments,
diffHead.sha ?? undefined,
),
[
baseData,
highlightFiles,
reviewDraft.threads,
reviewDraft.comments,
diffHead.sha,
staleDiff,
commit,
],
);
const viewedIds = data.files.filter((file) => viewed.isViewed(file.path)).map((file) => file.id);
const [selectedRowId, setSelectedRowId] = useState(null);
const [rangeEndRowId, setRangeEndRowId] = useState(null);
const startIndex = data.rows.findIndex((row) => row.id === selectedRowId);
const endIndex = data.rows.findIndex((row) => row.id === rangeEndRowId);
const selectedRows =
startIndex < 0
? []
: data.rows
.slice(
Math.min(startIndex, endIndex < 0 ? startIndex : endIndex),
Math.max(startIndex, endIndex < 0 ? startIndex : endIndex) + 1,
)
.filter((row) => row.kind === "line" && row.fileId === data.rows[startIndex]?.fileId);
const firstPosition = selectedRows[0] ? reviewPosition(selectedRows[0]) : null;
const lastRow = selectedRows.at(-1);
const lastPosition = lastRow ? reviewPosition(lastRow) : null;
const selectedRange =
firstPosition && lastPosition ? reviewCommentRange(firstPosition, lastPosition) : null;
const selectedRow = data.rows.find((row) => row.id === selectedRowId);
const selectedFile = data.files.find((file) => file.id === selectedRow?.fileId);
const commentOnLine = (rowId = selectedRowId) => {
const row = data.rows.find((row) => row.id === rowId);
const file = data.files.find((file) => file.id === row?.fileId);
if (!row || !file || !diffHead.sha || !canCommentOnDiff) return;
const rowPosition = reviewPosition(row);
const range =
rowId === selectedRowId ? selectedRange : rowPosition ? { position: rowPosition } : null;
if (!range) return;
const oldPath =
parsed.kind === "files"
? parsed.files.find((entry) => entry.id === file.id)?.previousPath
: null;
if (params.replacingDraftId) navigation.setParams({ replacingDraftId: undefined });
navigation.navigate("PullRequestInlineComment", {
...params,
...(params.replacingDraftId ? { draftId: params.replacingDraftId } : {}),
path: file.path,
...(oldPath ? { oldPath } : {}),
position: range.position,
...(range.startPosition ? { startPosition: range.startPosition } : {}),
headSha: diffHead.sha,
code:
rowId === selectedRowId
? selectedRows.map((line) => line.content ?? "").join("\n")
: (row.content ?? ""),
});
};
const effectivePickedPath = newChapterTarget ? null : pickedPath;
const jumpPath = effectivePickedPath ?? linkPath ?? rememberedPath;
const requestedFile =
result.parsed.kind === "files"
? result.parsed.files.find((file) => file.path === jumpPath)
: undefined;
const targetFile =
data.files.find((file) => file.path === jumpPath) ??
(linkAnchor?.kind === "diff" && !linkPath
? undefined
: !jumpPath || requestedFile
? data.files[0]
: undefined);
const range =
effectivePickedPath === null && linkAnchor?.kind === "diff" ? linkAnchor.range : null;
const focus =
params.focus ??
(range
? {
side: range.side === "left" ? ("old" as const) : ("new" as const),
start: range.start,
end: range.end,
endSide: range.endSide === "left" ? ("old" as const) : ("new" as const),
}
: undefined);
const focusedRows = guideFocusedRows(data.rows, targetFile?.id, focus);
const focusIndex = focusedRows.length
? data.rows.findIndex((row) => row.id === focusedRows[0]?.id)
: -1;
const [collapsedIds, setCollapsedIds] = useState(EMPTY_IDS);
const [expandedViewed, setExpandedViewed] = useState>(() => new Set());
const [visibleFileId, setVisibleFileId] = useState(null);
const nativeView = useRef(null);
// The filter the current jump was made under. Hiding or showing test files replaces the rows,
// so a jump made under the other filter has to run again. Native applies the entry
// destination before its first viewport, so the first one counts as made.
const jumpedUnder = useRef(targetFile ? filter.hideTestFiles : null);
const hasTestFiles =
result.parsed.kind === "files" &&
result.parsed.files.some((file) => isPullRequestTestFile(file.path));
const jumpStarted = () =>
jumpedUnder.current !== null && (!hasTestFiles || jumpedUnder.current === filter.hideTestFiles);
const activeChapterId = activeChapter?.id;
const [lastChapter, setLastChapter] = useState(activeChapterId);
if (lastChapter !== activeChapterId) {
setLastChapter(activeChapterId);
if (activeChapter) {
if (
!(linkAnchor?.kind === "diff" && !linkPath) &&
(!jumpPath || reviewFileGroup(jumpPath) !== activeChapter.id)
)
setJumpPath(activeChapter.files[0] ?? "");
setSelectedRowId(null);
setRangeEndRowId(null);
setCollapsedIds(EMPTY_IDS);
}
}
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- re-arms native file scrolling when a new chapter is mounted
useLayoutEffect(() => {
if (activeChapterId !== undefined) jumpedUnder.current = null;
}, [activeChapterId]);
const jumpScope = pullRequestFileJumpScope(params);
const jumpToFile = useEffectEvent((path: string) => {
if (params.focus) navigation.setParams({ focus: undefined });
setJumpPath(path);
setChapterId(reviewFileGroup(path));
setSelectedRowId(null);
setRangeEndRowId(null);
jumpedUnder.current = null;
const file = data.files.find((entry) => entry.path === path);
if (!file || !nativeView.current) return;
setCollapsedIds((ids) => ids.filter((id) => id !== file.id));
jumpedUnder.current = filter.hideTestFiles;
void nativeView.current.scrollToFile(file.id, false).catch(() => {
jumpedUnder.current = null;
});
});
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to file-jump events from the native review navigator
useEffect(() => subscribePullRequestFileJump(jumpScope, (path) => jumpToFile(path)), [jumpScope]);
const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences();
const bridge = useNativeReviewDiffBridge({
threadKey: null,
sectionId: `pull-request:${params.repository}:${params.number}:${commit ?? "all"}:${activeChapter?.id ?? "all"}`,
diff: result.patch,
data,
collapsedFileIds: [
...new Set([...collapsedIds, ...viewedIds.filter((id) => !expandedViewed.has(id))]),
],
viewedFileIds: viewedIds,
selectedRowIds: selectedRows.length
? selectedRows.map((row) => row.id)
: focusedRows.map((row) => row.id),
canHighlight: true,
});
const [NativeDiffView] = useState(() => resolveNativeReviewDiffView());
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Load the selected file when asynchronously fetched metadata makes it available.
useEffect(() => {
const parsed = result.parsed;
if (
!targetFile ||
(onlyPaths !== undefined &&
parsed.kind === "files" &&
onlyPaths.some((path) => !parsed.files.some((file) => file.path === path)))
)
loadMore();
}, [targetFile, loadMore, onlyPaths, result.parsed]);
const { onDebug: updateVisibleRange } = bridge;
const onDebug = useCallback>(
(event) => {
const drawn = isNativeReviewDiffDrawEvent(event.nativeEvent);
const decoded =
event.nativeEvent.message === "rows-decoded" && event.nativeEvent.rows === data.rows.length;
if (!drawn && !decoded) return;
// Filtering replaces the native row payload asynchronously. Old draw events
// must not consume the file jump or highlight the new payload's row indices.
if (
typeof event.nativeEvent.totalRows === "number" &&
event.nativeEvent.totalRows !== data.rows.length
)
return;
if (drawn) updateVisibleRange(event);
if (
targetFile &&
!jumpStarted() &&
nativeView.current &&
(Platform.OS !== "ios" || decoded)
) {
jumpedUnder.current = filter.hideTestFiles;
if (focusIndex >= 0) return;
void nativeView.current.scrollToFile(targetFile.id, false).catch(() => {
jumpedUnder.current = null;
});
return;
}
if (!drawn) return;
const lastRow = event.nativeEvent.lastRowIndex;
if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore();
},
[
updateVisibleRange,
data.rows.length,
loadMore,
targetFile,
focusIndex,
jumpStarted,
filter.hideTestFiles,
],
);
const onToggleFile = useCallback>(
(event) => {
const id = event.nativeEvent.fileId;
if (!id) return;
if (viewedIds.includes(id)) {
setExpandedViewed((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
} else
setCollapsedIds((ids) =>
ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id],
);
},
[viewedIds, setExpandedViewed, setCollapsedIds],
);
const visibleFile = data.files.find((file) => file.id === visibleFileId) ?? targetFile;
const toggleTestFiles = () => {
setJumpPath(visibleFile?.path ?? params.path);
setSelectedRowId(null);
setRangeEndRowId(null);
filter.toggle();
};
const commits =
[...(activity.data?.commits ?? [])].sort(
(a, b) => Date.parse(a.committedDate) - Date.parse(b.committedDate),
) ?? [];
const commitOids = commits.map((entry) => entry.oid);
const selectedCommits = reviewCommitSelection(commitOids, commit);
const lastReviewed = latestReviewedCommit(activity.data?.comments ?? [], detail.data?.viewer);
const [commitPickerOpen, setCommitPickerOpen] = useState(false);
const selectCommitScope = (scope: string | null) => {
setJumpPath("");
setSelectedRowId(null);
setRangeEndRowId(null);
setCommit(scope);
};
const viewingActions = !isGuideChapter
? [
{
id: "comments-only",
title: "Files with unresolved comments",
state: commentsOnly ? ("on" as const) : ("off" as const),
},
{
id: "hide-reviewed",
title: "Hide reviewed files",
state: hideReviewed ? ("on" as const) : ("off" as const),
attributes: { disabled: !viewed.supported },
},
{
id: "group-files",
title: "Group by file type",
state: groupByType ? ("on" as const) : ("off" as const),
},
]
: [];
const expandAllFiles = () => {
setCollapsedIds(EMPTY_IDS);
setExpandedViewed(new Set(viewedIds));
};
const collapseAllFiles = () => {
setCollapsedIds(data.files.map((file) => file.id));
setExpandedViewed(new Set());
};
const changeViewingOption = (id: string) => {
setJumpPath("");
setSelectedRowId(null);
setRangeEndRowId(null);
if (id === "comments-only") updateViewingPreferences({ commentsOnly: !commentsOnly });
else if (id === "hide-reviewed" && viewed.supported)
updateViewingPreferences({ hideReviewed: !hideReviewed });
else if (id === "group-files") updateViewingPreferences({ groupByType: !groupByType });
};
const selectChapter = (position: number) => {
const chapter = chapters[position];
if (!chapter) return;
setChapterId(chapter.id);
setJumpPath(chapter.files[0] ?? "");
setSelectedRowId(null);
setRangeEndRowId(null);
jumpedUnder.current = null;
setCollapsedIds(EMPTY_IDS);
};
const allCollapsed =
data.files.length > 0 &&
data.files.every(
(file) =>
collapsedIds.includes(file.id) ||
(viewedIds.includes(file.id) && !expandedViewed.has(file.id)),
);
const toggleAllFiles = allCollapsed ? expandAllFiles : collapseAllFiles;
const fileCommentCounts = reviewCommentCounts(
parsed.kind === "files" ? parsed.files : [],
commit ? [] : reviewDraft.threads,
commit ? [] : reviewDraft.comments,
);
const treeFiles =
parsed.kind === "files"
? parsed.files.map((file) => ({
path: file.path,
previousPath: file.previousPath,
changeType: file.changeType,
additions: file.additions,
deletions: file.deletions,
reviewed: viewed.isViewed(file.path),
unresolved: fileCommentCounts.get(file.path)?.unresolved ?? 0,
}))
: [];
const chooseTreeFile = (path: string) => {
setJumpPath(path);
setSelectedRowId(null);
setRangeEndRowId(null);
const file = data.files.find((entry) => entry.path === path);
if (!file) return;
setCollapsedIds((ids) => ids.filter((id) => id !== file.id));
setExpandedViewed((ids) => new Set(ids).add(file.id));
jumpedUnder.current = null;
if (nativeView.current) {
jumpedUnder.current = filter.hideTestFiles;
void nativeView.current.scrollToFile(file.id, true).catch(() => {
jumpedUnder.current = null;
});
}
};
const displayOptions = [
{
id: "wrap",
label: "Wrap lines",
checked: appearance.codeWordBreak,
onChange: setCodeWordBreak,
},
{
id: "whitespace",
label: "Hide whitespace changes",
checked: ignoreWhitespace,
onChange: setIgnoreWhitespace,
},
{
id: "tests",
label: "Hide test files",
checked: filter.hideTestFiles,
onChange: () => toggleTestFiles(),
},
...viewingActions.map((action) => ({
id: action.id,
label: action.title,
checked: action.state === "on",
disabled: "attributes" in action && action.attributes?.disabled === true,
onChange: () => changeViewingOption(action.id),
})),
];
const title = commit ? "Commit changes" : "Changes";
const seekingFile = !targetFile && result.nextCursor !== null;
const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error);
return (
{!embedded ? : null}
{!embedded ? (
) : null}
{!embedded && Platform.OS === "android" ? (
navigation.goBack()}
trailing={
}
/>
) : null}
{!embedded ? (
<>
setCommentsOpen(false)}
/>
setCommentsOpen(true)}
commentsInHorizontalBar={false}
controls={[
commits.length > 0 ? (
setCommitPickerOpen(true)}
/>
) : null,
!isGuideChapter ? (
) : null,
]}
railMenuItems={
<>
{commits.length > 0 ? (
setCommitPickerOpen(true)}
>
Commit range
) : null}
{!isGuideChapter ? (
{allCollapsed ? "Expand all files" : "Collapse all files"}
) : null}
>
}
>
setFilesOpen(true)}>
View files
setOptionsOpen(true)}>
View options
{permissions.verdicts.length ? (
navigation.navigate("PullRequestReview", { ...params, mode: "review" })
}
>
Submit review
) : null}
{permissions.canComment ? (
navigation.navigate("PullRequestReview", { ...params, mode: "comment" })
}
>
Add comment
) : null}
{selectedRow && selectedFile && canCommentOnDiff ? (
commentOnLine()}>
{params.replacingDraftId ? "Move draft to this line" : "Add review comment"}
) : null}
{selectedRow && selectedFile ? (
void Clipboard.setStringAsync(selectedRow.content ?? "")}
>
Copy line
) : null}
>
) : null}
{!embedded && !isGuideChapter && Platform.OS === "android" ? (
) : null}
{embedded ? (
{data.files.length} files
{!isGuideChapter ? (
) : null}
{!isGuideChapter && commits.length > 0 ? (
setCommitPickerOpen(true)}
>
{commit === null
? "All"
: `${selectedCommits.length} ${selectedCommits.length === 1 ? "commit" : "commits"}`}
) : null}
) : null}
setOptionsOpen(false)}
options={displayOptions}
/>
setFilesOpen(false)}
title={activeChapter || isGuideChapter ? "Files in this chapter" : "Files"}
files={treeFiles}
onSelectFile={chooseTreeFile}
onSetViewed={viewed.canToggle && !commit && !staleDiff ? viewed.setManyViewed : undefined}
onLoadMore={result.canLoadMore ? result.loadMore : undefined}
/>
setCommitPickerOpen(false)}
commits={commits}
scope={commit}
lastReviewed={lastReviewed}
canSelectRange={detail.data?.provider === "github"}
onChange={selectCommitScope}
/>
{activeChapter ? (
viewed.setManyViewed(chapterFiles, reviewedChapterFiles !== chapterFiles.length)
: undefined
}
/>
) : null}
{params.replacingDraftId ? (
Select a current line, then choose “Move draft to this line” from its menu.
) : null}
{viewed.error ? (
{viewed.error}
) : null}
{staleDiff ? (
The branch changed. Reload before adding line comments.
) : null}
{loadingDiff || result.error ? (
) : null}
{NativeDiffView && data.rows.length && !seekingFile ? (
{
const rowId = event.nativeEvent.rowId ?? null;
const row = data.rows.find((row) => row.id === rowId);
if (row?.kind !== "line" || !reviewPosition(row)) return;
setSelectedRowId(rowId);
setRangeEndRowId(null);
}}
onSelectLineRange={({ nativeEvent }) => {
const first = data.rows.find((row) => row.id === nativeEvent.startRowId);
const last = data.rows.find((row) => row.id === nativeEvent.endRowId);
if (
!first ||
!last ||
first.fileId !== last.fileId ||
!reviewPosition(first) ||
!reviewPosition(last)
)
return;
setSelectedRowId(first.id);
setRangeEndRowId(last.id);
}}
onToggleViewedFile={(event) => {
const file = data.files.find((file) => file.id === event.nativeEvent.fileId);
if (!file || !viewed.canToggle) return;
const wasViewed = viewed.isViewed(file.path);
setCollapsedIds((ids) =>
wasViewed
? ids.filter((id) => id !== file.id)
: ids.includes(file.id)
? ids
: [...ids, file.id],
);
void viewed.toggle(file.path);
}}
onToggleComment={(event) => {
const id = event.nativeEvent.commentId;
if (id?.startsWith("discussion:"))
navigation.navigate("PullRequestDiscussion", {
...params,
discussionId: id.slice("discussion:".length),
});
if (id?.startsWith("draft:")) {
const draft = reviewDraft.comments.find(
(draft) => draft.id === id.slice("draft:".length),
);
if (draft)
navigation.navigate("PullRequestInlineComment", {
...params,
...draft,
draftId: draft.id,
});
}
}}
onToggleFile={onToggleFile}
onVisibleFileChange={(event) => {
const fileId = event.nativeEvent.fileId ?? null;
setVisibleFileId(fileId);
const file = data.files.find((entry) => entry.id === fileId);
if (file && !isGuideChapter) rememberPullRequestView(scopeKey, { path: file.path });
}}
appearanceScheme={themeAppearance}
rowHeight={nativeReviewDiffStyle.rowHeight}
contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
contentInsetTop={
Platform.OS === "ios" ? (embedded || staleDiff ? 0 : insets.top) : undefined
}
contentInsetBottom={Platform.OS === "android" ? toolbarHeight : insets.bottom}
contentResetKey={`pull-request:${params.environmentId}:${params.repository}:${params.number}:${commit ?? "all"}`}
/>
) : !isGuideChapter &&
result.parsed.kind === "files" &&
result.parsed.files.length > 0 &&
!data.rows.length &&
!result.isPending &&
!result.error &&
!seekingFile &&
(commentsOnly || hideReviewed) ? (
updateViewingPreferences({ commentsOnly: false, hideReviewed: false })
}
/>
}
variant="plain"
/>
) : !isGuideChapter &&
filter.hiddenCount > 0 &&
!data.rows.length &&
!result.isPending &&
!result.error &&
!seekingFile ? (
) : !result.isPending && !result.error && !seekingFile ? (
) : null}
{result.nextCursor && result.isPending && data.rows.length && !seekingFile ? (
Loading more files…
) : null}
{result.error && result.nextCursor ? (
) : null}
{parsed.kind === "files" && parsed.notice ? (
{parsed.notice}
) : null}
{selectedRange ? (
{canCommentOnDiff ? (
commentOnLine()}
>
Comment · {reviewPositionLabel(selectedRange.position, selectedRange.startPosition)}
) : null}
{
setSelectedRowId(null);
setRangeEndRowId(null);
}}
>
) : null}
{!embedded ? (
) : null}
);
}
function PullRequestFileExpansionActions(props: {
readonly collapsed: boolean;
readonly onToggle: () => void;
}) {
return (
{props.collapsed ? "Expand all" : "Collapse all"}
);
}