15.7 KB465 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 {Comparison} from 'shared/Comparison';
9import type {ParsedDiff} from 'shared/patch/types';
10import type {Result} from '../types';
11import type {Context} from './SplitDiffView/types';
12
13import deepEqual from 'fast-deep-equal';
14import {Button} from 'isl-components/Button';
15import {Dropdown} from 'isl-components/Dropdown';
16import {ErrorBoundary, ErrorNotice} from 'isl-components/ErrorNotice';
17import {Icon} from 'isl-components/Icon';
18import {RadioGroup} from 'isl-components/Radio';
19import {Subtle} from 'isl-components/Subtle';
20import {Tooltip} from 'isl-components/Tooltip';
21import {useAtom, useAtomValue, useSetAtom} from 'jotai';
22import {useEffect, useMemo, useState} from 'react';
23import {
24 ComparisonType,
25 comparisonIsAgainstHead,
26 comparisonStringKey,
27 labelForComparison,
28} from 'shared/Comparison';
29import {group, notEmpty} from 'shared/utils';
30import serverAPI from '../ClientToServerAPI';
31import {EmptyState} from '../EmptyState';
32import {useGeneratedFileStatuses} from '../GeneratedFile';
33import {T, t} from '../i18n';
34import {atomFamilyWeak, atomLoadableWithRefresh, localStorageBackedAtom} from '../jotaiUtils';
35import platform from '../platform';
36import {latestHeadCommit} from '../serverAPIState';
37import {themeState} from '../theme';
38import {GeneratedStatus} from '../types';
39import {SplitDiffView} from './SplitDiffView';
40import {currentComparisonMode} from './atoms';
41import {parsePatchAndFilter, sortFilesByType} from './utils';
42
43import './ComparisonView.css';
44
45/**
46 * Transform Result<T> to Result<U> by applying `fn` on result.value.
47 * If the result is an error, just return it unchanged.
48 */
49function mapResult<T, U>(result: Result<T>, fn: (t: T) => U): Result<U> {
50 return result.error == null ? {value: fn(result.value)} : result;
51}
52
53const currentComparisonData = atomFamilyWeak((comparison: Comparison) =>
54 atomLoadableWithRefresh<Result<Array<ParsedDiff>>>(async () => {
55 serverAPI.postMessage({type: 'requestComparison', comparison});
56 const event = await serverAPI.nextMessageMatching('comparison', event =>
57 deepEqual(comparison, event.comparison),
58 );
59 return mapResult(event.data.diff, parsePatchAndFilter);
60 }),
61);
62
63type LineRangeKey = string;
64export function keyForLineRange(param: {path: string; comparison: Comparison}): LineRangeKey {
65 return `${param.path}:${comparisonStringKey(param.comparison)}`;
66}
67
68type ComparisonDisplayMode = 'unified' | 'split';
69const comparisonDisplayMode = localStorageBackedAtom<ComparisonDisplayMode | 'responsive'>(
70 'isl.comparison-display-mode',
71 'responsive',
72);
73
74export default function ComparisonView({
75 comparison,
76 dismiss,
77}: {
78 comparison: Comparison;
79 dismiss?: () => void;
80}) {
81 const compared = useAtomValue(currentComparisonData(comparison));
82
83 const displayMode = useComparisonDisplayMode();
84
85 const data = compared.state === 'hasData' ? compared.data : null;
86
87 const paths = useMemo(
88 () => data?.value?.map(file => file.newFileName).filter(notEmpty) ?? [],
89 [data?.value],
90 );
91 const generatedStatuses = useGeneratedFileStatuses(paths);
92 const [collapsedFiles, setCollapsedFile] = useCollapsedFilesState({
93 isLoading: compared.state === 'loading',
94 data,
95 });
96
97 let content;
98 if (data == null) {
99 content = <Icon icon="loading" />;
100 } else if (compared.state === 'hasError') {
101 const error = compared.error instanceof Error ? compared.error : new Error(`${compared.error}`);
102 content = <ErrorNotice error={error} title={t('Unable to load comparison')} />;
103 } else if (data?.value && data.value.length === 0) {
104 content =
105 comparison.type === ComparisonType.SinceLastCodeReviewSubmit ? (
106 <EmptyState>
107 <T>No Content Changes</T>
108 <br />
109 <Subtle>
110 <T> This commit might have been rebased</T>
111 </Subtle>
112 </EmptyState>
113 ) : (
114 <EmptyState>
115 <T>No Changes</T>
116 </EmptyState>
117 );
118 } else {
119 const files = data.value ?? [];
120 sortFilesByType(files);
121 const fileGroups = group(files, file => generatedStatuses[file.newFileName ?? '']);
122 content = (
123 <>
124 {fileGroups[GeneratedStatus.Manual]?.map((parsed, i) => (
125 <ComparisonViewFile
126 diff={parsed}
127 comparison={comparison}
128 key={i}
129 collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false}
130 setCollapsed={(collapsed: boolean) =>
131 setCollapsedFile(parsed.newFileName ?? '', collapsed)
132 }
133 generatedStatus={GeneratedStatus.Manual}
134 displayMode={displayMode}
135 />
136 ))}
137 {fileGroups[GeneratedStatus.PartiallyGenerated]?.map((parsed, i) => (
138 <ComparisonViewFile
139 diff={parsed}
140 comparison={comparison}
141 key={i}
142 collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false}
143 setCollapsed={(collapsed: boolean) =>
144 setCollapsedFile(parsed.newFileName ?? '', collapsed)
145 }
146 generatedStatus={GeneratedStatus.PartiallyGenerated}
147 displayMode={displayMode}
148 />
149 ))}
150 {fileGroups[GeneratedStatus.Generated]?.map((parsed, i) => (
151 <ComparisonViewFile
152 diff={parsed}
153 comparison={comparison}
154 key={i}
155 collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false}
156 setCollapsed={(collapsed: boolean) =>
157 setCollapsedFile(parsed.newFileName ?? '', collapsed)
158 }
159 generatedStatus={GeneratedStatus.Generated}
160 displayMode={displayMode}
161 />
162 ))}
163 </>
164 );
165 }
166
167 return (
168 <div data-testid="comparison-view" className="comparison-view">
169 <ComparisonViewHeader
170 comparison={comparison}
171 collapsedFiles={collapsedFiles}
172 setCollapsedFile={setCollapsedFile}
173 dismiss={dismiss}
174 />
175 <div className="comparison-view-details">{content}</div>
176 </div>
177 );
178}
179
180const defaultComparisons = [
181 ComparisonType.UncommittedChanges as const,
182 ComparisonType.HeadChanges as const,
183 ComparisonType.StackChanges as const,
184];
185function ComparisonViewHeader({
186 comparison,
187 collapsedFiles,
188 setCollapsedFile,
189 dismiss,
190}: {
191 comparison: Comparison;
192 collapsedFiles: Map<string, boolean>;
193 setCollapsedFile: (path: string, collapsed: boolean) => unknown;
194 dismiss?: () => void;
195}) {
196 const setComparisonMode = useSetAtom(currentComparisonMode);
197 const [compared, reloadComparison] = useAtom(currentComparisonData(comparison));
198
199 const data = compared.state === 'hasData' ? compared.data : null;
200
201 const allFilesExpanded =
202 data?.value?.every(
203 file => file.newFileName && collapsedFiles.get(file.newFileName) === false,
204 ) === true;
205 const noFilesExpanded =
206 data?.value?.every(
207 file => file.newFileName && collapsedFiles.get(file.newFileName) === true,
208 ) === true;
209 const isLoading = compared.state === 'loading';
210
211 return (
212 <>
213 <div className="comparison-view-header">
214 <span className="comparison-view-header-group">
215 <Dropdown
216 data-testid="comparison-view-picker"
217 value={comparison.type}
218 onChange={event => {
219 const newComparison = {
220 type: (event as React.FormEvent<HTMLSelectElement>).currentTarget
221 .value as (typeof defaultComparisons)[0],
222 };
223 setComparisonMode(previous => ({
224 ...previous,
225 comparison: newComparison,
226 }));
227 // When viewed in a dedicated viewer, change the title as the comparison changes
228 if (window.islAppMode != null && window.islAppMode.mode != 'isl') {
229 serverAPI.postMessage({
230 type: 'platform/changeTitle',
231 title: labelForComparison(newComparison),
232 });
233 }
234 }}
235 options={[
236 ...defaultComparisons.map(comparison => ({
237 value: comparison,
238 name: labelForComparison({type: comparison}),
239 })),
240
241 !defaultComparisons.includes(comparison.type as (typeof defaultComparisons)[0])
242 ? {value: comparison.type, name: labelForComparison(comparison)}
243 : undefined,
244 ].filter(notEmpty)}
245 />
246 <Tooltip
247 delayMs={1000}
248 title={t('Reload this comparison. Comparisons do not refresh automatically.')}>
249 <Button onClick={reloadComparison}>
250 <Icon icon="refresh" data-testid="comparison-refresh-button" />
251 </Button>
252 </Tooltip>
253 <Button
254 onClick={() => {
255 for (const file of data?.value ?? []) {
256 if (file.newFileName) {
257 setCollapsedFile(file.newFileName, false);
258 }
259 }
260 }}
261 disabled={isLoading || allFilesExpanded}
262 icon>
263 <Icon icon="unfold" slot="start" />
264 <T>Expand all files</T>
265 </Button>
266 <Button
267 onClick={() => {
268 for (const file of data?.value ?? []) {
269 if (file.newFileName) {
270 setCollapsedFile(file.newFileName, true);
271 }
272 }
273 }}
274 icon
275 disabled={isLoading || noFilesExpanded}>
276 <Icon icon="fold" slot="start" />
277 <T>Collapse all files</T>
278 </Button>
279 <Tooltip trigger="click" component={() => <ComparisonSettingsDropdown />}>
280 <Button icon>
281 <Icon icon="ellipsis" />
282 </Button>
283 </Tooltip>
284 {isLoading ? <Icon icon="loading" data-testid="comparison-loading" /> : null}
285 </span>
286 {dismiss == null ? null : (
287 <Button data-testid="close-comparison-view-button" icon onClick={dismiss}>
288 <Icon icon="x" />
289 </Button>
290 )}
291 </div>
292 </>
293 );
294}
295
296function ComparisonSettingsDropdown() {
297 const [mode, setMode] = useAtom(comparisonDisplayMode);
298 return (
299 <div className="dropdown-field">
300 <RadioGroup
301 title={t('Comparison Display Mode')}
302 choices={[
303 {value: 'responsive', title: <T>Responsive</T>},
304 {value: 'split', title: <T>Split</T>},
305 {value: 'unified', title: <T>Unified</T>},
306 ]}
307 current={mode}
308 onChange={setMode}
309 />
310 </div>
311 );
312}
313
314/**
315 * Derive from the parsed diff state which files should be expanded or collapsed by default.
316 * This state is the source of truth of which files are expanded/collapsed.
317 * This is a hook instead of a recoil selector since it depends on the comparison
318 * which is a prop.
319 */
320function useCollapsedFilesState(data: {
321 isLoading: boolean;
322 data: Result<Array<ParsedDiff>> | null;
323}): [Map<string, boolean>, (path: string, collapsed: boolean) => void] {
324 const [collapsedFiles, setCollapsedFiles] = useState(new Map());
325
326 useEffect(() => {
327 if (data.isLoading || data.data?.value == null) {
328 return;
329 }
330
331 const newCollapsedFiles = new Map(collapsedFiles);
332
333 // Allocate a number of changed lines we're willing to show expanded by default,
334 // add files until we just cross that threshold.
335 // This means a single very large file will start expanded already.
336 const TOTAL_DEFAULT_EXPANDED_SIZE = 4000;
337 let accumulatedSize = 0;
338 let indexToStartCollapsing = Infinity;
339 for (const [i, diff] of data.data.value.entries()) {
340 const sizeThisFile = diff.hunks.reduce((last, hunk) => last + hunk.lines.length, 0);
341 accumulatedSize += sizeThisFile;
342 if (accumulatedSize > TOTAL_DEFAULT_EXPANDED_SIZE) {
343 indexToStartCollapsing = i;
344 break;
345 }
346 }
347
348 let anyChanged = false;
349 for (const [i, diff] of data.data.value.entries()) {
350 if (!newCollapsedFiles.has(diff.newFileName)) {
351 newCollapsedFiles.set(diff.newFileName, i > 0 && i >= indexToStartCollapsing);
352 anyChanged = true;
353 }
354 // Leave existing files alone in case the user changed their expanded state.
355 }
356 if (anyChanged) {
357 setCollapsedFiles(newCollapsedFiles);
358 // We don't bother removing files that no longer appear in the list of files.
359 // That's not a big deal, this state is local to this instance of the comparison view anyway.
360 }
361 }, [data, collapsedFiles]);
362
363 const setCollapsed = (path: string, collapsed: boolean) => {
364 setCollapsedFiles(prev => {
365 const map = new Map(prev);
366 map.set(path, collapsed);
367 return map;
368 });
369 };
370
371 return [collapsedFiles, setCollapsed];
372}
373
374function splitOrUnifiedBasedOnWidth() {
375 return window.innerWidth > 600 ? 'split' : 'unified';
376}
377function useComparisonDisplayMode(): ComparisonDisplayMode {
378 const underlyingMode = useAtomValue(comparisonDisplayMode);
379 const [mode, setMode] = useState(
380 underlyingMode === 'responsive' ? splitOrUnifiedBasedOnWidth() : underlyingMode,
381 );
382 useEffect(() => {
383 if (underlyingMode !== 'responsive') {
384 setMode(underlyingMode);
385 return;
386 }
387 const update = () => {
388 setMode(splitOrUnifiedBasedOnWidth());
389 };
390 update();
391 window.addEventListener('resize', update);
392 return () => window.removeEventListener('resize', update);
393 }, [underlyingMode, setMode]);
394
395 return mode;
396}
397
398function ComparisonViewFile({
399 diff,
400 comparison,
401 collapsed,
402 setCollapsed,
403 generatedStatus,
404 displayMode,
405}: {
406 diff: ParsedDiff;
407 comparison: Comparison;
408 collapsed: boolean;
409 setCollapsed: (isCollapsed: boolean) => void;
410 generatedStatus: GeneratedStatus;
411 displayMode: ComparisonDisplayMode;
412}) {
413 const path = diff.newFileName ?? diff.oldFileName ?? '';
414 const context: Context = {
415 id: {path, comparison},
416 copy: platform.clipboardCopy,
417 openFile: () => platform.openFile(path),
418 // only offer clickable line numbers for comparisons against head, otherwise line numbers will be inaccurate
419 openFileToLine: comparisonIsAgainstHead(comparison)
420 ? (line: number) => platform.openFile(path, {line})
421 : undefined,
422
423 async fetchAdditionalLines(id, start, numLines) {
424 serverAPI.postMessage({
425 type: 'requestComparisonContextLines',
426 numLines,
427 start,
428 id,
429 });
430
431 const result = await serverAPI.nextMessageMatching(
432 'comparisonContextLines',
433 msg => msg.path === id.path,
434 );
435
436 return result.lines;
437 },
438 // We must ensure the lineRange gets invalidated when the underlying file's context lines
439 // have changed.
440 // This depends on the comparison:
441 // for Committed: the commit hash is included in the Comparison, thus the cached data will always be accurate.
442 // for Uncommitted, Head, and Stack:
443 // by referencing the latest head commit's hash, we ensure this selector reloads when the head commit changes.
444 // These comparisons are all against the working copy (not exactly head),
445 // but there's no change that could be made that would affect the context lines without
446 // also changing the head commit's hash.
447 // Note: we use latestHeadCommit WITHOUT previews, so we don't accidentally cache the file content
448 // AGAIN on the same data while waiting for some new operation to finish.
449 // eslint-disable-next-line react-hooks/rules-of-hooks
450 useComparisonInvalidationKeyHook: () => useAtomValue(latestHeadCommit)?.hash ?? '',
451 useThemeHook: () => useAtomValue(themeState),
452 t,
453 collapsed,
454 setCollapsed,
455 display: displayMode,
456 };
457 return (
458 <div className="comparison-view-file" key={path}>
459 <ErrorBoundary>
460 <SplitDiffView ctx={context} patch={diff} path={path} generatedStatus={generatedStatus} />
461 </ErrorBoundary>
462 </div>
463 );
464}
465