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.

310 linesCodeBlameRaw
1import { Stack, router, useLocalSearchParams, useNavigation } from "expo-router";
2import { Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native";
3import { useEffect, useMemo, useState } from "react";
4import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native";
5
6import { Sheet } from "@/components/chat/sheet";
7import { Segments } from "@/components/segments";
8import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui";
9import { Chip, ItemRow, roleLabel } from "@/components/work";
10import { useRepos } from "@/lib/code";
11import { useSignedIn } from "@/lib/session";
12import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work";
13import { radius, space, useColors } from "@/theme";
14
15type Params = { workspace?: string; state?: string; label?: string; milestone?: string; assignee?: string; repo?: string; role?: string };
16
17/** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */
18function filterOf(params: Params): WorkFilter {
19 const state = params.state === "closed" || params.state === "all" ? params.state : "open";
20 const role = params.role === "origin" || params.role === "target" || params.role === "touched" ? params.role : undefined;
21 const milestone = Number(params.milestone);
22 return {
23 state,
24 label: params.label || undefined,
25 milestone: Number.isInteger(milestone) && milestone > 0 ? milestone : undefined,
26 assignee: params.assignee || undefined,
27 repo: params.repo || undefined,
28 role,
29 };
30}
31
32/**
33 * Work: the workspace's work items, newest first, for every member,
34 * Code or not. Filters are in a sheet (the ⚙ in the header, the chips
35 * above the list, or Work in the bar again); pull down to read again,
36 * and the list loads more as it nears its end.
37 */
38export default function WorkList() {
39 const params = useLocalSearchParams<Params>();
40 const { workspace, me } = useSignedIn();
41 const slug = params.workspace ?? workspace?.slug;
42 const colors = useColors();
43 const linked = useMemo(() => filterOf(params), [params.state, params.label, params.milestone, params.assignee, params.repo, params.role]);
44 const [filter, setFilter] = useState<WorkFilter>(linked);
45 useEffect(() => setFilter(linked), [linked]);
46 const [filtering, setFiltering] = useState(false);
47 const items = useWorkItems(slug, filter);
48 const milestones = useMilestones(filter.milestone ? slug : undefined);
49
50 // Work in the bar again, on the list itself, opens its filters, as the site's bar opens Work's menu.
51 const navigation = useNavigation();
52 useEffect(() => {
53 const tabs = navigation.getParent();
54 if (!tabs) return;
55 return tabs.addListener("tabPress" as never, () => {
56 if (navigation.isFocused()) setFiltering(true);
57 });
58 }, [navigation]);
59
60 const active = [
61 filter.assignee ? { key: "assignee", label: filter.assignee === me.username ? "Assigned to me" : `@${filter.assignee}` } : null,
62 filter.label ? { key: "label", label: filter.label } : null,
63 filter.milestone ? { key: "milestone", label: milestones.data?.find((one) => one.number === filter.milestone)?.title ?? `Milestone ${filter.milestone}` } : null,
64 filter.repo ? { key: "repo", label: filter.role ? `${filter.repo} · ${roleLabel(filter.role)}` : filter.repo } : null,
65 ].filter((one): one is { key: keyof WorkFilter; label: string } => one !== null);
66
67 const header = (
68 <Stack.Screen
69 options={{
70 title: "Work",
71 headerRight: () => (
72 <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}>
73 <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} />
74 </Pressable>
75 ),
76 }}
77 />
78 );
79
80 if (!slug) {
81 return (
82 <Screen>
83 {header}
84 <Empty icon={ListTodo} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
85 </Screen>
86 );
87 }
88
89 return (
90 <Screen>
91 {header}
92 <View style={[styles.bar, { borderBottomColor: colors.line }]}>
93 <Segments
94 value={filter.state}
95 onChange={(state) => setFilter({ ...filter, state })}
96 options={[
97 { value: "open", label: "Open" },
98 { value: "closed", label: "Closed" },
99 { value: "all", label: "All" },
100 ]}
101 />
102 {active.length ? (
103 <View style={styles.chips}>
104 {active.map((one) => (
105 <Chip
106 key={one.key}
107 label={one.label}
108 tone="accent"
109 leading={<X size={12} color={colors.accent} />}
110 onPress={() => setFilter({ ...filter, [one.key]: undefined, ...(one.key === "repo" ? { role: undefined } : {}) })}
111 />
112 ))}
113 </View>
114 ) : null}
115 </View>
116
117 {items.isPending ? (
118 <Loading />
119 ) : items.error ? (
120 <ErrorView error={items.error} retry={() => items.refetch()} />
121 ) : (
122 <FlatList
123 data={items.items}
124 keyExtractor={(item) => item.id}
125 refreshControl={<RefreshControl refreshing={items.isRefetching && !items.isFetchingNextPage} onRefresh={() => items.refetch()} tintColor={colors.accent} />}
126 onEndReached={() => items.hasNextPage && !items.isFetchingNextPage && items.fetchNextPage()}
127 onEndReachedThreshold={0.5}
128 contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }}
129 ListFooterComponent={items.isFetchingNextPage ? <ActivityIndicator color={colors.accent} style={{ padding: space.lg }} /> : null}
130 ListEmptyComponent={
131 <Empty
132 icon={ListTodo}
133 title={active.length ? "Nothing matches" : filter.state === "closed" ? "Nothing closed yet" : "No open work items"}
134 body={active.length ? "Try fewer filters." : "Work items are any tracked work: a feature, a bug, a chore."}
135 />
136 }
137 renderItem={({ item }) => (
138 <ItemRow item={item} onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} />
139 )}
140 />
141 )}
142
143 <Pressable
144 accessibilityRole="button"
145 accessibilityLabel="New work item"
146 onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug } })}
147 style={({ pressed }) => [styles.fab, { backgroundColor: colors.accent, opacity: pressed ? 0.85 : 1 }]}
148 >
149 <Plus size={20} color={colors.onAccent} strokeWidth={2.5} />
150 <Text style={{ color: colors.onAccent, fontWeight: "600" }}>New</Text>
151 </Pressable>
152
153 <Filters visible={filtering} workspace={slug} filter={filter} me={me.username} onClose={() => setFiltering(false)} onChange={setFilter} />
154 </Screen>
155 );
156}
157
158/** The list's filters, in a sheet: state, assigned to you, label, milestone, repository and its role. */
159function Filters({
160 visible,
161 workspace,
162 filter,
163 me,
164 onClose,
165 onChange,
166}: {
167 visible: boolean;
168 workspace: string;
169 filter: WorkFilter;
170 me: string;
171 onClose: () => void;
172 onChange: (filter: WorkFilter) => void;
173}) {
174 const colors = useColors();
175 const { height } = useWindowDimensions();
176 const labels = useLabels(visible ? workspace : undefined);
177 const milestones = useMilestones(visible ? workspace : undefined);
178 const repos = useRepos("");
179 const set = (change: Partial<WorkFilter>) => onChange({ ...filter, ...change });
180 const toggle = <K extends keyof WorkFilter>(key: K, value: WorkFilter[K]) => set({ [key]: filter[key] === value ? undefined : value } as Partial<WorkFilter>);
181
182 return (
183 <Sheet visible={visible} onClose={onClose}>
184 <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}>
185 <Text size="heading">Filters</Text>
186 <Segments
187 value={filter.state}
188 onChange={(state) => set({ state })}
189 options={[
190 { value: "open", label: "Open" },
191 { value: "closed", label: "Closed" },
192 { value: "all", label: "All" },
193 ]}
194 />
195 <View style={{ marginHorizontal: -space.lg }}>
196 <Row
197 title="Assigned to me"
198 chevron={false}
199 trailing={<Check on={filter.assignee === me} />}
200 onPress={() => toggle("assignee", me)}
201 />
202 </View>
203 <Group title="Label" empty={labels.isPending ? "…" : "No labels yet"}>
204 {(labels.data ?? []).map((label) => (
205 <Chip
206 key={label.name}
207 label={label.name}
208 tone={filter.label === label.name ? "accent" : "neutral"}
209 leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />}
210 onPress={() => toggle("label", label.name)}
211 />
212 ))}
213 </Group>
214 <Group title="Milestone" empty={milestones.isPending ? "…" : "No open milestones"}>
215 {(milestones.data ?? []).map((milestone) => (
216 <Chip
217 key={milestone.number}
218 label={milestone.title}
219 tone={filter.milestone === milestone.number ? "accent" : "neutral"}
220 onPress={() => toggle("milestone", milestone.number)}
221 />
222 ))}
223 </Group>
224 <Group title="Repository" empty={repos.isPending ? "…" : "No repositories you can see"}>
225 {(repos.data ?? []).map((repo) => (
226 <Chip
227 key={repo.id}
228 label={repo.name}
229 tone={filter.repo === repo.name ? "accent" : "neutral"}
230 onPress={() => set({ repo: filter.repo === repo.name ? undefined : repo.name, role: undefined })}
231 />
232 ))}
233 </Group>
234 {filter.repo ? (
235 <View style={{ gap: space.sm }}>
236 <Text size="small" tone="muted">
237 Linked to {filter.repo} as
238 </Text>
239 <Segments
240 value={filter.role ?? "any"}
241 onChange={(role) => set({ role: role === "any" ? undefined : (role as LinkRole) })}
242 options={[
243 { value: "any", label: "Any" },
244 { value: "origin", label: "Origin" },
245 { value: "target", label: "Target" },
246 { value: "touched", label: "Touched" },
247 ]}
248 />
249 </View>
250 ) : null}
251 <View style={{ flexDirection: "row", gap: space.sm }}>
252 <View style={{ flex: 1 }}>
253 <Button title="Clear" variant="secondary" onPress={() => onChange({ state: "open" })} />
254 </View>
255 <View style={{ flex: 1 }}>
256 <Button title="Done" onPress={onClose} />
257 </View>
258 </View>
259 </ScrollView>
260 </Sheet>
261 );
262}
263
264function Group({ title, empty, children }: { title: string; empty: string; children: React.ReactNode[] }) {
265 return (
266 <View style={{ gap: space.sm }}>
267 <Text size="small" tone="muted">
268 {title}
269 </Text>
270 {children.length ? (
271 <View style={styles.chips}>{children}</View>
272 ) : (
273 <Text size="small" tone="faint">
274 {empty}
275 </Text>
276 )}
277 </View>
278 );
279}
280
281function Check({ on }: { on: boolean }) {
282 const colors = useColors();
283 return (
284 <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}>
285 {on ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null}
286 </View>
287 );
288}
289
290const styles = StyleSheet.create({
291 bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
292 chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
293 fab: {
294 position: "absolute",
295 right: space.lg,
296 bottom: space.lg,
297 flexDirection: "row",
298 alignItems: "center",
299 gap: space.xs,
300 paddingHorizontal: space.lg,
301 height: 48,
302 borderRadius: radius.full,
303 elevation: 4,
304 shadowColor: "#000",
305 shadowOpacity: 0.25,
306 shadowRadius: 8,
307 shadowOffset: { width: 0, height: 2 },
308 },
309 check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" },
310});