1import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
3
4import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";
5import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
6import { markReviewEvent, measureReviewWork } from "./reviewPerf";
7import { getCachedReviewParsedDiff } from "./reviewState";
8import {
9 applyReviewDiffMetadata,
10 buildReviewParsedDiff,
11 type ReviewParsedDiff,
12 type ReviewRenderableFile,
13 type ReviewSectionItem,
14} from "./reviewModel";
15
16import type {
17 EnvironmentId,
18 ReviewDiffFileStat,
19 ReviewDiffPreviewSource,
20} from "@t3tools/contracts";
21import { RegistryContext, useAtomValue } from "@effect/atom-react";
22import * as AsyncResult from "effect/reactivity/AsyncResult";
23import * as Atom from "effect/reactivity/Atom";
24import { reviewEnvironment } from "../../state/review";
25
26const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]);
27type ParsedFilePatch = AsyncResult.AsyncResult<
28 { source: ReviewDiffPreviewSource; parsed: ReviewParsedDiff },
29 unknown
30>;
31const normalizedFiles = new WeakMap<
32 ParsedFilePatch,
33 { stat: ReviewDiffFileStat; diffHash: string; file: ReviewRenderableFile }
34>();
35
36function getCachedReviewFile(
37 stat: ReviewDiffFileStat,
38 diffHash: string,
39 patch: ParsedFilePatch | null | undefined,
40): ReviewRenderableFile {
41 const cached = patch && normalizedFiles.get(patch);
42 if (cached && cached.stat === stat && cached.diffHash === diffHash) return cached.file;
43 const parsed = patch?._tag === "Success" ? patch.value.parsed : null;
44 const sourceFiles = patch?._tag === "Success" ? patch.value.source.files : undefined;
45 const loaded =
46 parsed?.kind === "files"
47 ? (parsed.files.find((file) => file.path === stat.path) ??
48 (parsed.files.length === 1 &&
49 sourceFiles?.length === 1 &&
50 sourceFiles[0]?.path === stat.path
51 ? parsed.files[0]
52 : undefined))
53 : undefined;
54 const file: ReviewRenderableFile = {
55 ...(loaded ?? {
56 path: stat.path,
57 previousPath: stat.previousPath,
58 changeType: "change" as const,
59 languageHint: null,
60 additionLines: [],
61 deletionLines: [],
62 rows: [],
63 cacheKey: `${diffHash}:${stat.path}`,
64 }),
65 id: stat.path,
66 path: stat.path,
67 previousPath: stat.previousPath,
68 additions: stat.additions,
69 deletions: stat.deletions,
70 ...(patch?._tag === "Success"
71 ? patch.value.source.truncated
72 ? { notice: "File preview exceeds the size limit. Counts include all changes." }
73 : loaded
74 ? {}
75 : { notice: "Could not display file preview." }
76 : {
77 notice:
78 patch?._tag === "Failure"
79 ? "Could not load diff. Select the file to retry."
80 : "Loading diff…",
81 }),
82 };
83 if (patch) normalizedFiles.set(patch, { stat, diffHash, file });
84 return file;
85}
86
87function isReviewDiffDebugLoggingEnabled(): boolean {
88 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
89}
90
91function logReviewDiffDiagnostic(message: string, details?: Record<string, unknown>): void {
92 if (!isReviewDiffDebugLoggingEnabled()) {
93 return;
94 }
95
96 if (details) {
97 console.log(`[review-sheet] ${message}`, details);
98 return;
99 }
100
101 console.log(`[review-sheet] ${message}`);
102}
103
104export function formatHeaderDiffSummary(
105 parsedDiff: ReviewParsedDiff,
106 files?: ReviewSectionItem["files"],
107): {
108 readonly additions: string | null;
109 readonly deletions: string | null;
110} {
111 if (files) {
112 return {
113 additions: `+${formatDiffCount(files.reduce((total, file) => total + file.additions, 0))}`,
114 deletions: `-${formatDiffCount(files.reduce((total, file) => total + file.deletions, 0))}`,
115 };
116 }
117 if (parsedDiff.kind !== "files") return { additions: null, deletions: null };
118 return {
119 additions: `+${formatDiffCount(parsedDiff.additions)}`,
120 deletions: `-${formatDiffCount(parsedDiff.deletions)}`,
121 };
122}
123
124export function useReviewDiffData(input: {
125 readonly threadKey: string | null;
126 readonly environmentId: EnvironmentId | undefined;
127 readonly cwd: string | null;
128 readonly selectedSection: ReviewSectionItem | null;
129 readonly revision: string | undefined;
130 readonly draftMessage: string;
131}) {
132 const { draftMessage, selectedSection, threadKey } = input;
133 const selectedSectionId = selectedSection?.id ?? null;
134 const source = selectedSection?.source;
135 const lazySource = source?.truncated && source.files ? source : null;
136 const previewDiff = useMemo<ReviewParsedDiff>(
137 () =>
138 lazySource
139 ? { kind: "empty" }
140 : measureReviewWork("parse-diff", () =>
141 getCachedReviewParsedDiff({
142 threadKey,
143 sectionId: selectedSection?.id ?? null,
144 diff: selectedSection?.diff,
145 }),
146 ),
147 [lazySource, selectedSection?.diff, selectedSection?.id, threadKey],
148 );
149 const registry = useContext(RegistryContext);
150 const { environmentId, cwd } = input;
151 const scope = JSON.stringify([
152 environmentId,
153 cwd,
154 source?.kind,
155 source?.baseRef,
156 source?.diffHash,
157 ]);
158 const [requested, setRequested] = useState({ scope, indices: [0, 1, 2] });
159 const indices = useMemo(
160 () => new Set(requested.scope === scope ? requested.indices : [0, 1, 2]),
161 [requested, scope],
162 );
163 const queries = useMemo(
164 () =>
165 !environmentId || !cwd || !lazySource
166 ? []
167 : (lazySource.files ?? []).map((file, index) =>
168 indices.has(index)
169 ? reviewEnvironment.diffFilePatch({
170 environmentId,
171 input: {
172 cacheKey: scope,
173 request: {
174 cwd,
175 ...(lazySource.baseRef ? { baseRef: lazySource.baseRef } : {}),
176 file: {
177 path: file.path,
178 previousPath: file.previousPath,
179 sourceKind: lazySource.kind,
180 },
181 },
182 },
183 })
184 : null,
185 ),
186 [environmentId, cwd, lazySource, indices, scope],
187 );
188 const parsedQuery = useMemo(
189 () =>
190 Atom.family((query: ReturnType<typeof reviewEnvironment.diffFilePatch>) =>
191 Atom.map(query, (result) =>
192 AsyncResult.map(result, (source) => ({
193 source,
194 parsed: buildReviewParsedDiff(source.diff, source.diffHash),
195 })),
196 ),
197 ),
198 [],
199 );
200 const patches = useAtomValue(
201 useMemo(
202 () => Atom.make((get) => queries.map((query) => (query ? get(parsedQuery(query)) : null))),
203 [queries, parsedQuery],
204 ),
205 );
206 const previousPreview = useRef({
207 scope,
208 revision: input.revision,
209 queries: [] as typeof queries,
210 });
211 useEffect(() => {
212 const previous = previousPreview.current;
213 previousPreview.current = { scope, revision: input.revision, queries };
214 for (const query of queries) {
215 if (!query) continue;
216 const changed = previous.scope === scope && previous.revision !== input.revision;
217 const cached =
218 !previous.queries.includes(query) &&
219 registry.get(query)._tag !== "Initial" &&
220 !registry.get(query).waiting;
221 if (changed || cached) registry.refresh(query);
222 }
223 }, [input.revision, queries, registry, scope]);
224 const loadVisibleFile = useCallback(
225 (fileId: string | null, retry = false) => {
226 const index =
227 fileId === null ? 0 : (lazySource?.files?.findIndex((file) => file.path === fileId) ?? -1);
228 if (index < 0) return;
229 setRequested((current) => {
230 const previous = current.scope === scope ? current.indices : [0, 1, 2];
231 const added = [index, index + 1, index + 2].filter((next) => !previous.includes(next));
232 return added.length === 0 ? current : { scope, indices: [...previous, ...added] };
233 });
234 if (retry && patches[index]?._tag === "Failure" && queries[index])
235 registry.refresh(queries[index]);
236 },
237 [lazySource, scope, patches, queries, registry],
238 );
239 const parsedDiff = useMemo<ReviewParsedDiff>(() => {
240 if (!lazySource?.files) return applyReviewDiffMetadata(previewDiff, selectedSection);
241 const files = lazySource.files.map((stat, index) =>
242 getCachedReviewFile(stat, lazySource.diffHash, patches[index]),
243 );
244 return {
245 kind: "files",
246 files,
247 fileCount: files.length,
248 additions: files.reduce((total, file) => total + file.additions, 0),
249 deletions: files.reduce((total, file) => total + file.deletions, 0),
250 notice: null,
251 };
252 }, [lazySource, previewDiff, selectedSection, patches]);
253 const headerDiffSummary = useMemo(
254 () => formatHeaderDiffSummary(parsedDiff, selectedSection?.files),
255 [parsedDiff, selectedSection?.files],
256 );
257 const inlineReviewComments = useMemo(
258 () => parseReviewInlineComments(draftMessage),
259 [draftMessage],
260 );
261 const selectedSectionInlineComments = useMemo(() => {
262 if (!selectedSectionId || inlineReviewComments.length === 0) {
263 return EMPTY_INLINE_REVIEW_COMMENTS;
264 }
265 return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId);
266 }, [inlineReviewComments, selectedSectionId]);
267 const nativeReviewDiffData = useMemo(
268 () =>
269 measureReviewWork("build-native-diff-data", () =>
270 getCachedNativeReviewDiffData({
271 parsedDiff,
272 comments: selectedSectionInlineComments,
273 }),
274 ),
275 [parsedDiff, selectedSectionInlineComments],
276 );
277 const pendingReviewCommentCount = useMemo(
278 () => countReviewCommentContexts(draftMessage),
279 [draftMessage],
280 );
281
282 useEffect(() => {
283 if (parsedDiff.kind !== "files") {
284 return;
285 }
286
287 markReviewEvent("parsed-diff-ready", {
288 sectionId: selectedSection?.id ?? null,
289 fileCount: parsedDiff.fileCount,
290 additions: parsedDiff.additions,
291 deletions: parsedDiff.deletions,
292 renderedItems: nativeReviewDiffData.rows.length,
293 });
294 logReviewDiffDiagnostic("parsed diff files", {
295 selectedSectionId: selectedSection?.id ?? null,
296 fileCount: parsedDiff.fileCount,
297 renderableFileCount: parsedDiff.files.length,
298 });
299 }, [nativeReviewDiffData.rows.length, parsedDiff, selectedSection?.id]);
300
301 return {
302 parsedDiff,
303 loadVisibleFile,
304 isPending: patches.some((patch) => patch?._tag === "Initial" || patch?.waiting),
305 headerDiffSummary,
306 nativeReviewDiffData,
307 pendingReviewCommentCount,
308 };
309}