1import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
2import { AnimatedLegendList } from "@legendapp/list/reanimated";
3import Animated from "react-native-reanimated";
4import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations";
5import { useNavigation } from "@react-navigation/native";
6import { useState } from "react";
7import { ActivityIndicator, Pressable, View } from "react-native";
8import { AppText as Text } from "../../components/AppText";
9import { EmptyState } from "../../components/EmptyState";
10import { ControlPillMenu } from "../../components/ControlPillMenu";
11import { MaterialButton } from "../../components/MaterialButton";
12import { MaterialIconButton } from "../../components/MaterialIconButton";
13import { SymbolView } from "../../components/AppSymbol";
14import { PierreEntryIcon } from "../../components/PierreEntryIcon";
15import { composerPullRequests } from "../../state/pull-requests";
16import { useEnvironmentQuery } from "../../state/query";
17import { PullRequestDiffStats, PullRequestReadStatus } from "./PullRequestPresentation";
18import {
19 pullRequestTarget as requestTarget,
20 usePullRequestDiff,
21 type PullRequestDiffParams,
22} from "./usePullRequestDiff";
23import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed";
24import { usePullRequestFileFilter } from "./usePullRequestFileFilter";
25
26function fileDirectory(path: string) {
27 const separator = path.lastIndexOf("/");
28 return separator < 0 ? "" : path.slice(0, separator);
29}
30
31export function usePullRequestFilesContent(props: {
32 readonly params: PullRequestDiffParams;
33 readonly onSelectFile?: (path: string) => void;
34 readonly groupByDirectory?: boolean;
35}) {
36 const navigation = useNavigation();
37 const result = usePullRequestDiff(props.params);
38 const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(props.params)));
39 const viewed = usePullRequestFilesViewed(props.params, props.params.commit ? null : detail.data);
40 const filter = usePullRequestFileFilter(result.parsed);
41 const parsed = filter.parsed;
42 const loading = result.isPending && parsed.kind === "empty" && !result.error;
43 const fileCount = props.params.commit ? undefined : detail.data?.changedFiles;
44 const summary = props.params.commit ? undefined : (detail.data ?? props.params.initialSummary);
45 const [collapsedDirectories, setCollapsedDirectories] = useState<ReadonlySet<string>>(
46 () => new Set(),
47 );
48 const files = parsed.kind === "files" ? parsed.files : [];
49 const orderedFiles = props.groupByDirectory
50 ? [...files].sort(
51 (a, b) =>
52 fileDirectory(a.path).localeCompare(fileDirectory(b.path)) ||
53 a.path.localeCompare(b.path),
54 )
55 : files;
56 const rows = orderedFiles.map((file, index) => {
57 const directory = fileDirectory(file.path);
58 const previousDirectory = fileDirectory(orderedFiles[index - 1]?.path ?? "");
59 const showDirectory =
60 props.groupByDirectory && (index === 0 || directory !== previousDirectory);
61 const collapsed = props.groupByDirectory && collapsedDirectories.has(directory);
62 if (collapsed && !showDirectory) return null;
63 return {
64 key: file.id,
65 content: (
66 <View>
67 {showDirectory ? (
68 <Pressable
69 accessibilityRole="button"
70 accessibilityLabel={directory || "Repository root"}
71 accessibilityState={{ expanded: !collapsed }}
72 onPress={() =>
73 setCollapsedDirectories((current) => {
74 const next = new Set(current);
75 if (next.has(directory)) next.delete(directory);
76 else next.add(directory);
77 return next;
78 })
79 }
80 className="min-h-11 flex-row items-center gap-2 pt-4 pb-2 active:opacity-70"
81 >
82 <SymbolView
83 name={collapsed ? "chevron.right" : "chevron.down"}
84 size={11}
85 tintColorClassName="accent-icon-muted"
86 />
87 <SymbolView name="folder" size={16} tintColorClassName="accent-icon-muted" />
88 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={2}>
89 {directory || "Repository root"}
90 </Text>
91 </Pressable>
92 ) : null}
93 {!collapsed ? (
94 <Pressable
95 accessibilityRole="button"
96 accessibilityLabel={`View diff for ${file.path}, ${file.additions} additions, ${file.deletions} deletions`}
97 onPress={() => {
98 if (props.onSelectFile) props.onSelectFile(file.path);
99 else
100 navigation.navigate("PullRequestFile", {
101 ...props.params,
102 path: file.path,
103 });
104 }}
105 className="min-h-14 flex-row items-center gap-3 border-b border-border-subtle py-3 active:opacity-70"
106 >
107 <PierreEntryIcon kind="file" path={file.path} size={20} />
108 <View className="min-w-0 flex-1">
109 <Text className="text-sm font-t3-medium text-foreground" numberOfLines={2}>
110 {file.path.split("/").at(-1)}
111 </Text>
112 {directory && !props.groupByDirectory ? (
113 <Text className="mt-0.5 text-2xs text-foreground-muted" numberOfLines={1}>
114 {directory}
115 </Text>
116 ) : null}
117 {file.previousPath ? (
118 <Text className="mt-0.5 text-2xs text-foreground-muted" numberOfLines={1}>
119 Renamed from {file.previousPath}
120 </Text>
121 ) : null}
122 </View>
123 <PullRequestDiffStats additions={file.additions} deletions={file.deletions} compact />
124 {viewed.isViewed(file.path) ? (
125 <SymbolView
126 name="checkmark.circle.fill"
127 size={18}
128 tintColorClassName="accent-adaptive-emerald-600-400"
129 />
130 ) : null}
131 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-muted" />
132 </Pressable>
133 ) : null}
134 </View>
135 ),
136 };
137 });
138 return {
139 refresh: result.refresh,
140 rows: loading
141 ? Array.from({ length: Math.min(fileCount ?? 4, 6) }, (_, index) => ({
142 key: `loading-file:${index}`,
143 content: (
144 <View
145 accessible={false}
146 importantForAccessibility="no-hide-descendants"
147 className="min-h-14 flex-row items-center gap-3 border-b border-border-subtle py-3"
148 >
149 <View className="size-5 rounded-md bg-subtle-strong" />
150 <View className="flex-1 gap-2">
151 <View
152 className="h-3.5 rounded bg-subtle-strong"
153 style={{ width: index % 2 ? "65%" : "80%" }}
154 />
155 <View className="h-2.5 w-2/5 rounded bg-subtle-strong" />
156 </View>
157 <View className="h-3 w-7 rounded bg-subtle-strong" />
158 </View>
159 ),
160 }))
161 : rows.filter((row) => row !== null),
162 header: (
163 <>
164 <PullRequestReadStatus pending={false} error={result.error} />
165 {loading ? (
166 <View
167 accessibilityLabel="Loading changed files"
168 accessibilityState={{ busy: true }}
169 className="min-h-17 flex-row items-center gap-2 py-3"
170 >
171 <View className="min-w-0 flex-1 gap-1">
172 <PullRequestSectionHeading>Changed files</PullRequestSectionHeading>
173 {fileCount !== undefined ? (
174 <Text className="text-2xs text-foreground-muted">
175 {fileCount} {fileCount === 1 ? "file" : "files"}
176 </Text>
177 ) : (
178 <View className="h-2.5 w-28 rounded bg-subtle-strong" />
179 )}
180 </View>
181 {summary?.additions !== undefined && summary.deletions !== undefined ? (
182 <PullRequestDiffStats
183 additions={summary.additions}
184 deletions={summary.deletions}
185 compact
186 />
187 ) : null}
188 <View className="size-11 items-center justify-center opacity-40">
189 <SymbolView
190 name="line.3.horizontal.decrease"
191 size={20}
192 tintColorClassName="accent-icon-muted"
193 />
194 </View>
195 </View>
196 ) : parsed.kind === "files" ? (
197 <>
198 <View className="min-h-17 flex-row items-center gap-2 py-3">
199 <View className="min-w-0 flex-1 gap-1">
200 <PullRequestSectionHeading>Changed files</PullRequestSectionHeading>
201 <Text className="text-2xs text-foreground-muted">
202 {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "}
203 {parsed.fileCount === 1 ? "file" : "files"}
204 {viewed.supported
205 ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed`
206 : ""}
207 {" · "}
208 {filter.hideTestFiles
209 ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden`
210 : "Test files included"}
211 </Text>
212 </View>
213 <PullRequestDiffStats
214 additions={parsed.additions}
215 deletions={parsed.deletions}
216 compact
217 />
218 <ControlPillMenu
219 title="Review files"
220 actions={[
221 {
222 id: "hide-tests",
223 title: "Hide test files",
224 image: "line.3.horizontal.decrease",
225 state: filter.hideTestFiles ? "on" : "off",
226 },
227 ]}
228 onPressAction={filter.toggle}
229 >
230 <View pointerEvents="none">
231 <MaterialIconButton
232 icon="line.3.horizontal.decrease"
233 accessibilityLabel="Filter review files"
234 selected={filter.hideTestFiles}
235 />
236 </View>
237 </ControlPillMenu>
238 </View>
239 {parsed.files.length === 0 && filter.hiddenCount > 0 ? (
240 <EmptyState
241 title={
242 result.nextCursor ? "No non-test files loaded yet" : "Only test files changed"
243 }
244 detail="Test files are hidden from this review."
245 action={
246 <MaterialButton
247 label="Show test files"
248 tone="contrast"
249 onPress={filter.showAll}
250 />
251 }
252 variant="plain"
253 />
254 ) : null}
255 </>
256 ) : !result.isPending && !result.error ? (
257 <Text className="py-6 text-sm text-foreground-muted">No text changes available.</Text>
258 ) : null}
259 </>
260 ),
261 footer: (
262 <>
263 {result.nextCursor ? (
264 <Pressable
265 onPress={result.error ? result.refresh : result.loadMore}
266 accessibilityRole="button"
267 className="min-h-11 items-center justify-center rounded-xl bg-grouped-card px-4 py-3"
268 >
269 {result.isPending ? (
270 <ActivityIndicator />
271 ) : (
272 <Text className="text-sm font-t3-medium">
273 {result.error ? "Retry loading files" : "Load more files"}
274 </Text>
275 )}
276 </Pressable>
277 ) : null}
278 {viewed.error ? (
279 <Text className="py-2 text-xs text-danger-foreground">{viewed.error}</Text>
280 ) : null}
281 {parsed.kind === "files" && parsed.notice ? (
282 <Text className="py-4 text-xs text-foreground-muted">{parsed.notice}</Text>
283 ) : null}
284 </>
285 ),
286 };
287}
288
289export function PullRequestFiles(props: Parameters<typeof usePullRequestFilesContent>[0]) {
290 const content = usePullRequestFilesContent(props);
291 const [animationsReady, setAnimationsReady] = useState(false);
292 return (
293 <AnimatedLegendList
294 data={content.rows}
295 keyExtractor={(row) => row.key}
296 itemLayoutAnimation={PR_ROW_LAYOUT}
297 onLoad={() => setAnimationsReady(true)}
298 renderItem={({ item }) => (
299 <Animated.View
300 key={item.key}
301 entering={animationsReady ? PR_ROW_ENTER : undefined}
302 exiting={animationsReady ? PR_ROW_EXIT : undefined}
303 >
304 {item.content}
305 </Animated.View>
306 )}
307 recycleItems={false}
308 estimatedItemSize={64}
309 ListHeaderComponent={content.header}
310 ListFooterComponent={content.footer}
311 contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }}
312 />
313 );
314}