Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

201 linesCodeBlameRaw
1import { CircleCheck, CircleDot, CircleX, Ellipsis, FolderGit2, MessageSquare, SendHorizontal } from "lucide-react-native";
2import { type ReactNode, useEffect, useState } from "react";
3import { ActivityIndicator, Keyboard, Pressable, StyleSheet, TextInput, View } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";
5
6import { Text } from "@/components/ui";
7import { ago } from "@/lib/time";
8import type { ApiItemRepo, ApiWorkItem, LinkRole } from "@/lib/work";
9import { radius, space, useColors } from "@/theme";
10
11/** A work item's state, as an icon in its colour: open, done, or closed as not planned. */
12export function ItemIcon({ item, size = 20 }: { item: Pick<ApiWorkItem, "state" | "reason">; size?: number }) {
13 const colors = useColors();
14 if (item.state === "open") return <CircleDot size={size} color={colors.success} accessibilityLabel="Open" />;
15 return item.reason === "not_planned" ? (
16 <CircleX size={size} color={colors.muted} accessibilityLabel="Closed as not planned" />
17 ) : (
18 <CircleCheck size={size} color={colors.merged} accessibilityLabel="Closed" />
19 );
20}
21
22export function stateLabel(item: Pick<ApiWorkItem, "state" | "reason">): string {
23 if (item.state === "open") return "Open";
24 return item.reason === "not_planned" ? "Not planned" : "Closed";
25}
26
27/** A small rounded tag: a label, a repository, a filter. */
28export function Chip({ label, tone = "neutral", onPress, leading }: { label: string; tone?: "neutral" | "accent"; onPress?: () => void; leading?: ReactNode }) {
29 const colors = useColors();
30 const on = tone === "accent";
31 return (
32 <Pressable
33 onPress={onPress}
34 disabled={!onPress}
35 accessibilityRole={onPress ? "button" : undefined}
36 style={({ pressed }) => [
37 styles.chip,
38 { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accentLow : pressed ? colors.raised : "transparent" },
39 ]}
40 >
41 {leading}
42 <Text size="small" numberOfLines={1} style={{ color: on ? colors.accent : colors.fgSoft, fontWeight: "500", maxWidth: 180 }}>
43 {label}
44 </Text>
45 </Pressable>
46 );
47}
48
49/** A repository's name in a chip: just the name when it is in the item's workspace. */
50export function repoName(path: string, workspace: string): string {
51 // A repository deleted before work items had no path left to keep.
52 if (!path) return "deleted repository";
53 const [owner, name] = path.split("/", 2);
54 return name && owner.toLowerCase() === workspace.toLowerCase() ? name : path;
55}
56
57/**
58 * The repositories an item is linked to, as chips that fit a phone's row:
59 * the first `max`, then "+2" for the rest. Origin and targets come first.
60 */
61export function RepoChips({ repos, workspace, max = 2 }: { repos: ApiItemRepo[]; workspace: string; max?: number }) {
62 const colors = useColors();
63 const shown = repos.filter((repo) => repo.role !== "touched").concat(repos.filter((repo) => repo.role === "touched"));
64 if (shown.length === 0) return null;
65 const rest = shown.length - max;
66 return (
67 <>
68 {shown.slice(0, max).map((repo) => (
69 <Chip key={repo.repo_id} label={repoName(repo.path, workspace)} leading={<FolderGit2 size={12} color={colors.muted} />} />
70 ))}
71 {rest > 0 ? <Chip label={`+${rest}`} /> : null}
72 </>
73 );
74}
75
76/** One work item in a list: its number and title, then state, repositories and labels on a line that wraps. */
77export function ItemRow({ item, onPress }: { item: ApiWorkItem; onPress: () => void }) {
78 const colors = useColors();
79 return (
80 <Pressable
81 onPress={onPress}
82 style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
83 >
84 <View style={{ paddingTop: 1 }}>
85 <ItemIcon item={item} size={18} />
86 </View>
87 <View style={{ flex: 1, gap: 6 }}>
88 <Text numberOfLines={2} style={{ fontWeight: "600" }}>
89 {item.title}
90 </Text>
91 <View style={styles.meta}>
92 <Text size="small" tone="muted">
93 #{item.number} · {ago(item.updated_at)}
94 </Text>
95 <RepoChips repos={item.repos} workspace={item.workspace} />
96 {item.labels.slice(0, 2).map((label) => (
97 <Chip key={label} label={label} />
98 ))}
99 {item.labels.length > 2 ? <Chip label={`+${item.labels.length - 2}`} /> : null}
100 {item.comment_count ? (
101 <View style={{ flexDirection: "row", alignItems: "center", gap: 3 }}>
102 <MessageSquare size={13} color={colors.faint} />
103 <Text size="small" tone="faint">
104 {item.comment_count}
105 </Text>
106 </View>
107 ) : null}
108 </View>
109 </View>
110 </Pressable>
111 );
112}
113
114const ROLES: Record<LinkRole, string> = { origin: "Origin", target: "Target", touched: "Touched" };
115
116export function roleLabel(role: LinkRole): string {
117 return ROLES[role];
118}
119
120/**
121 * The bar at the foot of an item: a box to comment in, the arrow to send
122 * it, and ⋯ for everything else (close, reopen, link a repository), all
123 * where a thumb reaches. What was typed stays until it is sent.
124 */
125export function CommentBar({ busy, onSend, onMore }: { busy: boolean; onSend: (body: string) => Promise<unknown>; onMore: () => void }) {
126 const colors = useColors();
127 const insets = useSafeAreaInsets();
128 const [text, setText] = useState("");
129 const [keyboard, setKeyboard] = useState(false);
130 useEffect(() => {
131 const shown = Keyboard.addListener("keyboardDidShow", () => setKeyboard(true));
132 const hidden = Keyboard.addListener("keyboardDidHide", () => setKeyboard(false));
133 return () => {
134 shown.remove();
135 hidden.remove();
136 };
137 }, []);
138 const ready = text.trim().length > 0 && !busy;
139 const send = () => {
140 if (!ready) return;
141 void onSend(text.trim()).then(
142 () => setText(""),
143 () => undefined,
144 );
145 };
146 return (
147 <View
148 style={[
149 styles.bar,
150 { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: keyboard ? space.sm : Math.max(insets.bottom, space.sm) },
151 ]}
152 >
153 <Pressable accessibilityRole="button" accessibilityLabel="More actions" onPress={onMore} style={[styles.round, { backgroundColor: colors.raised }]}>
154 <Ellipsis size={20} color={colors.fg} />
155 </Pressable>
156 <TextInput
157 value={text}
158 onChangeText={setText}
159 placeholder="Comment"
160 placeholderTextColor={colors.faint}
161 multiline
162 style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
163 />
164 <Pressable
165 accessibilityRole="button"
166 accessibilityLabel="Comment"
167 onPress={send}
168 disabled={!ready}
169 style={[styles.round, { backgroundColor: ready ? colors.accent : colors.raised }]}
170 >
171 {busy ? <ActivityIndicator color={colors.fg} /> : <SendHorizontal size={18} color={ready ? colors.onAccent : colors.faint} />}
172 </Pressable>
173 </View>
174 );
175}
176
177const styles = StyleSheet.create({
178 chip: { flexDirection: "row", alignItems: "center", gap: 4, borderWidth: 1, borderRadius: radius.full, paddingHorizontal: space.sm, paddingVertical: 1 },
179 row: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth },
180 meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
181 bar: {
182 flexDirection: "row",
183 alignItems: "flex-end",
184 gap: space.sm,
185 paddingHorizontal: space.md,
186 paddingTop: space.sm,
187 borderTopWidth: StyleSheet.hairlineWidth,
188 },
189 input: {
190 flex: 1,
191 minHeight: 40,
192 maxHeight: 140,
193 borderRadius: radius.lg,
194 borderWidth: 1,
195 paddingHorizontal: space.md,
196 paddingTop: 10,
197 paddingBottom: 10,
198 fontSize: 15,
199 },
200 round: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
201});