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