import { useCallback, useMemo, useState } from "react"; import { Pressable, ScrollView, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronDown, ChevronRight, CircleCheck, CircleDot, CircleSlash, CircleX, ExternalLink, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, MessageSquare, } from "lucide-react-native"; import { openExternalUrl } from "@/utils/open-external-url"; import { getActivityVerb, getStateLabel } from "@/git/pr-pane-data"; import type { CheckStatus, PrPaneActivity, PrPaneCheck, PrPaneData, PrState, } from "@/git/pr-pane-data"; function rowPressableStyle({ hovered }: { hovered?: boolean }) { return [styles.row, Boolean(hovered) && styles.hoverable]; } function activityPressableStyle({ hovered }: { hovered?: boolean }) { return [styles.activityRow, Boolean(hovered) && styles.hoverable]; } export function PrPane({ data }: { data: PrPaneData }) { const { theme } = useUnistyles(); const [checksOpen, setChecksOpen] = useState(true); const [reviewsOpen, setReviewsOpen] = useState(true); const handleOpenPrUrl = useCallback(() => { void openExternalUrl(data.url); }, [data.url]); const handleToggleChecks = useCallback(() => { setChecksOpen((o) => !o); }, []); const handleToggleReviews = useCallback(() => { setReviewsOpen((o) => !o); }, []); const passed = data.checks.filter((c) => c.status === "success").length; const failed = data.checks.filter((c) => c.status === "failure").length; const pending = data.checks.filter((c) => c.status === "pending").length; const approvals = data.activity.filter( (a) => a.kind === "review" && a.reviewState === "approved", ).length; const changesRequested = data.activity.filter( (a) => a.kind === "review" && a.reviewState === "changes_requested", ).length; const commentCount = data.activity.filter( (a) => a.kind === "comment" || (a.kind === "review" && a.reviewState === "commented"), ).length; const stateColor = getStateColor(data.state, theme); const StateIcon = getStateIcon(data.state); const stateLabel = getStateLabel(data.state); const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]); const keyedActivity = useMemo( () => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })), [data.activity], ); const checkSuccessIcon = useMemo( () => , [theme.colors.statusSuccess], ); const checkDangerIcon = useMemo( () => , [theme.colors.statusDanger], ); const checkWarningIcon = useMemo( () => , [theme.colors.statusWarning], ); const commentIcon = useMemo( () => , [theme.colors.foregroundMuted], ); return ( {({ hovered }) => ( <> {stateLabel} {data.title} {hovered ? ( {" "} ) : null} )}
} > {data.checks.map((check) => ( ))}
} > {keyedActivity.map(({ key, item }) => ( ))}
); } interface SectionProps { title: string; open: boolean; onToggle: () => void; summary: React.ReactNode; children: React.ReactNode; } function Section({ title, open, onToggle, summary, children }: SectionProps) { const { theme } = useUnistyles(); return ( {open ? ( ) : ( )} {title} {summary} {open && ( {children} )} ); } function SummaryPill({ count, color, icon, testID, }: { count: number; color: string; icon: React.ReactNode; testID?: string; }) { const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]); if (count === 0) return null; return ( {icon} {count} ); } function CheckRow({ check }: { check: PrPaneCheck }) { const handlePress = useCallback(() => { void openExternalUrl(check.url); }, [check.url]); return ( {check.name} {check.workflow && ( {check.workflow} )} {check.duration && {check.duration}} ); } function CheckStatusIcon({ status }: { status: CheckStatus }) { const { theme } = useUnistyles(); if (status === "success") return ; if (status === "failure") return ; if (status === "pending") return ; return ; } function ActivityRow({ item }: { item: PrPaneActivity }) { const verb = getActivityVerb(item); const handlePress = useCallback(() => { void openExternalUrl(item.url); }, [item.url]); const avatarStyle = useMemo( () => [styles.avatar, { backgroundColor: item.avatarColor }], [item.avatarColor], ); return ( {item.author.slice(0, 1).toUpperCase()} {item.author} {verb} {item.age} {item.body} ); } function getStateColor(state: PrState, theme: ReturnType["theme"]): string { if (state === "open") return theme.colors.statusSuccess; if (state === "draft") return theme.colors.foregroundMuted; if (state === "merged") return theme.colors.statusMerged; return theme.colors.statusDanger; } function getStateIcon(state: PrState) { if (state === "draft") return GitPullRequestDraft; if (state === "merged") return GitMerge; if (state === "closed") return GitPullRequestClosed; return GitPullRequest; } const styles = StyleSheet.create((theme) => ({ root: { flex: 1, minHeight: 0, backgroundColor: theme.colors.surfaceSidebar, }, hoverable: { backgroundColor: theme.colors.surfaceSidebarHover, }, header: { flexDirection: "column", gap: theme.spacing[1], paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[4], }, stateLine: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, stateLabel: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, title: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, lineHeight: 19, }, divider: { height: 1, backgroundColor: theme.colors.border, }, sectionOpen: { flexShrink: 1, minHeight: 0, }, sectionBody: { flexShrink: 1, minHeight: 0, }, sectionBodyContent: { paddingBottom: theme.spacing[3], }, sectionHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, sectionTitle: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, summaryWrap: { marginLeft: "auto", flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, summaryPill: { flexDirection: "row", alignItems: "center", gap: 3, }, summaryPillText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, activityRow: { flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, activityMain: { flex: 1, minWidth: 0, gap: 2 }, activityHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, rowTitle: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, flexShrink: 1, }, rowMeta: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, marginLeft: "auto", }, rowMetaMid: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, flexShrink: 1, }, rowBody: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, lineHeight: 16, }, avatar: { width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center", marginTop: 1, }, avatarText: { fontSize: 10, fontWeight: theme.fontWeight.normal, color: "#fff", }, }));