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<PullRequestSummary | null>(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<Input> { readonly environmentId: EnvironmentId; readonly input: Input;} interface MergedEnvironmentQueryView<A> { /** One entry per query target that has answered, in the order the targets were given. */ readonly values: ReadonlyArray<readonly [EnvironmentId, A]>; /** 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<Input, A>( label: string, atomFor: ( target: EnvironmentQueryTarget<Input>, ) => Atom.Atom<AsyncResult.AsyncResult<A, unknown>>,) { const family = Atom.family((key: string) => Atom.make((get): MergedEnvironmentQueryView<A> => { const targets = JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>; const values: Array<readonly [EnvironmentId, A]> = []; 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<MergedEnvironmentQueryView<A>>({ values: [], error: null, isPending: false, }).pipe(Atom.withLabel(`${label}:empty`)); return function useMergedQuery(targets: ReadonlyArray<EnvironmentQueryTarget<Input>>) { const key = JSON.stringify(targets); const view = useAtomValue(targets.length === 0 ? empty : family(key)); const refresh = useCallback( (override?: ReadonlyArray<EnvironmentQueryTarget<Input>>) => { const refreshTargets = override ?? (JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>); 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<Readonly<Record<string, never>>>) => pullRequestEnvironment.refreshes({ environmentId, input: {} }),); export function usePullRequestTurnRefreshes( environmentIds: ReadonlyArray<EnvironmentId>,): ReadonlyArray<readonly [EnvironmentId, number]> { 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<EnvironmentQueryTarget<PullRequestListInput>>) => void;} /** One listing per environment, merged into the single list the page renders. */export function usePullRequestList( targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>>,): 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<EnvironmentQueryTarget<PullRequestListStatsInput>>,): { readonly stats: ReadonlyArray<EnvironmentPullRequestStat> | null; readonly isPending: boolean; readonly refresh: ( targets?: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>, ) => 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 };}
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<PullRequestSummary | null>(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<Input> {
readonly environmentId: EnvironmentId;
readonly input: Input;
}
interface MergedEnvironmentQueryView<A> {
/** One entry per query target that has answered, in the order the targets were given. */
readonly values: ReadonlyArray<readonly [EnvironmentId, A]>;
/** 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<Input, A>(
label: string,
atomFor: (
target: EnvironmentQueryTarget<Input>,
) => Atom.Atom<AsyncResult.AsyncResult<A, unknown>>,
) {
const family = Atom.family((key: string) =>
Atom.make((get): MergedEnvironmentQueryView<A> => {
const targets = JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>;
const values: Array<readonly [EnvironmentId, A]> = [];
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<MergedEnvironmentQueryView<A>>({
values: [],
error: null,
isPending: false,
}).pipe(Atom.withLabel(`${label}:empty`));
return function useMergedQuery(targets: ReadonlyArray<EnvironmentQueryTarget<Input>>) {
const key = JSON.stringify(targets);
const view = useAtomValue(targets.length === 0 ? empty : family(key));
const refresh = useCallback(
(override?: ReadonlyArray<EnvironmentQueryTarget<Input>>) => {
const refreshTargets =
override ?? (JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>);
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<Readonly<Record<string, never>>>) =>
pullRequestEnvironment.refreshes({ environmentId, input: {} }),
);
export function usePullRequestTurnRefreshes(
environmentIds: ReadonlyArray<EnvironmentId>,
): ReadonlyArray<readonly [EnvironmentId, number]> {
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<EnvironmentQueryTarget<PullRequestListInput>>) => void;
}
/** One listing per environment, merged into the single list the page renders. */
export function usePullRequestList(
targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>>,
): 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<EnvironmentQueryTarget<PullRequestListStatsInput>>,
): {
readonly stats: ReadonlyArray<EnvironmentPullRequestStat> | null;
readonly isPending: boolean;
readonly refresh: (
targets?: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>,
) => 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 };
}