Skip to content
Source
Sign inSign up

PullRequestFiles.tsx

516 lines18.2 KB

1import { NativeScrollView } from "../../native/NativeScrollViewMarker";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
3import { AnimatedLegendList } from "@legendapp/list/reanimated";
4import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
5import type { PullRequestChangeBreakdown } from "@t3tools/contracts";
6import { useState } from "react";
7import { View } from "react-native";
8import Animated from "react-native-reanimated";
9import { PierreEntryIcon } from "../../components/PierreEntryIcon";
10import { AppText as Text } from "../../components/ui/AppText";
11import { SymbolView } from "../../components/ui/AppSymbol";
12import { Button } from "../../components/ui/Button";
13import { EmptyState } from "../../components/ui/EmptyState";
14import { IconButton } from "../../components/ui/IconButton";
15import { Menu } from "../../components/ui/Menu";
16import { RowPressable } from "../../components/ui/RowPressable";
17import { ScreenContent } from "../../components/ui/ScreenContent";
18import { SheetHeader } from "../../components/ui/SheetHeader";
19import { composerPullRequests } from "../../state/pull-requests";
20import { useEnvironmentQuery } from "../../state/query";
21import type { ReviewParsedDiff, ReviewRenderableFile } from "../review/reviewModel";
22import { PR_ROW_ENTER, PR_ROW_EXIT, PR_ROW_LAYOUT } from "./pullRequestAnimations";
23import { PullRequestDiffStats, PullRequestReadStatus } from "./PullRequestPresentation";
24import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
25import {
26 pullRequestTarget,
27 usePullRequestDiff,
28 type PullRequestDiffParams,
29 type PullRequestParams,
30} from "./usePullRequestDiff";
31import { usePullRequestFileFilter } from "./usePullRequestFileFilter";
32import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed";
33import { pullRequestFileJumpScope, requestPullRequestFileJump } from "./pullRequestFileJump";
34​
35const BREAKDOWN_LABELS = {
36 code: "Code",
37 tests: "Tests",
38 mocks: "Mocks",
39 generated: "Generated",
40 docs: "Docs",
41} satisfies Record<keyof NonNullable<PullRequestChangeBreakdown>, string>;
42​
43function fileDirectory(path: string) {
44 const separator = path.lastIndexOf("/");
45 return separator < 0 ? "" : path.slice(0, separator);
47​
48function useChangedFiles(params: PullRequestDiffParams) {
49 const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(params)));
50 // A commit's diff is part of the pull request, so the PR's file counts and viewed state don't apply.
51 const pr = params.commit ? null : detail.data;
52 const terminology = sourceControlClients.get(
53 (detail.data ?? params.initialSummary)?.provider,
54 ).changeRequest;
55 const diff = usePullRequestDiff(params, terminology);
56 const viewed = usePullRequestFilesViewed(params, pr);
57 const filter = usePullRequestFileFilter(diff.parsed);
58 const parsed = filter.parsed;
59 return {
60 diff,
61 filter,
62 viewed,
63 pr,
64 terminology,
65 parsed,
66 loading: diff.isPending && parsed.kind === "empty" && !diff.error,
67 };
69​
70type ChangedFiles = ReturnType<typeof useChangedFiles>;
71​
72function loadingRowCount(files: ChangedFiles) {
73 return Math.min(files.pr?.changedFiles ?? 4, 6);
75​
76function describeFiles(
77 parsed: Extract<ReviewParsedDiff, { readonly kind: "files" }>,
78 { filter, viewed }: ChangedFiles,
79) {
80 const noun = (count: number) => (count === 1 ? "file" : "files");
81 return [
82 `${parsed.fileCount} ${filter.hiddenCount ? "visible" : "changed"} ${noun(parsed.fileCount)}`,
83 viewed.supported
84 ? `${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed`
85 : null,
86 filter.hideTestFiles
87 ? `${filter.hiddenCount} test ${noun(filter.hiddenCount)} hidden`
88 : "Test files included",
89 ]
90 .filter(Boolean)
91 .join(" · ");
93​
94function FileRow(props: {
95 readonly file: ReviewRenderableFile;
96 readonly viewed: boolean;
97 /** Shown under the file name where files aren't grouped by directory. */
98 readonly directory?: string;
99 readonly onPress: () => void;
100}) {
101 const { file } = props;
102 return (
103 <RowPressable
104 accessibilityRole="button"
105 accessibilityLabel={`View diff for ${file.path}, ${file.additions} additions, ${file.deletions} deletions${props.viewed ? ", viewed" : ""}`}
106 onPress={props.onPress}
107 className="min-h-14 flex-row items-center gap-3 border-b border-border-subtle py-3 active:opacity-70"
108 >
109 <PierreEntryIcon kind="file" path={file.path} size={20} />
110 <View className="min-w-0 flex-1 gap-0.5">
111 <Text textRole="footnote" className="font-t3-medium" numberOfLines={2}>
112 {file.path.split("/").at(-1)}
113 </Text>
114 {props.directory ? (
115 <Text textRole="caption" className="text-foreground-muted" numberOfLines={1}>
116 {props.directory}
117 </Text>
118 ) : null}
119 {file.previousPath ? (
120 <Text textRole="caption" className="text-foreground-muted" numberOfLines={1}>
121 Renamed from {file.previousPath}
122 </Text>
123 ) : null}
124 </View>
125 <PullRequestDiffStats additions={file.additions} deletions={file.deletions} compact />
126 {props.viewed ? (
127 <SymbolView
128 name="checkmark.circle.fill"
129 size={18}
130 tintColorClassName="accent-adaptive-emerald-600-400"
131 />
132 ) : null}
133 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-muted" />
134 </RowPressable>
135 );
137​
138function FileRowSkeleton(props: { readonly index: number }) {
139 return (
140 <View
141 accessible={false}
142 importantForAccessibility="no-hide-descendants"
143 className="min-h-14 flex-row items-center gap-3 border-b border-border-subtle py-3"
144 >
145 <View className="size-5 rounded-md bg-subtle-strong" />
146 <View className="flex-1 gap-2">
147 <View
148 className="h-3.5 rounded bg-subtle-strong"
149 style={{ width: props.index % 2 ? "65%" : "80%" }}
150 />
151 <View className="h-2.5 w-2/5 rounded bg-subtle-strong" />
152 </View>
153 <View className="h-3 w-7 rounded bg-subtle-strong" />
154 </View>
155 );
157​
158function FileFilterMenu({ filter }: { readonly filter: ChangedFiles["filter"] }) {
159 return (
160 <Menu
161 title="Review files"
162 actions={[
163 {
164 id: "hide-tests",
165 title: "Hide test files",
166 image: "line.3.horizontal.decrease",
167 state: filter.hideTestFiles ? "on" : "off",
168 },
169 ]}
170 onPressAction={filter.toggle}
171 >
172 <View pointerEvents="none">
173 <IconButton
174 icon="line.3.horizontal.decrease"
175 accessibilityLabel="Filter review files"
176 selected={filter.hideTestFiles}
177 />
178 </View>
179 </Menu>
180 );
182​
183function NoVisibleFiles({ files }: { readonly files: ChangedFiles }) {
184 const { diff, filter, parsed } = files;
185 if (parsed.kind === "files") {
186 return parsed.files.length === 0 && filter.hiddenCount > 0 ? (
187 <EmptyState
188 title={diff.nextCursor ? "No non-test files loaded yet" : "Only test files changed"}
189 detail="Test files are hidden from this review."
190 action={<Button label="Show test files" tone="contrast" onPress={filter.showAll} />}
191 variant="plain"
192 />
193 ) : null;
194 }
195 return !diff.isPending && !diff.error ? (
196 <Text textRole="footnote" className="py-6 text-foreground-muted">
197 No text changes available.
198 </Text>
199 ) : null;
201​
202function FilesFooter({ files }: { readonly files: ChangedFiles }) {
203 const { diff, viewed, parsed } = files;
204 return (
205 <>
206 {diff.nextCursor ? (
207 <Button
208 label={diff.error ? "Retry loading files" : "Load more files"}
209 tone="secondary"
210 onPress={diff.error ? diff.refresh : diff.loadMore}
211 loading={diff.isPending}
212 fullWidth
213 />
214 ) : null}
215 {viewed.error ? (
216 <Text textRole="label" className="py-2 text-danger-foreground">
217 {viewed.error}
218 </Text>
219 ) : null}
220 {parsed.kind === "files" && parsed.notice ? (
221 <Text textRole="label" className="py-4 text-foreground-muted">
222 {parsed.notice}
223 </Text>
224 ) : null}
225 </>
226 );
228​
229function ChangeBreakdownLine(props: {
230 readonly breakdown: NonNullable<PullRequestChangeBreakdown>;
231}) {
232 const parts = (Object.keys(BREAKDOWN_LABELS) as Array<keyof typeof BREAKDOWN_LABELS>).flatMap(
233 (kind) => {
234 const { additions, deletions } = props.breakdown[kind];
235 return additions === 0 && deletions === 0
236 ? []
237 : [
238 `${BREAKDOWN_LABELS[kind]} +${additions.toLocaleString()} −${deletions.toLocaleString()}`,
239 ];
240 },
241 );
242 return (
243 <Text textRole="caption" className="tabular-nums text-foreground-muted" numberOfLines={2}>
244 {parts.join(" · ")}
245 </Text>
246 );
248​
249/**
250 * The changed-files section of the pull request overview. The overview tab is a single list,
251 * so the section supplies its header, rows, and footer instead of rendering a list of its own.
252 */
253export function usePullRequestFilesSection(params: PullRequestParams) {
254 const navigation = useNavigation();
255 const files = useChangedFiles(params);
256 const { diff, filter, viewed, pr, parsed, loading } = files;
257 const breakdown = useEnvironmentQuery(
258 composerPullRequests.changeBreakdown(pullRequestTarget(params)),
259 ).data;
260 const expected = pr ?? params.initialSummary;
261 return {
262 refresh: diff.refresh,
263 header: (
264 <>
265 <PullRequestReadStatus pending={false} error={diff.error} terminology={files.terminology} />
266 {loading ? (
267 <View
268 accessibilityLabel="Loading changed files"
269 accessibilityState={{ busy: true }}
270 className="min-h-17 flex-row items-center gap-2 py-3"
271 >
272 <View className="min-w-0 flex-1 gap-1">
273 <PullRequestSectionHeading>Changed files</PullRequestSectionHeading>
274 {pr ? (
275 <Text textRole="caption" className="text-foreground-muted">
276 {pr.changedFiles} {pr.changedFiles === 1 ? "file" : "files"}
277 </Text>
278 ) : (
279 <View className="h-2.5 w-28 rounded bg-subtle-strong" />
280 )}
281 </View>
282 {expected?.additions !== undefined && expected.deletions !== undefined ? (
283 <PullRequestDiffStats
284 additions={expected.additions}
285 deletions={expected.deletions}
286 compact
287 />
288 ) : null}
289 <View className="size-11 items-center justify-center opacity-40">
290 <SymbolView
291 name="line.3.horizontal.decrease"
292 size={20}
293 tintColorClassName="accent-icon-muted"
294 />
295 </View>
296 </View>
297 ) : parsed.kind === "files" ? (
298 <View className="min-h-17 flex-row items-center gap-2 py-3">
299 <View className="min-w-0 flex-1 gap-1">
300 <PullRequestSectionHeading>Changed files</PullRequestSectionHeading>
301 <Text textRole="caption" className="text-foreground-muted">
302 {describeFiles(parsed, files)}
303 </Text>
304 {breakdown ? <ChangeBreakdownLine breakdown={breakdown} /> : null}
305 </View>
306 <PullRequestDiffStats
307 additions={parsed.additions}
308 deletions={parsed.deletions}
309 compact
310 />
311 <FileFilterMenu filter={filter} />
312 </View>
313 ) : null}
314 <NoVisibleFiles files={files} />
315 </>
316 ),
317 rows: loading
318 ? Array.from({ length: loadingRowCount(files) }, (_, index) => ({
319 key: `loading-file:${index}`,
320 content: <FileRowSkeleton index={index} />,
321 }))
322 : (parsed.kind === "files" ? parsed.files : []).map((file) => ({
323 key: file.id,
324 content: (
325 <FileRow
326 file={file}
327 directory={fileDirectory(file.path)}
328 viewed={viewed.isViewed(file.path)}
329 onPress={() => navigation.navigate("PullRequestFile", { ...params, path: file.path })}
330 />
331 ),
332 })),
333 footer: <FilesFooter files={files} />,
334 };
336​
337type SheetItem =
338 | {
339 readonly kind: "directory";
340 readonly key: string;
341 readonly directory: string;
342 readonly collapsed: boolean;
343 }
344 | { readonly kind: "file"; readonly key: string; readonly file: ReviewRenderableFile };
345​
346function groupByDirectory(
347 files: ReadonlyArray<ReviewRenderableFile>,
348 collapsed: ReadonlySet<string>,
349) {
350 const sorted = files
351 .map((file) => ({ file, directory: fileDirectory(file.path) }))
352 .sort(
353 (a, b) => a.directory.localeCompare(b.directory) || a.file.path.localeCompare(b.file.path),
354 );
355 const items: SheetItem[] = [];
356 sorted.forEach(({ file, directory }, index) => {
357 if (directory !== sorted[index - 1]?.directory) {
358 items.push({
359 kind: "directory",
360 key: `directory:${directory}`,
361 directory,
362 collapsed: collapsed.has(directory),
363 });
364 }
365 if (!collapsed.has(directory)) items.push({ kind: "file", key: file.id, file });
366 });
367 return items;
369​
370function DirectoryRow(props: {
371 readonly directory: string;
372 readonly collapsed: boolean;
373 readonly onPress: () => void;
374}) {
375 const label = props.directory || "Repository root";
376 return (
377 <RowPressable
378 accessibilityRole="button"
379 accessibilityLabel={label}
380 accessibilityState={{ expanded: !props.collapsed }}
381 onPress={props.onPress}
382 className="min-h-11 flex-row items-center gap-2 pt-4 pb-2 active:opacity-70"
383 >
384 <SymbolView
385 name={props.collapsed ? "chevron.right" : "chevron.down"}
386 size={11}
387 tintColorClassName="accent-icon-muted"
388 />
389 <SymbolView name="folder" size={16} tintColorClassName="accent-icon-muted" />
390 <Text textRole="label" className="min-w-0 flex-1 text-foreground-muted" numberOfLines={2}>
391 {label}
392 </Text>
393 </RowPressable>
394 );
396​
397/** The `PullRequestFiles` sheet: a changed-file picker presented over a file diff. */
398export function PullRequestFilesSheet({ route }: StaticScreenProps<PullRequestDiffParams>) {
399 const params = route.params;
400 const navigation = useNavigation();
401 const jumpScope = pullRequestFileJumpScope(params);
402 const files = useChangedFiles(params);
403 const { diff, filter, viewed, parsed, loading } = files;
404 const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
405 const [animationsReady, setAnimationsReady] = useState(false);
406 const toggleDirectory = (directory: string) =>
407 setCollapsed((current) => {
408 const next = new Set(current);
409 if (!next.delete(directory)) next.add(directory);
410 return next;
411 });
412 return (
413 <View collapsable={false} className="flex-1 bg-sheet">
414 <SheetHeader
415 title="Changed files"
416 subtitle={`#${params.number} · ${params.commit?.slice(0, 7) ?? params.repository}`}
417 onClose={() => navigation.goBack()}
418 menus={[
419 {
420 title: "Filter review files",
421 icon: "line.3.horizontal.decrease",
422 items: [
423 {
424 id: "hide-tests",
425 title: "Hide test files",
426 icon: "line.3.horizontal.decrease",
427 selected: filter.hideTestFiles,
428 onPress: filter.toggle,
429 },
430 ],
431 },
432 ]}
433 />
434 <ScreenContent>
435 <AnimatedLegendList
436 renderScrollComponent={NativeScrollView}
437 className="flex-1"
438 data={parsed.kind === "files" ? groupByDirectory(parsed.files, collapsed) : []}
439 keyExtractor={(item) => item.key}
440 getItemType={(item) => item.kind}
441 itemLayoutAnimation={PR_ROW_LAYOUT}
442 onLoad={() => setAnimationsReady(true)}
443 renderItem={({ item }) => (
444 <Animated.View
445 entering={animationsReady ? PR_ROW_ENTER : undefined}
446 exiting={animationsReady ? PR_ROW_EXIT : undefined}
447 >
448 {item.kind === "directory" ? (
449 <DirectoryRow
450 directory={item.directory}
451 collapsed={item.collapsed}
452 onPress={() => toggleDirectory(item.directory)}
453 />
454 ) : (
455 <FileRow
456 file={item.file}
457 viewed={viewed.isViewed(item.file.path)}
458 onPress={() => {
459 requestPullRequestFileJump(jumpScope, item.file.path);
460 navigation.dispatch(
461 StackActions.popTo("PullRequestFile", {
462 ...params,
463 path: item.file.path,
464 focus: undefined,
465 }),
466 );
467 }}
468 />
469 )}
470 </Animated.View>
471 )}
472 contentInsetAdjustmentBehavior="automatic"
473 recycleItems={false}
474 estimatedItemSize={64}
475 ListHeaderComponent={
476 <>
477 <PullRequestReadStatus
478 pending={false}
479 error={diff.error}
480 terminology={files.terminology}
481 />
482 {parsed.kind === "files" ? (
483 <View className="flex-row items-center gap-2 py-2">
484 <Text textRole="caption" className="min-w-0 flex-1 text-foreground-muted">
485 {describeFiles(parsed, files)}
486 </Text>
487 <PullRequestDiffStats
488 additions={parsed.additions}
489 deletions={parsed.deletions}
490 compact
491 />
492 </View>
493 ) : null}
494 <NoVisibleFiles files={files} />
495 </>
496 }
497 ListEmptyComponent={
498 loading ? (
499 <View
500 accessible
501 accessibilityLabel="Loading changed files"
502 accessibilityState={{ busy: true }}
503 >
504 {Array.from({ length: loadingRowCount(files) }, (_, index) => (
505 <FileRowSkeleton key={index} index={index} />
506 ))}
507 </View>
508 ) : null
509 }
510 ListFooterComponent={<FilesFooter files={files} />}
511 contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }}
512 />
513 </ScreenContent>
514 </View>
515 );