| 1 | import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; |
| 2 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; |
| 3 | import Animated from "react-native-reanimated"; |
| 4 | import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; |
| 5 | import { useNavigation } from "@react-navigation/native"; |
| 6 | import { useState } from "react"; |
| 7 | import { ActivityIndicator, Pressable, View } from "react-native"; |
| 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { EmptyState } from "../../components/EmptyState"; |
| 10 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 11 | import { MaterialButton } from "../../components/MaterialButton"; |
| 12 | import { MaterialIconButton } from "../../components/MaterialIconButton"; |
| 13 | import { SymbolView } from "../../components/AppSymbol"; |
| 14 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 15 | import { composerPullRequests } from "../../state/pull-requests"; |
| 16 | import { useEnvironmentQuery } from "../../state/query"; |
| 17 | import { PullRequestDiffStats, PullRequestReadStatus } from "./PullRequestPresentation"; |
| 18 | import { |
| 19 | pullRequestTarget as requestTarget, |
| 20 | usePullRequestDiff, |
| 21 | type PullRequestDiffParams, |
| 22 | } from "./usePullRequestDiff"; |
| 23 | import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; |
| 24 | import { usePullRequestFileFilter } from "./usePullRequestFileFilter"; |
| 25 | |
| 26 | function fileDirectory(path: string) { |
| 27 | const separator = path.lastIndexOf("/"); |
| 28 | return separator < 0 ? "" : path.slice(0, separator); |
| 29 | } |
| 30 | |
| 31 | export 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 | |
| 289 | export 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 | } |