addons/isl/src/codeReview/DiffBadge.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 {ReactNode} from 'react';
083fd5f9import type {CommitInfo, DiffId, DiffSignalSummary, DiffSummary} from '../types';
b69ab3110import type {UICodeReviewProvider} from './UICodeReviewProvider';
b69ab3111
b69ab3112import * as stylex from '@stylexjs/stylex';
b69ab3113import {Button} from 'isl-components/Button';
b69ab3114import {Icon} from 'isl-components/Icon';
b69ab3115import {Tooltip} from 'isl-components/Tooltip';
b69ab3116import {useAtomValue} from 'jotai';
b69ab3117import {Component, lazy, Suspense, useState} from 'react';
b69ab3118import {useShowConfirmSubmitStack} from '../ConfirmSubmitStack';
b69ab3119import {Internal} from '../Internal';
b69ab3120import {Link} from '../Link';
b69ab3121import {clipboardCopyLink, clipboardCopyText} from '../clipboard';
b69ab3122import {useFeatureFlagSync} from '../featureFlags';
b69ab3123import {T, t} from '../i18n';
b69ab3124import {CircleEllipsisIcon} from '../icons/CircleEllipsisIcon';
b69ab3125import {CircleExclamationIcon} from '../icons/CircleExclamationIcon';
b69ab3126import {configBackedAtom, useAtomGet} from '../jotaiUtils';
b69ab3127import {PullRevOperation} from '../operations/PullRevOperation';
b69ab3128import {useRunOperation} from '../operationsState';
b69ab3129import platform from '../platform';
b69ab3130import {exactRevset} from '../types';
e62555231import {codeReviewProvider, diffIdForCommit, diffSummary} from './CodeReviewInfo';
b69ab3132import './DiffBadge.css';
b69ab3133import {openerUrlForDiffUrl} from './github/GitHubUrlOpener';
b69ab3134import {SyncStatus, syncStatusAtom} from './syncStatus';
b69ab3135
b69ab3136const DiffCommentsDetails = lazy(() => import('./DiffComments'));
b69ab3137
b69ab3138export const showDiffNumberConfig = configBackedAtom<boolean>('isl.show-diff-number', false);
b69ab3139
b69ab3140/**
b69ab3141 * Component that shows inline summary information about a Diff,
b69ab3142 * such as its status, number of comments, CI state, etc.
b69ab3143 */
b69ab3144export function DiffInfo({commit, hideActions}: {commit: CommitInfo; hideActions: boolean}) {
b69ab3145 const repo = useAtomValue(codeReviewProvider);
e62555246 const diffId = useAtomValue(diffIdForCommit(commit.hash));
b69ab3147 if (repo == null || diffId == null) {
b69ab3148 return null;
b69ab3149 }
b69ab3150
b69ab3151 // Do not show diff info (and "Ship It" button) if there are successors.
b69ab3152 // Users should look at the diff info and buttons from the successor commit instead.
b69ab3153 // But the diff number can still be useful so show it.
b69ab3154 if (commit.successorInfo != null) {
b69ab3155 return <DiffNumber>{repo.formatDiffNumber(diffId)}</DiffNumber>;
b69ab3156 }
b69ab3157 return (
b69ab3158 <DiffErrorBoundary provider={repo} diffId={diffId}>
b69ab3159 <Suspense fallback={<DiffSpinner diffId={diffId} provider={repo} />}>
b69ab3160 <DiffInfoInner commit={commit} diffId={diffId} provider={repo} hideActions={hideActions} />
b69ab3161 </Suspense>
b69ab3162 </DiffErrorBoundary>
b69ab3163 );
b69ab3164}
b69ab3165
b69ab3166const styles = stylex.create({
b69ab3167 diffBadge: {
b69ab3168 color: 'white',
b69ab3169 cursor: 'pointer',
b69ab3170 textDecoration: {
b69ab3171 default: 'none',
b69ab3172 ':hover': 'underline',
b69ab3173 },
b69ab3174 },
b69ab3175 diffFollower: {
b69ab3176 alignItems: 'center',
b69ab3177 display: 'inline-flex',
b69ab3178 gap: '5px',
b69ab3179 opacity: '0.9',
b69ab3180 fontSize: '90%',
b69ab3181 padding: '0 var(--halfpad)',
b69ab3182 },
b69ab3183 diffFollowerIcon: {
b69ab3184 '::before': {
b69ab3185 fontSize: '90%',
b69ab3186 },
b69ab3187 },
b69ab3188});
b69ab3189
b69ab3190export function DiffBadge({
b69ab3191 diff,
b69ab3192 children,
b69ab3193 url,
b69ab3194 provider,
b69ab3195 syncStatus,
b69ab3196}: {
b69ab3197 diff?: DiffSummary;
b69ab3198 children?: ReactNode;
b69ab3199 url?: string;
b69ab31100 provider: UICodeReviewProvider;
b69ab31101 syncStatus?: SyncStatus;
b69ab31102}) {
b69ab31103 const openerUrl = useAtomValue(openerUrlForDiffUrl(url));
b69ab31104
b69ab31105 return (
b69ab31106 <Link href={openerUrl} xstyle={styles.diffBadge}>
b69ab31107 <provider.DiffBadgeContent diff={diff} children={children} syncStatus={syncStatus} />
b69ab31108 </Link>
b69ab31109 );
b69ab31110}
b69ab31111
b69ab31112export function DiffFollower({commit}: {commit: CommitInfo}) {
b69ab31113 if (!commit.isFollower) {
b69ab31114 return null;
b69ab31115 }
b69ab31116
b69ab31117 return (
b69ab31118 <Tooltip title={t('This commit follows the Pull Request of its nearest descendant above')}>
b69ab31119 <span {...stylex.props(styles.diffFollower)}>
b69ab31120 <Icon icon="fold-up" size="S" {...stylex.props(styles.diffFollowerIcon)} />
b69ab31121 <T>follower</T>
b69ab31122 </span>
b69ab31123 </Tooltip>
b69ab31124 );
b69ab31125}
b69ab31126
b69ab31127function DiffSpinner({diffId, provider}: {diffId: DiffId; provider: UICodeReviewProvider}) {
b69ab31128 return (
b69ab31129 <span className="diff-spinner" data-testid="diff-spinner">
b69ab31130 <DiffBadge provider={provider}>
b69ab31131 <Icon icon="loading" />
b69ab31132 </DiffBadge>
b69ab31133 {provider.formatDiffNumber(diffId)}
b69ab31134 </span>
b69ab31135 );
b69ab31136}
b69ab31137
b69ab31138function DiffInfoInner({
b69ab31139 diffId,
b69ab31140 commit,
b69ab31141 provider,
b69ab31142 hideActions,
b69ab31143}: {
b69ab31144 diffId: DiffId;
b69ab31145 commit: CommitInfo;
b69ab31146 provider: UICodeReviewProvider;
b69ab31147 hideActions: boolean;
b69ab31148}) {
b69ab31149 const diffInfoResult = useAtomValue(diffSummary(diffId));
b69ab31150 const syncStatus = useAtomGet(syncStatusAtom, commit.hash);
b69ab31151 const startTestsEnabled = useFeatureFlagSync(Internal.featureFlags?.StartTestsButton);
b69ab31152 if (diffInfoResult.error) {
b69ab31153 return <DiffLoadError number={provider.formatDiffNumber(diffId)} provider={provider} />;
b69ab31154 }
b69ab31155 if (diffInfoResult?.value == null) {
b69ab31156 return <DiffSpinner diffId={diffId} provider={provider} />;
b69ab31157 }
b69ab31158 const info = diffInfoResult.value;
b69ab31159 const shouldHideActions = hideActions || provider.isDiffClosed(info);
b69ab31160 // deferredTestingInfo is fb-only (phabricator). Use 'in' check to avoid OSS type errors.
b69ab31161 const deferredTestingInfo:
b69ab31162 | {
b69ab31163 submitQueueRequestFBID?: string | null;
b69ab31164 explanation?: string | null;
b69ab31165 isDeferred?: boolean;
b69ab31166 }
b69ab31167 | undefined =
b69ab31168 'deferredTestingInfo' in info
b69ab31169 ? (info.deferredTestingInfo as {
b69ab31170 submitQueueRequestFBID?: string | null;
b69ab31171 explanation?: string | null;
b69ab31172 isDeferred?: boolean;
b69ab31173 })
b69ab31174 : undefined;
b69ab31175 // Use version-level isDeferred from deferredTestingInfo for accurate detection
b69ab31176 const isDeferred = deferredTestingInfo?.isDeferred === true || info.signalSummary === 'deferred';
b69ab31177
b69ab31178 return (
b69ab31179 <div
b69ab31180 className={`diff-info ${provider.name}-diff-info`}
b69ab31181 data-testid={`${provider.name}-diff-info`}>
b69ab31182 <DiffSignalSummary diff={info} />
b69ab31183 <DiffBadge provider={provider} diff={info} url={info.url} syncStatus={syncStatus} />
b69ab31184 {provider.DiffLandButtonContent && (
b69ab31185 <provider.DiffLandButtonContent diff={info} commit={commit} />
b69ab31186 )}
b69ab31187 {/* Show Start Tests button when deferred (fb-only) */}
b69ab31188 {startTestsEnabled &&
b69ab31189 isDeferred &&
b69ab31190 Internal.StartDeferredTestsButton != null &&
b69ab31191 deferredTestingInfo?.submitQueueRequestFBID && (
b69ab31192 <Suspense fallback={null}>
b69ab31193 <Internal.StartDeferredTestsButton
b69ab31194 diffId={diffId}
b69ab31195 submitQueueRequestFBID={deferredTestingInfo.submitQueueRequestFBID}
b69ab31196 explanation={deferredTestingInfo?.explanation}
b69ab31197 />
b69ab31198 </Suspense>
b69ab31199 )}
b69ab31200 <DiffComments diffId={diffId} diff={info} />
b69ab31201 <DiffNumber url={info.url}>{provider.formatDiffNumber(diffId)}</DiffNumber>
b69ab31202 {shouldHideActions ? null : syncStatus === SyncStatus.RemoteIsNewer ? (
b69ab31203 <DownloadNewVersionButton diffId={diffId} provider={provider} />
b69ab31204 ) : syncStatus === SyncStatus.BothChanged ? (
b69ab31205 <DownloadNewVersionButton diffId={diffId} provider={provider} bothChanged />
b69ab31206 ) : syncStatus === SyncStatus.LocalIsNewer ? (
b69ab31207 <ResubmitSyncButton commit={commit} provider={provider} />
b69ab31208 ) : null}
b69ab31209 </div>
b69ab31210 );
b69ab31211}
b69ab31212
b69ab31213function DownloadNewVersionButton({
b69ab31214 diffId,
b69ab31215 provider,
b69ab31216 bothChanged,
b69ab31217}: {
b69ab31218 diffId: DiffId;
b69ab31219 provider: UICodeReviewProvider;
b69ab31220 bothChanged?: boolean;
b69ab31221}) {
b69ab31222 const runOperation = useRunOperation();
b69ab31223 const tooltip = bothChanged
b69ab31224 ? t(
b69ab31225 '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.',
b69ab31226 {replace: {$provider: provider.label}},
b69ab31227 )
b69ab31228 : t('$provider has a newer version of this Diff. Click to download the newer version.', {
b69ab31229 replace: {$provider: provider.label},
b69ab31230 });
b69ab31231
b69ab31232 return (
b69ab31233 <Tooltip title={tooltip}>
b69ab31234 <Button
b69ab31235 icon
b69ab31236 onClick={async () => {
b69ab31237 if (bothChanged) {
b69ab31238 const confirmed = await platform.confirm(tooltip);
b69ab31239 if (confirmed !== true) {
b69ab31240 return;
b69ab31241 }
b69ab31242 }
b69ab31243 if (Internal.diffDownloadOperation != null) {
b69ab31244 runOperation(Internal.diffDownloadOperation(exactRevset(diffId)));
b69ab31245 } else {
b69ab31246 runOperation(new PullRevOperation(exactRevset(diffId)));
b69ab31247 }
b69ab31248 }}>
b69ab31249 <Icon icon="cloud-download" slot="start" />
b69ab31250 <T>Download New Version</T>
b69ab31251 </Button>
b69ab31252 </Tooltip>
b69ab31253 );
b69ab31254}
b69ab31255
b69ab31256function ResubmitSyncButton({
b69ab31257 commit,
b69ab31258 provider,
b69ab31259}: {
b69ab31260 commit: CommitInfo;
b69ab31261 provider: UICodeReviewProvider;
b69ab31262}) {
b69ab31263 const runOperation = useRunOperation();
b69ab31264 const confirmShouldSubmit = useShowConfirmSubmitStack();
b69ab31265
b69ab31266 return (
b69ab31267 <Tooltip
b69ab31268 title={t('This commit has changed locally since it was last submitted. Click to resubmit.')}>
b69ab31269 <Button
b69ab31270 icon
b69ab31271 data-testid="commit-submit-button"
b69ab31272 onClick={async () => {
b69ab31273 const confirmation = await confirmShouldSubmit('submit', [commit]);
b69ab31274 if (!confirmation) {
b69ab31275 return [];
b69ab31276 }
b69ab31277 runOperation(
b69ab31278 provider.submitOperation([commit], {
b69ab31279 draft: confirmation.submitAsDraft,
b69ab31280 updateMessage: confirmation.updateMessage,
b69ab31281 publishWhenReady: confirmation.publishWhenReady,
b69ab31282 }),
b69ab31283 );
b69ab31284 }}>
b69ab31285 <Icon icon="cloud-upload" slot="start" />
8d8e815286 <T>{provider.submitButtonLabel ?? 'Submit'}</T>
b69ab31287 </Button>
b69ab31288 </Tooltip>
b69ab31289 );
b69ab31290}
b69ab31291
b69ab31292function DiffNumber({children, url}: {children: string; url?: string}) {
b69ab31293 const [showing, setShowing] = useState(false);
b69ab31294 const showDiffNumber = useAtomValue(showDiffNumberConfig);
b69ab31295 if (!children || !showDiffNumber) {
b69ab31296 return null;
b69ab31297 }
b69ab31298
b69ab31299 return (
b69ab31300 <Tooltip trigger="manual" shouldShow={showing} title={t(`Copied ${children} to the clipboard`)}>
b69ab31301 <span
b69ab31302 className="diff-number"
b69ab31303 onClick={e => {
b69ab31304 url == null ? clipboardCopyText(children) : clipboardCopyLink(children, url);
b69ab31305 setShowing(true);
b69ab31306 setTimeout(() => setShowing(false), 2000);
b69ab31307 e.stopPropagation();
b69ab31308 }}>
b69ab31309 {children}
b69ab31310 </span>
b69ab31311 </Tooltip>
b69ab31312 );
b69ab31313}
b69ab31314
b69ab31315function DiffComments({diff, diffId}: {diff: DiffSummary; diffId: DiffId}) {
b69ab31316 if (!diff.commentCount) {
b69ab31317 return null;
b69ab31318 }
b69ab31319 return (
b69ab31320 <Tooltip
b69ab31321 trigger="click"
b69ab31322 component={() => (
b69ab31323 <Suspense>
b69ab31324 <DiffCommentsDetails diffId={diffId} />
b69ab31325 </Suspense>
b69ab31326 )}>
b69ab31327 <Button icon>
b69ab31328 <span className="diff-comments-count">
b69ab31329 {diff.commentCount}
b69ab31330 <Icon icon={diff.anyUnresolvedComments ? 'comment-unresolved' : 'comment'} />
b69ab31331 </span>
b69ab31332 </Button>
b69ab31333 </Tooltip>
b69ab31334 );
b69ab31335}
b69ab31336
083fd5f337export function SignalSummaryIcon({signal}: {signal: DiffSignalSummary}) {
b69ab31338 let icon;
b69ab31339 let tooltip;
083fd5f340 switch (signal) {
b69ab31341 case 'running':
b69ab31342 icon = <CircleEllipsisIcon />;
b69ab31343 tooltip = t('Test Signals are still running for this Diff.');
b69ab31344 break;
b69ab31345 case 'pass':
b69ab31346 icon = 'check';
b69ab31347 tooltip = t('Test Signals completed successfully for this Diff.');
b69ab31348 break;
b69ab31349 case 'failed':
b69ab31350 icon = 'error';
b69ab31351 tooltip = t(
b69ab31352 'An error was encountered during the test signals on this Diff. See Diff for more details.',
b69ab31353 );
b69ab31354 break;
b69ab31355 case 'no-signal':
b69ab31356 icon = 'question';
b69ab31357 tooltip = t('No signal from test run on this Diff.');
b69ab31358 break;
b69ab31359 case 'warning':
b69ab31360 icon = <CircleExclamationIcon />;
b69ab31361 tooltip = t(
b69ab31362 'Test Signals were not fully successful for this Diff. See Diff for more details.',
b69ab31363 );
b69ab31364 break;
b69ab31365 case 'land-cancelled':
b69ab31366 icon = <CircleExclamationIcon />;
b69ab31367 tooltip = t('Land is cancelled for this Diff. See Diff for more details.');
b69ab31368 break;
b69ab31369 case 'deferred':
b69ab31370 icon = 'debug-pause';
b69ab31371 tooltip = t('Tests are deferred for this Diff. Click "Start Tests" to run them.');
b69ab31372 break;
b69ab31373 }
b69ab31374 return (
083fd5f375 <div className={`diff-signal-summary diff-signal-${signal}`}>
b69ab31376 <Tooltip title={tooltip}>{typeof icon === 'string' ? <Icon icon={icon} /> : icon}</Tooltip>
b69ab31377 </div>
b69ab31378 );
b69ab31379}
b69ab31380
083fd5f381function DiffSignalSummary({diff}: {diff: DiffSummary}) {
083fd5f382 if (!diff.signalSummary) {
083fd5f383 return null;
083fd5f384 }
083fd5f385 return <SignalSummaryIcon signal={diff.signalSummary} />;
083fd5f386}
083fd5f387
b69ab31388export class DiffErrorBoundary extends Component<
b69ab31389 {
b69ab31390 children: React.ReactNode;
b69ab31391 diffId: string;
b69ab31392 provider: UICodeReviewProvider;
b69ab31393 },
b69ab31394 {error: Error | null}
b69ab31395> {
b69ab31396 state = {error: null};
b69ab31397 static getDerivedStateFromError(error: Error) {
b69ab31398 return {error};
b69ab31399 }
b69ab31400 render() {
b69ab31401 if (this.state.error != null) {
b69ab31402 return (
b69ab31403 <DiffLoadError
b69ab31404 provider={this.props.provider}
b69ab31405 number={this.props.provider.formatDiffNumber(this.props.diffId)}
b69ab31406 />
b69ab31407 );
b69ab31408 }
b69ab31409 return this.props.children;
b69ab31410 }
b69ab31411}
b69ab31412
b69ab31413function DiffLoadError({number, provider}: {number: string; provider: UICodeReviewProvider}) {
b69ab31414 return (
b69ab31415 <span className="diff-error diff-info" data-testid={`${provider.name}-error`}>
b69ab31416 <DiffBadge provider={provider}>
b69ab31417 <Icon icon="error" />
b69ab31418 </DiffBadge>{' '}
b69ab31419 {number}
b69ab31420 </span>
b69ab31421 );
b69ab31422}