import { useCallback, useMemo } from "react"; import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; import { ICON_SIZE } from "@/styles/theme"; import { classifyCheck, type CheckPresentation } from "@/git/check-presentation"; import { CheckPresentationIcon } from "@/git/check-presentation.view"; import { ChecksRing } from "./checks-ring"; import { summarizeChecks, type ChecksGroup } from "./checks-summary"; import { canAddPullRequestCheckLogsToChat } from "./context-attachment"; import type { PrPaneCheck } from "./data"; import { foregroundMutedColorMapping, sectionKitStyles } from "./section-kit"; import { useCheckGroupState } from "./check-group-state"; const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); /** * Roughly eight rows. A repo with thirty checks would otherwise push the activity * timeline off the pane entirely, so past this height the list scrolls in place. */ const LIST_MAX_HEIGHT = 268; /** * The three statuses the pane has always labelled for tests. Skipped has no id because * nothing asserts on it. */ const PART_TEST_ID: Partial> = { actionRequired: "pr-pane-check-action-required", warning: "pr-pane-check-warning", success: "pr-pane-check-passed", failure: "pr-pane-check-failed", pending: "pr-pane-check-pending", manual: "pr-pane-check-manual", }; /** * Identifies a check across refreshes. The forge's run id is stable where a forge exposes * one; the name/url pair is the fallback for forges that don't. */ export function getCheckIdentity(check: PrPaneCheck): string { if (check.detailRef?.checkRunId !== undefined) { return `${check.provider}:check-run:${check.detailRef.checkRunId}`; } if (check.detailRef?.workflowRunId !== undefined) { return `${check.provider}:workflow-run:${check.detailRef.workflowRunId}`; } return `${check.provider}:${check.name}:${check.url}`; } interface ChecksSectionProps { checks: readonly PrPaneCheck[]; open: boolean; onToggle: () => void; attachEnabled: boolean; loadingCheckKeys: ReadonlySet; onAddLogsToChat: (check: PrPaneCheck) => void; } /** * The CI surface of the PR pane: one line saying how the run went, and the checks behind * it grouped by status so a failure never needs scrolling to find. */ export function ChecksSection({ checks, open, onToggle, attachEnabled, loadingCheckKeys, onAddLogsToChat, }: ChecksSectionProps) { const { t } = useTranslation(); const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]); const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState(); return ( {summary.headline} {summary.parts.length > 0 ? ( {summary.detailLead} {summary.parts.map((part, index) => ( {index > 0 ? ", " : ""} {part.text} ))} {summary.detailTrail} ) : null} {open ? ( ) : ( )} {open && summary.groups.length > 0 ? ( {summary.groups.map((group) => ( ))} ) : null} ); } function CheckGroup({ group, collapsed, onToggle, attachEnabled, loadingCheckKeys, onAddLogsToChat, }: { group: ChecksGroup; collapsed: boolean; onToggle: (status: CheckPresentation) => void; attachEnabled: boolean; loadingCheckKeys: ReadonlySet; onAddLogsToChat: (check: PrPaneCheck) => void; }) { const handlePress = useCallback(() => onToggle(group.status), [group.status, onToggle]); return ( {group.label} {collapsed ? ( ) : ( )} {collapsed ? null : group.checks.map((check) => { const checkKey = getCheckIdentity(check); return ( ); })} ); } function CheckRow({ check, attachEnabled, isAddingLogsToChat, onAddLogsToChat, }: { check: PrPaneCheck; attachEnabled: boolean; isAddingLogsToChat: boolean; onAddLogsToChat: (check: PrPaneCheck) => void; }) { const { t } = useTranslation(); const handlePress = useCallback(() => { void openExternalUrl(check.url); }, [check.url]); const handleAddLogsToChat = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); onAddLogsToChat(check); }, [check, onAddLogsToChat], ); return ( {check.name} {check.workflow && ( {check.workflow} )} {attachEnabled && canAddPullRequestCheckLogsToChat(check) ? ( ) : null} {check.timing && {check.timing}} ); } function headerPressableStyle({ hovered }: { hovered?: boolean }) { return [styles.header, Boolean(hovered) && styles.hoverable]; } function rowPressableStyle({ hovered }: { hovered?: boolean }) { return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable]; } const styles = StyleSheet.create((theme) => ({ header: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[3], }, headerText: { flex: 1, minWidth: 0, gap: theme.spacing[0.5], }, headline: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, }, detail: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, list: { maxHeight: LIST_MAX_HEIGHT, }, listContent: { paddingBottom: theme.spacing[2], }, groupHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], }, groupLabel: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, }, hoverable: { backgroundColor: theme.colors.surfaceSidebarHover, }, addButton: { paddingHorizontal: theme.spacing[1], }, }));