Skip to content
Source
Sign inSign up

PullRequestsScreen.tsx

399 lines16.2 KB

1import { usePaneRole } from "../../components/ui/Pane";
2import { Button } from "../../components/ui/Button";
3import {
4 applyPullRequestDraftFilterChange,
5 parsePullRequestQuery,
6} from "@t3tools/client-runtime/pull-request-query";
7import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
8import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
9import { useNavigation } from "@react-navigation/native";
10import { use, useMemo, useRef, useState, type ReactNode } from "react";
11import { useAtomValue } from "@effect/atom-react";
12import { Keyboard, Platform, Pressable, View } from "react-native";
13import { RefreshControl } from "../../components/ui/RefreshControl";
14import { NativeScrollView } from "../../native/NativeScrollViewMarker";
15import { useSafeAreaInsets } from "react-native-safe-area-context";
16​
17import { AppText as Text } from "../../components/ui/AppText";
18import { useContentTopInset } from "../../components/ui/useBarInset";
19import { SymbolView } from "../../components/ui/AppSymbol";
20import { EmptyState } from "../../components/ui/EmptyState";
21import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
22import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
23import { useWorkspaceState } from "../../state/workspace";
24import { useProjects } from "../../state/entities";
25import { environmentServerConfigsAtom } from "../../state/server";
26import { PullRequestListRow } from "./PullRequestListRow";
27import {
28 PullRequestListFilters,
29 usePullRequestListFilters,
30 type PullRequestFilterMenu,
31} from "./PullRequestListFilters";
32import { usePullRequestList } from "./usePullRequestList";
33import { usePullRequestListControls } from "./usePullRequestListControls";
34import { usePullRequestListActions } from "./usePullRequestListActions";
35import {
36 filterPullRequestList,
37 pullRequestListFacets,
38 pullRequestListInput,
39 pullRequestListRowsAreEqual,
40 scopedPullRequestListKey as pullRequestKey,
41 type EnvironmentPullRequestListEntry,
42 withoutScratchProjectScope,
43 pullRequestProjectChoices,
44} from "./pullRequestList";
45import { useDebouncedValue } from "../../state/queries";
46import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext";
47import { useSearchScrollRestoration } from "../layout/workspace/useSearchScrollRestoration";
48import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail";
49​
50function PullRequestListError(props: { readonly message: string }) {
51 const [expanded, setExpanded] = useState(false);
52 return (
53 <Pressable
54 accessibilityRole="button"
55 accessibilityLabel={props.message}
56 accessibilityState={{ expanded }}
57 onPress={() => setExpanded((value) => !value)}
58 // A long press selects the text; collapsing under that selection crashes Android's
59 // smart-select animation, so it must not also count as a press.
60 onLongPress={() => undefined}
61 className="min-h-11 flex-row items-center gap-3 px-4 py-2.5 active:opacity-70"
62 >
63 <SymbolView
64 name="exclamationmark.triangle"
65 size={18}
66 tintColorClassName="accent-icon-muted"
67 />
68 <Text
69 selectable={expanded}
70 numberOfLines={expanded ? undefined : 1}
71 ellipsizeMode="tail"
72 className="min-w-0 flex-1 text-sm text-foreground-muted"
73 >
74 {props.message}
75 </Text>
76 <SymbolView
77 name={expanded ? "chevron.up" : "chevron.down"}
78 size={11}
79 tintColorClassName="accent-icon-muted"
80 />
81 </Pressable>
82 );
84​
85export function PullRequestsScreen(
86 props: {
87 readonly sidebar?: boolean;
88 readonly selectedPullRequestKey?: string | null;
89 /**
90 * Places the list under a header that opens the full filter menu (the Android toolbar). The
91 * filters live here, so the header gets them in the same render as the list.
92 */
93 readonly renderFrame?: (filters: PullRequestFilterMenu | null, list: ReactNode) => ReactNode;
94 } = {},
95) {
96 const navigation = useNavigation();
97 const primaryColumn = use(NativePrimaryColumnContext);
98 const workspace = useWorkspaceTabs()!;
99 const { environments } = useWorkspaceState();
100 const configs = useAtomValue(environmentServerConfigsAtom);
101 const insets = useSafeAreaInsets();
102 const contentTopInset = useContentTopInset();
103 // UIKit insets the list by its column's safe area, which this context can report as 0.
104 const columnSafeBottom = useNativeColumnLayoutMetrics()?.safeArea.bottom;
105 const bottomSafeArea =
106 Platform.OS === "ios" ? (columnSafeBottom ?? insets.bottom) : insets.bottom;
107 const listRef = useRef<LegendListRef>(null);
108 const tab = workspace.state.tabs.pullRequests;
109 const [initialScrollOffset] = useState(() => workspace.getScrollOffset("pullRequests"));
110 useSearchScrollRestoration(tab.searchMode, () => {
111 void listRef.current?.scrollToOffset({
112 offset: tab.scrollOffset - contentTopInset,
113 animated: false,
114 });
115 });
116 const maintainVisiblePosition = {
117 data: tab.searchMode === "resting",
118 shouldRestorePosition: () => workspace.getScrollOffset("pullRequests") > 0,
119 };
120 const debouncedQuery = useDebouncedValue(tab.query.trim(), 180);
121 const query = tab.searchMode === "resting" ? "" : debouncedQuery;
122 const typedQuery = tab.searchMode === "resting" ? "" : tab.query;
123 const typedDraft = parsePullRequestQuery(typedQuery).filters.draft;
124 const listControls = usePullRequestListControls();
125 const selectedEnvironmentId = listControls.environmentId;
126 const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
127 ? selectedEnvironmentId
128 : null;
129 const selectDetail = useSelectWorkspaceDetail();
130 const selectPullRequest = (item: EnvironmentPullRequestListEntry) => {
131 Keyboard.dismiss();
132 const params = {
133 environmentId: String(item.environmentId),
134 projectId: String(item.projectId),
135 repository: item.repository,
136 number: item.number,
137 host: item.host,
138 initialSummary: item,
139 };
140 selectDetail({ name: "PullRequest", params });
141 };
142 const allProjects = useProjects();
143 const savedControls = listControls.controlsFor(environmentId);
144 const controls = useMemo(
145 () => withoutScratchProjectScope(savedControls, allProjects),
146 [allProjects, savedControls],
147 );
148 const targets = useMemo(
149 () =>
150 environments
151 .filter(
152 (environment) =>
153 configs.get(environment.environmentId) !== undefined &&
154 (environmentId === null || environment.environmentId === environmentId) &&
155 (!controls.projectEnvironmentId ||
156 environment.environmentId === controls.projectEnvironmentId),
157 )
158 .sort((left, right) => left.environmentId.localeCompare(right.environmentId))
159 .map((environment) => ({
160 environmentId: environment.environmentId,
161 input: pullRequestListInput(controls, query),
162 })),
163 [environments, environmentId, configs, controls, query],
164 );
165 const result = usePullRequestList(targets);
166 const connectedTargets = targets.filter((target) =>
167 environments.some(
168 (environment) =>
169 environment.environmentId === target.environmentId &&
170 environment.connectionState === "connected",
171 ),
172 );
173 const { actions, select, busyKey } = usePullRequestListActions(
174 connectedTargets.map((target) => target.environmentId),
175 result.updateEntry,
176 );
177 const paneRole = usePaneRole();
178 const sidebar = props.sidebar === true || (primaryColumn !== null && paneRole === "sidebar");
179 const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey;
180 const connectedKey = connectedTargets.map((target) => target.environmentId).join("\n");
181 const entries = useMemo(
182 () =>
183 filterPullRequestList(
184 result.entries,
185 controls,
186 result.data?.viewers ?? {},
187 query,
188 result.data.localSearchHosts,
189 new Set(connectedKey.split("\n")),
190 ),
191 [result.entries, controls, query, result.data, connectedKey],
192 );
193 const facets = pullRequestListFacets(result.entries);
194 const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets });
195 if (knownFacets.environmentId !== environmentId || knownFacets.source !== facets) {
196 setKnownFacets((previous) => ({
197 source: facets,
198 environmentId,
199 authors: [
200 ...new Set([
201 ...(previous.environmentId === environmentId ? previous.authors : []),
202 ...facets.authors,
203 ]),
204 ].sort(),
205 labels: [
206 ...new Set([
207 ...(previous.environmentId === environmentId ? previous.labels : []),
208 ...facets.labels,
209 ]),
210 ].sort(),
211 labelColors: {
212 ...facets.labelColors,
213 ...(previous.environmentId === environmentId ? previous.labelColors : {}),
214 },
215 }));
216 }
217 const labelColors =
218 knownFacets.environmentId === environmentId ? knownFacets.labelColors : facets.labelColors;
219 const projects = pullRequestProjectChoices(allProjects, {
220 environmentId,
221 controls,
222 environmentLabels: new Map(
223 environments.map((item) => [item.environmentId, item.environmentLabel]),
224 ),
225 servesPullRequests: (id) => configs.get(id)?.environment.capabilities.pullRequests === true,
226 });
227 const filters = usePullRequestListFilters({
228 environments: environments.map((item) => ({
229 id: String(item.environmentId),
230 label: item.environmentLabel,
231 })),
232 environmentId,
233 onEnvironmentChange: listControls.setEnvironmentId,
234 value: typedDraft
235 ? { ...controls, filters: { ...controls.filters, draft: typedDraft } }
236 : controls,
237 onChange: (next) => {
238 const update = applyPullRequestDraftFilterChange(typedQuery, controls.filters, next.filters);
239 listControls.setControls(environmentId, { ...next, filters: update.filters });
240 if (update.query !== typedQuery)
241 workspace.dispatch({ type: "query", tab: "pullRequests", query: update.query });
242 void listRef.current?.scrollToOffset({
243 offset: -contentTopInset,
244 animated: false,
245 });
246 },
247 authors: knownFacets.environmentId === environmentId ? knownFacets.authors : facets.authors,
248 labels: knownFacets.environmentId === environmentId ? knownFacets.labels : facets.labels,
249 labelColors,
250 projects,
251 onSearch: (kind) =>
252 navigation.navigate("PullRequestFilterSearch", {
253 kind,
254 environmentId,
255 environmentIds: targets.map((target) => String(target.environmentId)),
256 suggestions: knownFacets.environmentId === environmentId ? knownFacets[kind] : facets[kind],
257 labelColors,
258 }),
259 });
260 const renderPullRequest = ({ item }: { readonly item: EnvironmentPullRequestListEntry }) => (
261 <PullRequestListRow
262 item={item}
263 onSelect={selectPullRequest}
264 getActions={actions}
265 onAction={select}
266 busy={busyKey === pullRequestKey(item)}
267 selected={selectedKey === pullRequestKey(item)}
268 sidebar={sidebar}
269 />
270 );
271 const list = (
272 <View className={sidebar ? "flex-1 bg-drawer" : "flex-1 bg-screen"}>
273 <LegendList
274 ref={listRef}
275 data={entries}
276 extraData={`${selectedKey}:${busyKey}`}
277 keyExtractor={pullRequestKey}
278 itemsAreEqual={pullRequestListRowsAreEqual}
279 estimatedItemSize={88}
280 drawDistance={1_000}
281 maintainVisibleContentPosition={result.continued ? maintainVisiblePosition : false}
282 onEndReached={() => void result.loadMore()}
283 onEndReachedThreshold={0.5}
284 onViewableItemsChanged={result.onViewableItemsChanged}
285 refreshControl={
286 <RefreshControl
287 refreshing={result.refreshing}
288 onRefresh={() => void result.onRefresh()}
289 // The list already accounts for the navigation header in its content inset.
290 progressViewOffset={Platform.OS === "ios" ? 60 : undefined}
291 />
292 }
293 alwaysBounceVertical
294 // Android flashes a list's scrollbar whenever it becomes visible, which was every return
295 // to this tab. The thread list shows none either.
296 showsVerticalScrollIndicator={Platform.OS !== "android"}
297 recycleItems
298 renderScrollComponent={Platform.OS === "ios" ? NativeScrollView : undefined}
299 contentInsetAdjustmentBehavior="never"
300 contentInset={Platform.OS === "ios" ? { top: contentTopInset } : undefined}
301 // A plain offset: LegendList adds the list header to an index-based restore, which
302 // landed every return one header lower.
303 initialScrollOffset={
304 initialScrollOffset > 0 ? initialScrollOffset - contentTopInset : undefined
305 }
306 contentContainerStyle={{
307 paddingBottom: Math.max(bottomSafeArea, workspace.bottomClearance),
308 }}
309 onScroll={(event) => {
310 workspace.recordScrollOffset(
311 "pullRequests",
312 Math.max(0, event.nativeEvent.contentOffset.y + contentTopInset),
313 );
314 }}
315 keyboardDismissMode="on-drag"
316 keyboardShouldPersistTaps="handled"
317 renderItem={renderPullRequest}
318 // Lifted above the rows so an iOS filter menu's dismissal morph is not covered.
319 ListHeaderComponentStyle={{ zIndex: 1 }}
320 ListHeaderComponent={
321 <>
322 <PullRequestListFilters sidebar={sidebar} filters={filters} />
323 {result.data?.errors.length ? (
324 <View key={JSON.stringify(targets)}>
325 {result.data.errors.map((error) => (
326 <PullRequestListError
327 key={`${error.environmentId} ${error.projectTitle} ${error.message}`}
328 message={`${error.projectTitle || environments.find((environment) => environment.environmentId === error.environmentId)?.environmentLabel || "Environment"}: ${error.message}`}
329 />
330 ))}
331 </View>
332 ) : null}
333 </>
334 }
335 ListFooterComponent={
336 result.loadingMore ? (
337 <View className="items-center py-5">
338 <AppActivityIndicator
339 colorClassName="accent-icon"
340 accessibilityLabel="Loading more pull requests"
341 />
342 </View>
343 ) : result.loadMoreError ? (
344 <View className="items-center gap-2 px-8 py-4">
345 <Text className="text-xs text-foreground-muted">{result.loadMoreError}</Text>
346 <Button
347 label="Try again"
348 accessibilityLabel="Retry loading more pull requests"
349 onPress={() => void result.loadMore(true)}
350 />
351 </View>
352 ) : result.hasMore ? (
353 // A filtered page can be too short to scroll, which leaves nothing to reach the end of.
354 <View className="items-center py-4">
355 <Button
356 label="Load more"
357 accessibilityLabel="Load more pull requests"
358 onPress={() => void result.loadMore()}
359 />
360 </View>
361 ) : undefined
362 }
363 ListEmptyComponent={
364 result.isPending && connectedTargets.length > 0 ? (
365 <View className="items-center py-12">
366 <AppActivityIndicator colorClassName="accent-icon" />
367 </View>
368 ) : (
369 <View className="px-8 py-12">
370 <EmptyState
371 title={
372 result.error
373 ? "Couldn't load pull requests"
374 : targets.length && connectedTargets.length === 0
375 ? "Environment offline"
376 : targets.length
377 ? query
378 ? "No results"
379 : "No matching pull requests"
380 : "Connect an environment"
381 }
382 detail={
383 result.error ??
384 (targets.length && connectedTargets.length === 0
385 ? "Reconnect to load pull requests that aren't saved on this device."
386 : targets.length
387 ? "Pull requests from the selected environments' projects appear here."
388 : "Add an environment in Settings to browse its pull requests.")
389 }
390 variant="plain"
391 />
392 </View>
393 )
394 }
395 />
396 </View>
397 );
398 return props.renderFrame ? props.renderFrame(filters.headerMenu, list) : list;