10.6 KB333 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {Atom, Getter} from 'jotai';
9import type {Loadable} from 'jotai/vanilla/utils/loadable';
10import type {CommitInfo, RepoRelativePath, SlocInfo} from '../types';
11
12import {atom, useAtomValue} from 'jotai';
13import {loadable} from 'jotai/utils';
14import {useEffect, useMemo, useRef, useState} from 'react';
15import serverAPI from '../ClientToServerAPI';
16import {commitInfoViewCurrentCommits} from '../CommitInfoView/CommitInfoState';
17import {getGeneratedFilesFrom} from '../GeneratedFile';
18import {pageVisibility} from '../codeReview/CodeReviewInfo';
19import {atomFamilyWeak, lazyAtom} from '../jotaiUtils';
20import {isFullyOrPartiallySelected} from '../partialSelection';
21import {uncommittedChangesWithPreviews} from '../previews';
22import {commitByHash} from '../serverAPIState';
23import {GeneratedStatus} from '../types';
24import {arraysEqual} from '../utils';
25import {MAX_FILES_ALLOWED_FOR_DIFF_STAT} from './diffStatConstants';
26
27const isPageHiddenAtom = atom(get => get(pageVisibility) === 'hidden');
28
29const getGeneratedFiles = (files: ReadonlyArray<RepoRelativePath>): Array<RepoRelativePath> => {
30 const generatedStatuses = getGeneratedFilesFrom(files);
31
32 return files.reduce<string[]>((filtered, path) => {
33 // check if the file should be excluded
34 // the __generated__ pattern is included in the exclusions, so we don't need to include it here
35 if (path.match(/__generated__/) || generatedStatuses[path] === GeneratedStatus.Generated) {
36 filtered.push(path);
37 }
38
39 return filtered;
40 }, []);
41};
42
43const filterGeneratedFiles = (files: ReadonlyArray<RepoRelativePath>): Array<RepoRelativePath> => {
44 const generatedStatuses = getGeneratedFilesFrom(files);
45
46 return files.filter(
47 path => !path.match(/__generated__/) && generatedStatuses[path] !== GeneratedStatus.Generated,
48 );
49};
50
51async function fetchSignificantLinesOfCode(
52 commit: Readonly<CommitInfo>,
53 additionalFilesToExclude: ReadonlyArray<RepoRelativePath> = [],
54 getExcludedFiles: (
55 files: ReadonlyArray<RepoRelativePath>,
56 ) => Array<RepoRelativePath> = getGeneratedFiles,
57): Promise<SlocInfo> {
58 const filesToQueryGeneratedStatus = commit.filePathsSample;
59 const excludedFiles = getExcludedFiles(filesToQueryGeneratedStatus);
60
61 serverAPI.postMessage({
62 type: 'fetchSignificantLinesOfCode',
63 hash: commit.hash,
64 excludedFiles: [...excludedFiles, ...additionalFilesToExclude],
65 });
66
67 const slocData = await serverAPI
68 .nextMessageMatching('fetchedSignificantLinesOfCode', message => message.hash === commit.hash)
69 .then(result => ({
70 sloc: result.result.value,
71 }));
72
73 return slocData;
74}
75
76const commitSlocFamily = atomFamilyWeak((hash: string) => {
77 return lazyAtom(async get => {
78 const commit = get(commitByHash(hash));
79 if (commit == null) {
80 return undefined;
81 }
82 if (commit.totalFileCount > MAX_FILES_ALLOWED_FOR_DIFF_STAT) {
83 return undefined;
84 }
85 if (commit.optimisticRevset != null) {
86 return undefined;
87 }
88 const sloc = await fetchSignificantLinesOfCode(commit);
89 return sloc;
90 }, undefined);
91});
92
93let previouslySelectedFiles: string[] = [];
94
95const selectedFilesAtom = atom(get => {
96 const isPathFullorPartiallySelected = get(isFullyOrPartiallySelected);
97
98 const uncommittedChanges = get(uncommittedChangesWithPreviews);
99 const selectedFiles = uncommittedChanges.reduce((selected, f) => {
100 if (!f.path.match(/__generated__/) && isPathFullorPartiallySelected(f.path)) {
101 selected.push(f.path);
102 }
103 return selected;
104 }, [] as string[]);
105
106 if (!arraysEqual(previouslySelectedFiles, selectedFiles)) {
107 previouslySelectedFiles = selectedFiles;
108 }
109 return previouslySelectedFiles;
110});
111
112/**
113 * FETCH PENDING AMEND SLOC
114 */
115const fetchPendingAmendSloc = async (
116 get: Getter,
117 includedFiles: string[],
118 requestId: number,
119): Promise<SlocInfo | undefined> => {
120 const commits = get(commitInfoViewCurrentCommits);
121 if (commits == null || commits.length > 1) {
122 return undefined;
123 }
124 const [commit] = commits;
125 if (commit.totalFileCount > MAX_FILES_ALLOWED_FOR_DIFF_STAT || commit.optimisticRevset != null) {
126 return undefined;
127 }
128
129 const filteredFiles = filterGeneratedFiles(includedFiles);
130 if (filteredFiles.length > MAX_FILES_ALLOWED_FOR_DIFF_STAT) {
131 return undefined;
132 }
133
134 if (filteredFiles.length === 0) {
135 return {sloc: 0};
136 }
137
138 //the calculation here is a bit tricky but in nutshell it is:
139 // SLOC for unselected committed files
140 // + SLOC for selected files (to be amended) in the commit
141 // ---------------------------------------------------------------------------------------------------
142 // => What SLOC would be after you do the amend.
143 // this way we won't show the split suggestions when the net effect of the amend will actually reduce SLOC (reverting for example)
144
145 //pass in the selected files to be excluded.
146 const unselectedCommittedSlocInfo = await fetchSignificantLinesOfCode(commit, includedFiles);
147
148 serverAPI.postMessage({
149 type: 'fetchPendingAmendSignificantLinesOfCode',
150 hash: commit.hash,
151 includedFiles: filteredFiles,
152 requestId,
153 });
154
155 const pendingLoc = await serverAPI
156 .nextMessageMatching(
157 'fetchedPendingAmendSignificantLinesOfCode',
158 message => message.requestId === requestId && message.hash === commit.hash,
159 )
160 .then(result => ({
161 sloc: result.result.value,
162 }));
163
164 if (unselectedCommittedSlocInfo === undefined) {
165 return pendingLoc;
166 }
167
168 if (pendingLoc === undefined) {
169 return unselectedCommittedSlocInfo;
170 }
171
172 const slocInfo = {
173 sloc: (unselectedCommittedSlocInfo.sloc ?? 0) + (pendingLoc.sloc ?? 0),
174 };
175
176 return slocInfo;
177};
178
179/**
180 * FETCH PENDING SLOC
181 */
182const fetchPendingSloc = async (
183 get: Getter,
184 includedFiles: string[],
185 requestId: number,
186): Promise<SlocInfo | undefined> => {
187 // this atom makes use of the fact that jotai will only use the most recently created request (ignoring older requests)
188 // to avoid race conditions when the response from an older request is sent after a newer one
189 // so for example:
190 // pendingRequestId A (slow) => Server (sleeps 5 sec)
191 // pendingRequestId B (fast) => Server responds immediately, client updates
192 // pendingRequestId A (slow) => Server responds, client ignores
193
194 // We don't want to fetch the pending changes if the page is hidden
195 // Use isPageHiddenAtom instead of pageVisibility directly to avoid
196 // re-triggering on focus/blur events (transitions between 'focused' and 'visible')
197 const pageIsHidden = get(isPageHiddenAtom);
198 const commits = get(commitInfoViewCurrentCommits);
199
200 if (pageIsHidden || commits == null || commits.length > 1) {
201 return undefined;
202 }
203
204 const [commit] = commits;
205 if (commit.totalFileCount > MAX_FILES_ALLOWED_FOR_DIFF_STAT) {
206 return undefined;
207 }
208
209 const filteredFiles = filterGeneratedFiles(includedFiles);
210 if (filteredFiles.length > MAX_FILES_ALLOWED_FOR_DIFF_STAT) {
211 return undefined;
212 }
213
214 if (filteredFiles.length === 0) {
215 return {sloc: 0};
216 }
217
218 serverAPI.postMessage({
219 type: 'fetchPendingSignificantLinesOfCode',
220 hash: commit.hash,
221 includedFiles: filteredFiles,
222 requestId,
223 });
224
225 const pendingLocData = await serverAPI
226 .nextMessageMatching(
227 'fetchedPendingSignificantLinesOfCode',
228 message => message.requestId === requestId && message.hash === commit.hash,
229 )
230 .then(result => ({
231 sloc: result.result.value,
232 }));
233
234 return pendingLocData;
235};
236
237function useFetchWithPrevious(atom: Atom<Loadable<Promise<SlocInfo | undefined>>>): {
238 slocInfo: SlocInfo | undefined;
239 isLoading: boolean;
240} {
241 const previous = useRef<SlocInfo | undefined>(undefined);
242 const results = useAtomValue(atom);
243 if (results.state === 'hasError') {
244 throw results.error;
245 }
246 if (results.state === 'loading') {
247 //using the previous value in the loading state to avoid flickering / jankiness in the UI
248 return {slocInfo: previous.current, isLoading: true};
249 }
250
251 previous.current = results.data;
252
253 return {slocInfo: results.data, isLoading: false};
254}
255
256export function useFetchSignificantLinesOfCode(commit: CommitInfo) {
257 const loadableAtom = loadable(commitSlocFamily(commit.hash));
258 const result = useAtomValue(loadableAtom);
259
260 if (result.state === 'hasError') {
261 throw result.error;
262 }
263
264 if (result.state === 'loading') {
265 return {slocInfo: undefined, isLoading: true};
266 }
267
268 return {slocInfo: result.data, isLoading: false};
269}
270
271// Debounce delay for SLOC requests to prevent spamming when many files are selected quickly
272const DEBOUNCE_DELAY_MS = 300;
273
274// Hook that debounces an atom value
275function useDebouncedAtomValue<T>(sourceAtom: Atom<T>, debounceMs: number): T {
276 const currentValue = useAtomValue(sourceAtom);
277 const [debouncedValue, setDebouncedValue] = useState(currentValue);
278 const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
279
280 useEffect(() => {
281 if (timeoutRef.current) {
282 clearTimeout(timeoutRef.current);
283 }
284
285 timeoutRef.current = setTimeout(() => {
286 setDebouncedValue(currentValue);
287 }, debounceMs);
288
289 return () => {
290 if (timeoutRef.current) {
291 clearTimeout(timeoutRef.current);
292 }
293 };
294 }, [currentValue, debounceMs]);
295
296 return debouncedValue;
297}
298
299let pendingRequestId = 0;
300export function useFetchPendingSignificantLinesOfCode() {
301 // Debounce selected files to prevent spamming SLOC requests
302 const debouncedSelectedFiles = useDebouncedAtomValue(selectedFilesAtom, DEBOUNCE_DELAY_MS);
303
304 // Use a derived atom that depends on the debounced value
305 const debouncedAtom = useMemo(() => {
306 return atom(get => {
307 // Force the atom to use the debounced value by creating a dependency
308 // Note: we can't pass debouncedSelectedFiles directly to the atom,
309 // so we create a new fetch call with it
310 return fetchPendingSloc(get, debouncedSelectedFiles, pendingRequestId++);
311 });
312 }, [debouncedSelectedFiles]);
313
314 const loadableAtom = loadable(debouncedAtom);
315 return useFetchWithPrevious(loadableAtom);
316}
317
318let pendingAmendRequestId = 0;
319export function useFetchPendingAmendSignificantLinesOfCode() {
320 // Debounce selected files to prevent spamming SLOC requests
321 const debouncedSelectedFiles = useDebouncedAtomValue(selectedFilesAtom, DEBOUNCE_DELAY_MS);
322
323 // Use a derived atom that depends on the debounced value
324 const debouncedAtom = useMemo(() => {
325 return atom(get => {
326 return fetchPendingAmendSloc(get, debouncedSelectedFiles, pendingAmendRequestId++);
327 });
328 }, [debouncedSelectedFiles]);
329
330 const loadableAtom = loadable(debouncedAtom);
331 return useFetchWithPrevious(loadableAtom);
332}
333