Skip to content
150 linesCodeBlameRaw
1import { CircleCheck, CircleDot, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native";
2import { ScrollView, StyleSheet, View } from "react-native";
3
4import { Badge, Text } from "@/components/ui";
5import type { ApiChanges, ApiComment, ApiIssue, ApiPull, CheckStatus } from "@/lib/code";
6import { ago } from "@/lib/time";
7import { fonts, radius, space, useColors } from "@/theme";
8
9/** A pull request's state, as an icon in its colour. */
10export 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
18export 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
24export 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
32export 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. */
38export 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. */
55export 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. */
90export 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
146const 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});