| 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 | |
| 8 | import type {Comparison} from 'shared/Comparison'; |
| 9 | import type {ParsedDiff} from 'shared/patch/types'; |
| 10 | import type {Result} from '../types'; |
| 11 | import type {Context} from './SplitDiffView/types'; |
| 12 | |
| 13 | import deepEqual from 'fast-deep-equal'; |
| 14 | import {Button} from 'isl-components/Button'; |
| 15 | import {Dropdown} from 'isl-components/Dropdown'; |
| 16 | import {ErrorBoundary, ErrorNotice} from 'isl-components/ErrorNotice'; |
| 17 | import {Icon} from 'isl-components/Icon'; |
| 18 | import {RadioGroup} from 'isl-components/Radio'; |
| 19 | import {Subtle} from 'isl-components/Subtle'; |
| 20 | import {Tooltip} from 'isl-components/Tooltip'; |
| 21 | import {useAtom, useAtomValue, useSetAtom} from 'jotai'; |
| 22 | import {useEffect, useMemo, useState} from 'react'; |
| 23 | import { |
| 24 | ComparisonType, |
| 25 | comparisonIsAgainstHead, |
| 26 | comparisonStringKey, |
| 27 | labelForComparison, |
| 28 | } from 'shared/Comparison'; |
| 29 | import {group, notEmpty} from 'shared/utils'; |
| 30 | import serverAPI from '../ClientToServerAPI'; |
| 31 | import {EmptyState} from '../EmptyState'; |
| 32 | import {useGeneratedFileStatuses} from '../GeneratedFile'; |
| 33 | import {T, t} from '../i18n'; |
| 34 | import {atomFamilyWeak, atomLoadableWithRefresh, localStorageBackedAtom} from '../jotaiUtils'; |
| 35 | import platform from '../platform'; |
| 36 | import {latestHeadCommit} from '../serverAPIState'; |
| 37 | import {themeState} from '../theme'; |
| 38 | import {GeneratedStatus} from '../types'; |
| 39 | import {SplitDiffView} from './SplitDiffView'; |
| 40 | import {currentComparisonMode} from './atoms'; |
| 41 | import {parsePatchAndFilter, sortFilesByType} from './utils'; |
| 42 | |
| 43 | import './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 | */ |
| 49 | function mapResult<T, U>(result: Result<T>, fn: (t: T) => U): Result<U> { |
| 50 | return result.error == null ? {value: fn(result.value)} : result; |
| 51 | } |
| 52 | |
| 53 | const 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 | |
| 63 | type LineRangeKey = string; |
| 64 | export function keyForLineRange(param: {path: string; comparison: Comparison}): LineRangeKey { |
| 65 | return `${param.path}:${comparisonStringKey(param.comparison)}`; |
| 66 | } |
| 67 | |
| 68 | type ComparisonDisplayMode = 'unified' | 'split'; |
| 69 | const comparisonDisplayMode = localStorageBackedAtom<ComparisonDisplayMode | 'responsive'>( |
| 70 | 'isl.comparison-display-mode', |
| 71 | 'responsive', |
| 72 | ); |
| 73 | |
| 74 | export 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 | |
| 180 | const defaultComparisons = [ |
| 181 | ComparisonType.UncommittedChanges as const, |
| 182 | ComparisonType.HeadChanges as const, |
| 183 | ComparisonType.StackChanges as const, |
| 184 | ]; |
| 185 | function 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 | |
| 296 | function 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 | */ |
| 320 | function 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 | |
| 374 | function splitOrUnifiedBasedOnWidth() { |
| 375 | return window.innerWidth > 600 ? 'split' : 'unified'; |
| 376 | } |
| 377 | function 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 | |
| 398 | function 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 | |