1import type { FileDiffMetadata } from "@pierre/diffs";
2import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
3import { useEffect, useState } from "react";
4
5import { getRenderablePatch, resolveFileDiffPath } from "~/lib/diffRendering";
6import { pullRequestEnvironment } from "~/state/pullRequests";
7import { useEnvironmentQuery } from "~/state/query";
8
9import { pullRequestReviewKey } from "../pullRequestReviewStore";
10
11interface LoadedDiff {
12 readonly key: string;
13 /** The page asked for next; null is the first. */
14 readonly cursor: string | null;
15 /** No page left to ask for. */
16 readonly done: boolean;
17 readonly files: ReadonlyMap<string, FileDiffMetadata>;
18}
19
20const NO_FILES: ReadonlyMap<string, FileDiffMetadata> = new Map();
21
22/**
23 * The pull request's parsed file diffs, read through the same paged query the Code tab uses, so
24 * the pages are shared. Paging stops once every wanted path has arrived: a stop's excerpts are
25 * usually in the first page, and a large change should not be read whole to show one of them.
26 * Pages already read are kept, so going back to an earlier stop costs nothing.
27 */
28export function useGuideDiffFiles({
29 environmentId,
30 reference,
31 headSha,
32 wantedPaths,
33}: {
34 environmentId: EnvironmentId;
35 reference: PullRequestRef;
36 /** The guide's head. A push writes a new guide, whose excerpts must read the new diff. */
37 headSha: string;
38 wantedPaths: ReadonlyArray<string>;
39}) {
40 const key = `${environmentId}:${pullRequestReviewKey(reference)}:${headSha}`;
41 const [loaded, setLoaded] = useState<LoadedDiff>({
42 key,
43 cursor: null,
44 done: false,
45 files: NO_FILES,
46 });
47 const current: LoadedDiff =
48 loaded.key === key ? loaded : { key, cursor: null, done: false, files: NO_FILES };
49 const missing = wantedPaths.some((path) => !current.files.has(path));
50 const loading = missing && !current.done;
51 const query = useEnvironmentQuery(
52 loading
53 ? pullRequestEnvironment.diff({
54 environmentId,
55 input: { ...reference, ...(current.cursor === null ? {} : { cursor: current.cursor }) },
56 })
57 : null,
58 );
59
60 const data = query.data;
61 const cursor = current.cursor;
62 useEffect(() => {
63 if (data === null) return;
64 setLoaded((previous) => {
65 const base =
66 previous.key === key ? previous : { key, cursor: null, done: false, files: NO_FILES };
67 // A page is absorbed once; the query hands the same answer back on every render.
68 if (base.cursor !== cursor || base.done) return base;
69 const parsed = getRenderablePatch(
70 data.patch,
71 `pull-request-guide:${key}:${cursor ?? "first"}`,
72 );
73 const files = new Map(base.files);
74 if (parsed?.kind === "files") {
75 for (const file of parsed.files) files.set(resolveFileDiffPath(file), file);
76 }
77 return { key, cursor: data.nextCursor, done: data.nextCursor === null, files };
78 });
79 }, [cursor, data, key]);
80
81 return {
82 files: current.files,
83 loading: loading && query.error === null,
84 error: loading ? query.error : null,
85 retry: query.refresh,
86 };
87}