1import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients";
2import {
3 EnvironmentId,
4 ProjectId,
5 type PullRequestListEntry,
6 type PullRequestSummary,
7} from "@t3tools/contracts";
8import { useAtomRefresh } from "@effect/atom-react";
9import { useLayoutEffect, useMemo, useState } from "react";
10
11import { composerPullRequests } from "../../state/pull-requests";
12import { useEnvironmentQuery } from "../../state/query";
13import { usePullToRefresh } from "../../state/use-pull-to-refresh";
14import {
15 getCachedPullRequestDiffPages,
16 retainPullRequestDiffPage,
17 type PullRequestDiffPage,
18} from "./pullRequestDiffPages";
19
20export type PullRequestParams = {
21 readonly environmentId: string;
22 readonly projectId: string;
23 readonly repository: string;
24 readonly number: number;
25 readonly host?: string;
26 readonly navigationUrl?: string;
27 readonly navigationId?: number;
28 readonly initialSummary?: PullRequestListEntry | PullRequestSummary;
29};
30
31export type PullRequestDiffParams = PullRequestParams & { readonly commit?: string };
32
33export function pullRequestTarget(params: PullRequestParams) {
34 return {
35 environmentId: EnvironmentId.make(params.environmentId),
36 input: {
37 projectId: ProjectId.make(params.projectId),
38 repository: params.repository,
39 number: params.number,
40 ...(params.host ? { host: params.host } : {}),
41 },
42 };
43}
44
45const NO_PAGES: readonly PullRequestDiffPage[] = [];
46
47export function usePullRequestDiffEntry(navigation: {
48 readonly addListener: (
49 type: "transitionStart" | "transitionEnd",
50 listener: (event: { readonly data: { readonly closing: boolean } }) => void,
51 ) => () => void;
52}) {
53 const [ready, setReady] = useState(false);
54 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to navigator transition events and frames to defer the diff until the opening transition ends
55 useLayoutEffect(() => {
56 if (ready) return;
57 let opening = false;
58 let closing = false;
59 const finish = () => {
60 if (!closing) setReady(true);
61 };
62 const removeStart = navigation.addListener("transitionStart", ({ data }) => {
63 closing = data.closing;
64 if (!closing) opening = true;
65 });
66 const removeEnd = navigation.addListener("transitionEnd", ({ data }) => {
67 closing = data.closing;
68 if (!closing) finish();
69 });
70 // Initial and unanimated presentations may have no transition events; let the shell paint first.
71 let frame = requestAnimationFrame(() => {
72 frame = requestAnimationFrame(() => {
73 if (!opening) finish();
74 });
75 });
76 // A missing native completion must not strand the route on its loading state.
77 const fallback = setTimeout(finish, 1_000);
78 return () => {
79 removeStart();
80 removeEnd();
81 cancelAnimationFrame(frame);
82 clearTimeout(fallback);
83 };
84 }, [navigation, ready]);
85 return ready;
86}
87
88export function usePullRequestDiff(
89 params: PullRequestDiffParams,
90 terminology: ChangeRequestTerminology,
91) {
92 const reference = pullRequestTarget(params);
93 const target = {
94 ...reference,
95 input: { ...reference.input, ...(params.commit ? { commit: params.commit } : {}) },
96 };
97 const key = JSON.stringify(target);
98 const firstPageAtom = composerPullRequests.diff(target);
99 const refreshFirstPage = useAtomRefresh(firstPageAtom);
100 const { refreshing, onRefresh } = usePullToRefresh(firstPageAtom);
101 const [state, setState] = useState<{
102 key: string;
103 cursor: string | null;
104 pages: readonly PullRequestDiffPage[];
105 }>({ key, cursor: null, pages: NO_PAGES });
106 const cursor = state.key === key ? state.cursor : null;
107 const query = useEnvironmentQuery(
108 composerPullRequests.diff({
109 ...target,
110 input: { ...target.input, ...(cursor ? { cursor } : {}) },
111 }),
112 );
113 const pages = useMemo(() => {
114 const loaded = state.key === key ? state.pages : NO_PAGES;
115 return query.data ? retainPullRequestDiffPage(loaded, cursor, query.data) : loaded;
116 }, [cursor, key, query.data, state.key, state.pages]);
117 if (state.key !== key || state.pages !== pages) setState({ key, cursor, pages });
118
119 const parsed = useMemo(
120 () => getCachedPullRequestDiffPages(pages, key, terminology),
121 [key, pages, terminology],
122 );
123 const patch = useMemo(() => pages.map((page) => page.result.patch).join("\n"), [pages]);
124 const nextCursor = pages.at(-1)?.result.nextCursor ?? null;
125 const canLoadMore =
126 nextCursor !== null && nextCursor !== cursor && !query.isPending && query.error === null;
127 const loadMore = () => {
128 if (!canLoadMore) return;
129 setState({ key, cursor: nextCursor, pages });
130 };
131 const { error, refresh: refreshPage } = query;
132 const refresh = () => {
133 if (cursor !== null && error !== null) {
134 refreshPage();
135 return;
136 }
137 setState({ key, cursor: null, pages: NO_PAGES });
138 refreshFirstPage();
139 };
140 const onPullRefresh = () => {
141 setState({ key, cursor: null, pages: NO_PAGES });
142 onRefresh();
143 };
144 return {
145 ...query,
146 refresh,
147 parsed,
148 patch,
149 nextCursor,
150 canLoadMore,
151 loadMore,
152 refreshing,
153 onPullRefresh,
154 };
155}