1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 capitalizeTerm,
4 sourceControlClients,
5} from "@t3tools/client-runtime/source-control-clients";
6import {
7 pullRequestListLines,
8 resolveThreadPullRequestChains,
9 threadPullRequestKeyOf,
10 type PullRequestListLine,
11} from "@t3tools/client-runtime/thread-pull-requests";
12import { memo, useMemo } from "react";
13import { Pressable, useWindowDimensions, View } from "react-native";
14
15import { SymbolView } from "../../components/ui/AppSymbol";
16import { AppText as Text } from "../../components/ui/AppText";
17import * as Dialog from "../../components/ui/Dialog";
18import { Menu } from "../../components/ui/Menu";
19import { cn } from "../../lib/cn";
20import { RelativeTime } from "../../components/RelativeTime";
21import type { EnvironmentRuntimeState } from "../../state/remote-runtime-types";
22import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
23import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs";
24import type { LinkedPullRequestActions } from "./useLinkedPullRequestActions";
25import { pullRequestListReviewStatus } from "../pull-requests/pullRequestList";
26import {
27 PullRequestAvatar,
28 PullRequestConflictBadge,
29 PullRequestDiffStats,
30} from "../pull-requests/PullRequestPresentation";
31import {
32 pullRequestChecksStatus,
33 pullRequestConflict,
34 pullRequestStatus,
35} from "../pull-requests/pullRequestStatus";
36
37export function useLinkedPullRequestLines(
38 thread: EnvironmentThreadShell | null,
39 runtime: EnvironmentRuntimeState | null,
40) {
41 const supported = (runtime?.serverConfig ?? null) !== null;
42 const pullRequests = thread?.pullRequests;
43 return useMemo(() => {
44 const chains = resolveThreadPullRequestChains(supported ? (pullRequests ?? []) : []);
45 const open = chains.filter((chain) => chain.layers.some(isOpenLink));
46 const done = chains.filter((chain) => !chain.layers.some(isOpenLink));
47 return [...pullRequestListLines(open), ...pullRequestListLines(done)];
48 }, [pullRequests, supported]);
49}
50
51function isOpenLink(link: PullRequestListLine["link"]) {
52 return link.snapshot === null || link.snapshot.state === "open";
53}
54
55export function linkedPullRequestKey(line: PullRequestListLine) {
56 return threadPullRequestKeyOf(line.link);
57}
58
59export const LinkedPullRequestRow = memo(function LinkedPullRequestRow(props: {
60 readonly line: PullRequestListLine;
61 readonly separator: boolean;
62 readonly surfaceClassName: string;
63 readonly openPullRequest: (url: string) => Promise<boolean>;
64 readonly actions: LinkedPullRequestActions | null;
65}) {
66 const { link, depth, stack } = props.line;
67 const snapshot = link.snapshot;
68 const status = snapshot === null ? null : pullRequestStatus(snapshot);
69 const conflict = snapshot === null ? null : pullRequestConflict(snapshot);
70 const open = snapshot === null || snapshot.state === "open";
71 const checks =
72 open && snapshot?.checksState ? pullRequestChecksStatus(snapshot.checksState) : null;
73 const review =
74 open && snapshot?.reviewDecision
75 ? pullRequestListReviewStatus({
76 reviewDecision: snapshot.reviewDecision,
77 viewerReviewRequested: false,
78 })
79 : null;
80 const watching = open && link.watch !== undefined;
81 const additions = snapshot?.additions ?? 0;
82 const deletions = snapshot?.deletions ?? 0;
83 const { shortLabel, singular } = (
84 sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined)
85 ).changeRequest;
86 const metaLineHeight = useScaledTextRole("label").lineHeight * useWindowDimensions().fontScale;
87 const row = (
88 <Pressable
89 accessibilityRole="button"
90 accessibilityLabel={`${capitalizeTerm(singular)} ${link.number}, ${snapshot?.title ?? "waiting for host state"}${status ? `, ${status.label}` : ""}${conflict ? `, ${conflict.label.toLowerCase()}` : ""}, ${link.repository}${stack ? `, stack of ${stack.size}` : ""}${watching ? ", watching" : ""}${checks ? `, checks ${snapshot?.checksState}` : ""}${review ? `, ${review.accessibleLabel}` : ""}`}
91 accessibilityHint={props.actions ? "Touch and hold for actions." : undefined}
92 onPress={() => {
93 void props.openPullRequest(link.url).then((opened) => {
94 if (!opened)
95 Dialog.alert(`Unable to open ${shortLabel}`, `The ${singular} could not be opened.`);
96 });
97 }}
98 style={{ paddingLeft: 16 + Math.min(depth, 3) * 16 }}
99 className="flex-row gap-3 rounded-xl pr-4 active:bg-row-hover"
100 >
101 {depth > 0 ? <View className="-ml-2 w-px self-stretch bg-border" /> : null}
102 <View className="self-start pt-2.5">
103 <SymbolView
104 name={status?.icon ?? PullRequestGlyph.pullRequest}
105 size={20}
106 tintColorClassName={status?.tint ?? "accent-icon-muted"}
107 />
108 {conflict ? <PullRequestConflictBadge surfaceClassName={props.surfaceClassName} /> : null}
109 </View>
110 <View
111 className={cn(
112 "min-w-0 flex-1 gap-1 py-2.5",
113 props.separator && "border-b border-border-subtle",
114 )}
115 >
116 <View className="flex-row items-center gap-1.5">
117 {snapshot?.author ? <PullRequestAvatar actor={snapshot.author} size={16} /> : null}
118 <Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
119 {link.repository}
120 </Text>
121 <Text className="shrink-0 text-xs text-foreground-tertiary">#{link.number}</Text>
122 <View className="flex-1" />
123 {stack ? (
124 <View className="flex-row items-center gap-1">
125 <SymbolView
126 name={PullRequestGlyph.stack}
127 size={12}
128 tintColorClassName="accent-foreground-muted"
129 />
130 <Text className="text-2xs tabular-nums text-foreground-muted">{stack.size}</Text>
131 </View>
132 ) : null}
133 {watching ? (
134 <SymbolView name="eye" size={13} tintColorClassName="accent-foreground-muted" />
135 ) : null}
136 {checks ? (
137 <SymbolView name={checks.icon} size={13} tintColorClassName={checks.tint} />
138 ) : null}
139 {review ? (
140 <SymbolView name={review.icon} size={13} tintColorClassName={review.tint} />
141 ) : null}
142 {additions + deletions > 0 || snapshot?.updatedAt ? (
143 <View
144 className="-ml-1.5 flex-row flex-wrap items-center gap-x-1.5 overflow-hidden"
145 style={{ height: metaLineHeight, flexShrink: 100 }}
146 >
147 <View className="w-0" />
148 {additions + deletions > 0 ? (
149 <PullRequestDiffStats additions={additions} deletions={deletions} compact />
150 ) : null}
151 {snapshot?.updatedAt ? (
152 <Text className="text-2xs tabular-nums text-foreground-tertiary">
153 <RelativeTime timestamp={snapshot.updatedAt} />
154 </Text>
155 ) : null}
156 </View>
157 ) : null}
158 </View>
159 <Text
160 className="text-base leading-5 font-t3-medium text-foreground"
161 numberOfLines={2}
162 ellipsizeMode="tail"
163 >
164 {snapshot?.title ?? "Waiting for host state"}
165 </Text>
166 </View>
167 </Pressable>
168 );
169 const actions = props.actions;
170 if (actions === null) return row;
171 return (
172 <Menu
173 shouldOpenOnLongPress
174 title={`#${link.number} · ${link.repository}`}
175 actions={actions.menuActions(link)}
176 onPressAction={({ nativeEvent }) => actions.onMenuAction(link, nativeEvent.event)}
177 >
178 {row}
179 </Menu>
180 );
181});