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.

162 linesCodeBlameRaw
1import { Stack, router, useLocalSearchParams } from "expo-router";
2import { useHeaderHeight } from "expo-router/react-navigation";
3import { Check } from "lucide-react-native";
4import { useRef, useState } from "react";
5import { KeyboardAvoidingView, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native";
6
7import { Button, Text } from "@/components/ui";
8import { Chip } from "@/components/work";
9import { useWorkspaceRepos } from "@/lib/code";
10import { originOf } from "@/lib/new-item";
11import { useSignedIn } from "@/lib/session";
12import { useLabels, useOpenWorkItem } from "@/lib/work";
13import { radius, space, useColors } from "@/theme";
14
15/**
16 * A new work item: a title, what it is about, its labels, and the
17 * repositories where it is meant to land (none is fine: it can be linked
18 * later): its own workspace's, whichever the app is in. Opened from a
19 * repository (`repo`), that repository is its origin, and a target to
20 * start with.
21 * Made for the on-screen keyboard: the title's return goes on to the
22 * description, and Create stays in the header above the keyboard.
23 */
24export default function NewWorkItem() {
25 const params = useLocalSearchParams<{ workspace?: string; repo?: string }>();
26 const { workspace: current } = useSignedIn();
27 const workspace = params.workspace ?? current?.slug ?? "";
28 const origin = originOf(workspace, params.repo) ?? undefined;
29 const colors = useColors();
30 const headerHeight = useHeaderHeight();
31 const [title, setTitle] = useState("");
32 const [body, setBody] = useState("");
33 const [labels, setLabels] = useState<string[]>([]);
34 // Filed from a repository, it starts as a target too, as on the site; untick it to leave it the origin only.
35 const [targets, setTargets] = useState<string[]>(origin ? [origin] : []);
36 const bodyField = useRef<TextInput>(null);
37 const known = useLabels(workspace);
38 const repos = useWorkspaceRepos(workspace);
39 const open = useOpenWorkItem(workspace);
40 const ready = title.trim().length > 0 && !open.isPending;
41
42 const toggle = (list: string[], value: string) => (list.includes(value) ? list.filter((one) => one !== value) : [...list, value]);
43 const create = () => {
44 if (!ready) return;
45 open.mutate(
46 { title: title.trim(), body: body.trim(), labels, targets, ...(origin ? { repo: origin } : {}) },
47 { onSuccess: (item) => router.replace({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } }) },
48 );
49 };
50
51 return (
52 <View style={{ flex: 1, backgroundColor: colors.bg }}>
53 <Stack.Screen
54 options={{
55 title: "New work item",
56 headerRight: () => (
57 <Pressable accessibilityRole="button" accessibilityLabel="Create" hitSlop={10} disabled={!ready} onPress={create}>
58 <Text style={{ color: ready ? colors.accent : colors.faint, fontWeight: "700" }}>Create</Text>
59 </Pressable>
60 ),
61 }}
62 />
63 <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
64 <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
65 {origin ? (
66 <Text size="small" tone="muted">
67 Filed from {origin}
68 </Text>
69 ) : (
70 <Text size="small" tone="muted">
71 In {workspace}
72 </Text>
73 )}
74 <TextInput
75 autoFocus
76 value={title}
77 onChangeText={setTitle}
78 placeholder="Title"
79 placeholderTextColor={colors.faint}
80 returnKeyType="next"
81 submitBehavior="submit"
82 onSubmitEditing={() => bodyField.current?.focus()}
83 style={[styles.title, { color: colors.fg, borderBottomColor: colors.line }]}
84 />
85 <TextInput
86 ref={bodyField}
87 value={body}
88 onChangeText={setBody}
89 placeholder="What is it about? Markdown works. Say what done means, under a “Definition of done” heading."
90 placeholderTextColor={colors.faint}
91 multiline
92 textAlignVertical="top"
93 style={[styles.body, { color: colors.fg, backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
94 />
95
96 <Group title="Labels" empty={known.isPending ? "…" : "This workspace has no labels yet."}>
97 {(known.data ?? []).map((label) => (
98 <Chip
99 key={label.name}
100 label={label.name}
101 tone={labels.includes(label.name) ? "accent" : "neutral"}
102 leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />}
103 onPress={() => setLabels(toggle(labels, label.name))}
104 />
105 ))}
106 </Group>
107
108 <Group
109 title="Where it lands"
110 hint="The repositories the work is meant to land in. It closes once it has landed in each. Leave them out to pick later."
111 empty={repos.isPending ? "…" : "No repositories you can see."}
112 >
113 {(repos.data ?? []).map((path) => {
114 const on = targets.some((one) => one.toLowerCase() === path.toLowerCase());
115 return (
116 <Chip
117 key={path}
118 label={path.split("/")[1] ?? path}
119 tone={on ? "accent" : "neutral"}
120 leading={on ? <Check size={12} color={colors.accent} /> : null}
121 onPress={() => setTargets(on ? targets.filter((one) => one.toLowerCase() !== path.toLowerCase()) : [...targets, path])}
122 />
123 );
124 })}
125 </Group>
126
127 {open.error ? <Text tone="danger">{open.error.message}</Text> : null}
128 <Button title="Create" busy={open.isPending} disabled={!ready} onPress={create} />
129 </ScrollView>
130 </KeyboardAvoidingView>
131 </View>
132 );
133}
134
135function Group({ title, hint, empty, children }: { title: string; hint?: string; empty: string; children: React.ReactNode[] }) {
136 return (
137 <View style={{ gap: space.sm }}>
138 <Text size="small" tone="muted" style={{ fontWeight: "600" }}>
139 {title}
140 </Text>
141 {hint ? (
142 <Text size="small" tone="faint">
143 {hint}
144 </Text>
145 ) : null}
146 {children.length ? (
147 <View style={styles.chips}>{children}</View>
148 ) : (
149 <Text size="small" tone="faint">
150 {empty}
151 </Text>
152 )}
153 </View>
154 );
155}
156
157const styles = StyleSheet.create({
158 content: { padding: space.lg, gap: space.lg, paddingBottom: space.xxl },
159 title: { fontSize: 20, fontWeight: "600", paddingVertical: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
160 body: { minHeight: 160, borderWidth: 1, borderRadius: radius.md, padding: space.md, fontSize: 15, lineHeight: 21 },
161 chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
162});