addons/isl/src/sloc/useFetchSignificantLinesOfCode.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {Atom, Getter} from 'jotai';
b69ab319import type {Loadable} from 'jotai/vanilla/utils/loadable';
b69ab3110import type {CommitInfo, RepoRelativePath, SlocInfo} from '../types';
b69ab3111
b69ab3112import {atom, useAtomValue} from 'jotai';
b69ab3113import {loadable} from 'jotai/utils';
b69ab3114import {useEffect, useMemo, useRef, useState} from 'react';
b69ab3115import serverAPI from '../ClientToServerAPI';
b69ab3116import {commitInfoViewCurrentCommits} from '../CommitInfoView/CommitInfoState';
b69ab3117import {getGeneratedFilesFrom} from '../GeneratedFile';
b69ab3118import {pageVisibility} from '../codeReview/CodeReviewInfo';
b69ab3119import {atomFamilyWeak, lazyAtom} from '../jotaiUtils';
b69ab3120import {isFullyOrPartiallySelected} from '../partialSelection';
b69ab3121import {uncommittedChangesWithPreviews} from '../previews';
b69ab3122import {commitByHash} from '../serverAPIState';
b69ab3123import {GeneratedStatus} from '../types';
b69ab3124import {arraysEqual} from '../utils';
b69ab3125import {MAX_FILES_ALLOWED_FOR_DIFF_STAT} from './diffStatConstants';
b69ab3126
b69ab3127const isPageHiddenAtom = atom(get => get(pageVisibility) === 'hidden');
b69ab3128
b69ab3129const getGeneratedFiles = (files: ReadonlyArray<RepoRelativePath>): Array<RepoRelativePath> => {
b69ab3130 const generatedStatuses = getGeneratedFilesFrom(files);
b69ab3131
b69ab3132 return files.reduce<string[]>((filtered, path) => {
b69ab3133 // check if the file should be excluded
b69ab3134 // the __generated__ pattern is included in the exclusions, so we don't need to include it here
b69ab3135 if (path.match(/__generated__/) || generatedStatuses[path] === GeneratedStatus.Generated) {
b69ab3136 filtered.push(path);
b69ab3137 }
b69ab3138
b69ab3139 return filtered;
b69ab3140 }, []);
b69ab3141};
b69ab3142
b69ab3143const filterGeneratedFiles = (files: ReadonlyArray<RepoRelativePath>): Array<RepoRelativePath> => {
b69ab3144 const generatedStatuses = getGeneratedFilesFrom(files);
b69ab3145
b69ab3146 return files.filter(
b69ab3147 path => !path.match(/__generated__/) && generatedStatuses[path] !== GeneratedStatus.Generated,
b69ab3148 );
b69ab3149};
b69ab3150
b69ab3151async function fetchSignificantLinesOfCode(
b69ab3152 commit: Readonly<CommitInfo>,
b69ab3153 additionalFilesToExclude: ReadonlyArray<RepoRelativePath> = [],
b69ab3154 getExcludedFiles: (
b69ab3155 files: ReadonlyArray<RepoRelativePath>,
b69ab3156 ) => Array<RepoRelativePath> = getGeneratedFiles,
b69ab3157): Promise<SlocInfo> {
b69ab3158 const filesToQueryGeneratedStatus = commit.filePathsSample;
b69ab3159 const excludedFiles = getExcludedFiles(filesToQueryGeneratedStatus);
b69ab3160
b69ab3161 serverAPI.postMessage({
b69ab3162 type: 'fetchSignificantLinesOfCode',
b69ab3163 hash: commit.hash,
b69ab3164 excludedFiles: [...excludedFiles, ...additionalFilesToExclude],
b69ab3165 });
b69ab3166
b69ab3167 const slocData = await serverAPI
b69ab3168 .nextMessageMatching('fetchedSignificantLinesOfCode', message => message.hash === commit.hash)
b69ab3169 .then(result => ({
b69ab3170 sloc: result.result.value,
b69ab3171 }));
b69ab3172
b69ab3173 return slocData;
b69ab3174}
b69ab3175
b69ab3176const commitSlocFamily = atomFamilyWeak((hash: string) => {
b69ab3177 return lazyAtom(async get => {
b69ab3178 const commit = get(commitByHash(hash));
b69ab3179 if (commit == null) {
b69ab3180 return undefined;
b69ab3181 }
b69ab3182 if (commit.totalFileCount > MAX_FILES_ALLOWED_FOR_DIFF_STAT) {
b69ab3183 return undefined;
b69ab3184 }
b69ab3185 if (commit.optimisticRevset != null) {
b69ab3186 return undefined;
b69ab3187 }
b69ab3188 const sloc = await fetchSignificantLinesOfCode(commit);
b69ab3189 return sloc;
b69ab3190 }, undefined);
b69ab3191});
b69ab3192
b69ab3193let previouslySelectedFiles: string[] = [];
b69ab3194
b69ab3195const selectedFilesAtom = atom(get => {
b69ab3196 const isPathFullorPartiallySelected = get(isFullyOrPartiallySelected);
b69ab3197
b69ab3198 const uncommittedChanges = get(uncommittedChangesWithPreviews);
b69ab3199 const selectedFiles = uncommittedChanges.reduce((selected, f) => {
b69ab31100 if (!f.path.match(/__generated__/) && isPathFullorPartiallySelected(f.path)) {
b69ab31101 selected.push(f.path);
b69ab31102 }
b69ab31103 return selected;
b69ab31104 }, [] as string[]);
b69ab31105
b69ab31106 if (!arraysEqual(previouslySelectedFiles, selectedFiles)) {
b69ab31107 previouslySelectedFiles = selectedFiles;
b69ab31108 }
b69ab31109 return previouslySelectedFiles;
b69ab31110});
b69ab31111
b69ab31112/**
b69ab31113 * FETCH PENDING AMEND SLOC
b69ab31114 */
b69ab31115const fetchPendingAmendSloc = async (
b69ab31116 get: Getter,
b69ab31117 includedFiles: string[],
b69ab31118 requestId: number,
b69ab31119): Promise<SlocInfo | undefined> => {
b69ab31120 const commits = get(commitInfoViewCurrentCommits);
b69ab31121 if (commits == null || commits.length > 1) {
b69ab31122 return undefined;
b69ab31123 }
b69ab31124 const [commit] = commits;
b69ab31125 if (commit.totalFileCount > MAX_FILES_ALLOWED_FOR_DIFF_STAT || commit.optimisticRevset != null) {
b69ab31126 return undefined;
b69ab31127 }
b69ab31128
b69ab31129 const filteredFiles = filterGeneratedFiles(includedFiles);
b69ab31130 if (filteredFiles.length > MAX_FILES_ALLOWED_FOR_DIFF_STAT) {
b69ab31131 return undefined;
b69ab31132 }
b69ab31133
b69ab31134 if (filteredFiles.length === 0) {
b69ab31135 return {sloc: 0};
b69ab31136 }
b69ab31137
b69ab31138 //the calculation here is a bit tricky but in nutshell it is:
b69ab31139 // SLOC for unselected committed files
b69ab31140 // + SLOC for selected files (to be amended) in the commit
b69ab31141 // ---------------------------------------------------------------------------------------------------
b69ab31142 // => What SLOC would be after you do the amend.
b69ab31143 // this way we won't show the split suggestions when the net effect of the amend will actually reduce SLOC (reverting for example)
b69ab31144
b69ab31145 //pass in the selected files to be excluded.
b69ab31146 const unselectedCommittedSlocInfo = await fetchSignificantLinesOfCode(commit, includedFiles);
b69ab31147
b69ab31148 serverAPI.postMessage({
b69ab31149 type: 'fetchPendingAmendSignificantLinesOfCode',
b69ab31150 hash: commit.hash,
b69ab31151 includedFiles: filteredFiles,
b69ab31152 requestId,
b69ab31153 });
b69ab31154
b69ab31155 const pendingLoc = await serverAPI
b69ab31156 .nextMessageMatching(
b69ab31157 'fetchedPendingAmendSignificantLinesOfCode',
b69ab31158 message => message.requestId === requestId && message.hash === commit.hash,
b69ab31159 )
b69ab31160 .then(result => ({
b69ab31161 sloc: result.result.value,
b69ab31162 }));
b69ab31163
b69ab31164 if (unselectedCommittedSlocInfo === undefined) {
b69ab31165 return pendingLoc;
b69ab31166 }
b69ab31167
b69ab31168 if (pendingLoc === undefined) {
b69ab31169 return unselectedCommittedSlocInfo;
b69ab31170 }
b69ab31171
b69ab31172 const slocInfo = {
b69ab31173 sloc: (unselectedCommittedSlocInfo.sloc ?? 0) + (pendingLoc.sloc ?? 0),
b69ab31174 };
b69ab31175
b69ab31176 return slocInfo;
b69ab31177};
b69ab31178
b69ab31179/**
b69ab31180 * FETCH PENDING SLOC
b69ab31181 */
b69ab31182const fetchPendingSloc = async (
b69ab31183 get: Getter,
b69ab31184 includedFiles: string[],
b69ab31185 requestId: number,
b69ab31186): Promise<SlocInfo | undefined> => {
b69ab31187 // this atom makes use of the fact that jotai will only use the most recently created request (ignoring older requests)
b69ab31188 // to avoid race conditions when the response from an older request is sent after a newer one
b69ab31189 // so for example:
b69ab31190 // pendingRequestId A (slow) => Server (sleeps 5 sec)
b69ab31191 // pendingRequestId B (fast) => Server responds immediately, client updates
b69ab31192 // pendingRequestId A (slow) => Server responds, client ignores
b69ab31193
b69ab31194 // We don't want to fetch the pending changes if the page is hidden
b69ab31195 // Use isPageHiddenAtom instead of pageVisibility directly to avoid
b69ab31196 // re-triggering on focus/blur events (transitions between 'focused' and 'visible')
b69ab31197 const pageIsHidden = get(isPageHiddenAtom);
b69ab31198 const commits = get(commitInfoViewCurrentCommits);
b69ab31199
b69ab31200 if (pageIsHidden || commits == null || commits.length > 1) {
b69ab31201 return undefined;
b69ab31202 }
b69ab31203
b69ab31204 const [commit] = commits;
b69ab31205 if (commit.totalFileCount > MAX_FILES_ALLOWED_FOR_DIFF_STAT) {
b69ab31206 return undefined;
b69ab31207 }
b69ab31208
b69ab31209 const filteredFiles = filterGeneratedFiles(includedFiles);
b69ab31210 if (filteredFiles.length > MAX_FILES_ALLOWED_FOR_DIFF_STAT) {
b69ab31211 return undefined;
b69ab31212 }
b69ab31213
b69ab31214 if (filteredFiles.length === 0) {
b69ab31215 return {sloc: 0};
b69ab31216 }
b69ab31217
b69ab31218 serverAPI.postMessage({
b69ab31219 type: 'fetchPendingSignificantLinesOfCode',
b69ab31220 hash: commit.hash,
b69ab31221 includedFiles: filteredFiles,
b69ab31222 requestId,
b69ab31223 });
b69ab31224
b69ab31225 const pendingLocData = await serverAPI
b69ab31226 .nextMessageMatching(
b69ab31227 'fetchedPendingSignificantLinesOfCode',
b69ab31228 message => message.requestId === requestId && message.hash === commit.hash,
b69ab31229 )
b69ab31230 .then(result => ({
b69ab31231 sloc: result.result.value,
b69ab31232 }));
b69ab31233
b69ab31234 return pendingLocData;
b69ab31235};
b69ab31236
b69ab31237function useFetchWithPrevious(atom: Atom<Loadable<Promise<SlocInfo | undefined>>>): {
b69ab31238 slocInfo: SlocInfo | undefined;
b69ab31239 isLoading: boolean;
b69ab31240} {
b69ab31241 const previous = useRef<SlocInfo | undefined>(undefined);
b69ab31242 const results = useAtomValue(atom);
b69ab31243 if (results.state === 'hasError') {
b69ab31244 throw results.error;
b69ab31245 }
b69ab31246 if (results.state === 'loading') {
b69ab31247 //using the previous value in the loading state to avoid flickering / jankiness in the UI
b69ab31248 return {slocInfo: previous.current, isLoading: true};
b69ab31249 }
b69ab31250
b69ab31251 previous.current = results.data;
b69ab31252
b69ab31253 return {slocInfo: results.data, isLoading: false};
b69ab31254}
b69ab31255
b69ab31256export function useFetchSignificantLinesOfCode(commit: CommitInfo) {
b69ab31257 const loadableAtom = loadable(commitSlocFamily(commit.hash));
b69ab31258 const result = useAtomValue(loadableAtom);
b69ab31259
b69ab31260 if (result.state === 'hasError') {
b69ab31261 throw result.error;
b69ab31262 }
b69ab31263
b69ab31264 if (result.state === 'loading') {
b69ab31265 return {slocInfo: undefined, isLoading: true};
b69ab31266 }
b69ab31267
b69ab31268 return {slocInfo: result.data, isLoading: false};
b69ab31269}
b69ab31270
b69ab31271// Debounce delay for SLOC requests to prevent spamming when many files are selected quickly
b69ab31272const DEBOUNCE_DELAY_MS = 300;
b69ab31273
b69ab31274// Hook that debounces an atom value
b69ab31275function useDebouncedAtomValue<T>(sourceAtom: Atom<T>, debounceMs: number): T {
b69ab31276 const currentValue = useAtomValue(sourceAtom);
b69ab31277 const [debouncedValue, setDebouncedValue] = useState(currentValue);
b69ab31278 const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
b69ab31279
b69ab31280 useEffect(() => {
b69ab31281 if (timeoutRef.current) {
b69ab31282 clearTimeout(timeoutRef.current);
b69ab31283 }
b69ab31284
b69ab31285 timeoutRef.current = setTimeout(() => {
b69ab31286 setDebouncedValue(currentValue);
b69ab31287 }, debounceMs);
b69ab31288
b69ab31289 return () => {
b69ab31290 if (timeoutRef.current) {
b69ab31291 clearTimeout(timeoutRef.current);
b69ab31292 }
b69ab31293 };
b69ab31294 }, [currentValue, debounceMs]);
b69ab31295
b69ab31296 return debouncedValue;
b69ab31297}
b69ab31298
b69ab31299let pendingRequestId = 0;
b69ab31300export function useFetchPendingSignificantLinesOfCode() {
b69ab31301 // Debounce selected files to prevent spamming SLOC requests
b69ab31302 const debouncedSelectedFiles = useDebouncedAtomValue(selectedFilesAtom, DEBOUNCE_DELAY_MS);
b69ab31303
b69ab31304 // Use a derived atom that depends on the debounced value
b69ab31305 const debouncedAtom = useMemo(() => {
b69ab31306 return atom(get => {
b69ab31307 // Force the atom to use the debounced value by creating a dependency
b69ab31308 // Note: we can't pass debouncedSelectedFiles directly to the atom,
b69ab31309 // so we create a new fetch call with it
b69ab31310 return fetchPendingSloc(get, debouncedSelectedFiles, pendingRequestId++);
b69ab31311 });
b69ab31312 }, [debouncedSelectedFiles]);
b69ab31313
b69ab31314 const loadableAtom = loadable(debouncedAtom);
b69ab31315 return useFetchWithPrevious(loadableAtom);
b69ab31316}
b69ab31317
b69ab31318let pendingAmendRequestId = 0;
b69ab31319export function useFetchPendingAmendSignificantLinesOfCode() {
b69ab31320 // Debounce selected files to prevent spamming SLOC requests
b69ab31321 const debouncedSelectedFiles = useDebouncedAtomValue(selectedFilesAtom, DEBOUNCE_DELAY_MS);
b69ab31322
b69ab31323 // Use a derived atom that depends on the debounced value
b69ab31324 const debouncedAtom = useMemo(() => {
b69ab31325 return atom(get => {
b69ab31326 return fetchPendingAmendSloc(get, debouncedSelectedFiles, pendingAmendRequestId++);
b69ab31327 });
b69ab31328 }, [debouncedSelectedFiles]);
b69ab31329
b69ab31330 const loadableAtom = loadable(debouncedAtom);
b69ab31331 return useFetchWithPrevious(loadableAtom);
b69ab31332}