12.9 KB423 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 {ReactNode} from 'react';
9import type {CommitInfo, DiffId, DiffSignalSummary, DiffSummary} from '../types';
10import type {UICodeReviewProvider} from './UICodeReviewProvider';
11
12import * as stylex from '@stylexjs/stylex';
13import {Button} from 'isl-components/Button';
14import {Icon} from 'isl-components/Icon';
15import {Tooltip} from 'isl-components/Tooltip';
16import {useAtomValue} from 'jotai';
17import {Component, lazy, Suspense, useState} from 'react';
18import {useShowConfirmSubmitStack} from '../ConfirmSubmitStack';
19import {Internal} from '../Internal';
20import {Link} from '../Link';
21import {clipboardCopyLink, clipboardCopyText} from '../clipboard';
22import {useFeatureFlagSync} from '../featureFlags';
23import {T, t} from '../i18n';
24import {CircleEllipsisIcon} from '../icons/CircleEllipsisIcon';
25import {CircleExclamationIcon} from '../icons/CircleExclamationIcon';
26import {configBackedAtom, useAtomGet} from '../jotaiUtils';
27import {PullRevOperation} from '../operations/PullRevOperation';
28import {useRunOperation} from '../operationsState';
29import platform from '../platform';
30import {exactRevset} from '../types';
31import {codeReviewProvider, diffIdForCommit, diffSummary} from './CodeReviewInfo';
32import './DiffBadge.css';
33import {openerUrlForDiffUrl} from './github/GitHubUrlOpener';
34import {SyncStatus, syncStatusAtom} from './syncStatus';
35
36const DiffCommentsDetails = lazy(() => import('./DiffComments'));
37
38export 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 */
44export 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
66const 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
90export 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
112export 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
127function 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
138function 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
213function 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
256function 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
292function 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
315function 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
337export 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
381function DiffSignalSummary({diff}: {diff: DiffSummary}) {
382 if (!diff.signalSummary) {
383 return null;
384 }
385 return <SignalSummaryIcon signal={diff.signalSummary} />;
386}
387
388export 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
413function 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