| 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 {ReactNode} from 'react'; |
| 9 | import type {CommitInfo, DiffId, DiffSignalSummary, DiffSummary} from '../types'; |
| 10 | import type {UICodeReviewProvider} from './UICodeReviewProvider'; |
| 11 | |
| 12 | import * as stylex from '@stylexjs/stylex'; |
| 13 | import {Button} from 'isl-components/Button'; |
| 14 | import {Icon} from 'isl-components/Icon'; |
| 15 | import {Tooltip} from 'isl-components/Tooltip'; |
| 16 | import {useAtomValue} from 'jotai'; |
| 17 | import {Component, lazy, Suspense, useState} from 'react'; |
| 18 | import {useShowConfirmSubmitStack} from '../ConfirmSubmitStack'; |
| 19 | import {Internal} from '../Internal'; |
| 20 | import {Link} from '../Link'; |
| 21 | import {clipboardCopyLink, clipboardCopyText} from '../clipboard'; |
| 22 | import {useFeatureFlagSync} from '../featureFlags'; |
| 23 | import {T, t} from '../i18n'; |
| 24 | import {CircleEllipsisIcon} from '../icons/CircleEllipsisIcon'; |
| 25 | import {CircleExclamationIcon} from '../icons/CircleExclamationIcon'; |
| 26 | import {configBackedAtom, useAtomGet} from '../jotaiUtils'; |
| 27 | import {PullRevOperation} from '../operations/PullRevOperation'; |
| 28 | import {useRunOperation} from '../operationsState'; |
| 29 | import platform from '../platform'; |
| 30 | import {exactRevset} from '../types'; |
| 31 | import {codeReviewProvider, diffIdForCommit, diffSummary} from './CodeReviewInfo'; |
| 32 | import './DiffBadge.css'; |
| 33 | import {openerUrlForDiffUrl} from './github/GitHubUrlOpener'; |
| 34 | import {SyncStatus, syncStatusAtom} from './syncStatus'; |
| 35 | |
| 36 | const DiffCommentsDetails = lazy(() => import('./DiffComments')); |
| 37 | |
| 38 | export const showDiffNumberConfig = configBackedAtom<boolean>('isl.show-diff-number', false); |
| 39 | |
| 40 | /** |
| 41 | * Component that shows inline summary information about a Diff, |
| 42 | * such as its status, number of comments, CI state, etc. |
| 43 | */ |
| 44 | export function DiffInfo({commit, hideActions}: {commit: CommitInfo; hideActions: boolean}) { |
| 45 | const repo = useAtomValue(codeReviewProvider); |
| 46 | const diffId = useAtomValue(diffIdForCommit(commit.hash)); |
| 47 | if (repo == null || diffId == null) { |
| 48 | return null; |
| 49 | } |
| 50 | |
| 51 | // Do not show diff info (and "Ship It" button) if there are successors. |
| 52 | // Users should look at the diff info and buttons from the successor commit instead. |
| 53 | // But the diff number can still be useful so show it. |
| 54 | if (commit.successorInfo != null) { |
| 55 | return <DiffNumber>{repo.formatDiffNumber(diffId)}</DiffNumber>; |
| 56 | } |
| 57 | return ( |
| 58 | <DiffErrorBoundary provider={repo} diffId={diffId}> |
| 59 | <Suspense fallback={<DiffSpinner diffId={diffId} provider={repo} />}> |
| 60 | <DiffInfoInner commit={commit} diffId={diffId} provider={repo} hideActions={hideActions} /> |
| 61 | </Suspense> |
| 62 | </DiffErrorBoundary> |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | const styles = stylex.create({ |
| 67 | diffBadge: { |
| 68 | color: 'white', |
| 69 | cursor: 'pointer', |
| 70 | textDecoration: { |
| 71 | default: 'none', |
| 72 | ':hover': 'underline', |
| 73 | }, |
| 74 | }, |
| 75 | diffFollower: { |
| 76 | alignItems: 'center', |
| 77 | display: 'inline-flex', |
| 78 | gap: '5px', |
| 79 | opacity: '0.9', |
| 80 | fontSize: '90%', |
| 81 | padding: '0 var(--halfpad)', |
| 82 | }, |
| 83 | diffFollowerIcon: { |
| 84 | '::before': { |
| 85 | fontSize: '90%', |
| 86 | }, |
| 87 | }, |
| 88 | }); |
| 89 | |
| 90 | export function DiffBadge({ |
| 91 | diff, |
| 92 | children, |
| 93 | url, |
| 94 | provider, |
| 95 | syncStatus, |
| 96 | }: { |
| 97 | diff?: DiffSummary; |
| 98 | children?: ReactNode; |
| 99 | url?: string; |
| 100 | provider: UICodeReviewProvider; |
| 101 | syncStatus?: SyncStatus; |
| 102 | }) { |
| 103 | const openerUrl = useAtomValue(openerUrlForDiffUrl(url)); |
| 104 | |
| 105 | return ( |
| 106 | <Link href={openerUrl} xstyle={styles.diffBadge}> |
| 107 | <provider.DiffBadgeContent diff={diff} children={children} syncStatus={syncStatus} /> |
| 108 | </Link> |
| 109 | ); |
| 110 | } |
| 111 | |
| 112 | export function DiffFollower({commit}: {commit: CommitInfo}) { |
| 113 | if (!commit.isFollower) { |
| 114 | return null; |
| 115 | } |
| 116 | |
| 117 | return ( |
| 118 | <Tooltip title={t('This commit follows the Pull Request of its nearest descendant above')}> |
| 119 | <span {...stylex.props(styles.diffFollower)}> |
| 120 | <Icon icon="fold-up" size="S" {...stylex.props(styles.diffFollowerIcon)} /> |
| 121 | <T>follower</T> |
| 122 | </span> |
| 123 | </Tooltip> |
| 124 | ); |
| 125 | } |
| 126 | |
| 127 | function DiffSpinner({diffId, provider}: {diffId: DiffId; provider: UICodeReviewProvider}) { |
| 128 | return ( |
| 129 | <span className="diff-spinner" data-testid="diff-spinner"> |
| 130 | <DiffBadge provider={provider}> |
| 131 | <Icon icon="loading" /> |
| 132 | </DiffBadge> |
| 133 | {provider.formatDiffNumber(diffId)} |
| 134 | </span> |
| 135 | ); |
| 136 | } |
| 137 | |
| 138 | function DiffInfoInner({ |
| 139 | diffId, |
| 140 | commit, |
| 141 | provider, |
| 142 | hideActions, |
| 143 | }: { |
| 144 | diffId: DiffId; |
| 145 | commit: CommitInfo; |
| 146 | provider: UICodeReviewProvider; |
| 147 | hideActions: boolean; |
| 148 | }) { |
| 149 | const diffInfoResult = useAtomValue(diffSummary(diffId)); |
| 150 | const syncStatus = useAtomGet(syncStatusAtom, commit.hash); |
| 151 | const startTestsEnabled = useFeatureFlagSync(Internal.featureFlags?.StartTestsButton); |
| 152 | if (diffInfoResult.error) { |
| 153 | return <DiffLoadError number={provider.formatDiffNumber(diffId)} provider={provider} />; |
| 154 | } |
| 155 | if (diffInfoResult?.value == null) { |
| 156 | return <DiffSpinner diffId={diffId} provider={provider} />; |
| 157 | } |
| 158 | const info = diffInfoResult.value; |
| 159 | const shouldHideActions = hideActions || provider.isDiffClosed(info); |
| 160 | // deferredTestingInfo is fb-only (phabricator). Use 'in' check to avoid OSS type errors. |
| 161 | const deferredTestingInfo: |
| 162 | | { |
| 163 | submitQueueRequestFBID?: string | null; |
| 164 | explanation?: string | null; |
| 165 | isDeferred?: boolean; |
| 166 | } |
| 167 | | undefined = |
| 168 | 'deferredTestingInfo' in info |
| 169 | ? (info.deferredTestingInfo as { |
| 170 | submitQueueRequestFBID?: string | null; |
| 171 | explanation?: string | null; |
| 172 | isDeferred?: boolean; |
| 173 | }) |
| 174 | : undefined; |
| 175 | // Use version-level isDeferred from deferredTestingInfo for accurate detection |
| 176 | const isDeferred = deferredTestingInfo?.isDeferred === true || info.signalSummary === 'deferred'; |
| 177 | |
| 178 | return ( |
| 179 | <div |
| 180 | className={`diff-info ${provider.name}-diff-info`} |
| 181 | data-testid={`${provider.name}-diff-info`}> |
| 182 | <DiffSignalSummary diff={info} /> |
| 183 | <DiffBadge provider={provider} diff={info} url={info.url} syncStatus={syncStatus} /> |
| 184 | {provider.DiffLandButtonContent && ( |
| 185 | <provider.DiffLandButtonContent diff={info} commit={commit} /> |
| 186 | )} |
| 187 | {/* Show Start Tests button when deferred (fb-only) */} |
| 188 | {startTestsEnabled && |
| 189 | isDeferred && |
| 190 | Internal.StartDeferredTestsButton != null && |
| 191 | deferredTestingInfo?.submitQueueRequestFBID && ( |
| 192 | <Suspense fallback={null}> |
| 193 | <Internal.StartDeferredTestsButton |
| 194 | diffId={diffId} |
| 195 | submitQueueRequestFBID={deferredTestingInfo.submitQueueRequestFBID} |
| 196 | explanation={deferredTestingInfo?.explanation} |
| 197 | /> |
| 198 | </Suspense> |
| 199 | )} |
| 200 | <DiffComments diffId={diffId} diff={info} /> |
| 201 | <DiffNumber url={info.url}>{provider.formatDiffNumber(diffId)}</DiffNumber> |
| 202 | {shouldHideActions ? null : syncStatus === SyncStatus.RemoteIsNewer ? ( |
| 203 | <DownloadNewVersionButton diffId={diffId} provider={provider} /> |
| 204 | ) : syncStatus === SyncStatus.BothChanged ? ( |
| 205 | <DownloadNewVersionButton diffId={diffId} provider={provider} bothChanged /> |
| 206 | ) : syncStatus === SyncStatus.LocalIsNewer ? ( |
| 207 | <ResubmitSyncButton commit={commit} provider={provider} /> |
| 208 | ) : null} |
| 209 | </div> |
| 210 | ); |
| 211 | } |
| 212 | |
| 213 | function DownloadNewVersionButton({ |
| 214 | diffId, |
| 215 | provider, |
| 216 | bothChanged, |
| 217 | }: { |
| 218 | diffId: DiffId; |
| 219 | provider: UICodeReviewProvider; |
| 220 | bothChanged?: boolean; |
| 221 | }) { |
| 222 | const runOperation = useRunOperation(); |
| 223 | const tooltip = bothChanged |
| 224 | ? t( |
| 225 | 'Both remote and local versions have changed.\n\n$provider has a new version of this Diff, but this commit has also changed locally since it was last submitted. You can download the new remote version, but it may not include your other local changes.', |
| 226 | {replace: {$provider: provider.label}}, |
| 227 | ) |
| 228 | : t('$provider has a newer version of this Diff. Click to download the newer version.', { |
| 229 | replace: {$provider: provider.label}, |
| 230 | }); |
| 231 | |
| 232 | return ( |
| 233 | <Tooltip title={tooltip}> |
| 234 | <Button |
| 235 | icon |
| 236 | onClick={async () => { |
| 237 | if (bothChanged) { |
| 238 | const confirmed = await platform.confirm(tooltip); |
| 239 | if (confirmed !== true) { |
| 240 | return; |
| 241 | } |
| 242 | } |
| 243 | if (Internal.diffDownloadOperation != null) { |
| 244 | runOperation(Internal.diffDownloadOperation(exactRevset(diffId))); |
| 245 | } else { |
| 246 | runOperation(new PullRevOperation(exactRevset(diffId))); |
| 247 | } |
| 248 | }}> |
| 249 | <Icon icon="cloud-download" slot="start" /> |
| 250 | <T>Download New Version</T> |
| 251 | </Button> |
| 252 | </Tooltip> |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | function ResubmitSyncButton({ |
| 257 | commit, |
| 258 | provider, |
| 259 | }: { |
| 260 | commit: CommitInfo; |
| 261 | provider: UICodeReviewProvider; |
| 262 | }) { |
| 263 | const runOperation = useRunOperation(); |
| 264 | const confirmShouldSubmit = useShowConfirmSubmitStack(); |
| 265 | |
| 266 | return ( |
| 267 | <Tooltip |
| 268 | title={t('This commit has changed locally since it was last submitted. Click to resubmit.')}> |
| 269 | <Button |
| 270 | icon |
| 271 | data-testid="commit-submit-button" |
| 272 | onClick={async () => { |
| 273 | const confirmation = await confirmShouldSubmit('submit', [commit]); |
| 274 | if (!confirmation) { |
| 275 | return []; |
| 276 | } |
| 277 | runOperation( |
| 278 | provider.submitOperation([commit], { |
| 279 | draft: confirmation.submitAsDraft, |
| 280 | updateMessage: confirmation.updateMessage, |
| 281 | publishWhenReady: confirmation.publishWhenReady, |
| 282 | }), |
| 283 | ); |
| 284 | }}> |
| 285 | <Icon icon="cloud-upload" slot="start" /> |
| 286 | <T>{provider.submitButtonLabel ?? 'Submit'}</T> |
| 287 | </Button> |
| 288 | </Tooltip> |
| 289 | ); |
| 290 | } |
| 291 | |
| 292 | function DiffNumber({children, url}: {children: string; url?: string}) { |
| 293 | const [showing, setShowing] = useState(false); |
| 294 | const showDiffNumber = useAtomValue(showDiffNumberConfig); |
| 295 | if (!children || !showDiffNumber) { |
| 296 | return null; |
| 297 | } |
| 298 | |
| 299 | return ( |
| 300 | <Tooltip trigger="manual" shouldShow={showing} title={t(`Copied ${children} to the clipboard`)}> |
| 301 | <span |
| 302 | className="diff-number" |
| 303 | onClick={e => { |
| 304 | url == null ? clipboardCopyText(children) : clipboardCopyLink(children, url); |
| 305 | setShowing(true); |
| 306 | setTimeout(() => setShowing(false), 2000); |
| 307 | e.stopPropagation(); |
| 308 | }}> |
| 309 | {children} |
| 310 | </span> |
| 311 | </Tooltip> |
| 312 | ); |
| 313 | } |
| 314 | |
| 315 | function DiffComments({diff, diffId}: {diff: DiffSummary; diffId: DiffId}) { |
| 316 | if (!diff.commentCount) { |
| 317 | return null; |
| 318 | } |
| 319 | return ( |
| 320 | <Tooltip |
| 321 | trigger="click" |
| 322 | component={() => ( |
| 323 | <Suspense> |
| 324 | <DiffCommentsDetails diffId={diffId} /> |
| 325 | </Suspense> |
| 326 | )}> |
| 327 | <Button icon> |
| 328 | <span className="diff-comments-count"> |
| 329 | {diff.commentCount} |
| 330 | <Icon icon={diff.anyUnresolvedComments ? 'comment-unresolved' : 'comment'} /> |
| 331 | </span> |
| 332 | </Button> |
| 333 | </Tooltip> |
| 334 | ); |
| 335 | } |
| 336 | |
| 337 | export function SignalSummaryIcon({signal}: {signal: DiffSignalSummary}) { |
| 338 | let icon; |
| 339 | let tooltip; |
| 340 | switch (signal) { |
| 341 | case 'running': |
| 342 | icon = <CircleEllipsisIcon />; |
| 343 | tooltip = t('Test Signals are still running for this Diff.'); |
| 344 | break; |
| 345 | case 'pass': |
| 346 | icon = 'check'; |
| 347 | tooltip = t('Test Signals completed successfully for this Diff.'); |
| 348 | break; |
| 349 | case 'failed': |
| 350 | icon = 'error'; |
| 351 | tooltip = t( |
| 352 | 'An error was encountered during the test signals on this Diff. See Diff for more details.', |
| 353 | ); |
| 354 | break; |
| 355 | case 'no-signal': |
| 356 | icon = 'question'; |
| 357 | tooltip = t('No signal from test run on this Diff.'); |
| 358 | break; |
| 359 | case 'warning': |
| 360 | icon = <CircleExclamationIcon />; |
| 361 | tooltip = t( |
| 362 | 'Test Signals were not fully successful for this Diff. See Diff for more details.', |
| 363 | ); |
| 364 | break; |
| 365 | case 'land-cancelled': |
| 366 | icon = <CircleExclamationIcon />; |
| 367 | tooltip = t('Land is cancelled for this Diff. See Diff for more details.'); |
| 368 | break; |
| 369 | case 'deferred': |
| 370 | icon = 'debug-pause'; |
| 371 | tooltip = t('Tests are deferred for this Diff. Click "Start Tests" to run them.'); |
| 372 | break; |
| 373 | } |
| 374 | return ( |
| 375 | <div className={`diff-signal-summary diff-signal-${signal}`}> |
| 376 | <Tooltip title={tooltip}>{typeof icon === 'string' ? <Icon icon={icon} /> : icon}</Tooltip> |
| 377 | </div> |
| 378 | ); |
| 379 | } |
| 380 | |
| 381 | function DiffSignalSummary({diff}: {diff: DiffSummary}) { |
| 382 | if (!diff.signalSummary) { |
| 383 | return null; |
| 384 | } |
| 385 | return <SignalSummaryIcon signal={diff.signalSummary} />; |
| 386 | } |
| 387 | |
| 388 | export class DiffErrorBoundary extends Component< |
| 389 | { |
| 390 | children: React.ReactNode; |
| 391 | diffId: string; |
| 392 | provider: UICodeReviewProvider; |
| 393 | }, |
| 394 | {error: Error | null} |
| 395 | > { |
| 396 | state = {error: null}; |
| 397 | static getDerivedStateFromError(error: Error) { |
| 398 | return {error}; |
| 399 | } |
| 400 | render() { |
| 401 | if (this.state.error != null) { |
| 402 | return ( |
| 403 | <DiffLoadError |
| 404 | provider={this.props.provider} |
| 405 | number={this.props.provider.formatDiffNumber(this.props.diffId)} |
| 406 | /> |
| 407 | ); |
| 408 | } |
| 409 | return this.props.children; |
| 410 | } |
| 411 | } |
| 412 | |
| 413 | function DiffLoadError({number, provider}: {number: string; provider: UICodeReviewProvider}) { |
| 414 | return ( |
| 415 | <span className="diff-error diff-info" data-testid={`${provider.name}-error`}> |
| 416 | <DiffBadge provider={provider}> |
| 417 | <Icon icon="error" /> |
| 418 | </DiffBadge>{' '} |
| 419 | {number} |
| 420 | </span> |
| 421 | ); |
| 422 | } |
| 423 | |