| 1 | import { CircleCheck, CircleDot, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native"; |
| 2 | import { ScrollView, StyleSheet, View } from "react-native"; |
| 3 | |
| 4 | import { Badge, Text } from "@/components/ui"; |
| 5 | import type { ApiChanges, ApiComment, ApiIssue, ApiPull, CheckStatus } from "@/lib/code"; |
| 6 | import { ago } from "@/lib/time"; |
| 7 | import { fonts, radius, space, useColors } from "@/theme"; |
| 8 | |
| 9 | /** A pull request's state, as an icon in its colour. */ |
| 10 | export function PullIcon({ pull }: { pull: Pick<ApiPull, "status"> }) { |
| 11 | const colors = useColors(); |
| 12 | if (pull.status === "merged") return <GitMerge size={20} color={colors.merged} />; |
| 13 | if (pull.status === "closed") return <GitPullRequestClosed size={20} color={colors.danger} />; |
| 14 | if (pull.status === "draft") return <GitPullRequestDraft size={20} color={colors.muted} />; |
| 15 | return <GitPullRequest size={20} color={colors.success} />; |
| 16 | } |
| 17 | |
| 18 | export function IssueIcon({ issue }: { issue: Pick<ApiIssue, "state" | "reason"> }) { |
| 19 | const colors = useColors(); |
| 20 | if (issue.state === "open") return <CircleDot size={20} color={colors.success} />; |
| 21 | return issue.reason === "not_planned" ? <CircleX size={20} color={colors.muted} /> : <CircleCheck size={20} color={colors.merged} />; |
| 22 | } |
| 23 | |
| 24 | export function Checks({ status }: { status: CheckStatus }) { |
| 25 | const colors = useColors(); |
| 26 | if (status === "passed") return <CircleCheck size={16} color={colors.success} accessibilityLabel="Checks passed" />; |
| 27 | if (status === "failed" || status === "errored") return <CircleX size={16} color={colors.danger} accessibilityLabel="Checks failed" />; |
| 28 | if (status === "queued" || status === "running") return <Loader size={16} color={colors.warn} accessibilityLabel="Checks running" />; |
| 29 | return null; |
| 30 | } |
| 31 | |
| 32 | export function StateBadge({ pull }: { pull: Pick<ApiPull, "status"> }) { |
| 33 | const tone = pull.status === "merged" ? "merged" : pull.status === "closed" ? "danger" : pull.status === "open" ? "success" : "neutral"; |
| 34 | return <Badge label={pull.status[0].toUpperCase() + pull.status.slice(1)} tone={tone} />; |
| 35 | } |
| 36 | |
| 37 | /** Markdown, shown as it was written until the app renders it. */ |
| 38 | export function Body({ text }: { text: string | null }) { |
| 39 | const colors = useColors(); |
| 40 | if (!text?.trim()) { |
| 41 | return ( |
| 42 | <Text tone="faint" style={{ fontStyle: "italic" }}> |
| 43 | No description. |
| 44 | </Text> |
| 45 | ); |
| 46 | } |
| 47 | return ( |
| 48 | <View style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface }]}> |
| 49 | <Text selectable>{text}</Text> |
| 50 | </View> |
| 51 | ); |
| 52 | } |
| 53 | |
| 54 | /** The conversation under an issue or pull request: comments, reviews and what happened. */ |
| 55 | export function Timeline({ comments }: { comments: ApiComment[] }) { |
| 56 | const colors = useColors(); |
| 57 | if (comments.length === 0) return null; |
| 58 | return ( |
| 59 | <View style={{ gap: space.md }}> |
| 60 | {comments.map((comment) => |
| 61 | comment.kind === "event" ? ( |
| 62 | <Text key={comment.id} size="small" tone="muted"> |
| 63 | {comment.author.display_username ?? comment.author.username} {comment.body} · {ago(comment.created_at)} |
| 64 | </Text> |
| 65 | ) : ( |
| 66 | <View key={comment.id} style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface, gap: space.xs }]}> |
| 67 | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> |
| 68 | <Text style={{ fontWeight: "600" }}>{comment.author.display_username ?? comment.author.username}</Text> |
| 69 | {comment.author.kind === "agent" ? <Badge label="Agent" tone="accent" /> : null} |
| 70 | {comment.verdict ? <Badge label={comment.verdict.replace(/_/g, " ")} tone={comment.verdict === "approve" ? "success" : "warn"} /> : null} |
| 71 | <Text size="small" tone="faint"> |
| 72 | {ago(comment.created_at)} |
| 73 | </Text> |
| 74 | </View> |
| 75 | {comment.path ? ( |
| 76 | <Text size="mono" tone="muted"> |
| 77 | {comment.path} |
| 78 | {comment.line ? `:${comment.line}` : ""} |
| 79 | </Text> |
| 80 | ) : null} |
| 81 | <Text selectable>{comment.body}</Text> |
| 82 | </View> |
| 83 | ), |
| 84 | )} |
| 85 | </View> |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | /** What a pull request changes, file by file, as unified hunks. */ |
| 90 | export function Diff({ changes }: { changes: ApiChanges }) { |
| 91 | const colors = useColors(); |
| 92 | return ( |
| 93 | <View style={{ gap: space.lg }}> |
| 94 | {changes.files.map((file) => ( |
| 95 | <View key={file.path} style={[styles.file, { borderColor: colors.line }]}> |
| 96 | <View style={[styles.fileHeader, { backgroundColor: colors.raised, borderBottomColor: colors.line }]}> |
| 97 | <Text size="mono" style={{ flex: 1 }} numberOfLines={1}> |
| 98 | {file.path} |
| 99 | </Text> |
| 100 | <Text size="mono" tone="success"> |
| 101 | +{file.additions} |
| 102 | </Text> |
| 103 | <Text size="mono" tone="danger"> |
| 104 | −{file.deletions} |
| 105 | </Text> |
| 106 | </View> |
| 107 | {file.binary ? ( |
| 108 | <Text tone="muted" size="small" style={{ padding: space.md }}> |
| 109 | A binary file. |
| 110 | </Text> |
| 111 | ) : ( |
| 112 | <ScrollView horizontal> |
| 113 | <View> |
| 114 | {(file.hunks ?? []).flatMap((hunk, index) => |
| 115 | hunk.lines.map((line, at) => ( |
| 116 | <Text |
| 117 | key={`${index}-${at}`} |
| 118 | style={{ |
| 119 | fontFamily: fonts.mono, |
| 120 | fontSize: 12, |
| 121 | lineHeight: 18, |
| 122 | paddingHorizontal: space.sm, |
| 123 | color: line.kind === "add" ? colors.success : line.kind === "delete" ? colors.danger : colors.fgSoft, |
| 124 | backgroundColor: line.kind === "add" ? colors.successLow : "transparent", |
| 125 | }} |
| 126 | > |
| 127 | {line.kind === "add" ? "+ " : line.kind === "delete" ? "− " : " "} |
| 128 | {line.text} |
| 129 | </Text> |
| 130 | )), |
| 131 | )} |
| 132 | </View> |
| 133 | </ScrollView> |
| 134 | )} |
| 135 | </View> |
| 136 | ))} |
| 137 | {changes.truncated ? ( |
| 138 | <Text tone="muted" size="small"> |
| 139 | Too much changed to show here. Open it on g1t for the rest. |
| 140 | </Text> |
| 141 | ) : null} |
| 142 | </View> |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | const styles = StyleSheet.create({ |
| 147 | box: { borderWidth: 1, borderRadius: radius.md, padding: space.md }, |
| 148 | file: { borderWidth: 1, borderRadius: radius.md, overflow: "hidden" }, |
| 149 | fileHeader: { flexDirection: "row", gap: space.sm, padding: space.sm, borderBottomWidth: StyleSheet.hairlineWidth }, |
| 150 | }); |