45.6 KB1245 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 {Operation} from '../operations/Operation';
9import type {CommitInfo, DiffId} from '../types';
10import type {CommitInfoMode, EditedMessage} from './CommitInfoState';
11import type {CommitMessageFields, FieldConfig, FieldsBeingEdited} from './types';
12
13import deepEqual from 'fast-deep-equal';
14import {Badge} from 'isl-components/Badge';
15import {Banner, BannerKind, BannerTooltip} from 'isl-components/Banner';
16import {Button} from 'isl-components/Button';
17import {Divider} from 'isl-components/Divider';
18import {ErrorNotice} from 'isl-components/ErrorNotice';
19import {Column} from 'isl-components/Flex';
20import {Icon} from 'isl-components/Icon';
21import {RadioGroup} from 'isl-components/Radio';
22import {Subtle} from 'isl-components/Subtle';
23import {Tooltip} from 'isl-components/Tooltip';
24import {atom, useAtom, useAtomValue} from 'jotai';
25import {useAtomCallback} from 'jotai/utils';
26import {useCallback, useEffect, useMemo} from 'react';
27import {ComparisonType} from 'shared/Comparison';
28import {useContextMenu} from 'shared/ContextMenu';
29import {usePrevious} from 'shared/hooks';
30import {firstLine, notEmpty, nullthrows} from 'shared/utils';
31import {tracker} from '../analytics';
32import {ChangedFilesWithFetching} from '../ChangedFilesWithFetching';
33import serverAPI from '../ClientToServerAPI';
34import {
35 allDiffSummaries,
36 codeReviewProvider,
37 latestCommitMessageFields,
38} from '../codeReview/CodeReviewInfo';
39import {submitAsDraft, SubmitAsDraftCheckbox} from '../codeReview/DraftCheckbox';
40import {showBranchingPrModal} from '../codeReview/github/BranchingPrModal';
41import {openCreateGroveRepoModal} from '../codeReview/grove/CreateGroveRepoBanner';
42import {overrideDisabledSubmitModes} from '../codeReview/github/branchPrState';
43import {publishWhenReady, PublishWhenReadyCheckbox} from '../codeReview/PublishWhenReadyCheckbox';
44import {Commit} from '../Commit';
45import {OpenComparisonViewButton} from '../ComparisonView/OpenComparisonViewButton';
46import {Center} from '../ComponentUtils';
47import {confirmNoBlockingDiagnostics} from '../Diagnostics';
48import {FoldButton, useRunFoldPreview} from '../fold';
49import {getCachedGeneratedFileStatuses, useGeneratedFileStatuses} from '../GeneratedFile';
50import {t, T} from '../i18n';
51import {IrrelevantCwdIcon} from '../icons/IrrelevantCwdIcon';
52import {numPendingImageUploads} from '../ImageUpload';
53import {readAtom, useAtomGet, writeAtom} from '../jotaiUtils';
54import {Link} from '../Link';
55import {
56 messageSyncingEnabledState,
57 messageSyncingOverrideState,
58 updateRemoteMessage,
59} from '../messageSyncing';
60import {OperationDisabledButton} from '../OperationDisabledButton';
61import {AmendMessageOperation} from '../operations/AmendMessageOperation';
62import {getAmendOperation} from '../operations/AmendOperation';
63import {getCommitOperation} from '../operations/CommitOperation';
64import {FOLD_COMMIT_PREVIEW_HASH_PREFIX} from '../operations/FoldOperation';
65import {GhStackSubmitOperation} from '../operations/GhStackSubmitOperation';
66import {PrSubmitOperation} from '../operations/PrSubmitOperation';
67import {SetConfigOperation} from '../operations/SetConfigOperation';
68import {useRunOperation} from '../operationsState';
69import {useUncommittedSelection} from '../partialSelection';
70import platform from '../platform';
71import {CommitPreview, dagWithPreviews, uncommittedChangesWithPreviews} from '../previews';
72import {repoRelativeCwd, useIsIrrelevantToCwd} from '../repositoryData';
73import {selectedCommits} from '../selection';
74import {authorString, commitByHash, latestHeadCommit, repositoryInfo} from '../serverAPIState';
75import {SplitButton} from '../stackEdit/ui/SplitButton';
76import {SubmitSelectionButton} from '../SubmitSelectionButton';
77import {SubmitUpdateMessageInput} from '../SubmitUpdateMessageInput';
78import {latestSuccessorUnlessExplicitlyObsolete} from '../successionUtils';
79import {SuggestedRebaseButton} from '../SuggestedRebase';
80import {showToast} from '../toast';
81import {GeneratedStatus, succeedableRevset} from '../types';
82import {UncommittedChanges} from '../UncommittedChanges';
83import {confirmUnsavedFiles} from '../UnsavedFiles';
84import {useModal} from '../useModal';
85import {firstOfIterable} from '../utils';
86import {CommitInfoField} from './CommitInfoField';
87import {
88 commitInfoViewCurrentCommits,
89 commitMode,
90 diffUpdateMessagesState,
91 editedCommitMessages,
92 forceNextCommitToEditAllFields,
93 hasUnsavedEditedCommitMessage,
94 unsavedFieldsBeingEdited,
95} from './CommitInfoState';
96import {
97 applyEditedFields,
98 commitMessageFieldsSchema,
99 commitMessageFieldsToString,
100 editedMessageSubset,
101 findEditedDiffNumber,
102 findFieldsBeingEdited,
103 parseCommitMessageFields,
104 removeNoopEdits,
105} from './CommitMessageFields';
106import {DiffStats, PendingDiffStats} from './DiffStats';
107import {FillCommitMessage} from './FillCommitMessage';
108import {CommitTitleByline, getFieldToAutofocus, Section, SmallCapsTitle} from './utils';
109
110import {useFeatureFlagSync} from '../featureFlags';
111import {AICodeReviewStatus} from '../firstPassCodeReview/AICodeReviewStatus';
112import {AICodeReviewUpsell} from '../firstPassCodeReview/AICodeReviewUpsell';
113import {Internal} from '../Internal';
114import {confirmSuggestedEditsForFiles} from '../SuggestedEdits';
115import './CommitInfoView.css';
116
117export function CommitInfoSidebar() {
118 const commitsToShow = useAtomValue(commitInfoViewCurrentCommits);
119
120 if (commitsToShow == null) {
121 return (
122 <div className="commit-info-view" data-testid="commit-info-view-loading">
123 <Center>
124 <Icon icon="loading" />
125 </Center>
126 </div>
127 );
128 } else {
129 if (commitsToShow.length > 1) {
130 return <MultiCommitInfo selectedCommits={commitsToShow} />;
131 }
132
133 // only one commit selected
134 return <CommitInfoDetails commit={commitsToShow[0]} />;
135 }
136}
137
138export function MultiCommitInfo({selectedCommits}: {selectedCommits: Array<CommitInfo>}) {
139 const commitsWithDiffs = selectedCommits.filter(commit => commit.diffId != null);
140 return (
141 <div className="commit-info-view-multi-commit" data-testid="commit-info-view">
142 <strong className="commit-list-header">
143 <Icon icon="layers" size="M" />
144 <T replace={{$num: selectedCommits.length}}>$num Commits Selected</T>
145 </strong>
146 <Divider />
147 <div className="commit-list">
148 {selectedCommits.map(commit => (
149 <Commit
150 key={commit.hash}
151 commit={commit}
152 hasChildren={false}
153 previewType={CommitPreview.NON_ACTIONABLE_COMMIT}
154 />
155 ))}
156 </div>
157 <div className="commit-info-actions-bar">
158 <div className="commit-info-actions-bar-right">
159 <SuggestedRebaseButton
160 sources={selectedCommits.map(commit => succeedableRevset(commit.hash))}
161 />
162 <FoldButton />
163 </div>
164 {commitsWithDiffs.length === 0 ? null : (
165 <SubmitUpdateMessageInput commits={selectedCommits} />
166 )}
167 <div className="commit-info-actions-bar-left">
168 <SubmitAsDraftCheckbox commitsToBeSubmit={selectedCommits} />
169 <PublishWhenReadyCheckbox />
170 </div>
171 <div className="commit-info-actions-bar-right">
172 <SubmitSelectionButton />
173 </div>
174 </div>
175 </div>
176 );
177}
178
179function useFetchActiveDiffDetails(diffId?: string) {
180 useEffect(() => {
181 if (diffId != null) {
182 serverAPI.postMessage({
183 type: 'fetchDiffSummaries',
184 diffIds: [diffId],
185 });
186 tracker.track('DiffFetchSource', {extras: {source: 'active_diff_details'}});
187 }
188 }, [diffId]);
189}
190
191export function CommitInfoDetails({commit}: {commit: CommitInfo}) {
192 const rollbackFeatureEnabled = useFeatureFlagSync(Internal.featureFlags?.ShowRollbackPlan);
193 const aiCodeReviewUpsellEnabled = useFeatureFlagSync(Internal.featureFlags?.AICodeReviewUpsell);
194 const aiFirstPassCodeReviewEnabled = useFeatureFlagSync(
195 Internal.featureFlags?.AIFirstPassCodeReview,
196 );
197 const [mode, setMode] = useAtom(commitMode);
198 const isCommitMode = mode === 'commit';
199 const hashOrHead = isCommitMode ? 'head' : commit.hash;
200 const [editedMessage, setEditedCommitMessage] = useAtom(editedCommitMessages(hashOrHead));
201 const uncommittedChanges = useAtomValue(uncommittedChangesWithPreviews);
202 const selection = useUncommittedSelection();
203 const schema = useAtomValue(commitMessageFieldsSchema);
204
205 const isFoldPreview = commit.hash.startsWith(FOLD_COMMIT_PREVIEW_HASH_PREFIX);
206 const isOptimistic =
207 useAtomValue(commitByHash(commit.hash)) == null && !isCommitMode && !isFoldPreview;
208
209 const cwd = useAtomValue(repoRelativeCwd);
210 const isIrrelevantToCwd = useIsIrrelevantToCwd(commit);
211
212 const isPublic = commit.phase === 'public';
213 const isObsoleted = commit.successorInfo != null;
214 const isAmendDisabled = mode === 'amend' && (isPublic || isObsoleted);
215
216 const fieldsBeingEdited = useAtomValue(unsavedFieldsBeingEdited(hashOrHead));
217 const previousFieldsBeingEdited = usePrevious(fieldsBeingEdited, deepEqual);
218
219 useFetchActiveDiffDetails(commit.diffId);
220
221 const [forceEditAll, setForceEditAll] = useAtom(forceNextCommitToEditAllFields);
222
223 useEffect(() => {
224 if (isCommitMode && commit.isDot) {
225 // no use resetting edited state for commit mode, where it's always being edited.
226 return;
227 }
228
229 if (!forceEditAll) {
230 // If the selected commit is changed, the fields being edited should slim down to only fields
231 // that are meaningfully edited on the new commit.
232 if (Object.keys(editedMessage).length > 0) {
233 const trimmedEdits = removeNoopEdits(schema, parsedFields, editedMessage);
234 if (Object.keys(trimmedEdits).length !== Object.keys(editedMessage).length) {
235 setEditedCommitMessage(trimmedEdits);
236 }
237 }
238 }
239 setForceEditAll(false);
240
241 // We only want to recompute this when the commit/mode changes.
242 // we expect the edited message to change constantly.
243 // eslint-disable-next-line react-hooks/exhaustive-deps
244 }, [commit.hash, isCommitMode]);
245
246 const parsedFields = useAtomValue(latestCommitMessageFields(hashOrHead));
247
248 const parentCommit = useAtomGet(dagWithPreviews, isCommitMode ? commit.hash : commit.parents[0]);
249 const parentFields =
250 parentCommit && parentCommit.phase !== 'public'
251 ? parseCommitMessageFields(schema, parentCommit.title, parentCommit.description)
252 : undefined;
253
254 const provider = useAtomValue(codeReviewProvider);
255 const startEditingField = (field: string) => {
256 const original = parsedFields[field];
257 // If you start editing a tokenized field, add a blank token so you can write a new token instead of
258 // modifying the last existing token.
259 const fieldValue = Array.isArray(original) && original.at(-1) ? [...original, ''] : original;
260
261 setEditedCommitMessage(last => ({
262 ...last,
263 [field]: fieldValue,
264 }));
265 };
266
267 const fieldToAutofocus = getFieldToAutofocus(
268 schema,
269 fieldsBeingEdited,
270 previousFieldsBeingEdited,
271 );
272
273 const diffSummaries = useAtomValue(allDiffSummaries);
274 const remoteTrackingBranch = provider?.getRemoteTrackingBranch(
275 diffSummaries?.value,
276 commit.diffId,
277 );
278
279 const selectedFiles = uncommittedChanges.filter(f =>
280 selection.isFullyOrPartiallySelected(f.path),
281 );
282 const selectedFilesLength = selectedFiles.length;
283 return (
284 <div className="commit-info-view" data-testid="commit-info-view">
285 {!commit.isDot ? null : (
286 <div className="commit-info-view-toolbar-top" data-testid="commit-info-toolbar-top">
287 <Tooltip
288 title={t(
289 'In Commit mode, you can edit the blank commit message for a new commit. \n\n' +
290 'In Amend mode, you can view and edit the commit message for the current head commit.',
291 )}>
292 <RadioGroup
293 horizontal
294 choices={[
295 {title: t('Commit'), value: 'commit'},
296 {title: t('Amend'), value: 'amend'},
297 ]}
298 current={mode}
299 onChange={setMode}
300 />
301 </Tooltip>
302 </div>
303 )}
304 {isCommitMode && <FillCommitMessage commit={commit} mode={mode} />}
305 <div
306 className="commit-info-view-main-content"
307 // remount this if we change to commit mode
308 key={mode}>
309 {schema
310 .filter(field => !isCommitMode || field.type !== 'read-only')
311 .map(field => {
312 if (!rollbackFeatureEnabled && field.type === 'custom') {
313 return;
314 }
315
316 const setField = (newVal: string) =>
317 setEditedCommitMessage(val => ({
318 ...val,
319 [field.key]: field.type === 'field' ? newVal.split(',') : newVal,
320 }));
321
322 let editedFieldValue = editedMessage?.[field.key];
323 if (editedFieldValue == null && isCommitMode) {
324 // If the field is supposed to edited but not in the editedMessage,
325 // it means we're loading from a blank slate. This is when we can load from the commit template.
326 editedFieldValue = parsedFields[field.key];
327 }
328
329 const parentVal = parentFields?.[field.key];
330
331 return (
332 <CommitInfoField
333 key={field.key}
334 field={field}
335 content={parsedFields[field.key as keyof CommitMessageFields]}
336 autofocus={fieldToAutofocus === field.key}
337 readonly={isOptimistic || isAmendDisabled || isObsoleted}
338 isBeingEdited={fieldsBeingEdited[field.key]}
339 startEditingField={() => startEditingField(field.key)}
340 editedField={editedFieldValue}
341 setEditedField={setField}
342 copyFromParent={
343 parentVal != null
344 ? () => {
345 tracker.track('CopyCommitFieldsFromParent');
346 const val = Array.isArray(parentVal) ? parentVal.join(',') : parentVal;
347 setField(field.type === 'field' ? val + ',' : val);
348 }
349 : undefined
350 }
351 extra={
352 field.key === 'Title' ? (
353 <>
354 {aiCodeReviewUpsellEnabled &&
355 Internal.aiCodeReview?.enabled &&
356 commit.isDot &&
357 !isPublic && (
358 <AICodeReviewUpsell
359 isCommitMode={isCommitMode}
360 hasUncommittedChanges={uncommittedChanges.length > 0}
361 />
362 )}
363 {!isCommitMode ? (
364 <>
365 <CommitTitleByline commit={commit} />
366 {isFoldPreview && <FoldPreviewBanner />}
367 <ShowingRemoteMessageBanner
368 commit={commit}
369 latestFields={parsedFields}
370 editedCommitMessageKey={isCommitMode ? 'head' : commit.hash}
371 />
372 {!isPublic && isIrrelevantToCwd ? (
373 <Tooltip
374 title={
375 <T
376 replace={{
377 $prefix: <pre>{commit.maxCommonPathPrefix}</pre>,
378 $cwd: <pre>{cwd}</pre>,
379 }}>
380 This commit only contains files within: $prefix These are
381 irrelevant to your current working directory: $cwd
382 </T>
383 }>
384 <Banner kind={BannerKind.default}>
385 <IrrelevantCwdIcon />
386 <div style={{paddingLeft: 'var(--halfpad)'}}>
387 <T replace={{$cwd: <code>{cwd}</code>}}>
388 All files in this commit are outside $cwd
389 </T>
390 </div>
391 </Banner>
392 </Tooltip>
393 ) : null}
394 </>
395 ) : undefined}
396 </>
397 ) : undefined
398 }
399 />
400 );
401 })}
402 {remoteTrackingBranch == null ? null : (
403 <Section>
404 <SmallCapsTitle>
405 <Icon icon="source-control"></Icon>
406 <T>Remote Tracking Branch</T>
407 </SmallCapsTitle>
408 <div className="commit-info-tokenized-field">
409 <span className="token">{remoteTrackingBranch}</span>
410 </div>
411 </Section>
412 )}
413 <Divider />
414 {commit.isDot && !isAmendDisabled ? (
415 <Section data-testid="changes-to-amend">
416 <SmallCapsTitle>
417 {isCommitMode ? <T>Changes to Commit</T> : <T>Changes to Amend</T>}
418 <Badge>
419 {selectedFilesLength === uncommittedChanges.length
420 ? null
421 : selectedFilesLength + '/'}
422 {uncommittedChanges.length}
423 </Badge>
424 </SmallCapsTitle>
425 {uncommittedChanges.length > 0 ? <PendingDiffStats /> : null}
426 {uncommittedChanges.length === 0 ? (
427 <Subtle>
428 {isCommitMode ? <T>No changes to commit</T> : <T>No changes to amend</T>}
429 </Subtle>
430 ) : (
431 <UncommittedChanges place={isCommitMode ? 'commit sidebar' : 'amend sidebar'} />
432 )}
433 </Section>
434 ) : null}
435 {isCommitMode ? null : (
436 <Section data-testid="committed-changes">
437 <SmallCapsTitle>
438 <T>Files Changed</T>
439 <Badge>{commit.totalFileCount}</Badge>
440 </SmallCapsTitle>
441 {commit.phase !== 'public' ? <DiffStats commit={commit} /> : null}
442 <div className="changed-file-list">
443 <div className="button-row">
444 <OpenComparisonViewButton
445 comparison={{type: ComparisonType.Committed, hash: commit.hash}}
446 />
447 <OpenAllFilesButton commit={commit} />
448 <SplitButton trackerEventName="SplitOpenFromSplitSuggestion" commit={commit} />
449 </div>
450 <ChangedFilesWithFetching commit={commit} />
451 </div>
452 </Section>
453 )}
454 </div>
455 {!isAmendDisabled && (
456 <div className="commit-info-view-toolbar-bottom">
457 {isFoldPreview ? (
458 <FoldPreviewActions />
459 ) : (
460 <>
461 {aiCodeReviewUpsellEnabled && aiFirstPassCodeReviewEnabled && <AICodeReviewStatus />}
462 <ActionsBar
463 commit={commit}
464 latestMessage={parsedFields}
465 editedMessage={editedMessage}
466 fieldsBeingEdited={fieldsBeingEdited}
467 isCommitMode={isCommitMode}
468 setMode={setMode}
469 />
470 </>
471 )}
472 </div>
473 )}
474 </div>
475 );
476}
477
478/**
479 * No files are generated -> "Open all" button
480 * All files are generated -> "Open all" button, with warning that they're all generated
481 * Some files are generated -> "Open non-generated files" button
482 */
483function OpenAllFilesButton({commit}: {commit: CommitInfo}) {
484 const paths = useMemo(() => commit.filePathsSample, [commit]);
485 const statuses = useGeneratedFileStatuses(paths);
486 const allAreGenerated = paths.every(file => statuses[file] === GeneratedStatus.Generated);
487 const someAreGenerated = paths.some(file => statuses[file] === GeneratedStatus.Generated);
488 return (
489 <Tooltip
490 title={
491 someAreGenerated
492 ? allAreGenerated
493 ? t('Opens all files for editing.\nNote: All files are generated.')
494 : t('Open all non-generated files for editing')
495 : t('Open all files for editing')
496 }>
497 <Button
498 icon
499 onClick={() => {
500 tracker.track('OpenAllFiles');
501 const statuses = getCachedGeneratedFileStatuses(commit.filePathsSample);
502 const toOpen = allAreGenerated
503 ? commit.filePathsSample
504 : commit.filePathsSample.filter(
505 file => statuses[file] == null || statuses[file] !== GeneratedStatus.Generated,
506 );
507 platform.openFiles(toOpen);
508 }}>
509 <Icon icon="go-to-file" slot="start" />
510 {someAreGenerated && !allAreGenerated ? (
511 <T>Open Non-Generated Files</T>
512 ) : (
513 <T>Open All Files</T>
514 )}
515 </Button>
516 </Tooltip>
517 );
518}
519
520/**
521 * Two parsed commit messages are considered unchanged if all the textareas (summary, test plan) are unchanged.
522 * This avoids marking tiny changes like adding a reviewer as substatively changing the message.
523 */
524function areTextFieldsUnchanged(
525 schema: Array<FieldConfig>,
526 a: CommitMessageFields,
527 b: CommitMessageFields,
528) {
529 for (const field of schema) {
530 if (field.type === 'textarea') {
531 if (a[field.key] !== b[field.key]) {
532 return false;
533 }
534 }
535 }
536 return true;
537}
538
539function FoldPreviewBanner() {
540 return (
541 <BannerTooltip
542 tooltip={t(
543 'This is the commit message after combining these commits with the fold command. ' +
544 'You can edit this message before confirming and running fold.',
545 )}>
546 <Banner kind={BannerKind.green} icon={<Icon icon="info" />}>
547 <T>Previewing result of combined commits</T>
548 </Banner>
549 </BannerTooltip>
550 );
551}
552
553function ShowingRemoteMessageBanner({
554 commit,
555 latestFields,
556 editedCommitMessageKey,
557}: {
558 commit: CommitInfo;
559 latestFields: CommitMessageFields;
560 editedCommitMessageKey: string;
561}) {
562 const provider = useAtomValue(codeReviewProvider);
563 const schema = useAtomValue(commitMessageFieldsSchema);
564 const runOperation = useRunOperation();
565 const syncingEnabled = useAtomValue(messageSyncingEnabledState);
566 const syncingOverride = useAtomValue(messageSyncingOverrideState);
567
568 const loadLocalMessage = useCallback(() => {
569 const originalFields = parseCommitMessageFields(schema, commit.title, commit.description);
570 const beingEdited = findFieldsBeingEdited(schema, originalFields, latestFields);
571
572 writeAtom(editedCommitMessages(editedCommitMessageKey), () =>
573 editedMessageSubset(originalFields, beingEdited),
574 );
575 }, [commit, editedCommitMessageKey, latestFields, schema]);
576
577 const contextMenu = useContextMenu(() => {
578 return [
579 {
580 label: <T>Load local commit message instead</T>,
581 onClick: loadLocalMessage,
582 },
583 {
584 label: <T>Sync local commit to match remote</T>,
585 onClick: () => {
586 runOperation(
587 new AmendMessageOperation(
588 latestSuccessorUnlessExplicitlyObsolete(commit),
589 commitMessageFieldsToString(schema, latestFields),
590 ),
591 );
592 },
593 },
594 ];
595 });
596
597 if (!provider || (syncingOverride == null && !syncingEnabled)) {
598 return null;
599 }
600
601 if (syncingOverride === false) {
602 return (
603 <BannerTooltip
604 tooltip={t(
605 'Message syncing with $provider has been temporarily disabled due to a failed sync.\n\n' +
606 'Your local commit message is shown instead.\n' +
607 "Changes you make won't be automatically synced.\n\n" +
608 'Make sure to manually sync your message with $provider, then re-enable or restart ISL to start syncing again.',
609 {replace: {$provider: provider.label}},
610 )}>
611 <Banner
612 icon={<Icon icon="warn" />}
613 alwaysShowButtons
614 kind={BannerKind.warning}
615 buttons={
616 <Button
617 icon
618 onClick={() => {
619 writeAtom(messageSyncingOverrideState, null);
620 }}>
621 <T>Show Remote Messages Instead</T>
622 </Button>
623 }>
624 <T replace={{$provider: provider.label}}>Not syncing messages with $provider</T>
625 </Banner>
626 </BannerTooltip>
627 );
628 }
629
630 const originalFields = parseCommitMessageFields(schema, commit.title, commit.description);
631
632 if (areTextFieldsUnchanged(schema, originalFields, latestFields)) {
633 return null;
634 }
635
636 return (
637 <BannerTooltip
638 tooltip={t(
639 'Viewing the newer commit message from $provider. This message will be used when your code is landed. You can also load the local message instead.',
640 {replace: {$provider: provider.label}},
641 )}>
642 <Banner
643 icon={<Icon icon="info" />}
644 alwaysShowButtons
645 buttons={
646 <Button
647 icon
648 data-testid="message-sync-banner-context-menu"
649 onClick={e => {
650 contextMenu(e);
651 }}>
652 <Icon icon="ellipsis" />
653 </Button>
654 }>
655 <T replace={{$provider: provider.label}}>Showing latest commit message from $provider</T>
656 </Banner>
657 </BannerTooltip>
658 );
659}
660
661function FoldPreviewActions() {
662 const [cancel, run] = useRunFoldPreview();
663 return (
664 <div className="commit-info-actions-bar" data-testid="commit-info-actions-bar">
665 <div className="commit-info-actions-bar-right">
666 <Button onClick={cancel}>
667 <T>Cancel</T>
668 </Button>
669 <Button primary onClick={run}>
670 <T>Run Combine</T>
671 </Button>
672 </div>
673 </div>
674 );
675}
676
677const imageUploadsPendingAtom = atom(get => {
678 return get(numPendingImageUploads(undefined)) > 0;
679});
680
681function ActionsBar({
682 commit,
683 latestMessage,
684 editedMessage,
685 fieldsBeingEdited,
686 isCommitMode,
687 setMode,
688}: {
689 commit: CommitInfo;
690 latestMessage: CommitMessageFields;
691 editedMessage: EditedMessage;
692 fieldsBeingEdited: FieldsBeingEdited;
693 isCommitMode: boolean;
694 setMode: (mode: CommitInfoMode) => unknown;
695}) {
696 const isAnythingBeingEdited = Object.values(fieldsBeingEdited).some(Boolean);
697 const uncommittedChanges = useAtomValue(uncommittedChangesWithPreviews);
698 const selection = useUncommittedSelection();
699 const anythingToCommit =
700 !selection.isNothingSelected() &&
701 ((!isCommitMode && isAnythingBeingEdited) || uncommittedChanges.length > 0);
702
703 const provider = useAtomValue(codeReviewProvider);
704 const schema = useAtomValue(commitMessageFieldsSchema);
705 const headCommit = useAtomValue(latestHeadCommit);
706
707 const messageSyncEnabled = useAtomValue(messageSyncingEnabledState);
708
709 // after committing/amending, if you've previously selected the head commit,
710 // we should show you the newly amended/committed commit instead of the old one.
711 const deselectIfHeadIsSelected = useAtomCallback((get, set) => {
712 if (!commit.isDot) {
713 return;
714 }
715 const selected = get(selectedCommits);
716 // only reset if selection exactly matches our expectation
717 if (selected && selected.size === 1 && firstOfIterable(selected.values()) === commit.hash) {
718 set(selectedCommits, new Set());
719 }
720 });
721
722 const clearEditedCommitMessage = useCallback(
723 async (skipConfirmation?: boolean) => {
724 if (!skipConfirmation) {
725 const hasUnsavedEdits = readAtom(
726 hasUnsavedEditedCommitMessage(isCommitMode ? 'head' : commit.hash),
727 );
728 if (hasUnsavedEdits) {
729 const confirmed = await platform.confirm(
730 t('Are you sure you want to discard your edited message?'),
731 );
732 if (confirmed === false) {
733 return;
734 }
735 }
736 }
737
738 // Delete the edited message atom (and delete from persisted storage)
739 writeAtom(editedCommitMessages(isCommitMode ? 'head' : commit.hash), undefined);
740 },
741 [commit.hash, isCommitMode],
742 );
743 const doAmendOrCommit = () => {
744 const updatedMessage = applyEditedFields(latestMessage, editedMessage);
745 const message = commitMessageFieldsToString(schema, updatedMessage);
746 const allFiles = uncommittedChanges.map(file => file.path);
747
748 const operation = isCommitMode
749 ? getCommitOperation(message, headCommit, selection.selection, allFiles)
750 : getAmendOperation(message, headCommit, selection.selection, allFiles);
751
752 selection.discardPartialSelections();
753
754 clearEditedCommitMessage(/* skip confirmation */ true);
755 // reset to amend mode now that the commit has been made
756 setMode('amend');
757 deselectIfHeadIsSelected();
758
759 return operation;
760 };
761
762 const showOptionModal = useModal();
763
764 const codeReviewProviderName = provider?.label;
765
766 const areImageUploadsOngoing = useAtomValue(imageUploadsPendingAtom);
767
768 // Generally "Amend"/"Commit" for head commit, but if there's no changes while amending, just use "Amend message"
769 const showCommitOrAmend =
770 commit.isDot && (isCommitMode || anythingToCommit || !isAnythingBeingEdited);
771
772 return (
773 <div className="commit-info-actions-bar" data-testid="commit-info-actions-bar">
774 {isCommitMode || commit.diffId == null ? null : (
775 <SubmitUpdateMessageInput commits={[commit]} />
776 )}
777 <div className="commit-info-actions-bar-left">
778 <SubmitAsDraftCheckbox commitsToBeSubmit={isCommitMode ? [] : [commit]} />
779 <PublishWhenReadyCheckbox />
780 </div>
781 <div className="commit-info-actions-bar-right">
782 {isAnythingBeingEdited && !isCommitMode ? (
783 <Button onClick={() => clearEditedCommitMessage()}>
784 <T>Cancel</T>
785 </Button>
786 ) : null}
787
788 {showCommitOrAmend ? (
789 <Tooltip
790 title={
791 areImageUploadsOngoing
792 ? t('Image uploads are still pending')
793 : isCommitMode
794 ? selection.isEverythingSelected()
795 ? t('No changes to commit')
796 : t('No selected changes to commit')
797 : selection.isEverythingSelected()
798 ? t('No changes to amend')
799 : t('No selected changes to amend')
800 }
801 trigger={areImageUploadsOngoing || !anythingToCommit ? 'hover' : 'disabled'}>
802 <OperationDisabledButton
803 contextKey={isCommitMode ? 'commit' : 'amend'}
804 disabled={!anythingToCommit || editedMessage == null || areImageUploadsOngoing}
805 runOperation={async () => {
806 if (!isCommitMode) {
807 const updatedMessage = applyEditedFields(latestMessage, editedMessage);
808 const stringifiedMessage = commitMessageFieldsToString(schema, updatedMessage);
809 const diffId = findEditedDiffNumber(updatedMessage) ?? commit.diffId;
810 // if there's a diff attached, we should also update the remote message
811 if (messageSyncEnabled && diffId) {
812 const shouldAbort = await tryToUpdateRemoteMessage(
813 commit,
814 diffId,
815 stringifiedMessage,
816 showOptionModal,
817 'amend',
818 );
819 if (shouldAbort) {
820 return;
821 }
822 }
823 }
824
825 {
826 const shouldContinue = await confirmUnsavedFiles();
827 if (!shouldContinue) {
828 return;
829 }
830 }
831
832 {
833 const shouldContinue = await confirmSuggestedEditsForFiles(
834 isCommitMode ? 'commit' : 'amend',
835 'accept',
836 selection.selection,
837 );
838 if (!shouldContinue) {
839 return;
840 }
841 }
842
843 return doAmendOrCommit();
844 }}>
845 {isCommitMode ? <T>Commit</T> : <T>Amend</T>}
846 </OperationDisabledButton>
847 </Tooltip>
848 ) : (
849 <Tooltip
850 title={
851 areImageUploadsOngoing
852 ? t('Image uploads are still pending')
853 : !isAnythingBeingEdited
854 ? t('No message edits to amend')
855 : messageSyncEnabled && commit.diffId != null
856 ? t(
857 'Amend the commit message with the newly entered message, then sync that message up to $provider.',
858 {replace: {$provider: codeReviewProviderName ?? 'remote'}},
859 )
860 : t('Amend the commit message with the newly entered message.')
861 }>
862 <OperationDisabledButton
863 contextKey={`amend-message-${commit.hash}`}
864 data-testid="amend-message-button"
865 disabled={!isAnythingBeingEdited || editedMessage == null || areImageUploadsOngoing}
866 runOperation={async () => {
867 const updatedMessage = applyEditedFields(latestMessage, editedMessage);
868 const stringifiedMessage = commitMessageFieldsToString(schema, updatedMessage);
869 const diffId = findEditedDiffNumber(updatedMessage) ?? commit.diffId;
870 // if there's a diff attached, we should also update the remote message
871 if (messageSyncEnabled && diffId) {
872 const shouldAbort = await tryToUpdateRemoteMessage(
873 commit,
874 diffId,
875 stringifiedMessage,
876 showOptionModal,
877 'amendMessage',
878 );
879 if (shouldAbort) {
880 return;
881 }
882 }
883
884 const intendedAuthor = readAtom(authorString);
885 const authorArg =
886 intendedAuthor != null && commit.author !== intendedAuthor
887 ? intendedAuthor
888 : undefined;
889
890 const operation = new AmendMessageOperation(
891 latestSuccessorUnlessExplicitlyObsolete(commit),
892 stringifiedMessage,
893 authorArg,
894 );
895 clearEditedCommitMessage(/* skip confirmation */ true);
896 return operation;
897 }}>
898 <T>Amend Message</T>
899 </OperationDisabledButton>
900 </Tooltip>
901 )}
902 <SubmitButton
903 commit={commit}
904 getAmendOrCommitOperation={doAmendOrCommit}
905 anythingToCommit={anythingToCommit}
906 isAnythingBeingEdited={isAnythingBeingEdited}
907 isCommitMode={isCommitMode}
908 />
909 </div>
910 </div>
911 );
912}
913
914function SubmitButton({
915 commit,
916 getAmendOrCommitOperation,
917 anythingToCommit,
918 isAnythingBeingEdited,
919 isCommitMode,
920}: {
921 commit: CommitInfo;
922 getAmendOrCommitOperation: () => Operation;
923 anythingToCommit: boolean;
924 isAnythingBeingEdited: boolean;
925 isCommitMode: boolean;
926}) {
927 const [repoInfo, setRepoInfo] = useAtom(repositoryInfo);
928 const diffSummaries = useAtomValue(allDiffSummaries);
929 const shouldSubmitAsDraft = useAtomValue(submitAsDraft);
930 const shouldPublishWhenReady = useAtomValue(publishWhenReady);
931 const [updateMessage, setUpdateMessage] = useAtom(diffUpdateMessagesState(commit.hash));
932 const provider = useAtomValue(codeReviewProvider);
933
934 const codeReviewProviderType = repoInfo?.codeReviewSystem.type ?? 'unknown';
935 const canSubmitWithCodeReviewProvider =
936 codeReviewProviderType !== 'none' && codeReviewProviderType !== 'unknown';
937 const submittable =
938 diffSummaries.value && provider?.getSubmittableDiffs([commit], diffSummaries.value);
939 const canSubmitIndividualDiffs = submittable && submittable.length > 0;
940
941 const showOptionModal = useModal();
942 const forceEnableSubmit = useAtomValue(overrideDisabledSubmitModes);
943 const submitDisabledReason = forceEnableSubmit ? undefined : provider?.submitDisabledReason?.();
944 const messageSyncEnabled = useAtomValue(messageSyncingEnabledState);
945 const areImageUploadsOngoing = useAtomValue(imageUploadsPendingAtom);
946
947 const runOperation = useRunOperation();
948
949 const selection = useUncommittedSelection();
950
951 // When no code review system is configured, show a "Create Grove Repository" button
952 if (codeReviewProviderType === 'none') {
953 return (
954 <Tooltip title={t('Create a repository on Grove to enable push and code review')} placement="top">
955 <Button kind="primary" onClick={openCreateGroveRepoModal}>
956 <T>Create Grove Repository</T>
957 </Button>
958 </Tooltip>
959 );
960 }
961
962 const isBranchingPREnabled =
963 codeReviewProviderType === 'github' && repoInfo?.preferredSubmitCommand === 'push';
964
965 const disabledReason = areImageUploadsOngoing
966 ? t('Image uploads are still pending')
967 : submitDisabledReason
968 ? submitDisabledReason
969 : !canSubmitWithCodeReviewProvider
970 ? t('No code review system found for this repository')
971 : null;
972
973 const getApplicableOperations = async (): Promise<Array<Operation> | undefined> => {
974 const shouldContinue = await confirmUnsavedFiles();
975 if (!shouldContinue) {
976 return;
977 }
978
979 if (!(await confirmNoBlockingDiagnostics(selection, isCommitMode ? undefined : commit))) {
980 return;
981 }
982
983 let amendOrCommitOp;
984 if (commit.isDot && anythingToCommit) {
985 // TODO: we should also amend if there are pending commit message changes, and change the button
986 // to amend message & submit.
987 // Or just remove the submit button if you start editing since we'll update the remote message anyway...
988 amendOrCommitOp = getAmendOrCommitOperation();
989 }
990
991 if (
992 repoInfo?.type === 'success' &&
993 repoInfo.codeReviewSystem.type === 'github' &&
994 repoInfo.preferredSubmitCommand == null
995 ) {
996 const buttons = [t('Cancel') as 'Cancel', 'ghstack', 'pr'] as const;
997 const cancel = buttons[0];
998 const answer = await showOptionModal({
999 type: 'confirm',
1000 icon: 'warning',
1001 title: t('Preferred Code Review command not yet configured'),
1002 message: (
1003 <div className="commit-info-confirm-modal-paragraphs">
1004 <div>
1005 <T replace={{$pr: <code>sl pr</code>, $ghstack: <code>sl ghstack</code>}}>
1006 You can configure Sapling to use either $pr or $ghstack to submit for code review on
1007 GitHub.
1008 </T>
1009 </div>
1010 <div>
1011 <T
1012 replace={{
1013 $config: <code>github.preferred_submit_command</code>,
1014 }}>
1015 Each submit command has tradeoffs, due to how GitHub creates Pull Requests. This can
1016 be controlled by the $config config.
1017 </T>
1018 </div>
1019 <div>
1020 <T>To continue, select a command to use to submit.</T>
1021 </div>
1022 <Link href="https://sapling-scm.com/docs/git/intro#pull-requests">
1023 <T>Learn More</T>
1024 </Link>
1025 </div>
1026 ),
1027 buttons,
1028 });
1029 if (answer === cancel || answer == null) {
1030 return;
1031 }
1032 const rememberConfigOp = new SetConfigOperation(
1033 'local',
1034 'github.preferred_submit_command',
1035 answer,
1036 );
1037 setRepoInfo(info => ({
1038 ...nullthrows(info),
1039 preferredSubmitCommand: answer,
1040 }));
1041 // setRepoInfo updates `provider`, but we still have a stale reference in this callback.
1042 // So this one time, we need to manually run the new submit command.
1043 // Future submit calls can delegate to provider.submitOperation();
1044 const submitOp =
1045 answer === 'ghstack'
1046 ? new GhStackSubmitOperation({
1047 draft: shouldSubmitAsDraft,
1048 })
1049 : answer === 'pr'
1050 ? new PrSubmitOperation({
1051 draft: shouldSubmitAsDraft,
1052 })
1053 : null;
1054
1055 // TODO: account for branching PR
1056
1057 return [amendOrCommitOp, rememberConfigOp, submitOp].filter(notEmpty);
1058 }
1059
1060 // Only do message sync if we're amending the local commit in some way.
1061 // If we're just doing a submit, we expect the message to have been synced previously
1062 // during another amend or amend message.
1063 const shouldUpdateMessage = !isCommitMode && messageSyncEnabled && anythingToCommit;
1064
1065 const submitOp = isBranchingPREnabled
1066 ? null // branching PRs will show a follow-up modal which controls submitting
1067 : nullthrows(provider).submitOperation(
1068 commit.isDot ? [] : [commit], // [] means to submit the head commit
1069 {
1070 draft: shouldSubmitAsDraft,
1071 updateFields: shouldUpdateMessage,
1072 updateMessage: updateMessage || undefined,
1073 publishWhenReady: shouldPublishWhenReady,
1074 },
1075 );
1076
1077 // clear out the update message now that we've used it to submit
1078 if (updateMessage) {
1079 setUpdateMessage('');
1080 }
1081
1082 return [amendOrCommitOp, submitOp].filter(notEmpty);
1083 };
1084
1085 return (commit.isDot && (anythingToCommit || !isAnythingBeingEdited)) ||
1086 (!commit.isDot &&
1087 canSubmitIndividualDiffs &&
1088 // For non-head commits, "submit" doesn't update the message, which is confusing.
1089 // Just hide the submit button so you're encouraged to "amend message" first.
1090 !isAnythingBeingEdited) ? (
1091 <Tooltip
1092 title={
1093 disabledReason ??
1094 t('$action with $provider', {
1095 replace: {
1096 $action: provider?.submitButtonLabel ?? 'Submit',
1097 $provider: provider?.label ?? 'remote',
1098 },
1099 })
1100 }
1101 placement="top">
1102 {isBranchingPREnabled ? (
1103 <Button
1104 primary
1105 disabled={disabledReason != null}
1106 onClick={async () => {
1107 try {
1108 const operations = await getApplicableOperations();
1109 if (operations == null || operations.length === 0) {
1110 return;
1111 }
1112
1113 for (const operation of operations) {
1114 runOperation(operation);
1115 }
1116 const dag = readAtom(dagWithPreviews);
1117 const topOfStack = commit.isDot && isCommitMode ? dag.resolve('.') : commit;
1118 if (topOfStack == null) {
1119 throw new Error('could not find commit to push');
1120 }
1121 const pushOps = await showBranchingPrModal(topOfStack);
1122 if (pushOps == null) {
1123 return;
1124 }
1125 for (const pushOp of pushOps) {
1126 runOperation(pushOp);
1127 }
1128 } catch (err) {
1129 const error = err as Error;
1130 showToast(<ErrorNotice error={error} title={<T>Failed to push commits</T>} />, {
1131 durationMs: 10000,
1132 });
1133 }
1134 }}>
1135 {commit.isDot && anythingToCommit ? (
1136 isCommitMode ? (
1137 <T>Commit and Push...</T>
1138 ) : (
1139 <T>Amend and Push...</T>
1140 )
1141 ) : (
1142 <T>Push...</T>
1143 )}
1144 </Button>
1145 ) : (
1146 <OperationDisabledButton
1147 kind="primary"
1148 contextKey={`submit-${commit.isDot ? 'head' : commit.hash}`}
1149 disabled={disabledReason != null}
1150 runOperation={getApplicableOperations}>
1151 {commit.isDot && anythingToCommit ? (
1152 isCommitMode ? (
1153 <T replace={{$action: provider?.submitButtonLabel ?? 'Submit'}}>
1154 Commit and $action
1155 </T>
1156 ) : (
1157 <T replace={{$action: provider?.submitButtonLabel ?? 'Submit'}}>
1158 Amend and $action
1159 </T>
1160 )
1161 ) : (
1162 <T>{provider?.submitButtonLabel ?? 'Submit'}</T>
1163 )}
1164 </OperationDisabledButton>
1165 )}
1166 </Tooltip>
1167 ) : null;
1168}
1169
1170async function tryToUpdateRemoteMessage(
1171 commit: CommitInfo,
1172 diffId: DiffId,
1173 latestMessageString: string,
1174 showOptionModal: ReturnType<typeof useModal>,
1175 reason: 'amend' | 'amendMessage',
1176): Promise<boolean> {
1177 // TODO: we could skip the update if the new message matches the old one,
1178 // which is possible when amending changes without changing the commit message
1179
1180 let optedOutOfSync = false;
1181 if (diffId !== commit.diffId) {
1182 const buttons = [
1183 t('Cancel') as 'Cancel',
1184 t('Use Remote Message'),
1185 t('Sync New Message'),
1186 ] as const;
1187 const cancel = buttons[0];
1188 const syncButton = buttons[2];
1189 const answer = await showOptionModal({
1190 type: 'confirm',
1191 icon: 'warning',
1192 title: t('Sync message for newly attached Diff?'),
1193 message: (
1194 <T>
1195 You're changing the attached Diff for this commit. Would you like you sync your new local
1196 message up to the remote Diff, or just use the existing remote message for this Diff?
1197 </T>
1198 ),
1199 buttons,
1200 });
1201 tracker.track('ConfirmSyncNewDiffNumber', {
1202 extras: {
1203 choice: answer,
1204 },
1205 });
1206 if (answer === cancel || answer == null) {
1207 return true; // abort
1208 }
1209 optedOutOfSync = answer !== syncButton;
1210 }
1211 if (!optedOutOfSync) {
1212 const title = firstLine(latestMessageString);
1213 const description = latestMessageString.slice(title.length);
1214 // don't wait for the update mutation to go through, just let it happen in parallel with the metaedit
1215 tracker
1216 .operation('SyncDiffMessageMutation', 'SyncMessageError', {extras: {reason}}, () =>
1217 updateRemoteMessage(diffId, title, description),
1218 )
1219 .catch(err => {
1220 // Uh oh we failed to sync. Let's override all syncing so you can see your local changes
1221 // and we don't get you stuck in a syncing loop.
1222
1223 writeAtom(messageSyncingOverrideState, false);
1224
1225 showToast(
1226 <Banner kind={BannerKind.error}>
1227 <Column alignStart>
1228 <div>
1229 <T>Failed to sync message to remote. Further syncing has been disabled.</T>
1230 </div>
1231 <div>
1232 <T>Try manually syncing and restarting ISL.</T>
1233 </div>
1234 <div>{firstLine(err.message || err.toString())}</div>
1235 </Column>
1236 </Banner>,
1237 {
1238 durationMs: 20_000,
1239 },
1240 );
1241 });
1242 }
1243 return false;
1244}
1245