37.8 KB1101 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 {ContextMenuItem} from 'shared/ContextMenu';
10import type {UICodeReviewProvider} from './codeReview/UICodeReviewProvider';
11import type {DagCommitInfo} from './dag/dag';
12import type {CommitInfo, Hash, SuccessorInfo} from './types';
13import {succeedableRevset, WarningCheckResult} from './types';
14
15import * as stylex from '@stylexjs/stylex';
16import {Button} from 'isl-components/Button';
17import {Icon} from 'isl-components/Icon';
18import {Subtle} from 'isl-components/Subtle';
19import {Tooltip} from 'isl-components/Tooltip';
20import {atom, useAtomValue, useSetAtom} from 'jotai';
21import React, {memo, useEffect} from 'react';
22import {ComparisonType} from 'shared/Comparison';
23import {contextMenuState, useContextMenu} from 'shared/ContextMenu';
24import {MS_PER_DAY} from 'shared/constants';
25import {useAutofocusRef} from 'shared/hooks';
26import {notEmpty, nullthrows} from 'shared/utils';
27import {spacing} from '../../components/theme/tokens.stylex';
28import {AllBookmarksTruncated, Bookmark, Bookmarks, createBookmarkAtCommit} from './Bookmark';
29import {openBrowseUrlForHash, supportsBrowseUrlForHash} from './BrowseRepo';
30import {hasUnsavedEditedCommitMessage} from './CommitInfoView/CommitInfoState';
31import {showComparison} from './ComparisonView/atoms';
32import {Row} from './ComponentUtils';
33import {DragToRebase} from './DragToRebase';
34import {EducationInfoTip} from './Education';
35import {HighlightCommitsWhileHovering} from './HighlightedCommits';
36import {Internal} from './Internal';
37import {SubmitSelectionButton} from './SubmitSelectionButton';
38import {SubmitSingleCommitButton} from './SubmitSingleCommitButton';
39import {getSuggestedRebaseOperation, suggestedRebaseDestinations} from './SuggestedRebase';
40import {UncommitButton} from './UncommitButton';
41import {UncommittedChanges} from './UncommittedChanges';
42import {tracker} from './analytics';
43import {clipboardLinkHtml} from './clipboard';
44import {
45 allDiffSummaries,
46 branchingDiffInfos,
47 canopySignalForCommit,
48 codeReviewProvider,
49 diffSummary,
50 ensureCanopySignalsFetched,
51 latestCommitMessageTitle,
52} from './codeReview/CodeReviewInfo';
53import {DiffBadge, DiffFollower, DiffInfo, SignalSummaryIcon} from './codeReview/DiffBadge';
54import {submitAsDraft} from './codeReview/DraftCheckbox';
55import {SyncStatus, syncStatusAtom} from './codeReview/syncStatus';
56import {useFeatureFlagSync} from './featureFlags';
57import {FoldButton, useRunFoldPreview} from './fold';
58import {findPublicBaseAncestor} from './getCommitTree';
59import {t, T} from './i18n';
60import {IconStack} from './icons/IconStack';
61import {IrrelevantCwdIcon} from './icons/IrrelevantCwdIcon';
62import {atomFamilyWeak, localStorageBackedAtom, readAtom, writeAtom} from './jotaiUtils';
63import {CONFLICT_SIDE_LABELS} from './mergeConflicts/consts';
64import {getAmendToOperation, isAmendToAllowedForCommit} from './operationUtils';
65import {GotoOperation} from './operations/GotoOperation';
66import {HideOperation} from './operations/HideOperation';
67import {
68 inlineProgressByHash,
69 operationBeingPreviewed,
70 useRunOperation,
71 useRunPreviewedOperation,
72} from './operationsState';
73import platform from './platform';
74import {CommitPreview, dagWithPreviews, uncommittedChangesWithPreviews} from './previews';
75import {RelativeDate, relativeDate} from './relativeDate';
76import {repoRelativeCwd, useIsIrrelevantToCwd} from './repositoryData';
77import {isNarrowCommitTree} from './responsive';
78import {
79 actioningCommit,
80 selectedCommitInfos,
81 selectedCommits,
82 useCommitCallbacks,
83} from './selection';
84import {inMergeConflicts, mergeConflicts, repositoryInfo} from './serverAPIState';
85import {SmartActionsDropdown} from './smartActions/SmartActionsDropdown';
86import {SmartActionsMenu} from './smartActions/SmartActionsMenu';
87import {useConfirmUnsavedEditsBeforeSplit} from './stackEdit/ui/ConfirmUnsavedEditsBeforeSplit';
88import {SplitButton} from './stackEdit/ui/SplitButton';
89import {editingStackIntentionHashes} from './stackEdit/ui/stackEditState';
90import {latestSuccessorUnlessExplicitlyObsolete} from './successionUtils';
91import {copyAndShowToast} from './toast';
92import {showModal} from './useModal';
93import {short} from './utils';
94
95export const rebaseOffWarmWarningEnabled = localStorageBackedAtom<boolean>(
96 'isl.rebase-off-warm-warning-enabled',
97 true,
98);
99
100export const distantRebaseWarningEnabled = localStorageBackedAtom<boolean>(
101 'isl.distant-rebase-warning-enabled',
102 true,
103);
104
105export const rebaseOntoMasterWarningEnabled = localStorageBackedAtom<boolean>(
106 'isl.rebase-onto-master-warning-enabled',
107 true,
108);
109
110/**
111 * Some preview types should not allow actions on top of them
112 * For example, you can't click goto on the preview of dragging a rebase,
113 * but you can goto on the optimistic form of a running rebase.
114 */
115function previewPreventsActions(preview?: CommitPreview): boolean {
116 switch (preview) {
117 case CommitPreview.REBASE_OLD:
118 case CommitPreview.REBASE_DESCENDANT:
119 case CommitPreview.REBASE_ROOT:
120 case CommitPreview.HIDDEN_ROOT:
121 case CommitPreview.HIDDEN_DESCENDANT:
122 case CommitPreview.FOLD:
123 case CommitPreview.FOLD_PREVIEW:
124 case CommitPreview.NON_ACTIONABLE_COMMIT:
125 return true;
126 }
127 return false;
128}
129
130const commitLabelForCommit = atomFamilyWeak((hash: string) =>
131 atom(get => {
132 const conflicts = get(mergeConflicts);
133 const {localShort, incomingShort} = CONFLICT_SIDE_LABELS;
134 const hashes = conflicts?.hashes;
135 if (hash === hashes?.other) {
136 return incomingShort;
137 } else if (hash === hashes?.local) {
138 return localShort;
139 }
140 return null;
141 }),
142);
143
144export const Commit = memo(
145 ({
146 commit,
147 previewType,
148 hasChildren,
149 }: {
150 commit: DagCommitInfo | CommitInfo;
151 previewType?: CommitPreview;
152 hasChildren: boolean;
153 }) => {
154 const isPublic = commit.phase === 'public';
155 const isObsoleted = commit.successorInfo != null;
156 const hasUncommittedChanges = (readAtom(uncommittedChangesWithPreviews).length ?? 0) > 0;
157
158 const isIrrelevantToCwd = useIsIrrelevantToCwd(commit);
159
160 const handlePreviewedOperation = useRunPreviewedOperation();
161 const runOperation = useRunOperation();
162 const setEditStackIntentionHashes = useSetAtom(editingStackIntentionHashes);
163
164 const inlineProgress = useAtomValue(inlineProgressByHash(commit.hash));
165
166 const {isSelected, onDoubleClickToShowDrawer} = useCommitCallbacks(commit);
167 const actionsPrevented = previewPreventsActions(previewType);
168
169 const isActioning = useAtomValue(actioningCommit) === commit.hash;
170 const isContextMenuOpen = useAtomValue(contextMenuState) != null;
171
172 useEffect(() => {
173 if (!isContextMenuOpen && isActioning) {
174 writeAtom(actioningCommit, null);
175 }
176 }, [isContextMenuOpen, isActioning]);
177
178 const inConflicts = useAtomValue(inMergeConflicts);
179
180 const isNarrow = useAtomValue(isNarrowCommitTree);
181
182 const title = useAtomValue(latestCommitMessageTitle(commit.hash));
183
184 const commitLabel = useAtomValue(commitLabelForCommit(commit.hash));
185
186 const clipboardCopy = (text: string, url?: string) =>
187 copyAndShowToast(text, url == null ? undefined : clipboardLinkHtml(text, url));
188
189 const confirmUnsavedEditsBeforeSplit = useConfirmUnsavedEditsBeforeSplit();
190 async function handleSplit() {
191 if (!(await confirmUnsavedEditsBeforeSplit([commit], 'split'))) {
192 return;
193 }
194 setEditStackIntentionHashes(['split', new Set([commit.hash])]);
195 tracker.track('SplitOpenFromCommitContextMenu');
196 }
197
198 const makeContextMenuOptions = () => {
199 const syncStatus = readAtom(syncStatusAtom)?.get(commit.hash);
200
201 const items: Array<ContextMenuItem & {loggingLabel?: string}> = [
202 {
203 label: <T replace={{$hash: short(commit?.hash)}}>Copy Commit Hash "$hash"</T>,
204 onClick: () => clipboardCopy(commit.hash),
205 loggingLabel: 'Copy Commit Hash',
206 },
207 ];
208 if (isPublic && readAtom(supportsBrowseUrlForHash)) {
209 items.push({
210 label: (
211 <Row>
212 <T>Browse Repo At This Commit</T>
213 <Icon icon="link-external" />
214 </Row>
215 ),
216 onClick: () => {
217 openBrowseUrlForHash(commit.hash);
218 },
219 loggingLabel: 'Browse Repo At This Commit',
220 });
221 }
222 const selectedDiffIDs = readAtom(selectedCommitInfos)
223 .map(info => info.diffId)
224 .filter(notEmpty);
225 if (selectedDiffIDs.length > 1) {
226 items.push({
227 label: (
228 <T replace={{$count: selectedDiffIDs.length < 10 ? selectedDiffIDs.length : '9+'}}>
229 Copy $count Selected Diff Numbers
230 </T>
231 ),
232 onClick: () => {
233 const text = selectedDiffIDs.join(' ');
234 clipboardCopy(text);
235 },
236 loggingLabel: 'Copy Selected Diff Numbers',
237 });
238 } else if (!isPublic && commit.diffId != null) {
239 items.push({
240 label: <T replace={{$number: commit.diffId}}>Copy Diff Number "$number"</T>,
241 onClick: () => {
242 const info = readAtom(diffSummary(commit.diffId));
243 const url = info?.value?.url;
244 clipboardCopy(commit.diffId ?? '', url);
245 },
246 loggingLabel: 'Copy Diff Number',
247 });
248 }
249 if (!isPublic) {
250 items.push({
251 label: <T>View Changes in Commit</T>,
252 onClick: () => showComparison({type: ComparisonType.Committed, hash: commit.hash}),
253 loggingLabel: 'View Changes in Commit',
254 });
255
256 const provider = readAtom(codeReviewProvider);
257 if (provider != null) {
258 const selectedInfos = readAtom(selectedCommitInfos);
259 const diffSummaries = readAtom(allDiffSummaries);
260 const dag = readAtom(dagWithPreviews);
261
262 const isMultiSelect =
263 selectedInfos.length > 1 && selectedInfos.some(c => c.hash === commit.hash);
264 const commits = isMultiSelect
265 ? dag.getBatch(dag.sortAsc(dag.present(new Set(selectedInfos.map(c => c.hash)))))
266 : [commit];
267 const submittable =
268 (diffSummaries?.value != null
269 ? provider.getSubmittableDiffs(commits, diffSummaries.value)
270 : undefined) ?? [];
271
272 if (submittable.length > 0) {
273 const actionLabel = provider.submitButtonLabel ?? 'Submit';
274 items.push({
275 label:
276 submittable.length > 1 ? (
277 <T replace={{$count: submittable.length, $action: actionLabel}}>
278 $action $count Commits
279 </T>
280 ) : (
281 <T replace={{$action: actionLabel}}>$action Commit</T>
282 ),
283 onClick: () => {
284 runOperation(
285 provider.submitOperation(submittable, {
286 draft: readAtom(submitAsDraft),
287 }),
288 );
289 },
290 loggingLabel:
291 submittable.length > 1
292 ? `${actionLabel} Multiple Commits`
293 : `${actionLabel} Commit`,
294 });
295 }
296 }
297 }
298 if (!isPublic && syncStatus != null && syncStatus !== SyncStatus.InSync) {
299 const provider = readAtom(codeReviewProvider);
300 if (provider?.supportsComparingSinceLastSubmit) {
301 items.push({
302 label: <T replace={{$provider: provider?.label ?? 'remote'}}>Compare with $provider</T>,
303 onClick: () => {
304 showComparison({type: ComparisonType.SinceLastCodeReviewSubmit, hash: commit.hash});
305 },
306 loggingLabel: 'Compare with Provider',
307 });
308 }
309 }
310 if (!isPublic && commit.diffId != null) {
311 const provider = readAtom(codeReviewProvider);
312 const summary = readAtom(diffSummary(commit.diffId));
313 if (summary.value) {
314 const actions = provider?.getUpdateDiffActions(summary.value);
315 if (actions != null && actions.length > 0) {
316 items.push({
317 label: <T replace={{$number: commit.diffId}}>Update Diff $number</T>,
318 type: 'submenu',
319 children: actions,
320 });
321 }
322 }
323 }
324 if (!isPublic && !actionsPrevented && !inConflicts) {
325 const suggestedRebases = readAtom(suggestedRebaseDestinations);
326 items.push({
327 label: 'Rebase onto',
328 type: 'submenu',
329 children:
330 suggestedRebases?.map(([dest, name]) => ({
331 label: name,
332 onClick: async () => {
333 const operation = getSuggestedRebaseOperation(
334 dest,
335 latestSuccessorUnlessExplicitlyObsolete(commit),
336 );
337
338 const shouldProceed = await runWarningChecks([
339 () => maybeWarnAboutRebaseOntoMaster(dest),
340 () => maybeWarnAboutRebaseOffWarm(dest),
341 ]);
342
343 if (shouldProceed) {
344 runOperation(operation);
345 }
346 },
347 })) ?? [],
348 });
349 if (isAmendToAllowedForCommit(commit)) {
350 items.push({
351 label: <T>Amend changes to here</T>,
352 onClick: () => runOperation(getAmendToOperation(commit)),
353 });
354 }
355 if (!isObsoleted) {
356 items.push({
357 label: hasUncommittedChanges ? (
358 <span className="context-menu-disabled-option">
359 <T>Split... </T>
360 <Subtle>
361 <T>(disabled due to uncommitted changes)</T>
362 </Subtle>
363 </span>
364 ) : (
365 <T>Split...</T>
366 ),
367 onClick: hasUncommittedChanges ? () => null : handleSplit,
368 loggingLabel: 'Split',
369 });
370 }
371 items.push({
372 label: <T>Create Bookmark...</T>,
373 onClick: () => {
374 createBookmarkAtCommit(commit);
375 },
376 loggingLabel: 'Create Bookmark',
377 });
378 items.push({
379 label: hasChildren ? <T>Hide Commit and Descendants</T> : <T>Hide Commit</T>,
380 onClick: () =>
381 writeAtom(
382 operationBeingPreviewed,
383 new HideOperation(latestSuccessorUnlessExplicitlyObsolete(commit)),
384 ),
385 loggingLabel: 'Hide Commit',
386 });
387 }
388 if (!actionsPrevented && !commit.isDot) {
389 items.push({
390 label: <T>Goto</T>,
391 onClick: async () => {
392 await gotoAction(runOperation, commit);
393 },
394 loggingLabel: 'Goto',
395 });
396 }
397 if (commit.fullRepoBranch != null) {
398 const fullRepoBranchItems = Internal.getFullRepoBranchMergeContextMenuItems?.(
399 commit.fullRepoBranch,
400 );
401 if (fullRepoBranchItems != null && fullRepoBranchItems instanceof Array) {
402 items.push(...fullRepoBranchItems);
403 }
404 }
405 return items;
406 };
407
408 const contextMenu = useContextMenu((): Array<ContextMenuItem> => {
409 return makeContextMenuOptions().map((item: ContextMenuItem & {loggingLabel?: string}) => {
410 if (item.type == null && notEmpty(item.loggingLabel)) {
411 return {
412 ...item,
413 onClick: () => {
414 tracker.track('CommitContextMenuItemClick', {
415 extras: {choice: item.loggingLabel},
416 });
417 item.onClick?.();
418 },
419 };
420 }
421 return item;
422 });
423 });
424
425 const commitActions = [];
426
427 if (previewType === CommitPreview.REBASE_ROOT) {
428 commitActions.push(
429 <React.Fragment key="rebase">
430 <Button onClick={() => handlePreviewedOperation(/* cancel */ true)}>
431 <T>Cancel</T>
432 </Button>
433 <Button
434 primary
435 onClick={() => {
436 return handleRebaseConfirmation(commit, handlePreviewedOperation);
437 }}>
438 <T>Run Rebase</T>
439 </Button>
440 </React.Fragment>,
441 );
442 } else if (previewType === CommitPreview.HIDDEN_ROOT) {
443 commitActions.push(
444 <React.Fragment key="hide">
445 <Button onClick={() => handlePreviewedOperation(/* cancel */ true)}>
446 <T>Cancel</T>
447 </Button>
448 <ConfirmHideButton onClick={() => handlePreviewedOperation(/* cancel */ false)} />
449 </React.Fragment>,
450 );
451 } else if (previewType === CommitPreview.FOLD_PREVIEW) {
452 commitActions.push(<ConfirmCombineButtons key="fold" />);
453 }
454
455 if (!isPublic && !actionsPrevented && isSelected) {
456 commitActions.push(
457 <SubmitSelectionButton key="submit-selection-btn" commit={commit} />,
458 <FoldButton key="fold-button" commit={commit} />,
459 );
460 }
461
462 if (!actionsPrevented && !commit.isDot) {
463 commitActions.push(
464 <span className="goto-button" key="goto-button">
465 <Tooltip
466 title={t(
467 'Update files in the working copy to match this commit. Mark this commit as the "current commit".',
468 )}
469 delayMs={250}>
470 <Button
471 aria-label={t('Go to commit "$title"', {replace: {$title: commit.title}})}
472 xstyle={styles.gotoButton}
473 onClick={async event => {
474 event.stopPropagation(); // don't toggle selection by letting click propagate onto selection target.
475 await gotoAction(runOperation, commit);
476 }}>
477 <T>Goto</T>
478 <Icon icon="newline" />
479 </Button>
480 </Tooltip>
481 </span>,
482 );
483 }
484
485 if (!isPublic && !actionsPrevented && commit.isDot && !inConflicts) {
486 commitActions.push(<SubmitSingleCommitButton key="submit" />);
487 commitActions.push(<UncommitButton key="uncommit" />);
488 }
489
490 if (!isPublic && !actionsPrevented && commit.isDot && !isObsoleted && !inConflicts) {
491 commitActions.push(
492 <SplitButton icon key="split" trackerEventName="SplitOpenFromHeadCommit" commit={commit} />,
493 );
494 }
495
496 const useV2SmartActions = useFeatureFlagSync(Internal.featureFlags?.SmartActionsRedesign);
497 if (!isPublic && !actionsPrevented) {
498 if (useV2SmartActions) {
499 if (commit.isDot && !hasUncommittedChanges && !inConflicts) {
500 commitActions.push(<SmartActionsDropdown key="smartActions" commit={commit} />);
501 }
502 } else {
503 commitActions.push(<SmartActionsMenu key="smartActions" commit={commit} />);
504 }
505 }
506
507 if (!actionsPrevented) {
508 commitActions.push(
509 <OpenCommitInfoButton
510 key="open-sidebar"
511 revealCommit={onDoubleClickToShowDrawer}
512 commit={commit}
513 />,
514 );
515 }
516
517 if ((commit as DagCommitInfo).isYouAreHere) {
518 return (
519 <div className="head-commit-info">
520 <UncommittedChanges place="main" />
521 </div>
522 );
523 }
524
525 return (
526 <div
527 className={
528 'commit' +
529 (commit.isDot ? ' head-commit' : '') +
530 (commit.successorInfo != null ? ' obsolete' : '') +
531 (isIrrelevantToCwd ? ' irrelevant' : '')
532 }
533 onContextMenu={e => {
534 writeAtom(actioningCommit, commit.hash);
535 contextMenu(e);
536 }}
537 data-testid={`commit-${commit.hash}`}>
538 <div
539 className={
540 'commit-rows' +
541 (isSelected ? ' commit-row-selected' : '') +
542 (isActioning ? ' commit-row-actioning' : '')
543 }
544 data-testid={isSelected ? 'selected-commit' : undefined}>
545 <DragToRebase
546 className={
547 'commit-details' + (previewType != null ? ` commit-preview-${previewType}` : '')
548 }
549 commit={commit}
550 previewType={previewType}>
551 {!isPublic && isIrrelevantToCwd && (
552 <Tooltip
553 title={
554 <T
555 replace={{
556 $prefix: <pre>{commit.maxCommonPathPrefix}</pre>,
557 $cwd: <pre>{readAtom(repoRelativeCwd)}</pre>,
558 }}>
559 This commit only contains files within: $prefix These are irrelevant to your
560 current working directory: $cwd
561 </T>
562 }>
563 <IrrelevantCwdIcon />
564 </Tooltip>
565 )}
566 <span className="commit-title">
567 {commitLabel && <CommitLabel>{commitLabel}</CommitLabel>}
568 <span>{title}</span>
569 <CommitDate date={commit.date} />
570 </span>
571 <UnsavedEditedMessageIndicator commit={commit} />
572 {!isPublic && <BranchingPrs bookmarks={commit.remoteBookmarks} />}
573 <AllBookmarksTruncated
574 local={commit.bookmarks}
575 remote={
576 isPublic
577 ? commit.remoteBookmarks
578 : /* draft commits with remote bookmarks are probably branching PRs, rendered above. */ []
579 }
580 stable={commit?.stableCommitMetadata ?? []}
581 fullRepoBranch={commit.fullRepoBranch}
582 />
583 {isNarrow ? commitActions : null}
584 </DragToRebase>
585 <DivIfChildren className="commit-second-row">
586 {!isPublic ? (
587 <DiffInfo commit={commit} hideActions={actionsPrevented || inlineProgress != null} />
588 ) : null}
589 {isPublic ? <CanopySignal hash={commit.hash} /> : null}
590 {commit.successorInfo != null ? (
591 <SuccessorInfoToDisplay successorInfo={commit.successorInfo} />
592 ) : null}
593 {inlineProgress && <InlineProgressSpan message={inlineProgress} />}
594 {commit.isFollower ? <DiffFollower commit={commit} /> : null}
595 </DivIfChildren>
596 {!isNarrow ? commitActions : null}
597 </div>
598 </div>
599 );
600 },
601 (prevProps, nextProps) => {
602 const prevCommit = prevProps.commit;
603 const nextCommit = nextProps.commit;
604 const commitEqual =
605 'equals' in nextCommit ? nextCommit.equals(prevCommit) : nextCommit === prevCommit;
606 return (
607 commitEqual &&
608 nextProps.previewType === prevProps.previewType &&
609 nextProps.hasChildren === prevProps.hasChildren
610 );
611 },
612);
613
614function BranchingPrs({bookmarks}: {bookmarks: ReadonlyArray<string>}) {
615 const provider = useAtomValue(codeReviewProvider);
616 if (provider == null || !provider.supportBranchingPrs) {
617 // If we don't have a provider, just render them as bookmarks so they don't get hidden.
618 return <Bookmarks bookmarks={bookmarks} kind="remote" />;
619 }
620 return bookmarks.map(bookmark => (
621 <BranchingPr key={bookmark} provider={provider} bookmark={bookmark} />
622 ));
623}
624
625function BranchingPr({bookmark, provider}: {bookmark: string; provider: UICodeReviewProvider}) {
626 const branchName = nullthrows(provider.branchNameForRemoteBookmark)(bookmark);
627 const info = useAtomValue(branchingDiffInfos(branchName));
628 return (
629 <>
630 <Bookmark kind="remote">{bookmark}</Bookmark>
631 {info.value == null ? null : (
632 <DiffBadge diff={info.value} provider={provider} url={info.value.url} />
633 )}
634 </>
635 );
636}
637
638const styles = stylex.create({
639 commitLabel: {
640 fontVariant: 'all-petite-caps',
641 opacity: '0.8',
642 fontWeight: 'bold',
643 fontSize: '90%',
644 },
645 gotoButton: {
646 gap: spacing.half,
647 },
648});
649
650function CommitLabel({children}: {children?: ReactNode}) {
651 return <div {...stylex.props(styles.commitLabel)}>{children}</div>;
652}
653
654export function InlineProgressSpan(props: {message: string}) {
655 return (
656 <span className="commit-inline-operation-progress">
657 <Icon icon="loading" /> <T>{props.message}</T>
658 </span>
659 );
660}
661
662function OpenCommitInfoButton({
663 commit,
664 revealCommit,
665}: {
666 commit: CommitInfo;
667 revealCommit: () => unknown;
668}) {
669 return (
670 <Tooltip title={t("Open commit's details in sidebar")} delayMs={250}>
671 <Button
672 icon
673 onClick={e => {
674 revealCommit();
675 e.stopPropagation();
676 e.preventDefault();
677 }}
678 className="open-commit-info-button"
679 aria-label={t('Open commit "$title"', {replace: {$title: commit.title}})}
680 data-testid="open-commit-info-button">
681 <Icon icon="chevron-right" />
682 </Button>
683 </Tooltip>
684 );
685}
686
687function ConfirmHideButton({onClick}: {onClick: () => unknown}) {
688 const ref = useAutofocusRef() as React.MutableRefObject<null>;
689 return (
690 <Button ref={ref} primary onClick={onClick}>
691 <T>Hide</T>
692 </Button>
693 );
694}
695
696function ConfirmCombineButtons() {
697 const ref = useAutofocusRef() as React.MutableRefObject<null>;
698 const [cancel, run] = useRunFoldPreview();
699
700 return (
701 <>
702 <Button onClick={cancel}>
703 <T>Cancel</T>
704 </Button>
705 <Button ref={ref} primary onClick={run}>
706 <T>Run Combine</T>
707 </Button>
708 </>
709 );
710}
711
712function CommitDate({date}: {date: Date}) {
713 return (
714 <span className="commit-date" title={date.toLocaleString()}>
715 <RelativeDate date={date} useShortVariant />
716 </span>
717 );
718}
719
720function DivIfChildren({
721 children,
722 ...props
723}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
724 if (!children || (Array.isArray(children) && children.filter(notEmpty).length === 0)) {
725 return null;
726 }
727 return <div {...props}>{children}</div>;
728}
729
730function UnsavedEditedMessageIndicator({commit}: {commit: CommitInfo}) {
731 const isEdted = useAtomValue(hasUnsavedEditedCommitMessage(commit.hash));
732 if (!isEdted) {
733 return null;
734 }
735 return (
736 <div className="unsaved-message-indicator" data-testid="unsaved-message-indicator">
737 <Tooltip title={t('This commit has unsaved changes to its message')}>
738 <IconStack>
739 <Icon icon="output" />
740 <Icon icon="circle-large-filled" />
741 </IconStack>
742 </Tooltip>
743 </div>
744 );
745}
746
747export function SuccessorInfoToDisplay({successorInfo}: {successorInfo: SuccessorInfo}) {
748 const successorType = successorInfo.type;
749 const inner: JSX.Element = {
750 pushrebase: <T>Landed as a newer commit</T>,
751 land: <T>Landed as a newer commit</T>,
752 amend: <T>Amended as a newer commit</T>,
753 rebase: <T>Rebased as a newer commit</T>,
754 split: <T>Split as a newer commit</T>,
755 fold: <T>Folded as a newer commit</T>,
756 histedit: <T>Histedited as a newer commit</T>,
757 }[successorType] ?? <T>Rewritten as a newer commit</T>;
758 const isSuccessorPublic = successorType === 'land' || successorType === 'pushrebase';
759 return (
760 <Row style={{gap: 'var(--halfpad)'}}>
761 <HighlightCommitsWhileHovering toHighlight={[successorInfo.hash]}>
762 {inner}
763 </HighlightCommitsWhileHovering>
764 <EducationInfoTip>
765 <ObsoleteTip isSuccessorPublic={isSuccessorPublic} />
766 </EducationInfoTip>
767 </Row>
768 );
769}
770
771function ObsoleteTipInner(props: {isSuccessorPublic?: boolean}) {
772 const tips: string[] = props.isSuccessorPublic
773 ? [
774 t('Avoid editing (e.g., amend, rebase) this obsoleted commit. It cannot be landed again.'),
775 t(
776 'The new commit was landed in a public branch and became immutable. It cannot be edited or hidden.',
777 ),
778 t('If you want to make changes, create a new commit.'),
779 ]
780 : [
781 t(
782 'Avoid editing (e.g., amend, rebase) this obsoleted commit. You should use the new commit instead.',
783 ),
784 t(
785 'If you do edit, there will be multiple new versions. They look like duplications and there is no easy way to de-duplicate (e.g. merge all edits back into one commit).',
786 ),
787 t(
788 'To revert to this obsoleted commit, simply hide the new one. It will remove the "obsoleted" status.',
789 ),
790 ];
791
792 return (
793 <div style={{maxWidth: '60vw'}}>
794 <T>This commit is "obsoleted" because a newer version exists.</T>
795 <ul>
796 {tips.map((tip, i) => (
797 <li key={i}>{tip}</li>
798 ))}
799 </ul>
800 </div>
801 );
802}
803
804async function maybeWarnAboutOldDestination(dest: CommitInfo): Promise<WarningCheckResult> {
805 const provider = readAtom(codeReviewProvider);
806 // Cutoff age is determined by the code review provider since internal repos have different requirements than GitHub-backed repos.
807 const MAX_AGE_CUTOFF_MS = provider?.gotoDistanceWarningAgeCutoff ?? 30 * MS_PER_DAY;
808
809 const dag = readAtom(dagWithPreviews);
810 const currentBase = findPublicBaseAncestor(dag);
811 const destBase = findPublicBaseAncestor(dag, dest.hash);
812 if (!currentBase || !destBase) {
813 // can't determine if we can show warning
814 return WarningCheckResult.PASS;
815 }
816
817 const ageDiff = currentBase.date.valueOf() - destBase.date.valueOf();
818 if (ageDiff < MAX_AGE_CUTOFF_MS) {
819 // Either destination base is within time limit or destination base is newer than the current base.
820 // No need to warn.
821 return WarningCheckResult.PASS;
822 }
823
824 const confirmed = await platform.confirm(
825 t(
826 Internal.warnAboutOldGotoReason ??
827 'The destination commit is $age older than the current commit. ' +
828 "Going here may be slow. It's often faster to rebase the commit to a newer base before going. " +
829 'Do you want to `goto` anyway?',
830 {
831 replace: {
832 $age: relativeDate(destBase.date, {reference: currentBase.date, useRelativeForm: true}),
833 },
834 },
835 ),
836 );
837 return confirmed ? WarningCheckResult.BYPASS : WarningCheckResult.FAIL;
838}
839
840async function maybeWarnAboutRebaseOffWarm(dest: CommitInfo): Promise<WarningCheckResult> {
841 const isRebaseOffWarmWarningEnabled = readAtom(rebaseOffWarmWarningEnabled);
842 if (!isRebaseOffWarmWarningEnabled || dest.stableCommitMetadata == null) {
843 return WarningCheckResult.PASS;
844 }
845
846 const dag = readAtom(dagWithPreviews);
847 const src = findPublicBaseAncestor(dag);
848 const destBase = findPublicBaseAncestor(dag, dest.hash);
849
850 if (!src || !destBase) {
851 return WarningCheckResult.PASS;
852 }
853
854 if (Internal.maybeWarnAboutRebaseOffWarm?.(src, destBase)) {
855 const buttons = [
856 t('Opt Out of Future Warnings'),
857 {label: t('Cancel'), primary: true},
858 t('Continue Anyway'),
859 ];
860 const answer = await showModal({
861 type: 'confirm',
862 buttons,
863 title: <T>Move off Warm Commit</T>,
864 message: t(
865 Internal.warnAboutRebaseOffWarmReason ??
866 "The commit you're on is a warmed up commit. Moving off will cause slower builds and performance.\n" +
867 "It's recommended to rebase your changes onto the warmed up commit instead.\n" +
868 "If you need fresher changes, it's recommended to reserve a new OD and work off the warm commit.\n" +
869 'Do you want to continue anyway?',
870 ),
871 });
872 const userEnv = (await Internal.getDevEnvType?.()) ?? 'NotImplemented';
873 const cwd = readAtom(repoRelativeCwd);
874 tracker.track('WarnAboutRebaseOffWarm', {
875 extras: {
876 userAction: answer,
877 envType: userEnv,
878 cwd,
879 },
880 });
881 if (answer === buttons[0]) {
882 writeAtom(rebaseOffWarmWarningEnabled, false);
883 return WarningCheckResult.PASS;
884 }
885 return answer === buttons[2] ? WarningCheckResult.BYPASS : WarningCheckResult.FAIL;
886 }
887
888 return WarningCheckResult.PASS;
889}
890
891async function maybeWarnAboutRebaseOntoMaster(commit: CommitInfo): Promise<WarningCheckResult> {
892 const isRebaseOntoMasterWarningEnabled = readAtom(rebaseOntoMasterWarningEnabled);
893 if (!isRebaseOntoMasterWarningEnabled) {
894 return WarningCheckResult.PASS;
895 }
896
897 const dag = readAtom(dagWithPreviews);
898 const src = findPublicBaseAncestor(dag);
899 const destBase = findPublicBaseAncestor(dag, commit.hash);
900
901 if (!src || !destBase) {
902 // can't determine if we can show warning
903 return WarningCheckResult.PASS;
904 }
905
906 if (Internal.maybeWarnAboutRebaseOntoMaster?.(src, destBase)) {
907 const buttons = [
908 t('Opt Out of Future Warnings'),
909 {label: t('Cancel'), primary: true},
910 t('Continue Anyway'),
911 ];
912 const answer = await showModal({
913 type: 'confirm',
914 buttons,
915 title: <T>Rebase onto Master Warning</T>,
916 message: t(
917 Internal.warnAboutRebaseOntoMasterReason ??
918 'You are about to rebase directly onto master/main. ' +
919 'This is generally not recommended as it can cause unexpected failures and slower builds. ' +
920 'Consider rebasing onto a stable or warm branch instead. ' +
921 'Do you want to continue anyway?',
922 ),
923 });
924 const userEnv = (await Internal.getDevEnvType?.()) ?? 'NotImplemented';
925 const cwd = readAtom(repoRelativeCwd);
926 tracker.track('WarnAboutRebaseOntoMaster', {
927 extras: {
928 userAction: answer,
929 envType: userEnv,
930 cwd,
931 },
932 });
933 if (answer === buttons[0]) {
934 writeAtom(rebaseOntoMasterWarningEnabled, false);
935 return WarningCheckResult.PASS;
936 }
937 return answer === buttons[2] ? WarningCheckResult.BYPASS : WarningCheckResult.FAIL;
938 }
939
940 return WarningCheckResult.PASS;
941}
942
943async function gotoAction(runOperation: ReturnType<typeof useRunOperation>, commit: CommitInfo) {
944 const shouldProceed = await runWarningChecks([
945 () => maybeWarnAboutRebaseOntoMaster(commit),
946 () => maybeWarnAboutOldDestination(commit),
947 () => maybeWarnAboutRebaseOffWarm(commit),
948 ]);
949
950 if (!shouldProceed) {
951 return;
952 }
953
954 const dest =
955 // If the commit has a remote bookmark, use that instead of the hash. This is easier to read in the command history
956 // and works better with optimistic state
957 commit.remoteBookmarks.length > 0
958 ? succeedableRevset(commit.remoteBookmarks[0])
959 : latestSuccessorUnlessExplicitlyObsolete(commit);
960 runOperation(new GotoOperation(dest));
961 // Instead of propagating, ensure we remove the selection, so we view the new head commit by default
962 // (since the head commit is the default thing shown in the sidebar)
963 writeAtom(selectedCommits, new Set());
964}
965
966const ObsoleteTip = React.memo(ObsoleteTipInner);
967
968/**
969 * Runs a series of validation checks sequentially. Returns true if all checks pass
970 * or the user manually bypassed a warning, otherwise returns false if any check fails.
971 */
972async function runWarningChecks(
973 checks: Array<() => Promise<WarningCheckResult>>,
974): Promise<boolean> {
975 for (const check of checks) {
976 // eslint-disable-next-line no-await-in-loop
977 const result = await check();
978 if (result !== WarningCheckResult.PASS) {
979 return result === WarningCheckResult.BYPASS;
980 }
981 }
982 return true;
983}
984
985async function handleRebaseConfirmation(
986 commit: CommitInfo,
987 handlePreviewedOperation: (cancel: boolean) => void,
988): Promise<void> {
989 const shouldProceed = await runWarningChecks([
990 () => maybeWarnAboutRebaseOntoMaster(commit),
991 () => maybeWarnAboutDistantRebase(commit),
992 () => maybeWarnAboutRebaseOffWarm(commit),
993 ]);
994
995 if (!shouldProceed) {
996 return;
997 }
998
999 handlePreviewedOperation(/* cancel */ false);
1000
1001 const dag = readAtom(dagWithPreviews);
1002 const onto = dag.get(commit.parents[0]);
1003 if (onto) {
1004 tracker.track('ConfirmDragAndDropRebase', {
1005 extras: {
1006 remoteBookmarks: onto.remoteBookmarks,
1007 locations: onto.stableCommitMetadata?.map(s => s.value),
1008 },
1009 });
1010 }
1011}
1012
1013async function maybeWarnAboutDistantRebase(commit: CommitInfo): Promise<WarningCheckResult> {
1014 const isDistantRebaseWarningEnabled = readAtom(distantRebaseWarningEnabled);
1015 if (!isDistantRebaseWarningEnabled) {
1016 return WarningCheckResult.PASS;
1017 }
1018 const dag = readAtom(dagWithPreviews);
1019 const onto = dag.get(commit.parents[0]);
1020 if (!onto) {
1021 return WarningCheckResult.PASS; // If there's no target commit, proceed without warning
1022 }
1023 const currentBase = findPublicBaseAncestor(dag);
1024 const destBase = findPublicBaseAncestor(dag, onto.hash);
1025 if (!currentBase || !destBase) {
1026 // can't determine if we can show warning
1027 return WarningCheckResult.PASS;
1028 }
1029
1030 if (Internal.maybeWarnAboutDistantRebase?.(currentBase, destBase)) {
1031 const buttons = [
1032 t('Opt Out of Future Warnings'),
1033 {label: t('Cancel'), primary: true},
1034 t('Rebase Anyway'),
1035 ];
1036 const answer = await showModal({
1037 type: 'confirm',
1038 buttons,
1039 title: <T>Distant Rebase Warning</T>,
1040 message: t(
1041 Internal.warnAboutDistantRebaseReason ??
1042 'The target commit is $age away from your current commit. ' +
1043 'Rebasing across a large time gap may cause slower builds and performance. ' +
1044 "It's recommended to rebase the destination commit(s) to the nearest stable or warm commit first and then attempt this rebase. " +
1045 'Do you want to `rebase` anyway?',
1046 {
1047 replace: {
1048 $age: relativeDate(onto.date, {reference: commit.date, useRelativeForm: true}),
1049 },
1050 },
1051 ),
1052 });
1053 const userEnv = (await Internal.getDevEnvType?.()) ?? 'NotImplemented';
1054 const cwd = readAtom(repoRelativeCwd);
1055 tracker.track('WarnAboutDistantRebase', {
1056 extras: {
1057 userAction: answer,
1058 envType: userEnv,
1059 cwd,
1060 },
1061 });
1062 if (answer === buttons[0]) {
1063 writeAtom(distantRebaseWarningEnabled, false);
1064 return WarningCheckResult.PASS;
1065 }
1066 return answer === buttons[2] ? WarningCheckResult.BYPASS : WarningCheckResult.FAIL;
1067 }
1068
1069 return WarningCheckResult.PASS;
1070}
1071
1072function CanopySignal({hash}: {hash: Hash}) {
1073 useEffect(() => {
1074 ensureCanopySignalsFetched();
1075 }, []);
1076 const info = useAtomValue(canopySignalForCommit(hash));
1077 const repoInfo = useAtomValue(repositoryInfo);
1078 if (!info) {
1079 return null;
1080 }
1081 const handleClick =
1082 info.commitId != null && repoInfo?.codeReviewSystem.type === 'grove'
1083 ? () => {
1084 const {owner, repo, apiUrl} = repoInfo.codeReviewSystem as {
1085 apiUrl: string;
1086 owner: string;
1087 repo: string;
1088 };
1089 const baseUrl = apiUrl.replace('/api', '').replace(/^https?:\/\//, '');
1090 const canopyHost = `canopy.${baseUrl}`;
1091 const url = `https://${canopyHost}/${owner}/${repo}/runs/${info.commitId}`;
1092 platform.openExternalLink(url);
1093 }
1094 : undefined;
1095 return (
1096 <span onClick={handleClick} style={handleClick ? {cursor: 'pointer'} : undefined}>
1097 <SignalSummaryIcon signal={info.signal} />
1098 </span>
1099 );
1100}
1101