| 1 | import * as Clipboard from "expo-clipboard"; |
| 2 | import { Stack, router, useLocalSearchParams } from "expo-router"; |
| 3 | import { useHeaderHeight } from "expo-router/react-navigation"; |
| 4 | import * as WebBrowser from "expo-web-browser"; |
| 5 | import { CircleCheck, CircleDot, CircleX, Copy, ExternalLink, FolderGit2, Link2, Plus, Search, Unlink } from "lucide-react-native"; |
| 6 | import { useState } from "react"; |
| 7 | import { Alert, KeyboardAvoidingView, RefreshControl, ScrollView, StyleSheet, TextInput, View, useWindowDimensions } from "react-native"; |
| 8 | |
| 9 | import { Sheet, SheetAction } from "@/components/chat/sheet"; |
| 10 | import { Body, Checks, PullIcon, Timeline } from "@/components/code"; |
| 11 | import { Badge, Button, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; |
| 12 | import { Chip, CommentBar, ItemIcon, repoName, roleLabel, stateLabel } from "@/components/work"; |
| 13 | import { useRepos } from "@/lib/code"; |
| 14 | import { siteUrl } from "@/lib/server"; |
| 15 | import { useSignedIn } from "@/lib/session"; |
| 16 | import { ago } from "@/lib/time"; |
| 17 | import { type ApiItemRepo, type ApiWorkPull, type ApiWorkRepo, useItemActions, useWorkContext, useWorkItem } from "@/lib/work"; |
| 18 | import { radius, space, useColors } from "@/theme"; |
| 19 | |
| 20 | /** |
| 21 | * A work item: what it asks for, the repositories it is linked to (where |
| 22 | * it came from, where it is meant to land, and what has landed where), |
| 23 | * each one's pull requests for it, and its conversation. The bar at the |
| 24 | * foot comments, and its ⋯ closes, reopens and links a repository. |
| 25 | */ |
| 26 | export default function WorkItem() { |
| 27 | const params = useLocalSearchParams<{ number: string; workspace?: string }>(); |
| 28 | const { server, workspace: current } = useSignedIn(); |
| 29 | const workspace = params.workspace ?? current?.slug ?? ""; |
| 30 | const number = Number(params.number); |
| 31 | const detail = useWorkItem(workspace, number); |
| 32 | const context = useWorkContext(workspace, number); |
| 33 | const actions = useItemActions(workspace, number); |
| 34 | const headerHeight = useHeaderHeight(); |
| 35 | const colors = useColors(); |
| 36 | const [more, setMore] = useState(false); |
| 37 | const [linking, setLinking] = useState(false); |
| 38 | const [picked, setPicked] = useState<ApiItemRepo | null>(null); |
| 39 | |
| 40 | const title = <Stack.Screen options={{ title: `#${number}` }} />; |
| 41 | if (detail.isPending || detail.error) { |
| 42 | return ( |
| 43 | <Screen> |
| 44 | {title} |
| 45 | {detail.error ? <ErrorView error={detail.error} retry={() => detail.refetch()} /> : <Loading />} |
| 46 | </Screen> |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | const { item, comments } = detail.data; |
| 51 | const by = item.author.display_username ?? item.author.username; |
| 52 | const refs = { workspace }; |
| 53 | const sitePath = `/${workspace}/-/work/${number}`; |
| 54 | // The context has each repository's pull requests; the item alone still has its repositories. |
| 55 | const repos: ApiWorkRepo[] = context.data?.repos ?? item.repos.map((repo) => ({ ...repo, pulls: [] })); |
| 56 | const missing = context.data?.missing ?? item.repos.filter((repo) => repo.role === "target" && !repo.landed).map((repo) => repo.path); |
| 57 | const linked = new Set(repos.map((repo) => repo.path.toLowerCase())); |
| 58 | const suggested = (context.data?.suggested_targets ?? []).filter((one) => !linked.has(one.path.toLowerCase())); |
| 59 | |
| 60 | const failed = (what: string) => (error: unknown) => Alert.alert(what, error instanceof Error ? error.message : "Something went wrong."); |
| 61 | const close = (reason: "completed" | "not_planned") => { |
| 62 | setMore(false); |
| 63 | actions.close.mutate(reason, { onError: failed("Could not close it") }); |
| 64 | }; |
| 65 | const reopen = () => { |
| 66 | setMore(false); |
| 67 | actions.reopen.mutate(undefined, { onError: failed("Could not reopen it") }); |
| 68 | }; |
| 69 | const link = (repo: string) => { |
| 70 | setLinking(false); |
| 71 | actions.link.mutate({ repo, role: "target" }, { onError: failed("Could not link it") }); |
| 72 | }; |
| 73 | const openRepo = (path: string) => { |
| 74 | const [owner, name] = path.split("/", 2); |
| 75 | router.push({ pathname: "/code/[owner]/[repo]", params: { owner, repo: name } }); |
| 76 | }; |
| 77 | const openPull = (pull: ApiWorkPull) => { |
| 78 | const [owner, name] = pull.repo.split("/", 2); |
| 79 | router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo: name, number: String(pull.number) } }); |
| 80 | }; |
| 81 | const busy = actions.close.isPending || actions.reopen.isPending; |
| 82 | |
| 83 | return ( |
| 84 | <Screen> |
| 85 | {title} |
| 86 | <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}> |
| 87 | <ScrollView |
| 88 | contentContainerStyle={styles.content} |
| 89 | keyboardShouldPersistTaps="handled" |
| 90 | keyboardDismissMode="interactive" |
| 91 | refreshControl={ |
| 92 | <RefreshControl refreshing={detail.isRefetching} onRefresh={() => Promise.all([detail.refetch(), context.refetch()])} tintColor={colors.accent} /> |
| 93 | } |
| 94 | > |
| 95 | <View style={{ gap: space.sm }}> |
| 96 | <Text size="heading" selectable> |
| 97 | {item.title} |
| 98 | </Text> |
| 99 | <View style={styles.wrap}> |
| 100 | <ItemIcon item={item} size={18} /> |
| 101 | <Text size="small" style={{ fontWeight: "600", color: item.state === "open" ? colors.success : colors.muted }}> |
| 102 | {stateLabel(item)} |
| 103 | </Text> |
| 104 | <Text size="small" tone="muted"> |
| 105 | #{item.number} · {by} · {ago(item.created_at)} |
| 106 | </Text> |
| 107 | </View> |
| 108 | {item.labels.length || item.milestone || item.assignees.length ? ( |
| 109 | <View style={styles.wrap}> |
| 110 | {item.labels.map((label) => ( |
| 111 | <Chip key={label} label={label} /> |
| 112 | ))} |
| 113 | {item.milestone ? <Chip label={item.milestone.title} /> : null} |
| 114 | {item.assignees.map((name) => ( |
| 115 | <Chip key={name} label={`@${name}`} /> |
| 116 | ))} |
| 117 | </View> |
| 118 | ) : null} |
| 119 | </View> |
| 120 | |
| 121 | <Body text={item.body} refs={refs} /> |
| 122 | |
| 123 | <View style={{ marginHorizontal: -space.lg }}> |
| 124 | <SectionHeader title="Repositories" /> |
| 125 | {repos.length === 0 ? ( |
| 126 | <Text tone="muted" style={{ paddingHorizontal: space.lg }}> |
| 127 | Not linked to a repository yet. Link the ones where the work is meant to land. |
| 128 | </Text> |
| 129 | ) : ( |
| 130 | repos.map((repo) => ( |
| 131 | <View key={repo.repo_id}> |
| 132 | <Row |
| 133 | leading={<FolderGit2 size={20} color={colors.muted} />} |
| 134 | title={repoName(repo.path, workspace)} |
| 135 | subtitle={`${roleLabel(repo.role)}${repo.legacy_number ? ` · was #${repo.legacy_number} there` : ""}`} |
| 136 | trailing={repo.landed ? <Badge label="Landed" tone="merged" /> : repo.role === "target" ? <Badge label="Not landed" tone="warn" /> : null} |
| 137 | // A deleted repository has nothing to open or unlink. |
| 138 | onPress={repo.path ? () => setPicked(repo) : undefined} |
| 139 | /> |
| 140 | {repo.pulls.map((pull) => ( |
| 141 | <View key={`${pull.repo}#${pull.number}`} style={{ paddingLeft: space.xl }}> |
| 142 | <Row |
| 143 | leading={<PullIcon pull={pull} />} |
| 144 | title={pull.title} |
| 145 | subtitle={`#${pull.number} · ${pull.status}`} |
| 146 | trailing={<Checks status={pull.check_status} />} |
| 147 | onPress={() => openPull(pull)} |
| 148 | /> |
| 149 | </View> |
| 150 | ))} |
| 151 | </View> |
| 152 | )) |
| 153 | )} |
| 154 | {missing.length && item.state === "open" ? ( |
| 155 | <Text size="small" tone="muted" style={{ paddingHorizontal: space.lg, paddingTop: space.sm }}> |
| 156 | Closes once it lands in {missing.map((path) => repoName(path, workspace)).join(", ")}. |
| 157 | </Text> |
| 158 | ) : null} |
| 159 | <View style={{ paddingHorizontal: space.lg, paddingTop: space.md }}> |
| 160 | <Button title="Link a repository" variant="secondary" icon={Link2} onPress={() => setLinking(true)} /> |
| 161 | </View> |
| 162 | </View> |
| 163 | |
| 164 | {suggested.length ? ( |
| 165 | <View style={{ marginHorizontal: -space.lg }}> |
| 166 | <SectionHeader title="Suggested" /> |
| 167 | {suggested.map((one) => ( |
| 168 | <Row |
| 169 | key={one.repo_id} |
| 170 | leading={<FolderGit2 size={20} color={colors.faint} />} |
| 171 | title={repoName(one.path, workspace)} |
| 172 | subtitle={one.why} |
| 173 | chevron={false} |
| 174 | trailing={ |
| 175 | <Button |
| 176 | title="Add as target" |
| 177 | variant="secondary" |
| 178 | icon={Plus} |
| 179 | busy={actions.link.isPending && actions.link.variables?.repo === one.path} |
| 180 | onPress={() => link(one.path)} |
| 181 | /> |
| 182 | } |
| 183 | /> |
| 184 | ))} |
| 185 | </View> |
| 186 | ) : null} |
| 187 | |
| 188 | <Timeline comments={comments} refs={refs} /> |
| 189 | |
| 190 | {item.state === "open" ? ( |
| 191 | <Button title="Close" variant="secondary" icon={CircleCheck} busy={busy} onPress={() => setMore(true)} /> |
| 192 | ) : ( |
| 193 | <Button title="Reopen" variant="secondary" icon={CircleDot} busy={busy} onPress={reopen} /> |
| 194 | )} |
| 195 | </ScrollView> |
| 196 | <CommentBar |
| 197 | busy={actions.comment.isPending} |
| 198 | onMore={() => setMore(true)} |
| 199 | onSend={(body) => actions.comment.mutateAsync(body).catch((error: unknown) => (failed("Could not comment")(error), Promise.reject(error)))} |
| 200 | /> |
| 201 | </KeyboardAvoidingView> |
| 202 | |
| 203 | <Sheet visible={more} onClose={() => setMore(false)}> |
| 204 | {item.state === "open" ? ( |
| 205 | <> |
| 206 | <SheetAction icon={CircleCheck} label="Close as done" onPress={() => close("completed")} /> |
| 207 | <SheetAction icon={CircleX} label="Close as not planned" onPress={() => close("not_planned")} /> |
| 208 | </> |
| 209 | ) : ( |
| 210 | <SheetAction icon={CircleDot} label="Reopen" onPress={reopen} /> |
| 211 | )} |
| 212 | <SheetAction |
| 213 | icon={Link2} |
| 214 | label="Link a repository" |
| 215 | onPress={() => { |
| 216 | setMore(false); |
| 217 | setLinking(true); |
| 218 | }} |
| 219 | /> |
| 220 | <SheetAction |
| 221 | icon={Copy} |
| 222 | label="Copy link" |
| 223 | onPress={() => { |
| 224 | setMore(false); |
| 225 | void Clipboard.setStringAsync(siteUrl(server, sitePath)); |
| 226 | }} |
| 227 | /> |
| 228 | <SheetAction |
| 229 | icon={ExternalLink} |
| 230 | label="Open on g1t" |
| 231 | onPress={() => { |
| 232 | setMore(false); |
| 233 | void WebBrowser.openBrowserAsync(siteUrl(server, sitePath)); |
| 234 | }} |
| 235 | /> |
| 236 | </Sheet> |
| 237 | |
| 238 | <Sheet visible={picked !== null} onClose={() => setPicked(null)}> |
| 239 | {picked ? ( |
| 240 | <> |
| 241 | <Text size="heading" style={{ paddingHorizontal: space.lg }}> |
| 242 | {picked.path} |
| 243 | </Text> |
| 244 | <SheetAction |
| 245 | icon={FolderGit2} |
| 246 | label="Open the repository" |
| 247 | onPress={() => { |
| 248 | setPicked(null); |
| 249 | openRepo(picked.path); |
| 250 | }} |
| 251 | /> |
| 252 | {picked.role !== "origin" ? ( |
| 253 | <SheetAction |
| 254 | icon={Unlink} |
| 255 | label="Unlink it" |
| 256 | danger |
| 257 | onPress={() => { |
| 258 | setPicked(null); |
| 259 | actions.unlink.mutate(picked.path, { onError: failed("Could not unlink it") }); |
| 260 | }} |
| 261 | /> |
| 262 | ) : null} |
| 263 | </> |
| 264 | ) : null} |
| 265 | </Sheet> |
| 266 | |
| 267 | <LinkSheet visible={linking} linked={linked} onClose={() => setLinking(false)} onPick={link} /> |
| 268 | </Screen> |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | /** Picks a repository of the workspace to link as a target. */ |
| 273 | function LinkSheet({ visible, linked, onClose, onPick }: { visible: boolean; linked: Set<string>; onClose: () => void; onPick: (path: string) => void }) { |
| 274 | const colors = useColors(); |
| 275 | const { height } = useWindowDimensions(); |
| 276 | const [search, setSearch] = useState(""); |
| 277 | const repos = useRepos(visible ? search.trim() : ""); |
| 278 | const choices = (repos.data ?? []).filter((repo) => !linked.has(`${repo.namespace}/${repo.name}`.toLowerCase())); |
| 279 | return ( |
| 280 | <Sheet visible={visible} onClose={onClose}> |
| 281 | <View style={{ paddingHorizontal: space.lg, gap: space.sm }}> |
| 282 | <Text size="heading">Link a repository</Text> |
| 283 | <Text size="small" tone="muted"> |
| 284 | As a target: where the work is meant to land. It closes once it has landed in every target. |
| 285 | </Text> |
| 286 | <View style={[styles.search, { borderColor: colors.lineStrong, backgroundColor: colors.bg }]}> |
| 287 | <Search size={16} color={colors.faint} /> |
| 288 | <TextInput |
| 289 | value={search} |
| 290 | onChangeText={setSearch} |
| 291 | placeholder="Find a repository" |
| 292 | placeholderTextColor={colors.faint} |
| 293 | autoCapitalize="none" |
| 294 | autoCorrect={false} |
| 295 | style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: space.sm }} |
| 296 | /> |
| 297 | </View> |
| 298 | </View> |
| 299 | <ScrollView style={{ maxHeight: height * 0.5 }} keyboardShouldPersistTaps="handled"> |
| 300 | {repos.isPending ? ( |
| 301 | <Loading /> |
| 302 | ) : choices.length === 0 ? ( |
| 303 | <Text tone="muted" style={{ padding: space.lg }}> |
| 304 | {search ? "Nothing matches." : "Every repository you can see is linked already."} |
| 305 | </Text> |
| 306 | ) : ( |
| 307 | choices.map((repo) => ( |
| 308 | <Row |
| 309 | key={repo.id} |
| 310 | leading={<FolderGit2 size={20} color={colors.muted} />} |
| 311 | title={repo.name} |
| 312 | subtitle={`${repo.namespace}/${repo.name}`} |
| 313 | chevron={false} |
| 314 | trailing={<Plus size={18} color={colors.accent} />} |
| 315 | onPress={() => onPick(`${repo.namespace}/${repo.name}`)} |
| 316 | /> |
| 317 | )) |
| 318 | )} |
| 319 | </ScrollView> |
| 320 | </Sheet> |
| 321 | ); |
| 322 | } |
| 323 | |
| 324 | const styles = StyleSheet.create({ |
| 325 | content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl }, |
| 326 | wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }, |
| 327 | search: { flexDirection: "row", alignItems: "center", gap: space.sm, borderWidth: 1, borderRadius: radius.md, paddingHorizontal: space.md }, |
| 328 | }); |