| 1 | import { RegistryContext, useAtomValue } from "@effect/atom-react"; |
| 2 | import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 3 | import type { EnvironmentId, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts"; |
| 4 | import * as Cause from "effect/Cause"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 7 | import { use, useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect } from "react"; |
| 8 | import { composerPullRequests } from "../../state/pull-requests"; |
| 9 | import { |
| 10 | appendPullRequestListPage, |
| 11 | mergeEnvironmentPullRequestPages, |
| 12 | nextPullRequestListInput, |
| 13 | pullRequestListHead, |
| 14 | pullRequestListKey, |
| 15 | scopedPullRequestListKey, |
| 16 | type EnvironmentPullRequestListEntry, |
| 17 | type PullRequestListPages, |
| 18 | } from "./pullRequestList"; |
| 19 | |
| 20 | // Retain each environment's pages when moving between tabs or changing list scope. |
| 21 | const pagesFamily = Atom.family((_key: string) => |
| 22 | Atom.make<PullRequestListPages | null>(null).pipe(Atom.setIdleTTL(5 * 60_000)), |
| 23 | ); |
| 24 | const statsFamily = Atom.family((_environmentId: string) => |
| 25 | Atom.make<ReadonlyMap<string, PullRequestDiffStat>>(new Map()).pipe(Atom.setIdleTTL(5 * 60_000)), |
| 26 | ); |
| 27 | const statKey = (row: Pick<PullRequestDiffStat, "projectId" | "repository" | "number">) => |
| 28 | `${row.projectId}:${row.repository}:${row.number}`; |
| 29 | |
| 30 | type ListTarget = { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput }; |
| 31 | const sourcesFamily = Atom.family((key: string) => { |
| 32 | const targets: readonly ListTarget[] = JSON.parse(key); |
| 33 | return Atom.make((get) => |
| 34 | targets.map((target) => { |
| 35 | const result = get(composerPullRequests.list(target)); |
| 36 | const head = Option.getOrNull(AsyncResult.value(result)); |
| 37 | const cached = get(pagesFamily(JSON.stringify(target))); |
| 38 | return { |
| 39 | target, |
| 40 | result, |
| 41 | pages: head ? pullRequestListHead(cached, head) : cached, |
| 42 | stats: get(statsFamily(target.environmentId)), |
| 43 | }; |
| 44 | }), |
| 45 | ); |
| 46 | }); |
| 47 | |
| 48 | export function usePullRequestList(targets: readonly ListTarget[]) { |
| 49 | const registry = use(RegistryContext); |
| 50 | const scopeKey = JSON.stringify(targets); |
| 51 | const activeScope = useRef(scopeKey); |
| 52 | const sources = useAtomValue(sourcesFamily(scopeKey)); |
| 53 | const requests = useRef<{ page?: AbortController; refresh?: AbortController }>({}); |
| 54 | const [loading, setLoading] = useState({ |
| 55 | key: scopeKey, |
| 56 | page: false, |
| 57 | refresh: false, |
| 58 | error: null as string | null, |
| 59 | }); |
| 60 | const state = loading.key === scopeKey ? loading : { page: false, refresh: false, error: null }; |
| 61 | useLayoutEffect(() => { |
| 62 | activeScope.current = scopeKey; |
| 63 | return () => { |
| 64 | requests.current.page?.abort(); |
| 65 | requests.current.refresh?.abort(); |
| 66 | requests.current = {}; |
| 67 | }; |
| 68 | }, [scopeKey, activeScope]); |
| 69 | useEffect(() => { |
| 70 | for (const { target, pages } of sources) |
| 71 | if (pages) registry.set(pagesFamily(JSON.stringify(target)), pages); |
| 72 | }, [registry, sources]); |
| 73 | |
| 74 | const loadMore = useCallback( |
| 75 | async (retry = false) => { |
| 76 | if (requests.current.page || requests.current.refresh || (!retry && state.error)) return; |
| 77 | const continuations = targets.flatMap((target) => { |
| 78 | const pagesAtom = pagesFamily(JSON.stringify(target)); |
| 79 | const pages = registry.get(pagesAtom); |
| 80 | const input = pages && nextPullRequestListInput(pages, target.input); |
| 81 | return pages && input ? [{ target, pagesAtom, pages, input }] : []; |
| 82 | }); |
| 83 | if (!continuations.length) return; |
| 84 | const controller = new AbortController(); |
| 85 | requests.current.page = controller; |
| 86 | setLoading({ key: scopeKey, page: true, refresh: false, error: null }); |
| 87 | const responses = await Promise.allSettled( |
| 88 | continuations.map(async ({ target, pagesAtom, pages, input }) => { |
| 89 | const response = await executeAtomQuery( |
| 90 | registry, |
| 91 | composerPullRequests.list({ ...target, input }), |
| 92 | { |
| 93 | signal: controller.signal, |
| 94 | refresh: retry, |
| 95 | reportFailure: false, |
| 96 | }, |
| 97 | ); |
| 98 | if (controller.signal.aborted || activeScope.current !== scopeKey) return; |
| 99 | if (response._tag === "Failure") throw squashAtomCommandFailure(response); |
| 100 | if (response.value.errors.length) |
| 101 | throw new Error(response.value.errors.map((error) => error.message).join("\n")); |
| 102 | const next = appendPullRequestListPage( |
| 103 | registry.get(pagesAtom) ?? pages, |
| 104 | response.value, |
| 105 | input, |
| 106 | ); |
| 107 | registry.set(pagesAtom, next); |
| 108 | if ( |
| 109 | input.cursors && |
| 110 | Object.keys(next.nextCursors).length && |
| 111 | JSON.stringify(Object.entries(input.cursors).sort()) === |
| 112 | JSON.stringify(Object.entries(next.nextCursors).sort()) |
| 113 | ) |
| 114 | throw new Error("Couldn't advance the list. Try again."); |
| 115 | }), |
| 116 | ); |
| 117 | if (requests.current.page === controller) delete requests.current.page; |
| 118 | if (!controller.signal.aborted && activeScope.current === scopeKey) |
| 119 | setLoading({ key: scopeKey, page: false, refresh: false, error: requestErrors(responses) }); |
| 120 | }, |
| 121 | [targets, registry, scopeKey, state.error, activeScope], |
| 122 | ); |
| 123 | |
| 124 | const onRefresh = async () => { |
| 125 | if (!targets.length || requests.current.refresh) return; |
| 126 | requests.current.page?.abort(); |
| 127 | delete requests.current.page; |
| 128 | const controller = new AbortController(); |
| 129 | requests.current.refresh = controller; |
| 130 | setLoading({ key: scopeKey, page: false, refresh: true, error: null }); |
| 131 | const responses = await Promise.allSettled( |
| 132 | targets.map(async (target) => { |
| 133 | await composerPullRequests.invalidate.run(registry, { |
| 134 | environmentId: target.environmentId, |
| 135 | input: {}, |
| 136 | }); |
| 137 | if (controller.signal.aborted) return; |
| 138 | const response = await executeAtomQuery(registry, composerPullRequests.list(target), { |
| 139 | refresh: true, |
| 140 | signal: controller.signal, |
| 141 | reportFailure: false, |
| 142 | }); |
| 143 | if (controller.signal.aborted || activeScope.current !== scopeKey) return; |
| 144 | if (response._tag === "Failure") throw squashAtomCommandFailure(response); |
| 145 | registry.set( |
| 146 | pagesFamily(JSON.stringify(target)), |
| 147 | pullRequestListHead(null, response.value), |
| 148 | ); |
| 149 | }), |
| 150 | ); |
| 151 | if (requests.current.refresh === controller) delete requests.current.refresh; |
| 152 | if (!controller.signal.aborted && activeScope.current === scopeKey) |
| 153 | setLoading({ key: scopeKey, page: false, refresh: false, error: requestErrors(responses) }); |
| 154 | }; |
| 155 | |
| 156 | const data = useMemo( |
| 157 | () => |
| 158 | mergeEnvironmentPullRequestPages( |
| 159 | sources.flatMap(({ target, pages, stats }) => |
| 160 | pages |
| 161 | ? [ |
| 162 | { |
| 163 | environmentId: target.environmentId, |
| 164 | pages: { |
| 165 | ...pages, |
| 166 | entries: pages.entries.map((entry) => { |
| 167 | const stat = stats.get(statKey(entry)); |
| 168 | return stat |
| 169 | ? { ...entry, additions: stat.additions, deletions: stat.deletions } |
| 170 | : entry; |
| 171 | }), |
| 172 | }, |
| 173 | }, |
| 174 | ] |
| 175 | : [], |
| 176 | ), |
| 177 | ), |
| 178 | [sources], |
| 179 | ); |
| 180 | const [visible, setVisible] = useState<{ |
| 181 | key: string; |
| 182 | rows: readonly EnvironmentPullRequestListEntry[]; |
| 183 | }>({ key: scopeKey, rows: [] }); |
| 184 | const onViewableItemsChanged = useCallback( |
| 185 | ({ |
| 186 | viewableItems, |
| 187 | }: { |
| 188 | viewableItems: readonly { item: EnvironmentPullRequestListEntry; isViewable: boolean }[]; |
| 189 | }) => { |
| 190 | const rows = viewableItems.filter((token) => token.isViewable).map((token) => token.item); |
| 191 | setVisible((previous) => |
| 192 | previous.key === scopeKey && |
| 193 | previous.rows.map(scopedPullRequestListKey).join() === |
| 194 | rows.map(scopedPullRequestListKey).join() |
| 195 | ? previous |
| 196 | : { key: scopeKey, rows }, |
| 197 | ); |
| 198 | }, |
| 199 | [scopeKey], |
| 200 | ); |
| 201 | const visibleRows = |
| 202 | visible.key === scopeKey && visible.rows.length ? visible.rows : data.entries.slice(0, 8); |
| 203 | const statsKey = JSON.stringify( |
| 204 | targets.flatMap(({ environmentId }) => { |
| 205 | const refs = visibleRows |
| 206 | .filter( |
| 207 | (row) => |
| 208 | row.environmentId === environmentId && |
| 209 | row.provider === "github" && |
| 210 | row.additions + row.deletions === 0, |
| 211 | ) |
| 212 | .map(({ projectId, host, repository, number }) => ({ |
| 213 | projectId, |
| 214 | host, |
| 215 | repository, |
| 216 | number, |
| 217 | })); |
| 218 | return refs.length ? [{ environmentId, input: { refs } }] : []; |
| 219 | }), |
| 220 | ); |
| 221 | const statsAtom = useMemo(() => { |
| 222 | const queries: readonly Parameters<typeof composerPullRequests.listStats>[0][] = |
| 223 | JSON.parse(statsKey); |
| 224 | return Atom.make((get) => |
| 225 | queries.map((target) => ({ |
| 226 | environmentId: target.environmentId, |
| 227 | result: get(composerPullRequests.listStats(target)), |
| 228 | })), |
| 229 | ); |
| 230 | }, [statsKey]); |
| 231 | const counts = useAtomValue(statsAtom); |
| 232 | useEffect(() => { |
| 233 | for (const { environmentId, result } of counts) { |
| 234 | if (result._tag !== "Success" || result.waiting) continue; |
| 235 | registry.update(statsFamily(environmentId), (previous) => { |
| 236 | const next = new Map(previous); |
| 237 | for (const row of result.value.stats) next.set(statKey(row), row); |
| 238 | return next; |
| 239 | }); |
| 240 | } |
| 241 | }, [registry, counts]); |
| 242 | |
| 243 | const updateEntry = useCallback( |
| 244 | (entry: EnvironmentPullRequestListEntry, patch: Partial<EnvironmentPullRequestListEntry>) => { |
| 245 | const target = targets.find((target) => target.environmentId === entry.environmentId); |
| 246 | if (!target) return; |
| 247 | registry.update(pagesFamily(JSON.stringify(target)), (previous) => |
| 248 | previous |
| 249 | ? { |
| 250 | ...previous, |
| 251 | entries: previous.entries.map((row) => |
| 252 | pullRequestListKey(row) === pullRequestListKey(entry) |
| 253 | ? { ...row, ...patch, observedAt: Date.now() } |
| 254 | : row, |
| 255 | ), |
| 256 | } |
| 257 | : previous, |
| 258 | ); |
| 259 | registry.refresh(composerPullRequests.list(target)); |
| 260 | }, |
| 261 | [registry, targets], |
| 262 | ); |
| 263 | const errors = useMemo( |
| 264 | () => |
| 265 | sources.flatMap(({ target, result }) => { |
| 266 | if (result._tag !== "Failure") return []; |
| 267 | const cause = Cause.squash(result.cause); |
| 268 | return [ |
| 269 | { |
| 270 | environmentId: target.environmentId, |
| 271 | projectTitle: "", |
| 272 | message: cause instanceof Error ? cause.message : "Couldn't load pull requests.", |
| 273 | }, |
| 274 | ]; |
| 275 | }), |
| 276 | [sources], |
| 277 | ); |
| 278 | const listing = useMemo(() => ({ ...data, errors: [...data.errors, ...errors] }), [data, errors]); |
| 279 | return { |
| 280 | data: listing, |
| 281 | entries: data.entries, |
| 282 | error: errors.map((error) => error.message).join("\n") || null, |
| 283 | isPending: sources.some(({ result }) => result.waiting) && !data.entries.length, |
| 284 | continued: data.continued, |
| 285 | loadMore, |
| 286 | onRefresh, |
| 287 | onViewableItemsChanged, |
| 288 | updateEntry, |
| 289 | refreshing: state.refresh, |
| 290 | loadingMore: state.page, |
| 291 | loadMoreError: state.error, |
| 292 | hasMore: sources.some( |
| 293 | ({ pages, target }) => pages && nextPullRequestListInput(pages, target.input), |
| 294 | ), |
| 295 | }; |
| 296 | } |
| 297 | |
| 298 | function requestErrors(results: readonly PromiseSettledResult<unknown>[]) { |
| 299 | return ( |
| 300 | results |
| 301 | .flatMap((result) => |
| 302 | result.status === "rejected" |
| 303 | ? [ |
| 304 | result.reason instanceof Error |
| 305 | ? result.reason.message |
| 306 | : "Couldn't load pull requests.", |
| 307 | ] |
| 308 | : [], |
| 309 | ) |
| 310 | .join("\n") || null |
| 311 | ); |
| 312 | } |