1import { RegistryContext, useAtomValue } from "@effect/atom-react";
2import type { FileDiffMetadata } from "@pierre/diffs";
3import type {
4 EnvironmentId,
5 ReviewDiffPreviewInput,
6 ReviewDiffPreviewSource,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9import * as AsyncResult from "effect/reactivity/AsyncResult";
10import * as Atom from "effect/reactivity/Atom";
11import { useContext, useState } from "react";
12import { useStableCallback } from "~/hooks/useStableCallback";
13import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering";
14import { reviewEnvironment } from "~/state/review";
15
16const filePathCollator = new Intl.Collator(undefined, {
17 numeric: true,
18 sensitivity: "base",
19});
20const INITIAL_FILE_COUNT = 4;
21
22export function useReviewFilePatches({
23 environmentId,
24 cwd,
25 source,
26 baseRef,
27 ignoreWhitespace,
28 theme,
29 previewInput,
30 preview,
31}: {
32 environmentId: EnvironmentId | undefined;
33 cwd: string | undefined;
34 source: ReviewDiffPreviewSource | null;
35 baseRef: string | null;
36 ignoreWhitespace: boolean;
37 theme: "light" | "dark";
38 /** The preview the file list came from; a newer revision of it refreshes loaded patches. */
39 previewInput: ReviewDiffPreviewInput | undefined;
40 preview: RenderablePatch | null;
41}) {
42 const registry = useContext(RegistryContext);
43 const scope = JSON.stringify([
44 environmentId,
45 cwd,
46 source?.kind,
47 source?.baseRef,
48 source?.headRef,
49 baseRef,
50 ignoreWhitespace,
51 ]);
52 const files = sortedReviewFiles(source);
53 const automaticPaths = files.slice(0, INITIAL_FILE_COUNT).map((file) => file.path);
54 const [requested, setRequested] = useState(() => ({ scope, paths: automaticPaths }));
55 const previousPaths = requested.scope === scope ? requested.paths : [];
56 const paths = [...new Set([...previousPaths, ...automaticPaths])];
57 if (requested.scope !== scope || paths.length !== previousPaths.length) {
58 setRequested({ scope, paths });
59 }
60 const queries = reviewFileQueries({
61 environmentId,
62 cwd,
63 source,
64 baseRef,
65 ignoreWhitespace,
66 previewInput,
67 scope,
68 requested,
69 });
70 const queriedPatchesAtom = reviewPatchesAtom(
71 JSON.stringify([theme, queries.map(({ index, target }) => [index, target])]),
72 );
73 const patches = useAtomValue(queriedPatchesAtom);
74 const retry = useStableCallback((path: string) => {
75 const files = sortedReviewFiles(source);
76 const query = queries.find(({ index }) => files[index]?.path === path)?.query;
77 if (query) registry.refresh(query);
78 });
79 const pendingIndex = files.findIndex((_, index) => {
80 const patch = patches.get(index);
81 return !patch || patch._tag === "Initial";
82 });
83 const settledFileCount = source
84 ? pendingIndex < 0
85 ? files.length
86 : pendingIndex
87 : preview?.kind === "files"
88 ? preview.files.length
89 : 0;
90 const requestFiles = (indices: number[]) =>
91 setRequested((current) => {
92 const previous = current.scope === scope ? current.paths : [];
93 const added = indices.flatMap((index) => {
94 const path = files[index]?.path;
95 return path !== undefined && !previous.includes(path) ? [path] : [];
96 });
97 return added.length === 0 ? current : { scope, paths: [...previous, ...added] };
98 });
99 // The first unsettled file already requested means a batch is in flight; wait for it.
100 const loadNextFiles = () => {
101 if (queries.some(({ index }) => index === settledFileCount)) return;
102 requestFiles(Array.from({ length: 4 }, (_, index) => settledFileCount + index));
103 };
104 const requestFile = (path: string) =>
105 requestFiles([files.findIndex((file) => file.path === path)]);
106 const renderableFiles = source
107 ? files.flatMap((file, index): FileDiffMetadata[] => {
108 const result = patches.get(index);
109 const value = result && Option.getOrUndefined(AsyncResult.value(result));
110 if (value?.patch?.kind === "files") {
111 const loaded = value.patch.files.filter(
112 (candidate) => resolveFileDiffPath(candidate) === file.path,
113 );
114 if (loaded.length > 0) return loaded;
115 }
116 return [
117 {
118 name: file.path,
119 ...(file.previousPath ? { prevName: file.previousPath } : {}),
120 type: file.previousPath ? "rename-changed" : "change",
121 hunks: [],
122 additionLines: [],
123 deletionLines: [],
124 additionCharacterCount: 0,
125 deletionCharacterCount: 0,
126 splitLineCount: 0,
127 unifiedLineCount: 0,
128 isPartial: true,
129 cacheKey: `${scope}:${file.path}:pending`,
130 },
131 ];
132 })
133 : (preview?.kind === "files" ? preview.files : []).toSorted((a, b) =>
134 filePathCollator.compare(resolveFileDiffPath(a), resolveFileDiffPath(b)),
135 );
136 const fileStates = new Map(
137 files.map((file, index) => {
138 const patch = patches.get(index);
139 return [
140 file.path,
141 {
142 error:
143 patch?._tag === "Failure" ||
144 (patch?._tag === "Success" &&
145 (patch.value.patch?.kind !== "files" ||
146 !patch.value.patch.files.some(
147 (candidate) => resolveFileDiffPath(candidate) === file.path,
148 ))),
149 truncated: patch?._tag === "Success" && patch.value.source.truncated,
150 },
151 ] as const;
152 }),
153 );
154 return {
155 fileStates,
156 isPending: [...patches.values()].some((patch) => patch._tag === "Initial" || patch.waiting),
157 retry,
158 requestFile,
159 renderableFiles,
160 remainingFileCount: source ? files.length - settledFileCount : 0,
161 loadNextFiles,
162 };
163}
164
165function reviewFileQueries({
166 environmentId,
167 cwd,
168 source,
169 baseRef,
170 ignoreWhitespace,
171 previewInput,
172 scope,
173 requested,
174}: {
175 environmentId: EnvironmentId | undefined;
176 cwd: string | undefined;
177 source: ReviewDiffPreviewSource | null;
178 baseRef: string | null;
179 ignoreWhitespace: boolean;
180 previewInput: ReviewDiffPreviewInput | undefined;
181 scope: string;
182 requested: { scope: string; paths: string[] };
183}) {
184 const files = sortedReviewFiles(source);
185 const paths = new Set([
186 ...files.slice(0, INITIAL_FILE_COUNT).map((file) => file.path),
187 ...(requested.scope === scope ? requested.paths : []),
188 ]);
189 const queries =
190 !environmentId || !cwd || !source
191 ? []
192 : files
193 .flatMap((file, index) => (paths.has(file.path) ? [{ file, index }] : []))
194 .map(({ file, index }) => {
195 const target: ReviewPatchTarget = {
196 environmentId,
197 input: {
198 cacheKey: scope,
199 ...(previewInput ? { preview: previewInput } : {}),
200 request: {
201 cwd,
202 ...(baseRef ? { baseRef } : {}),
203 ...(source.kind === "commit" ? { commitSha: source.id } : {}),
204 ignoreWhitespace,
205 file: {
206 path: file.path,
207 previousPath: file.previousPath,
208 sourceKind: source.kind,
209 },
210 },
211 },
212 };
213 return { index, target, query: reviewEnvironment.diffFilePatch(target) };
214 });
215 return queries;
216}
217
218function sortedReviewFiles(source: ReviewDiffPreviewSource | null) {
219 return source?.files?.toSorted((a, b) => filePathCollator.compare(a.path, b.path)) ?? [];
220}
221
222type ReviewPatchTarget = Parameters<typeof reviewEnvironment.diffFilePatch>[0];
223
224const parsedPatchAtom = Atom.family((key: string) => {
225 const [theme, target] = JSON.parse(key) as ["light" | "dark", ReviewPatchTarget];
226 return Atom.map(reviewEnvironment.diffFilePatch(target), (result) =>
227 AsyncResult.map(result, (source) => {
228 let patch = getRenderablePatch(source.diff, `diff-panel:${theme}`, {
229 compactPartialHunkOffsets: true,
230 });
231 if (patch?.kind === "files" && patch.files.length === 1 && source.files?.length === 1) {
232 const stat = source.files[0]!;
233 const file = { ...patch.files[0]!, name: stat.path };
234 if (stat.previousPath !== null) file.prevName = stat.previousPath;
235 else delete file.prevName;
236 patch = { ...patch, files: [file] };
237 }
238 return { source, patch };
239 }),
240 );
241});
242
243const reviewPatchesAtom = Atom.family((key: string) => {
244 const [theme, entries] = JSON.parse(key) as [
245 "light" | "dark",
246 ReadonlyArray<readonly [number, ReviewPatchTarget]>,
247 ];
248 const parsed = entries.map(
249 ([index, target]) => [index, parsedPatchAtom(JSON.stringify([theme, target]))] as const,
250 );
251 return Atom.make((get) => new Map(parsed.map(([index, atom]) => [index, get(atom)])));
252});