import { useAtomValue } from "@effect/atom-react"; import { createLinkedPullRequestSummaryAtomFamily, createPullRequestEnvironmentAtoms, createPullRequestStackAtomFamily, } from "@t3tools/client-runtime/state/pull-requests"; import type { EnvironmentId, PullRequestListInput, PullRequestListStatsInput, PullRequestRef, PullRequestSummary, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useLayoutEffect, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { mergePullRequestLists, type EnvironmentPullRequestStat, type MergedPullRequestList, } from "../components/pullRequest/pullRequestList.logic"; import { formatEnvironmentQueryError } from "./query"; export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily( connectionAtomRuntime, pullRequestEnvironment.refreshes, ); const observedPullRequestSummaryAtom = Atom.family((key: string) => Atom.make(null).pipe( Atom.setIdleTTL(5 * 60_000), Atom.withLabel(`web-pull-requests:observed-summary:${key}`), ), ); export function newestPullRequestSummary( current: PullRequestSummary | null, observed: PullRequestSummary | null, ): PullRequestSummary | null { if (current === null) return observed; if (observed === null) return current; if (current.state === "merged") return current; if (observed.state === "merged") return observed; return Date.parse(observed.updatedAt) >= Date.parse(current.updatedAt) ? observed : current; } export function useSharedPullRequestSummary( environmentId: EnvironmentId | null, reference: PullRequestRef | null, current: PullRequestSummary | null, ): PullRequestSummary | null { const key = environmentId === null || reference === null ? "none" : JSON.stringify([ environmentId, reference.projectId, reference.host?.toLowerCase() ?? null, reference.repository.toLowerCase(), reference.number, ]); const atom = observedPullRequestSummaryAtom(key); const observed = useAtomValue(atom); useLayoutEffect(() => { if (environmentId === null || current === null) return; appAtomRegistry.modify(atom, (previous) => { const next = newestPullRequestSummary(previous, current); return next === previous ? [false, previous] : [true, next]; }); }, [atom, current, environmentId]); return newestPullRequestSummary(current, observed); } export const pullRequestStackAtom = createPullRequestStackAtomFamily( connectionAtomRuntime, pullRequestEnvironment.refreshes, ); export interface EnvironmentQueryTarget { readonly environmentId: EnvironmentId; readonly input: Input; } interface MergedEnvironmentQueryView { /** One entry per query target that has answered, in the order the targets were given. */ readonly values: ReadonlyArray; /** The first environment that failed. Others may still have answered — this is not fatal. */ readonly error: string | null; readonly isPending: boolean; } /** * The same per-environment query read across several environments at once. React cannot subscribe * to a list of atoms whose length changes, so the fan-out happens inside one derived atom keyed by * the targets — the same shape the cross-environment thread search uses. * * An environment that fails contributes nothing rather than blanking the page: the pull request * list is a union, and one unreachable machine should not hide the others' rows. */ function createMergedEnvironmentQuery( label: string, atomFor: ( target: EnvironmentQueryTarget, ) => Atom.Atom>, ) { const family = Atom.family((key: string) => Atom.make((get): MergedEnvironmentQueryView => { const targets = JSON.parse(key) as ReadonlyArray>; const values: Array = []; let error: string | null = null; let isPending = false; for (const target of targets) { const result = get(atomFor(target)); isPending ||= result.waiting; if (result._tag === "Failure" && error === null) { error = formatEnvironmentQueryError(result.cause); } const value = Option.getOrNull(AsyncResult.value(result)); if (value !== null) values.push([target.environmentId, value]); } return { values, error, isPending }; }).pipe(Atom.withLabel(`${label}:${key}`)), ); const empty = Atom.make>({ values: [], error: null, isPending: false, }).pipe(Atom.withLabel(`${label}:empty`)); return function useMergedQuery(targets: ReadonlyArray>) { const key = JSON.stringify(targets); const view = useAtomValue(targets.length === 0 ? empty : family(key)); const refresh = useCallback( (override?: ReadonlyArray>) => { const refreshTargets = override ?? (JSON.parse(key) as ReadonlyArray>); for (const atom of new Set(refreshTargets.map(atomFor))) { appAtomRegistry.refresh(atom); } }, [key], ); return { ...view, refresh }; }; } const usePullRequestListsQuery = createMergedEnvironmentQuery( "web-pull-requests:list", pullRequestEnvironment.list, ); const usePullRequestStatsQuery = createMergedEnvironmentQuery( "web-pull-requests:list-stats", pullRequestEnvironment.listStats, ); const usePullRequestTurnRefreshQuery = createMergedEnvironmentQuery( "web-pull-requests:turn-refreshes", ({ environmentId }: EnvironmentQueryTarget>>) => pullRequestEnvironment.refreshes({ environmentId, input: {} }), ); export function usePullRequestTurnRefreshes( environmentIds: ReadonlyArray, ): ReadonlyArray { return usePullRequestTurnRefreshQuery( environmentIds.map((environmentId) => ({ environmentId, input: {} })), ).values; } export function usePullRequestTurnRefresh(environmentId: EnvironmentId): number | null { const result = useAtomValue(pullRequestEnvironment.refreshes({ environmentId, input: {} })); return Option.getOrNull(AsyncResult.value(result)); } export interface MergedPullRequestListView { readonly data: MergedPullRequestList | null; readonly error: string | null; readonly isPending: boolean; readonly refresh: (targets?: ReadonlyArray>) => void; } /** One listing per environment, merged into the single list the page renders. */ export function usePullRequestList( targets: ReadonlyArray>, ): MergedPullRequestListView { const query = usePullRequestListsQuery(targets); const data = useMemo(() => mergePullRequestLists(query.values), [query.values]); return { data, error: query.error, isPending: query.isPending, refresh: query.refresh }; } /** The line counts for the rows on screen, asked of each environment for its own rows. */ export function usePullRequestListStats( targets: ReadonlyArray>, ): { readonly stats: ReadonlyArray | null; readonly isPending: boolean; readonly refresh: ( targets?: ReadonlyArray>, ) => void; } { const query = usePullRequestStatsQuery(targets); const stats = useMemo( () => query.values.length === 0 ? null : query.values.flatMap(([environmentId, result]) => result.stats.map((stat) => ({ ...stat, environmentId })), ), [query.values], ); return { stats, isPending: query.isPending, refresh: query.refresh }; }