addons/isl/src/ComparisonView/ComparisonView.tsxblame
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 {Comparison} from 'shared/Comparison';
b69ab319import type {ParsedDiff} from 'shared/patch/types';
b69ab3110import type {Result} from '../types';
b69ab3111import type {Context} from './SplitDiffView/types';
b69ab3112
b69ab3113import deepEqual from 'fast-deep-equal';
b69ab3114import {Button} from 'isl-components/Button';
b69ab3115import {Dropdown} from 'isl-components/Dropdown';
b69ab3116import {ErrorBoundary, ErrorNotice} from 'isl-components/ErrorNotice';
b69ab3117import {Icon} from 'isl-components/Icon';
b69ab3118import {RadioGroup} from 'isl-components/Radio';
b69ab3119import {Subtle} from 'isl-components/Subtle';
b69ab3120import {Tooltip} from 'isl-components/Tooltip';
b69ab3121import {useAtom, useAtomValue, useSetAtom} from 'jotai';
b69ab3122import {useEffect, useMemo, useState} from 'react';
b69ab3123import {
b69ab3124 ComparisonType,
b69ab3125 comparisonIsAgainstHead,
b69ab3126 comparisonStringKey,
b69ab3127 labelForComparison,
b69ab3128} from 'shared/Comparison';
b69ab3129import {group, notEmpty} from 'shared/utils';
b69ab3130import serverAPI from '../ClientToServerAPI';
b69ab3131import {EmptyState} from '../EmptyState';
b69ab3132import {useGeneratedFileStatuses} from '../GeneratedFile';
b69ab3133import {T, t} from '../i18n';
b69ab3134import {atomFamilyWeak, atomLoadableWithRefresh, localStorageBackedAtom} from '../jotaiUtils';
b69ab3135import platform from '../platform';
b69ab3136import {latestHeadCommit} from '../serverAPIState';
b69ab3137import {themeState} from '../theme';
b69ab3138import {GeneratedStatus} from '../types';
b69ab3139import {SplitDiffView} from './SplitDiffView';
b69ab3140import {currentComparisonMode} from './atoms';
b69ab3141import {parsePatchAndFilter, sortFilesByType} from './utils';
b69ab3142
b69ab3143import './ComparisonView.css';
b69ab3144
b69ab3145/**
b69ab3146 * Transform Result<T> to Result<U> by applying `fn` on result.value.
b69ab3147 * If the result is an error, just return it unchanged.
b69ab3148 */
b69ab3149function mapResult<T, U>(result: Result<T>, fn: (t: T) => U): Result<U> {
b69ab3150 return result.error == null ? {value: fn(result.value)} : result;
b69ab3151}
b69ab3152
b69ab3153const currentComparisonData = atomFamilyWeak((comparison: Comparison) =>
b69ab3154 atomLoadableWithRefresh<Result<Array<ParsedDiff>>>(async () => {
b69ab3155 serverAPI.postMessage({type: 'requestComparison', comparison});
b69ab3156 const event = await serverAPI.nextMessageMatching('comparison', event =>
b69ab3157 deepEqual(comparison, event.comparison),
b69ab3158 );
b69ab3159 return mapResult(event.data.diff, parsePatchAndFilter);
b69ab3160 }),
b69ab3161);
b69ab3162
b69ab3163type LineRangeKey = string;
b69ab3164export function keyForLineRange(param: {path: string; comparison: Comparison}): LineRangeKey {
b69ab3165 return `${param.path}:${comparisonStringKey(param.comparison)}`;
b69ab3166}
b69ab3167
b69ab3168type ComparisonDisplayMode = 'unified' | 'split';
b69ab3169const comparisonDisplayMode = localStorageBackedAtom<ComparisonDisplayMode | 'responsive'>(
b69ab3170 'isl.comparison-display-mode',
b69ab3171 'responsive',
b69ab3172);
b69ab3173
b69ab3174export default function ComparisonView({
b69ab3175 comparison,
b69ab3176 dismiss,
b69ab3177}: {
b69ab3178 comparison: Comparison;
b69ab3179 dismiss?: () => void;
b69ab3180}) {
b69ab3181 const compared = useAtomValue(currentComparisonData(comparison));
b69ab3182
b69ab3183 const displayMode = useComparisonDisplayMode();
b69ab3184
b69ab3185 const data = compared.state === 'hasData' ? compared.data : null;
b69ab3186
b69ab3187 const paths = useMemo(
b69ab3188 () => data?.value?.map(file => file.newFileName).filter(notEmpty) ?? [],
b69ab3189 [data?.value],
b69ab3190 );
b69ab3191 const generatedStatuses = useGeneratedFileStatuses(paths);
b69ab3192 const [collapsedFiles, setCollapsedFile] = useCollapsedFilesState({
b69ab3193 isLoading: compared.state === 'loading',
b69ab3194 data,
b69ab3195 });
b69ab3196
b69ab3197 let content;
b69ab3198 if (data == null) {
b69ab3199 content = <Icon icon="loading" />;
b69ab31100 } else if (compared.state === 'hasError') {
b69ab31101 const error = compared.error instanceof Error ? compared.error : new Error(`${compared.error}`);
b69ab31102 content = <ErrorNotice error={error} title={t('Unable to load comparison')} />;
b69ab31103 } else if (data?.value && data.value.length === 0) {
b69ab31104 content =
b69ab31105 comparison.type === ComparisonType.SinceLastCodeReviewSubmit ? (
b69ab31106 <EmptyState>
b69ab31107 <T>No Content Changes</T>
b69ab31108 <br />
b69ab31109 <Subtle>
b69ab31110 <T> This commit might have been rebased</T>
b69ab31111 </Subtle>
b69ab31112 </EmptyState>
b69ab31113 ) : (
b69ab31114 <EmptyState>
b69ab31115 <T>No Changes</T>
b69ab31116 </EmptyState>
b69ab31117 );
b69ab31118 } else {
b69ab31119 const files = data.value ?? [];
b69ab31120 sortFilesByType(files);
b69ab31121 const fileGroups = group(files, file => generatedStatuses[file.newFileName ?? '']);
b69ab31122 content = (
b69ab31123 <>
b69ab31124 {fileGroups[GeneratedStatus.Manual]?.map((parsed, i) => (
b69ab31125 <ComparisonViewFile
b69ab31126 diff={parsed}
b69ab31127 comparison={comparison}
b69ab31128 key={i}
b69ab31129 collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false}
b69ab31130 setCollapsed={(collapsed: boolean) =>
b69ab31131 setCollapsedFile(parsed.newFileName ?? '', collapsed)
b69ab31132 }
b69ab31133 generatedStatus={GeneratedStatus.Manual}
b69ab31134 displayMode={displayMode}
b69ab31135 />
b69ab31136 ))}
b69ab31137 {fileGroups[GeneratedStatus.PartiallyGenerated]?.map((parsed, i) => (
b69ab31138 <ComparisonViewFile
b69ab31139 diff={parsed}
b69ab31140 comparison={comparison}
b69ab31141 key={i}
b69ab31142 collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false}
b69ab31143 setCollapsed={(collapsed: boolean) =>
b69ab31144 setCollapsedFile(parsed.newFileName ?? '', collapsed)
b69ab31145 }
b69ab31146 generatedStatus={GeneratedStatus.PartiallyGenerated}
b69ab31147 displayMode={displayMode}
b69ab31148 />
b69ab31149 ))}
b69ab31150 {fileGroups[GeneratedStatus.Generated]?.map((parsed, i) => (
b69ab31151 <ComparisonViewFile
b69ab31152 diff={parsed}
b69ab31153 comparison={comparison}
b69ab31154 key={i}
b69ab31155 collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false}
b69ab31156 setCollapsed={(collapsed: boolean) =>
b69ab31157 setCollapsedFile(parsed.newFileName ?? '', collapsed)
b69ab31158 }
b69ab31159 generatedStatus={GeneratedStatus.Generated}
b69ab31160 displayMode={displayMode}
b69ab31161 />
b69ab31162 ))}
b69ab31163 </>
b69ab31164 );
b69ab31165 }
b69ab31166
b69ab31167 return (
b69ab31168 <div data-testid="comparison-view" className="comparison-view">
b69ab31169 <ComparisonViewHeader
b69ab31170 comparison={comparison}
b69ab31171 collapsedFiles={collapsedFiles}
b69ab31172 setCollapsedFile={setCollapsedFile}
b69ab31173 dismiss={dismiss}
b69ab31174 />
b69ab31175 <div className="comparison-view-details">{content}</div>
b69ab31176 </div>
b69ab31177 );
b69ab31178}
b69ab31179
b69ab31180const defaultComparisons = [
b69ab31181 ComparisonType.UncommittedChanges as const,
b69ab31182 ComparisonType.HeadChanges as const,
b69ab31183 ComparisonType.StackChanges as const,
b69ab31184];
b69ab31185function ComparisonViewHeader({
b69ab31186 comparison,
b69ab31187 collapsedFiles,
b69ab31188 setCollapsedFile,
b69ab31189 dismiss,
b69ab31190}: {
b69ab31191 comparison: Comparison;
b69ab31192 collapsedFiles: Map<string, boolean>;
b69ab31193 setCollapsedFile: (path: string, collapsed: boolean) => unknown;
b69ab31194 dismiss?: () => void;
b69ab31195}) {
b69ab31196 const setComparisonMode = useSetAtom(currentComparisonMode);
b69ab31197 const [compared, reloadComparison] = useAtom(currentComparisonData(comparison));
b69ab31198
b69ab31199 const data = compared.state === 'hasData' ? compared.data : null;
b69ab31200
b69ab31201 const allFilesExpanded =
b69ab31202 data?.value?.every(
b69ab31203 file => file.newFileName && collapsedFiles.get(file.newFileName) === false,
b69ab31204 ) === true;
b69ab31205 const noFilesExpanded =
b69ab31206 data?.value?.every(
b69ab31207 file => file.newFileName && collapsedFiles.get(file.newFileName) === true,
b69ab31208 ) === true;
b69ab31209 const isLoading = compared.state === 'loading';
b69ab31210
b69ab31211 return (
b69ab31212 <>
b69ab31213 <div className="comparison-view-header">
b69ab31214 <span className="comparison-view-header-group">
b69ab31215 <Dropdown
b69ab31216 data-testid="comparison-view-picker"
b69ab31217 value={comparison.type}
b69ab31218 onChange={event => {
b69ab31219 const newComparison = {
b69ab31220 type: (event as React.FormEvent<HTMLSelectElement>).currentTarget
b69ab31221 .value as (typeof defaultComparisons)[0],
b69ab31222 };
b69ab31223 setComparisonMode(previous => ({
b69ab31224 ...previous,
b69ab31225 comparison: newComparison,
b69ab31226 }));
b69ab31227 // When viewed in a dedicated viewer, change the title as the comparison changes
b69ab31228 if (window.islAppMode != null && window.islAppMode.mode != 'isl') {
b69ab31229 serverAPI.postMessage({
b69ab31230 type: 'platform/changeTitle',
b69ab31231 title: labelForComparison(newComparison),
b69ab31232 });
b69ab31233 }
b69ab31234 }}
b69ab31235 options={[
b69ab31236 ...defaultComparisons.map(comparison => ({
b69ab31237 value: comparison,
b69ab31238 name: labelForComparison({type: comparison}),
b69ab31239 })),
b69ab31240
b69ab31241 !defaultComparisons.includes(comparison.type as (typeof defaultComparisons)[0])
b69ab31242 ? {value: comparison.type, name: labelForComparison(comparison)}
b69ab31243 : undefined,
b69ab31244 ].filter(notEmpty)}
b69ab31245 />
b69ab31246 <Tooltip
b69ab31247 delayMs={1000}
b69ab31248 title={t('Reload this comparison. Comparisons do not refresh automatically.')}>
b69ab31249 <Button onClick={reloadComparison}>
b69ab31250 <Icon icon="refresh" data-testid="comparison-refresh-button" />
b69ab31251 </Button>
b69ab31252 </Tooltip>
b69ab31253 <Button
b69ab31254 onClick={() => {
b69ab31255 for (const file of data?.value ?? []) {
b69ab31256 if (file.newFileName) {
b69ab31257 setCollapsedFile(file.newFileName, false);
b69ab31258 }
b69ab31259 }
b69ab31260 }}
b69ab31261 disabled={isLoading || allFilesExpanded}
b69ab31262 icon>
b69ab31263 <Icon icon="unfold" slot="start" />
b69ab31264 <T>Expand all files</T>
b69ab31265 </Button>
b69ab31266 <Button
b69ab31267 onClick={() => {
b69ab31268 for (const file of data?.value ?? []) {
b69ab31269 if (file.newFileName) {
b69ab31270 setCollapsedFile(file.newFileName, true);
b69ab31271 }
b69ab31272 }
b69ab31273 }}
b69ab31274 icon
b69ab31275 disabled={isLoading || noFilesExpanded}>
b69ab31276 <Icon icon="fold" slot="start" />
b69ab31277 <T>Collapse all files</T>
b69ab31278 </Button>
b69ab31279 <Tooltip trigger="click" component={() => <ComparisonSettingsDropdown />}>
b69ab31280 <Button icon>
b69ab31281 <Icon icon="ellipsis" />
b69ab31282 </Button>
b69ab31283 </Tooltip>
b69ab31284 {isLoading ? <Icon icon="loading" data-testid="comparison-loading" /> : null}
b69ab31285 </span>
b69ab31286 {dismiss == null ? null : (
b69ab31287 <Button data-testid="close-comparison-view-button" icon onClick={dismiss}>
b69ab31288 <Icon icon="x" />
b69ab31289 </Button>
b69ab31290 )}
b69ab31291 </div>
b69ab31292 </>
b69ab31293 );
b69ab31294}
b69ab31295
b69ab31296function ComparisonSettingsDropdown() {
b69ab31297 const [mode, setMode] = useAtom(comparisonDisplayMode);
b69ab31298 return (
b69ab31299 <div className="dropdown-field">
b69ab31300 <RadioGroup
b69ab31301 title={t('Comparison Display Mode')}
b69ab31302 choices={[
b69ab31303 {value: 'responsive', title: <T>Responsive</T>},
b69ab31304 {value: 'split', title: <T>Split</T>},
b69ab31305 {value: 'unified', title: <T>Unified</T>},
b69ab31306 ]}
b69ab31307 current={mode}
b69ab31308 onChange={setMode}
b69ab31309 />
b69ab31310 </div>
b69ab31311 );
b69ab31312}
b69ab31313
b69ab31314/**
b69ab31315 * Derive from the parsed diff state which files should be expanded or collapsed by default.
b69ab31316 * This state is the source of truth of which files are expanded/collapsed.
b69ab31317 * This is a hook instead of a recoil selector since it depends on the comparison
b69ab31318 * which is a prop.
b69ab31319 */
b69ab31320function useCollapsedFilesState(data: {
b69ab31321 isLoading: boolean;
b69ab31322 data: Result<Array<ParsedDiff>> | null;
b69ab31323}): [Map<string, boolean>, (path: string, collapsed: boolean) => void] {
b69ab31324 const [collapsedFiles, setCollapsedFiles] = useState(new Map());
b69ab31325
b69ab31326 useEffect(() => {
b69ab31327 if (data.isLoading || data.data?.value == null) {
b69ab31328 return;
b69ab31329 }
b69ab31330
b69ab31331 const newCollapsedFiles = new Map(collapsedFiles);
b69ab31332
b69ab31333 // Allocate a number of changed lines we're willing to show expanded by default,
b69ab31334 // add files until we just cross that threshold.
b69ab31335 // This means a single very large file will start expanded already.
b69ab31336 const TOTAL_DEFAULT_EXPANDED_SIZE = 4000;
b69ab31337 let accumulatedSize = 0;
b69ab31338 let indexToStartCollapsing = Infinity;
b69ab31339 for (const [i, diff] of data.data.value.entries()) {
b69ab31340 const sizeThisFile = diff.hunks.reduce((last, hunk) => last + hunk.lines.length, 0);
b69ab31341 accumulatedSize += sizeThisFile;
b69ab31342 if (accumulatedSize > TOTAL_DEFAULT_EXPANDED_SIZE) {
b69ab31343 indexToStartCollapsing = i;
b69ab31344 break;
b69ab31345 }
b69ab31346 }
b69ab31347
b69ab31348 let anyChanged = false;
b69ab31349 for (const [i, diff] of data.data.value.entries()) {
b69ab31350 if (!newCollapsedFiles.has(diff.newFileName)) {
b69ab31351 newCollapsedFiles.set(diff.newFileName, i > 0 && i >= indexToStartCollapsing);
b69ab31352 anyChanged = true;
b69ab31353 }
b69ab31354 // Leave existing files alone in case the user changed their expanded state.
b69ab31355 }
b69ab31356 if (anyChanged) {
b69ab31357 setCollapsedFiles(newCollapsedFiles);
b69ab31358 // We don't bother removing files that no longer appear in the list of files.
b69ab31359 // That's not a big deal, this state is local to this instance of the comparison view anyway.
b69ab31360 }
b69ab31361 }, [data, collapsedFiles]);
b69ab31362
b69ab31363 const setCollapsed = (path: string, collapsed: boolean) => {
b69ab31364 setCollapsedFiles(prev => {
b69ab31365 const map = new Map(prev);
b69ab31366 map.set(path, collapsed);
b69ab31367 return map;
b69ab31368 });
b69ab31369 };
b69ab31370
b69ab31371 return [collapsedFiles, setCollapsed];
b69ab31372}
b69ab31373
b69ab31374function splitOrUnifiedBasedOnWidth() {
b69ab31375 return window.innerWidth > 600 ? 'split' : 'unified';
b69ab31376}
b69ab31377function useComparisonDisplayMode(): ComparisonDisplayMode {
b69ab31378 const underlyingMode = useAtomValue(comparisonDisplayMode);
b69ab31379 const [mode, setMode] = useState(
b69ab31380 underlyingMode === 'responsive' ? splitOrUnifiedBasedOnWidth() : underlyingMode,
b69ab31381 );
b69ab31382 useEffect(() => {
b69ab31383 if (underlyingMode !== 'responsive') {
b69ab31384 setMode(underlyingMode);
b69ab31385 return;
b69ab31386 }
b69ab31387 const update = () => {
b69ab31388 setMode(splitOrUnifiedBasedOnWidth());
b69ab31389 };
b69ab31390 update();
b69ab31391 window.addEventListener('resize', update);
b69ab31392 return () => window.removeEventListener('resize', update);
b69ab31393 }, [underlyingMode, setMode]);
b69ab31394
b69ab31395 return mode;
b69ab31396}
b69ab31397
b69ab31398function ComparisonViewFile({
b69ab31399 diff,
b69ab31400 comparison,
b69ab31401 collapsed,
b69ab31402 setCollapsed,
b69ab31403 generatedStatus,
b69ab31404 displayMode,
b69ab31405}: {
b69ab31406 diff: ParsedDiff;
b69ab31407 comparison: Comparison;
b69ab31408 collapsed: boolean;
b69ab31409 setCollapsed: (isCollapsed: boolean) => void;
b69ab31410 generatedStatus: GeneratedStatus;
b69ab31411 displayMode: ComparisonDisplayMode;
b69ab31412}) {
b69ab31413 const path = diff.newFileName ?? diff.oldFileName ?? '';
b69ab31414 const context: Context = {
b69ab31415 id: {path, comparison},
b69ab31416 copy: platform.clipboardCopy,
b69ab31417 openFile: () => platform.openFile(path),
b69ab31418 // only offer clickable line numbers for comparisons against head, otherwise line numbers will be inaccurate
b69ab31419 openFileToLine: comparisonIsAgainstHead(comparison)
b69ab31420 ? (line: number) => platform.openFile(path, {line})
b69ab31421 : undefined,
b69ab31422
b69ab31423 async fetchAdditionalLines(id, start, numLines) {
b69ab31424 serverAPI.postMessage({
b69ab31425 type: 'requestComparisonContextLines',
b69ab31426 numLines,
b69ab31427 start,
b69ab31428 id,
b69ab31429 });
b69ab31430
b69ab31431 const result = await serverAPI.nextMessageMatching(
b69ab31432 'comparisonContextLines',
b69ab31433 msg => msg.path === id.path,
b69ab31434 );
b69ab31435
b69ab31436 return result.lines;
b69ab31437 },
b69ab31438 // We must ensure the lineRange gets invalidated when the underlying file's context lines
b69ab31439 // have changed.
b69ab31440 // This depends on the comparison:
b69ab31441 // for Committed: the commit hash is included in the Comparison, thus the cached data will always be accurate.
b69ab31442 // for Uncommitted, Head, and Stack:
b69ab31443 // by referencing the latest head commit's hash, we ensure this selector reloads when the head commit changes.
b69ab31444 // These comparisons are all against the working copy (not exactly head),
b69ab31445 // but there's no change that could be made that would affect the context lines without
b69ab31446 // also changing the head commit's hash.
b69ab31447 // Note: we use latestHeadCommit WITHOUT previews, so we don't accidentally cache the file content
b69ab31448 // AGAIN on the same data while waiting for some new operation to finish.
b69ab31449 // eslint-disable-next-line react-hooks/rules-of-hooks
b69ab31450 useComparisonInvalidationKeyHook: () => useAtomValue(latestHeadCommit)?.hash ?? '',
b69ab31451 useThemeHook: () => useAtomValue(themeState),
b69ab31452 t,
b69ab31453 collapsed,
b69ab31454 setCollapsed,
b69ab31455 display: displayMode,
b69ab31456 };
b69ab31457 return (
b69ab31458 <div className="comparison-view-file" key={path}>
b69ab31459 <ErrorBoundary>
b69ab31460 <SplitDiffView ctx={context} patch={diff} path={path} generatedStatus={generatedStatus} />
b69ab31461 </ErrorBoundary>
b69ab31462 </div>
b69ab31463 );
b69ab31464}