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