Files
paseo/packages/app/src/git/pull-request-panel/checks-section.tsx
T
paseo-bot[bot] b7c8be4081 Show the PR panel's Activity and Add to chat labels in the app language (#6208)
* fix(app): show PR panel activity and add-to-chat labels in the app language

The PR panel's Activity header, its empty state, every "Add to chat"
button, "Add all to chat", "Adding...", and the comment and thread menu
labels were written in the components, so they stayed English in every
other language. The sidebar status group rows also appended an English
" group" to their accessibility label. They now come from the
translation files, with copy for all nine supported languages.

Closes #6203

* test(app): check the new PR panel labels are translated in every language

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 12:55:44 +02:00

291 lines
9.1 KiB
TypeScript

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<Record<CheckPresentation, string>> = {
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<string>;
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 (
<View testID="pr-pane-checks">
<Pressable
onPress={onToggle}
style={headerPressableStyle}
accessibilityRole="button"
accessibilityLabel={
summary.detail ? `${summary.headline}. ${summary.detail}` : summary.headline
}
>
<ChecksRing summary={summary} size={ICON_SIZE.lg} />
<View style={styles.headerText}>
<Text style={styles.headline} numberOfLines={1}>
{summary.headline}
</Text>
{summary.parts.length > 0 ? (
<Text style={styles.detail} numberOfLines={1} testID="pr-pane-check-summary">
{summary.detailLead}
{summary.parts.map((part, index) => (
<Text key={part.status}>
{index > 0 ? ", " : ""}
<Text testID={PART_TEST_ID[part.status]}>{part.text}</Text>
</Text>
))}
{summary.detailTrail}
</Text>
) : null}
</View>
{open ? (
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
)}
</Pressable>
{open && summary.groups.length > 0 ? (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
nestedScrollEnabled
>
{summary.groups.map((group) => (
<CheckGroup
key={group.status}
group={group}
collapsed={collapsedGroups.has(group.status)}
onToggle={handleToggleGroup}
attachEnabled={attachEnabled}
loadingCheckKeys={loadingCheckKeys}
onAddLogsToChat={onAddLogsToChat}
/>
))}
</ScrollView>
) : null}
</View>
);
}
function CheckGroup({
group,
collapsed,
onToggle,
attachEnabled,
loadingCheckKeys,
onAddLogsToChat,
}: {
group: ChecksGroup;
collapsed: boolean;
onToggle: (status: CheckPresentation) => void;
attachEnabled: boolean;
loadingCheckKeys: ReadonlySet<string>;
onAddLogsToChat: (check: PrPaneCheck) => void;
}) {
const handlePress = useCallback(() => onToggle(group.status), [group.status, onToggle]);
return (
<View testID={`pr-pane-check-group-${group.status}`}>
<Pressable onPress={handlePress} style={styles.groupHeader} accessibilityRole="button">
<Text style={styles.groupLabel}>{group.label}</Text>
{collapsed ? (
<ThemedChevronRight size={ICON_SIZE.xs} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronDown size={ICON_SIZE.xs} uniProps={foregroundMutedColorMapping} />
)}
</Pressable>
{collapsed
? null
: group.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={onAddLogsToChat}
/>
);
})}
</View>
);
}
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 (
<Pressable onPress={handlePress} style={rowPressableStyle} testID="pr-pane-check-row">
<CheckPresentationIcon presentation={classifyCheck(check)} size={14} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{check.name}
</Text>
{check.workflow && (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{check.workflow}
</Text>
)}
<View style={sectionKitStyles.checkTrailing}>
{attachEnabled && canAddPullRequestCheckLogsToChat(check) ? (
<Button
variant="ghost"
size="xs"
leftIcon={MessageSquarePlus}
loading={isAddingLogsToChat}
onPress={handleAddLogsToChat}
style={styles.addButton}
>
{isAddingLogsToChat
? t("workspace.git.pr.actions.addingToChat")
: t("workspace.git.pr.actions.addToChat")}
</Button>
) : null}
{check.timing && <Text style={sectionKitStyles.checkDuration}>{check.timing}</Text>}
</View>
</Pressable>
);
}
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],
},
}));