| 1 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; |
| 3 | |
| 4 | import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; |
| 5 | import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; |
| 6 | import { markReviewEvent, measureReviewWork } from "./reviewPerf"; |
| 7 | import { getCachedReviewParsedDiff } from "./reviewState"; |
| 8 | import { |
| 9 | applyReviewDiffMetadata, |
| 10 | buildReviewParsedDiff, |
| 11 | type ReviewParsedDiff, |
| 12 | type ReviewRenderableFile, |
| 13 | type ReviewSectionItem, |
| 14 | } from "./reviewModel"; |
| 15 | |
| 16 | import type { |
| 17 | EnvironmentId, |
| 18 | ReviewDiffFileStat, |
| 19 | ReviewDiffPreviewSource, |
| 20 | } from "@t3tools/contracts"; |
| 21 | import { RegistryContext, useAtomValue } from "@effect/atom-react"; |
| 22 | import * as AsyncResult from "effect/reactivity/AsyncResult"; |
| 23 | import * as Atom from "effect/reactivity/Atom"; |
| 24 | import { reviewEnvironment } from "../../state/review"; |
| 25 | |
| 26 | const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]); |
| 27 | type ParsedFilePatch = AsyncResult.AsyncResult< |
| 28 | { source: ReviewDiffPreviewSource; parsed: ReviewParsedDiff }, |
| 29 | unknown |
| 30 | >; |
| 31 | const normalizedFiles = new WeakMap< |
| 32 | ParsedFilePatch, |
| 33 | { stat: ReviewDiffFileStat; diffHash: string; file: ReviewRenderableFile } |
| 34 | >(); |
| 35 | |
| 36 | function 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 | |
| 87 | function isReviewDiffDebugLoggingEnabled(): boolean { |
| 88 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; |
| 89 | } |
| 90 | |
| 91 | function 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 | |
| 104 | export 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 | |
| 124 | export 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 | } |