addons/isl/src/CommitInfoView/CommitInfoView.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 {Operation} from '../operations/Operation';
b69ab319import type {CommitInfo, DiffId} from '../types';
b69ab3110import type {CommitInfoMode, EditedMessage} from './CommitInfoState';
b69ab3111import type {CommitMessageFields, FieldConfig, FieldsBeingEdited} from './types';
b69ab3112
b69ab3113import deepEqual from 'fast-deep-equal';
b69ab3114import {Badge} from 'isl-components/Badge';
b69ab3115import {Banner, BannerKind, BannerTooltip} from 'isl-components/Banner';
b69ab3116import {Button} from 'isl-components/Button';
b69ab3117import {Divider} from 'isl-components/Divider';
b69ab3118import {ErrorNotice} from 'isl-components/ErrorNotice';
b69ab3119import {Column} from 'isl-components/Flex';
b69ab3120import {Icon} from 'isl-components/Icon';
b69ab3121import {RadioGroup} from 'isl-components/Radio';
b69ab3122import {Subtle} from 'isl-components/Subtle';
b69ab3123import {Tooltip} from 'isl-components/Tooltip';
b69ab3124import {atom, useAtom, useAtomValue} from 'jotai';
b69ab3125import {useAtomCallback} from 'jotai/utils';
b69ab3126import {useCallback, useEffect, useMemo} from 'react';
b69ab3127import {ComparisonType} from 'shared/Comparison';
b69ab3128import {useContextMenu} from 'shared/ContextMenu';
b69ab3129import {usePrevious} from 'shared/hooks';
b69ab3130import {firstLine, notEmpty, nullthrows} from 'shared/utils';
b69ab3131import {tracker} from '../analytics';
b69ab3132import {ChangedFilesWithFetching} from '../ChangedFilesWithFetching';
b69ab3133import serverAPI from '../ClientToServerAPI';
b69ab3134import {
b69ab3135 allDiffSummaries,
b69ab3136 codeReviewProvider,
b69ab3137 latestCommitMessageFields,
b69ab3138} from '../codeReview/CodeReviewInfo';
b69ab3139import {submitAsDraft, SubmitAsDraftCheckbox} from '../codeReview/DraftCheckbox';
b69ab3140import {showBranchingPrModal} from '../codeReview/github/BranchingPrModal';
6c9fcae41import {openCreateGroveRepoModal} from '../codeReview/grove/CreateGroveRepoBanner';
b69ab3142import {overrideDisabledSubmitModes} from '../codeReview/github/branchPrState';
b69ab3143import {publishWhenReady, PublishWhenReadyCheckbox} from '../codeReview/PublishWhenReadyCheckbox';
b69ab3144import {Commit} from '../Commit';
b69ab3145import {OpenComparisonViewButton} from '../ComparisonView/OpenComparisonViewButton';
b69ab3146import {Center} from '../ComponentUtils';
b69ab3147import {confirmNoBlockingDiagnostics} from '../Diagnostics';
b69ab3148import {FoldButton, useRunFoldPreview} from '../fold';
b69ab3149import {getCachedGeneratedFileStatuses, useGeneratedFileStatuses} from '../GeneratedFile';
b69ab3150import {t, T} from '../i18n';
b69ab3151import {IrrelevantCwdIcon} from '../icons/IrrelevantCwdIcon';
b69ab3152import {numPendingImageUploads} from '../ImageUpload';
b69ab3153import {readAtom, useAtomGet, writeAtom} from '../jotaiUtils';
b69ab3154import {Link} from '../Link';
b69ab3155import {
b69ab3156 messageSyncingEnabledState,
b69ab3157 messageSyncingOverrideState,
b69ab3158 updateRemoteMessage,
b69ab3159} from '../messageSyncing';
b69ab3160import {OperationDisabledButton} from '../OperationDisabledButton';
b69ab3161import {AmendMessageOperation} from '../operations/AmendMessageOperation';
b69ab3162import {getAmendOperation} from '../operations/AmendOperation';
b69ab3163import {getCommitOperation} from '../operations/CommitOperation';
b69ab3164import {FOLD_COMMIT_PREVIEW_HASH_PREFIX} from '../operations/FoldOperation';
b69ab3165import {GhStackSubmitOperation} from '../operations/GhStackSubmitOperation';
b69ab3166import {PrSubmitOperation} from '../operations/PrSubmitOperation';
b69ab3167import {SetConfigOperation} from '../operations/SetConfigOperation';
b69ab3168import {useRunOperation} from '../operationsState';
b69ab3169import {useUncommittedSelection} from '../partialSelection';
b69ab3170import platform from '../platform';
b69ab3171import {CommitPreview, dagWithPreviews, uncommittedChangesWithPreviews} from '../previews';
b69ab3172import {repoRelativeCwd, useIsIrrelevantToCwd} from '../repositoryData';
b69ab3173import {selectedCommits} from '../selection';
b69ab3174import {authorString, commitByHash, latestHeadCommit, repositoryInfo} from '../serverAPIState';
b69ab3175import {SplitButton} from '../stackEdit/ui/SplitButton';
b69ab3176import {SubmitSelectionButton} from '../SubmitSelectionButton';
b69ab3177import {SubmitUpdateMessageInput} from '../SubmitUpdateMessageInput';
b69ab3178import {latestSuccessorUnlessExplicitlyObsolete} from '../successionUtils';
b69ab3179import {SuggestedRebaseButton} from '../SuggestedRebase';
b69ab3180import {showToast} from '../toast';
b69ab3181import {GeneratedStatus, succeedableRevset} from '../types';
b69ab3182import {UncommittedChanges} from '../UncommittedChanges';
b69ab3183import {confirmUnsavedFiles} from '../UnsavedFiles';
b69ab3184import {useModal} from '../useModal';
b69ab3185import {firstOfIterable} from '../utils';
b69ab3186import {CommitInfoField} from './CommitInfoField';
b69ab3187import {
b69ab3188 commitInfoViewCurrentCommits,
b69ab3189 commitMode,
b69ab3190 diffUpdateMessagesState,
b69ab3191 editedCommitMessages,
b69ab3192 forceNextCommitToEditAllFields,
b69ab3193 hasUnsavedEditedCommitMessage,
b69ab3194 unsavedFieldsBeingEdited,
b69ab3195} from './CommitInfoState';
b69ab3196import {
b69ab3197 applyEditedFields,
b69ab3198 commitMessageFieldsSchema,
b69ab3199 commitMessageFieldsToString,
b69ab31100 editedMessageSubset,
b69ab31101 findEditedDiffNumber,
b69ab31102 findFieldsBeingEdited,
b69ab31103 parseCommitMessageFields,
b69ab31104 removeNoopEdits,
b69ab31105} from './CommitMessageFields';
b69ab31106import {DiffStats, PendingDiffStats} from './DiffStats';
b69ab31107import {FillCommitMessage} from './FillCommitMessage';
b69ab31108import {CommitTitleByline, getFieldToAutofocus, Section, SmallCapsTitle} from './utils';
b69ab31109
b69ab31110import {useFeatureFlagSync} from '../featureFlags';
b69ab31111import {AICodeReviewStatus} from '../firstPassCodeReview/AICodeReviewStatus';
b69ab31112import {AICodeReviewUpsell} from '../firstPassCodeReview/AICodeReviewUpsell';
b69ab31113import {Internal} from '../Internal';
b69ab31114import {confirmSuggestedEditsForFiles} from '../SuggestedEdits';
b69ab31115import './CommitInfoView.css';
b69ab31116
b69ab31117export function CommitInfoSidebar() {
b69ab31118 const commitsToShow = useAtomValue(commitInfoViewCurrentCommits);
b69ab31119
b69ab31120 if (commitsToShow == null) {
b69ab31121 return (
b69ab31122 <div className="commit-info-view" data-testid="commit-info-view-loading">
b69ab31123 <Center>
b69ab31124 <Icon icon="loading" />
b69ab31125 </Center>
b69ab31126 </div>
b69ab31127 );
b69ab31128 } else {
b69ab31129 if (commitsToShow.length > 1) {
b69ab31130 return <MultiCommitInfo selectedCommits={commitsToShow} />;
b69ab31131 }
b69ab31132
b69ab31133 // only one commit selected
b69ab31134 return <CommitInfoDetails commit={commitsToShow[0]} />;
b69ab31135 }
b69ab31136}
b69ab31137
b69ab31138export function MultiCommitInfo({selectedCommits}: {selectedCommits: Array<CommitInfo>}) {
b69ab31139 const commitsWithDiffs = selectedCommits.filter(commit => commit.diffId != null);
b69ab31140 return (
b69ab31141 <div className="commit-info-view-multi-commit" data-testid="commit-info-view">
b69ab31142 <strong className="commit-list-header">
b69ab31143 <Icon icon="layers" size="M" />
b69ab31144 <T replace={{$num: selectedCommits.length}}>$num Commits Selected</T>
b69ab31145 </strong>
b69ab31146 <Divider />
b69ab31147 <div className="commit-list">
b69ab31148 {selectedCommits.map(commit => (
b69ab31149 <Commit
b69ab31150 key={commit.hash}
b69ab31151 commit={commit}
b69ab31152 hasChildren={false}
b69ab31153 previewType={CommitPreview.NON_ACTIONABLE_COMMIT}
b69ab31154 />
b69ab31155 ))}
b69ab31156 </div>
b69ab31157 <div className="commit-info-actions-bar">
b69ab31158 <div className="commit-info-actions-bar-right">
b69ab31159 <SuggestedRebaseButton
b69ab31160 sources={selectedCommits.map(commit => succeedableRevset(commit.hash))}
b69ab31161 />
b69ab31162 <FoldButton />
b69ab31163 </div>
b69ab31164 {commitsWithDiffs.length === 0 ? null : (
b69ab31165 <SubmitUpdateMessageInput commits={selectedCommits} />
b69ab31166 )}
b69ab31167 <div className="commit-info-actions-bar-left">
b69ab31168 <SubmitAsDraftCheckbox commitsToBeSubmit={selectedCommits} />
b69ab31169 <PublishWhenReadyCheckbox />
b69ab31170 </div>
b69ab31171 <div className="commit-info-actions-bar-right">
b69ab31172 <SubmitSelectionButton />
b69ab31173 </div>
b69ab31174 </div>
b69ab31175 </div>
b69ab31176 );
b69ab31177}
b69ab31178
b69ab31179function useFetchActiveDiffDetails(diffId?: string) {
b69ab31180 useEffect(() => {
b69ab31181 if (diffId != null) {
b69ab31182 serverAPI.postMessage({
b69ab31183 type: 'fetchDiffSummaries',
b69ab31184 diffIds: [diffId],
b69ab31185 });
b69ab31186 tracker.track('DiffFetchSource', {extras: {source: 'active_diff_details'}});
b69ab31187 }
b69ab31188 }, [diffId]);
b69ab31189}
b69ab31190
b69ab31191export function CommitInfoDetails({commit}: {commit: CommitInfo}) {
b69ab31192 const rollbackFeatureEnabled = useFeatureFlagSync(Internal.featureFlags?.ShowRollbackPlan);
b69ab31193 const aiCodeReviewUpsellEnabled = useFeatureFlagSync(Internal.featureFlags?.AICodeReviewUpsell);
b69ab31194 const aiFirstPassCodeReviewEnabled = useFeatureFlagSync(
b69ab31195 Internal.featureFlags?.AIFirstPassCodeReview,
b69ab31196 );
b69ab31197 const [mode, setMode] = useAtom(commitMode);
b69ab31198 const isCommitMode = mode === 'commit';
b69ab31199 const hashOrHead = isCommitMode ? 'head' : commit.hash;
b69ab31200 const [editedMessage, setEditedCommitMessage] = useAtom(editedCommitMessages(hashOrHead));
b69ab31201 const uncommittedChanges = useAtomValue(uncommittedChangesWithPreviews);
b69ab31202 const selection = useUncommittedSelection();
b69ab31203 const schema = useAtomValue(commitMessageFieldsSchema);
b69ab31204
b69ab31205 const isFoldPreview = commit.hash.startsWith(FOLD_COMMIT_PREVIEW_HASH_PREFIX);
b69ab31206 const isOptimistic =
b69ab31207 useAtomValue(commitByHash(commit.hash)) == null && !isCommitMode && !isFoldPreview;
b69ab31208
b69ab31209 const cwd = useAtomValue(repoRelativeCwd);
b69ab31210 const isIrrelevantToCwd = useIsIrrelevantToCwd(commit);
b69ab31211
b69ab31212 const isPublic = commit.phase === 'public';
b69ab31213 const isObsoleted = commit.successorInfo != null;
b69ab31214 const isAmendDisabled = mode === 'amend' && (isPublic || isObsoleted);
b69ab31215
b69ab31216 const fieldsBeingEdited = useAtomValue(unsavedFieldsBeingEdited(hashOrHead));
b69ab31217 const previousFieldsBeingEdited = usePrevious(fieldsBeingEdited, deepEqual);
b69ab31218
b69ab31219 useFetchActiveDiffDetails(commit.diffId);
b69ab31220
b69ab31221 const [forceEditAll, setForceEditAll] = useAtom(forceNextCommitToEditAllFields);
b69ab31222
b69ab31223 useEffect(() => {
b69ab31224 if (isCommitMode && commit.isDot) {
b69ab31225 // no use resetting edited state for commit mode, where it's always being edited.
b69ab31226 return;
b69ab31227 }
b69ab31228
b69ab31229 if (!forceEditAll) {
b69ab31230 // If the selected commit is changed, the fields being edited should slim down to only fields
b69ab31231 // that are meaningfully edited on the new commit.
b69ab31232 if (Object.keys(editedMessage).length > 0) {
b69ab31233 const trimmedEdits = removeNoopEdits(schema, parsedFields, editedMessage);
b69ab31234 if (Object.keys(trimmedEdits).length !== Object.keys(editedMessage).length) {
b69ab31235 setEditedCommitMessage(trimmedEdits);
b69ab31236 }
b69ab31237 }
b69ab31238 }
b69ab31239 setForceEditAll(false);
b69ab31240
b69ab31241 // We only want to recompute this when the commit/mode changes.
b69ab31242 // we expect the edited message to change constantly.
b69ab31243 // eslint-disable-next-line react-hooks/exhaustive-deps
b69ab31244 }, [commit.hash, isCommitMode]);
b69ab31245
b69ab31246 const parsedFields = useAtomValue(latestCommitMessageFields(hashOrHead));
b69ab31247
b69ab31248 const parentCommit = useAtomGet(dagWithPreviews, isCommitMode ? commit.hash : commit.parents[0]);
b69ab31249 const parentFields =
b69ab31250 parentCommit && parentCommit.phase !== 'public'
b69ab31251 ? parseCommitMessageFields(schema, parentCommit.title, parentCommit.description)
b69ab31252 : undefined;
b69ab31253
b69ab31254 const provider = useAtomValue(codeReviewProvider);
b69ab31255 const startEditingField = (field: string) => {
b69ab31256 const original = parsedFields[field];
b69ab31257 // If you start editing a tokenized field, add a blank token so you can write a new token instead of
b69ab31258 // modifying the last existing token.
b69ab31259 const fieldValue = Array.isArray(original) && original.at(-1) ? [...original, ''] : original;
b69ab31260
b69ab31261 setEditedCommitMessage(last => ({
b69ab31262 ...last,
b69ab31263 [field]: fieldValue,
b69ab31264 }));
b69ab31265 };
b69ab31266
b69ab31267 const fieldToAutofocus = getFieldToAutofocus(
b69ab31268 schema,
b69ab31269 fieldsBeingEdited,
b69ab31270 previousFieldsBeingEdited,
b69ab31271 );
b69ab31272
b69ab31273 const diffSummaries = useAtomValue(allDiffSummaries);
b69ab31274 const remoteTrackingBranch = provider?.getRemoteTrackingBranch(
b69ab31275 diffSummaries?.value,
b69ab31276 commit.diffId,
b69ab31277 );
b69ab31278
b69ab31279 const selectedFiles = uncommittedChanges.filter(f =>
b69ab31280 selection.isFullyOrPartiallySelected(f.path),
b69ab31281 );
b69ab31282 const selectedFilesLength = selectedFiles.length;
b69ab31283 return (
b69ab31284 <div className="commit-info-view" data-testid="commit-info-view">
b69ab31285 {!commit.isDot ? null : (
b69ab31286 <div className="commit-info-view-toolbar-top" data-testid="commit-info-toolbar-top">
b69ab31287 <Tooltip
b69ab31288 title={t(
b69ab31289 'In Commit mode, you can edit the blank commit message for a new commit. \n\n' +
b69ab31290 'In Amend mode, you can view and edit the commit message for the current head commit.',
b69ab31291 )}>
b69ab31292 <RadioGroup
b69ab31293 horizontal
b69ab31294 choices={[
b69ab31295 {title: t('Commit'), value: 'commit'},
b69ab31296 {title: t('Amend'), value: 'amend'},
b69ab31297 ]}
b69ab31298 current={mode}
b69ab31299 onChange={setMode}
b69ab31300 />
b69ab31301 </Tooltip>
b69ab31302 </div>
b69ab31303 )}
b69ab31304 {isCommitMode && <FillCommitMessage commit={commit} mode={mode} />}
b69ab31305 <div
b69ab31306 className="commit-info-view-main-content"
b69ab31307 // remount this if we change to commit mode
b69ab31308 key={mode}>
b69ab31309 {schema
b69ab31310 .filter(field => !isCommitMode || field.type !== 'read-only')
b69ab31311 .map(field => {
b69ab31312 if (!rollbackFeatureEnabled && field.type === 'custom') {
b69ab31313 return;
b69ab31314 }
b69ab31315
b69ab31316 const setField = (newVal: string) =>
b69ab31317 setEditedCommitMessage(val => ({
b69ab31318 ...val,
b69ab31319 [field.key]: field.type === 'field' ? newVal.split(',') : newVal,
b69ab31320 }));
b69ab31321
b69ab31322 let editedFieldValue = editedMessage?.[field.key];
b69ab31323 if (editedFieldValue == null && isCommitMode) {
b69ab31324 // If the field is supposed to edited but not in the editedMessage,
b69ab31325 // it means we're loading from a blank slate. This is when we can load from the commit template.
b69ab31326 editedFieldValue = parsedFields[field.key];
b69ab31327 }
b69ab31328
b69ab31329 const parentVal = parentFields?.[field.key];
b69ab31330
b69ab31331 return (
b69ab31332 <CommitInfoField
b69ab31333 key={field.key}
b69ab31334 field={field}
b69ab31335 content={parsedFields[field.key as keyof CommitMessageFields]}
b69ab31336 autofocus={fieldToAutofocus === field.key}
b69ab31337 readonly={isOptimistic || isAmendDisabled || isObsoleted}
b69ab31338 isBeingEdited={fieldsBeingEdited[field.key]}
b69ab31339 startEditingField={() => startEditingField(field.key)}
b69ab31340 editedField={editedFieldValue}
b69ab31341 setEditedField={setField}
b69ab31342 copyFromParent={
b69ab31343 parentVal != null
b69ab31344 ? () => {
b69ab31345 tracker.track('CopyCommitFieldsFromParent');
b69ab31346 const val = Array.isArray(parentVal) ? parentVal.join(',') : parentVal;
b69ab31347 setField(field.type === 'field' ? val + ',' : val);
b69ab31348 }
b69ab31349 : undefined
b69ab31350 }
b69ab31351 extra={
b69ab31352 field.key === 'Title' ? (
b69ab31353 <>
b69ab31354 {aiCodeReviewUpsellEnabled &&
b69ab31355 Internal.aiCodeReview?.enabled &&
b69ab31356 commit.isDot &&
b69ab31357 !isPublic && (
b69ab31358 <AICodeReviewUpsell
b69ab31359 isCommitMode={isCommitMode}
b69ab31360 hasUncommittedChanges={uncommittedChanges.length > 0}
b69ab31361 />
b69ab31362 )}
b69ab31363 {!isCommitMode ? (
b69ab31364 <>
b69ab31365 <CommitTitleByline commit={commit} />
b69ab31366 {isFoldPreview && <FoldPreviewBanner />}
b69ab31367 <ShowingRemoteMessageBanner
b69ab31368 commit={commit}
b69ab31369 latestFields={parsedFields}
b69ab31370 editedCommitMessageKey={isCommitMode ? 'head' : commit.hash}
b69ab31371 />
b69ab31372 {!isPublic && isIrrelevantToCwd ? (
b69ab31373 <Tooltip
b69ab31374 title={
b69ab31375 <T
b69ab31376 replace={{
b69ab31377 $prefix: <pre>{commit.maxCommonPathPrefix}</pre>,
b69ab31378 $cwd: <pre>{cwd}</pre>,
b69ab31379 }}>
b69ab31380 This commit only contains files within: $prefix These are
b69ab31381 irrelevant to your current working directory: $cwd
b69ab31382 </T>
b69ab31383 }>
b69ab31384 <Banner kind={BannerKind.default}>
b69ab31385 <IrrelevantCwdIcon />
b69ab31386 <div style={{paddingLeft: 'var(--halfpad)'}}>
b69ab31387 <T replace={{$cwd: <code>{cwd}</code>}}>
b69ab31388 All files in this commit are outside $cwd
b69ab31389 </T>
b69ab31390 </div>
b69ab31391 </Banner>
b69ab31392 </Tooltip>
b69ab31393 ) : null}
b69ab31394 </>
b69ab31395 ) : undefined}
b69ab31396 </>
b69ab31397 ) : undefined
b69ab31398 }
b69ab31399 />
b69ab31400 );
b69ab31401 })}
b69ab31402 {remoteTrackingBranch == null ? null : (
b69ab31403 <Section>
b69ab31404 <SmallCapsTitle>
b69ab31405 <Icon icon="source-control"></Icon>
b69ab31406 <T>Remote Tracking Branch</T>
b69ab31407 </SmallCapsTitle>
b69ab31408 <div className="commit-info-tokenized-field">
b69ab31409 <span className="token">{remoteTrackingBranch}</span>
b69ab31410 </div>
b69ab31411 </Section>
b69ab31412 )}
b69ab31413 <Divider />
b69ab31414 {commit.isDot && !isAmendDisabled ? (
b69ab31415 <Section data-testid="changes-to-amend">
b69ab31416 <SmallCapsTitle>
b69ab31417 {isCommitMode ? <T>Changes to Commit</T> : <T>Changes to Amend</T>}
b69ab31418 <Badge>
b69ab31419 {selectedFilesLength === uncommittedChanges.length
b69ab31420 ? null
b69ab31421 : selectedFilesLength + '/'}
b69ab31422 {uncommittedChanges.length}
b69ab31423 </Badge>
b69ab31424 </SmallCapsTitle>
b69ab31425 {uncommittedChanges.length > 0 ? <PendingDiffStats /> : null}
b69ab31426 {uncommittedChanges.length === 0 ? (
b69ab31427 <Subtle>
b69ab31428 {isCommitMode ? <T>No changes to commit</T> : <T>No changes to amend</T>}
b69ab31429 </Subtle>
b69ab31430 ) : (
b69ab31431 <UncommittedChanges place={isCommitMode ? 'commit sidebar' : 'amend sidebar'} />
b69ab31432 )}
b69ab31433 </Section>
b69ab31434 ) : null}
b69ab31435 {isCommitMode ? null : (
b69ab31436 <Section data-testid="committed-changes">
b69ab31437 <SmallCapsTitle>
b69ab31438 <T>Files Changed</T>
b69ab31439 <Badge>{commit.totalFileCount}</Badge>
b69ab31440 </SmallCapsTitle>
b69ab31441 {commit.phase !== 'public' ? <DiffStats commit={commit} /> : null}
b69ab31442 <div className="changed-file-list">
b69ab31443 <div className="button-row">
b69ab31444 <OpenComparisonViewButton
b69ab31445 comparison={{type: ComparisonType.Committed, hash: commit.hash}}
b69ab31446 />
b69ab31447 <OpenAllFilesButton commit={commit} />
b69ab31448 <SplitButton trackerEventName="SplitOpenFromSplitSuggestion" commit={commit} />
b69ab31449 </div>
b69ab31450 <ChangedFilesWithFetching commit={commit} />
b69ab31451 </div>
b69ab31452 </Section>
b69ab31453 )}
b69ab31454 </div>
b69ab31455 {!isAmendDisabled && (
b69ab31456 <div className="commit-info-view-toolbar-bottom">
b69ab31457 {isFoldPreview ? (
b69ab31458 <FoldPreviewActions />
b69ab31459 ) : (
b69ab31460 <>
b69ab31461 {aiCodeReviewUpsellEnabled && aiFirstPassCodeReviewEnabled && <AICodeReviewStatus />}
b69ab31462 <ActionsBar
b69ab31463 commit={commit}
b69ab31464 latestMessage={parsedFields}
b69ab31465 editedMessage={editedMessage}
b69ab31466 fieldsBeingEdited={fieldsBeingEdited}
b69ab31467 isCommitMode={isCommitMode}
b69ab31468 setMode={setMode}
b69ab31469 />
b69ab31470 </>
b69ab31471 )}
b69ab31472 </div>
b69ab31473 )}
b69ab31474 </div>
b69ab31475 );
b69ab31476}
b69ab31477
b69ab31478/**
b69ab31479 * No files are generated -> "Open all" button
b69ab31480 * All files are generated -> "Open all" button, with warning that they're all generated
b69ab31481 * Some files are generated -> "Open non-generated files" button
b69ab31482 */
b69ab31483function OpenAllFilesButton({commit}: {commit: CommitInfo}) {
b69ab31484 const paths = useMemo(() => commit.filePathsSample, [commit]);
b69ab31485 const statuses = useGeneratedFileStatuses(paths);
b69ab31486 const allAreGenerated = paths.every(file => statuses[file] === GeneratedStatus.Generated);
b69ab31487 const someAreGenerated = paths.some(file => statuses[file] === GeneratedStatus.Generated);
b69ab31488 return (
b69ab31489 <Tooltip
b69ab31490 title={
b69ab31491 someAreGenerated
b69ab31492 ? allAreGenerated
b69ab31493 ? t('Opens all files for editing.\nNote: All files are generated.')
b69ab31494 : t('Open all non-generated files for editing')
b69ab31495 : t('Open all files for editing')
b69ab31496 }>
b69ab31497 <Button
b69ab31498 icon
b69ab31499 onClick={() => {
b69ab31500 tracker.track('OpenAllFiles');
b69ab31501 const statuses = getCachedGeneratedFileStatuses(commit.filePathsSample);
b69ab31502 const toOpen = allAreGenerated
b69ab31503 ? commit.filePathsSample
b69ab31504 : commit.filePathsSample.filter(
b69ab31505 file => statuses[file] == null || statuses[file] !== GeneratedStatus.Generated,
b69ab31506 );
b69ab31507 platform.openFiles(toOpen);
b69ab31508 }}>
b69ab31509 <Icon icon="go-to-file" slot="start" />
b69ab31510 {someAreGenerated && !allAreGenerated ? (
b69ab31511 <T>Open Non-Generated Files</T>
b69ab31512 ) : (
b69ab31513 <T>Open All Files</T>
b69ab31514 )}
b69ab31515 </Button>
b69ab31516 </Tooltip>
b69ab31517 );
b69ab31518}
b69ab31519
b69ab31520/**
b69ab31521 * Two parsed commit messages are considered unchanged if all the textareas (summary, test plan) are unchanged.
b69ab31522 * This avoids marking tiny changes like adding a reviewer as substatively changing the message.
b69ab31523 */
b69ab31524function areTextFieldsUnchanged(
b69ab31525 schema: Array<FieldConfig>,
b69ab31526 a: CommitMessageFields,
b69ab31527 b: CommitMessageFields,
b69ab31528) {
b69ab31529 for (const field of schema) {
b69ab31530 if (field.type === 'textarea') {
b69ab31531 if (a[field.key] !== b[field.key]) {
b69ab31532 return false;
b69ab31533 }
b69ab31534 }
b69ab31535 }
b69ab31536 return true;
b69ab31537}
b69ab31538
b69ab31539function FoldPreviewBanner() {
b69ab31540 return (
b69ab31541 <BannerTooltip
b69ab31542 tooltip={t(
b69ab31543 'This is the commit message after combining these commits with the fold command. ' +
b69ab31544 'You can edit this message before confirming and running fold.',
b69ab31545 )}>
b69ab31546 <Banner kind={BannerKind.green} icon={<Icon icon="info" />}>
b69ab31547 <T>Previewing result of combined commits</T>
b69ab31548 </Banner>
b69ab31549 </BannerTooltip>
b69ab31550 );
b69ab31551}
b69ab31552
b69ab31553function ShowingRemoteMessageBanner({
b69ab31554 commit,
b69ab31555 latestFields,
b69ab31556 editedCommitMessageKey,
b69ab31557}: {
b69ab31558 commit: CommitInfo;
b69ab31559 latestFields: CommitMessageFields;
b69ab31560 editedCommitMessageKey: string;
b69ab31561}) {
b69ab31562 const provider = useAtomValue(codeReviewProvider);
b69ab31563 const schema = useAtomValue(commitMessageFieldsSchema);
b69ab31564 const runOperation = useRunOperation();
b69ab31565 const syncingEnabled = useAtomValue(messageSyncingEnabledState);
b69ab31566 const syncingOverride = useAtomValue(messageSyncingOverrideState);
b69ab31567
b69ab31568 const loadLocalMessage = useCallback(() => {
b69ab31569 const originalFields = parseCommitMessageFields(schema, commit.title, commit.description);
b69ab31570 const beingEdited = findFieldsBeingEdited(schema, originalFields, latestFields);
b69ab31571
b69ab31572 writeAtom(editedCommitMessages(editedCommitMessageKey), () =>
b69ab31573 editedMessageSubset(originalFields, beingEdited),
b69ab31574 );
b69ab31575 }, [commit, editedCommitMessageKey, latestFields, schema]);
b69ab31576
b69ab31577 const contextMenu = useContextMenu(() => {
b69ab31578 return [
b69ab31579 {
b69ab31580 label: <T>Load local commit message instead</T>,
b69ab31581 onClick: loadLocalMessage,
b69ab31582 },
b69ab31583 {
b69ab31584 label: <T>Sync local commit to match remote</T>,
b69ab31585 onClick: () => {
b69ab31586 runOperation(
b69ab31587 new AmendMessageOperation(
b69ab31588 latestSuccessorUnlessExplicitlyObsolete(commit),
b69ab31589 commitMessageFieldsToString(schema, latestFields),
b69ab31590 ),
b69ab31591 );
b69ab31592 },
b69ab31593 },
b69ab31594 ];
b69ab31595 });
b69ab31596
b69ab31597 if (!provider || (syncingOverride == null && !syncingEnabled)) {
b69ab31598 return null;
b69ab31599 }
b69ab31600
b69ab31601 if (syncingOverride === false) {
b69ab31602 return (
b69ab31603 <BannerTooltip
b69ab31604 tooltip={t(
b69ab31605 'Message syncing with $provider has been temporarily disabled due to a failed sync.\n\n' +
b69ab31606 'Your local commit message is shown instead.\n' +
b69ab31607 "Changes you make won't be automatically synced.\n\n" +
b69ab31608 'Make sure to manually sync your message with $provider, then re-enable or restart ISL to start syncing again.',
b69ab31609 {replace: {$provider: provider.label}},
b69ab31610 )}>
b69ab31611 <Banner
b69ab31612 icon={<Icon icon="warn" />}
b69ab31613 alwaysShowButtons
b69ab31614 kind={BannerKind.warning}
b69ab31615 buttons={
b69ab31616 <Button
b69ab31617 icon
b69ab31618 onClick={() => {
b69ab31619 writeAtom(messageSyncingOverrideState, null);
b69ab31620 }}>
b69ab31621 <T>Show Remote Messages Instead</T>
b69ab31622 </Button>
b69ab31623 }>
b69ab31624 <T replace={{$provider: provider.label}}>Not syncing messages with $provider</T>
b69ab31625 </Banner>
b69ab31626 </BannerTooltip>
b69ab31627 );
b69ab31628 }
b69ab31629
b69ab31630 const originalFields = parseCommitMessageFields(schema, commit.title, commit.description);
b69ab31631
b69ab31632 if (areTextFieldsUnchanged(schema, originalFields, latestFields)) {
b69ab31633 return null;
b69ab31634 }
b69ab31635
b69ab31636 return (
b69ab31637 <BannerTooltip
b69ab31638 tooltip={t(
b69ab31639 '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.',
b69ab31640 {replace: {$provider: provider.label}},
b69ab31641 )}>
b69ab31642 <Banner
b69ab31643 icon={<Icon icon="info" />}
b69ab31644 alwaysShowButtons
b69ab31645 buttons={
b69ab31646 <Button
b69ab31647 icon
b69ab31648 data-testid="message-sync-banner-context-menu"
b69ab31649 onClick={e => {
b69ab31650 contextMenu(e);
b69ab31651 }}>
b69ab31652 <Icon icon="ellipsis" />
b69ab31653 </Button>
b69ab31654 }>
b69ab31655 <T replace={{$provider: provider.label}}>Showing latest commit message from $provider</T>
b69ab31656 </Banner>
b69ab31657 </BannerTooltip>
b69ab31658 );
b69ab31659}
b69ab31660
b69ab31661function FoldPreviewActions() {
b69ab31662 const [cancel, run] = useRunFoldPreview();
b69ab31663 return (
b69ab31664 <div className="commit-info-actions-bar" data-testid="commit-info-actions-bar">
b69ab31665 <div className="commit-info-actions-bar-right">
b69ab31666 <Button onClick={cancel}>
b69ab31667 <T>Cancel</T>
b69ab31668 </Button>
b69ab31669 <Button primary onClick={run}>
b69ab31670 <T>Run Combine</T>
b69ab31671 </Button>
b69ab31672 </div>
b69ab31673 </div>
b69ab31674 );
b69ab31675}
b69ab31676
b69ab31677const imageUploadsPendingAtom = atom(get => {
b69ab31678 return get(numPendingImageUploads(undefined)) > 0;
b69ab31679});
b69ab31680
b69ab31681function ActionsBar({
b69ab31682 commit,
b69ab31683 latestMessage,
b69ab31684 editedMessage,
b69ab31685 fieldsBeingEdited,
b69ab31686 isCommitMode,
b69ab31687 setMode,
b69ab31688}: {
b69ab31689 commit: CommitInfo;
b69ab31690 latestMessage: CommitMessageFields;
b69ab31691 editedMessage: EditedMessage;
b69ab31692 fieldsBeingEdited: FieldsBeingEdited;
b69ab31693 isCommitMode: boolean;
b69ab31694 setMode: (mode: CommitInfoMode) => unknown;
b69ab31695}) {
b69ab31696 const isAnythingBeingEdited = Object.values(fieldsBeingEdited).some(Boolean);
b69ab31697 const uncommittedChanges = useAtomValue(uncommittedChangesWithPreviews);
b69ab31698 const selection = useUncommittedSelection();
b69ab31699 const anythingToCommit =
b69ab31700 !selection.isNothingSelected() &&
b69ab31701 ((!isCommitMode && isAnythingBeingEdited) || uncommittedChanges.length > 0);
b69ab31702
b69ab31703 const provider = useAtomValue(codeReviewProvider);
b69ab31704 const schema = useAtomValue(commitMessageFieldsSchema);
b69ab31705 const headCommit = useAtomValue(latestHeadCommit);
b69ab31706
b69ab31707 const messageSyncEnabled = useAtomValue(messageSyncingEnabledState);
b69ab31708
b69ab31709 // after committing/amending, if you've previously selected the head commit,
b69ab31710 // we should show you the newly amended/committed commit instead of the old one.
b69ab31711 const deselectIfHeadIsSelected = useAtomCallback((get, set) => {
b69ab31712 if (!commit.isDot) {
b69ab31713 return;
b69ab31714 }
b69ab31715 const selected = get(selectedCommits);
b69ab31716 // only reset if selection exactly matches our expectation
b69ab31717 if (selected && selected.size === 1 && firstOfIterable(selected.values()) === commit.hash) {
b69ab31718 set(selectedCommits, new Set());
b69ab31719 }
b69ab31720 });
b69ab31721
b69ab31722 const clearEditedCommitMessage = useCallback(
b69ab31723 async (skipConfirmation?: boolean) => {
b69ab31724 if (!skipConfirmation) {
b69ab31725 const hasUnsavedEdits = readAtom(
b69ab31726 hasUnsavedEditedCommitMessage(isCommitMode ? 'head' : commit.hash),
b69ab31727 );
b69ab31728 if (hasUnsavedEdits) {
b69ab31729 const confirmed = await platform.confirm(
b69ab31730 t('Are you sure you want to discard your edited message?'),
b69ab31731 );
b69ab31732 if (confirmed === false) {
b69ab31733 return;
b69ab31734 }
b69ab31735 }
b69ab31736 }
b69ab31737
b69ab31738 // Delete the edited message atom (and delete from persisted storage)
b69ab31739 writeAtom(editedCommitMessages(isCommitMode ? 'head' : commit.hash), undefined);
b69ab31740 },
b69ab31741 [commit.hash, isCommitMode],
b69ab31742 );
b69ab31743 const doAmendOrCommit = () => {
b69ab31744 const updatedMessage = applyEditedFields(latestMessage, editedMessage);
b69ab31745 const message = commitMessageFieldsToString(schema, updatedMessage);
b69ab31746 const allFiles = uncommittedChanges.map(file => file.path);
b69ab31747
b69ab31748 const operation = isCommitMode
b69ab31749 ? getCommitOperation(message, headCommit, selection.selection, allFiles)
b69ab31750 : getAmendOperation(message, headCommit, selection.selection, allFiles);
b69ab31751
b69ab31752 selection.discardPartialSelections();
b69ab31753
b69ab31754 clearEditedCommitMessage(/* skip confirmation */ true);
b69ab31755 // reset to amend mode now that the commit has been made
b69ab31756 setMode('amend');
b69ab31757 deselectIfHeadIsSelected();
b69ab31758
b69ab31759 return operation;
b69ab31760 };
b69ab31761
b69ab31762 const showOptionModal = useModal();
b69ab31763
b69ab31764 const codeReviewProviderName = provider?.label;
b69ab31765
b69ab31766 const areImageUploadsOngoing = useAtomValue(imageUploadsPendingAtom);
b69ab31767
b69ab31768 // Generally "Amend"/"Commit" for head commit, but if there's no changes while amending, just use "Amend message"
b69ab31769 const showCommitOrAmend =
b69ab31770 commit.isDot && (isCommitMode || anythingToCommit || !isAnythingBeingEdited);
b69ab31771
b69ab31772 return (
b69ab31773 <div className="commit-info-actions-bar" data-testid="commit-info-actions-bar">
b69ab31774 {isCommitMode || commit.diffId == null ? null : (
b69ab31775 <SubmitUpdateMessageInput commits={[commit]} />
b69ab31776 )}
b69ab31777 <div className="commit-info-actions-bar-left">
b69ab31778 <SubmitAsDraftCheckbox commitsToBeSubmit={isCommitMode ? [] : [commit]} />
b69ab31779 <PublishWhenReadyCheckbox />
b69ab31780 </div>
b69ab31781 <div className="commit-info-actions-bar-right">
b69ab31782 {isAnythingBeingEdited && !isCommitMode ? (
b69ab31783 <Button onClick={() => clearEditedCommitMessage()}>
b69ab31784 <T>Cancel</T>
b69ab31785 </Button>
b69ab31786 ) : null}
b69ab31787
b69ab31788 {showCommitOrAmend ? (
b69ab31789 <Tooltip
b69ab31790 title={
b69ab31791 areImageUploadsOngoing
b69ab31792 ? t('Image uploads are still pending')
b69ab31793 : isCommitMode
b69ab31794 ? selection.isEverythingSelected()
b69ab31795 ? t('No changes to commit')
b69ab31796 : t('No selected changes to commit')
b69ab31797 : selection.isEverythingSelected()
b69ab31798 ? t('No changes to amend')
b69ab31799 : t('No selected changes to amend')
b69ab31800 }
b69ab31801 trigger={areImageUploadsOngoing || !anythingToCommit ? 'hover' : 'disabled'}>
b69ab31802 <OperationDisabledButton
b69ab31803 contextKey={isCommitMode ? 'commit' : 'amend'}
b69ab31804 disabled={!anythingToCommit || editedMessage == null || areImageUploadsOngoing}
b69ab31805 runOperation={async () => {
b69ab31806 if (!isCommitMode) {
b69ab31807 const updatedMessage = applyEditedFields(latestMessage, editedMessage);
b69ab31808 const stringifiedMessage = commitMessageFieldsToString(schema, updatedMessage);
b69ab31809 const diffId = findEditedDiffNumber(updatedMessage) ?? commit.diffId;
b69ab31810 // if there's a diff attached, we should also update the remote message
b69ab31811 if (messageSyncEnabled && diffId) {
b69ab31812 const shouldAbort = await tryToUpdateRemoteMessage(
b69ab31813 commit,
b69ab31814 diffId,
b69ab31815 stringifiedMessage,
b69ab31816 showOptionModal,
b69ab31817 'amend',
b69ab31818 );
b69ab31819 if (shouldAbort) {
b69ab31820 return;
b69ab31821 }
b69ab31822 }
b69ab31823 }
b69ab31824
b69ab31825 {
b69ab31826 const shouldContinue = await confirmUnsavedFiles();
b69ab31827 if (!shouldContinue) {
b69ab31828 return;
b69ab31829 }
b69ab31830 }
b69ab31831
b69ab31832 {
b69ab31833 const shouldContinue = await confirmSuggestedEditsForFiles(
b69ab31834 isCommitMode ? 'commit' : 'amend',
b69ab31835 'accept',
b69ab31836 selection.selection,
b69ab31837 );
b69ab31838 if (!shouldContinue) {
b69ab31839 return;
b69ab31840 }
b69ab31841 }
b69ab31842
b69ab31843 return doAmendOrCommit();
b69ab31844 }}>
b69ab31845 {isCommitMode ? <T>Commit</T> : <T>Amend</T>}
b69ab31846 </OperationDisabledButton>
b69ab31847 </Tooltip>
b69ab31848 ) : (
b69ab31849 <Tooltip
b69ab31850 title={
b69ab31851 areImageUploadsOngoing
b69ab31852 ? t('Image uploads are still pending')
b69ab31853 : !isAnythingBeingEdited
b69ab31854 ? t('No message edits to amend')
b69ab31855 : messageSyncEnabled && commit.diffId != null
b69ab31856 ? t(
b69ab31857 'Amend the commit message with the newly entered message, then sync that message up to $provider.',
b69ab31858 {replace: {$provider: codeReviewProviderName ?? 'remote'}},
b69ab31859 )
b69ab31860 : t('Amend the commit message with the newly entered message.')
b69ab31861 }>
b69ab31862 <OperationDisabledButton
b69ab31863 contextKey={`amend-message-${commit.hash}`}
b69ab31864 data-testid="amend-message-button"
b69ab31865 disabled={!isAnythingBeingEdited || editedMessage == null || areImageUploadsOngoing}
b69ab31866 runOperation={async () => {
b69ab31867 const updatedMessage = applyEditedFields(latestMessage, editedMessage);
b69ab31868 const stringifiedMessage = commitMessageFieldsToString(schema, updatedMessage);
b69ab31869 const diffId = findEditedDiffNumber(updatedMessage) ?? commit.diffId;
b69ab31870 // if there's a diff attached, we should also update the remote message
b69ab31871 if (messageSyncEnabled && diffId) {
b69ab31872 const shouldAbort = await tryToUpdateRemoteMessage(
b69ab31873 commit,
b69ab31874 diffId,
b69ab31875 stringifiedMessage,
b69ab31876 showOptionModal,
b69ab31877 'amendMessage',
b69ab31878 );
b69ab31879 if (shouldAbort) {
b69ab31880 return;
b69ab31881 }
b69ab31882 }
b69ab31883
b69ab31884 const intendedAuthor = readAtom(authorString);
b69ab31885 const authorArg =
b69ab31886 intendedAuthor != null && commit.author !== intendedAuthor
b69ab31887 ? intendedAuthor
b69ab31888 : undefined;
b69ab31889
b69ab31890 const operation = new AmendMessageOperation(
b69ab31891 latestSuccessorUnlessExplicitlyObsolete(commit),
b69ab31892 stringifiedMessage,
b69ab31893 authorArg,
b69ab31894 );
b69ab31895 clearEditedCommitMessage(/* skip confirmation */ true);
b69ab31896 return operation;
b69ab31897 }}>
b69ab31898 <T>Amend Message</T>
b69ab31899 </OperationDisabledButton>
b69ab31900 </Tooltip>
b69ab31901 )}
b69ab31902 <SubmitButton
b69ab31903 commit={commit}
b69ab31904 getAmendOrCommitOperation={doAmendOrCommit}
b69ab31905 anythingToCommit={anythingToCommit}
b69ab31906 isAnythingBeingEdited={isAnythingBeingEdited}
b69ab31907 isCommitMode={isCommitMode}
b69ab31908 />
b69ab31909 </div>
b69ab31910 </div>
b69ab31911 );
b69ab31912}
b69ab31913
b69ab31914function SubmitButton({
b69ab31915 commit,
b69ab31916 getAmendOrCommitOperation,
b69ab31917 anythingToCommit,
b69ab31918 isAnythingBeingEdited,
b69ab31919 isCommitMode,
b69ab31920}: {
b69ab31921 commit: CommitInfo;
b69ab31922 getAmendOrCommitOperation: () => Operation;
b69ab31923 anythingToCommit: boolean;
b69ab31924 isAnythingBeingEdited: boolean;
b69ab31925 isCommitMode: boolean;
b69ab31926}) {
b69ab31927 const [repoInfo, setRepoInfo] = useAtom(repositoryInfo);
b69ab31928 const diffSummaries = useAtomValue(allDiffSummaries);
b69ab31929 const shouldSubmitAsDraft = useAtomValue(submitAsDraft);
b69ab31930 const shouldPublishWhenReady = useAtomValue(publishWhenReady);
b69ab31931 const [updateMessage, setUpdateMessage] = useAtom(diffUpdateMessagesState(commit.hash));
b69ab31932 const provider = useAtomValue(codeReviewProvider);
b69ab31933
b69ab31934 const codeReviewProviderType = repoInfo?.codeReviewSystem.type ?? 'unknown';
b69ab31935 const canSubmitWithCodeReviewProvider =
b69ab31936 codeReviewProviderType !== 'none' && codeReviewProviderType !== 'unknown';
b69ab31937 const submittable =
b69ab31938 diffSummaries.value && provider?.getSubmittableDiffs([commit], diffSummaries.value);
b69ab31939 const canSubmitIndividualDiffs = submittable && submittable.length > 0;
b69ab31940
b69ab31941 const showOptionModal = useModal();
b69ab31942 const forceEnableSubmit = useAtomValue(overrideDisabledSubmitModes);
b69ab31943 const submitDisabledReason = forceEnableSubmit ? undefined : provider?.submitDisabledReason?.();
b69ab31944 const messageSyncEnabled = useAtomValue(messageSyncingEnabledState);
b69ab31945 const areImageUploadsOngoing = useAtomValue(imageUploadsPendingAtom);
b69ab31946
b69ab31947 const runOperation = useRunOperation();
b69ab31948
b69ab31949 const selection = useUncommittedSelection();
b69ab31950
6c9fcae951 // When no code review system is configured, show a "Create Grove Repository" button
6c9fcae952 if (codeReviewProviderType === 'none') {
6c9fcae953 return (
6c9fcae954 <Tooltip title={t('Create a repository on Grove to enable push and code review')} placement="top">
6c9fcae955 <Button kind="primary" onClick={openCreateGroveRepoModal}>
6c9fcae956 <T>Create Grove Repository</T>
6c9fcae957 </Button>
6c9fcae958 </Tooltip>
6c9fcae959 );
6c9fcae960 }
6c9fcae961
b69ab31962 const isBranchingPREnabled =
b69ab31963 codeReviewProviderType === 'github' && repoInfo?.preferredSubmitCommand === 'push';
b69ab31964
b69ab31965 const disabledReason = areImageUploadsOngoing
b69ab31966 ? t('Image uploads are still pending')
b69ab31967 : submitDisabledReason
b69ab31968 ? submitDisabledReason
b69ab31969 : !canSubmitWithCodeReviewProvider
b69ab31970 ? t('No code review system found for this repository')
b69ab31971 : null;
b69ab31972
b69ab31973 const getApplicableOperations = async (): Promise<Array<Operation> | undefined> => {
b69ab31974 const shouldContinue = await confirmUnsavedFiles();
b69ab31975 if (!shouldContinue) {
b69ab31976 return;
b69ab31977 }
b69ab31978
b69ab31979 if (!(await confirmNoBlockingDiagnostics(selection, isCommitMode ? undefined : commit))) {
b69ab31980 return;
b69ab31981 }
b69ab31982
b69ab31983 let amendOrCommitOp;
b69ab31984 if (commit.isDot && anythingToCommit) {
b69ab31985 // TODO: we should also amend if there are pending commit message changes, and change the button
b69ab31986 // to amend message & submit.
b69ab31987 // Or just remove the submit button if you start editing since we'll update the remote message anyway...
b69ab31988 amendOrCommitOp = getAmendOrCommitOperation();
b69ab31989 }
b69ab31990
b69ab31991 if (
b69ab31992 repoInfo?.type === 'success' &&
b69ab31993 repoInfo.codeReviewSystem.type === 'github' &&
b69ab31994 repoInfo.preferredSubmitCommand == null
b69ab31995 ) {
b69ab31996 const buttons = [t('Cancel') as 'Cancel', 'ghstack', 'pr'] as const;
b69ab31997 const cancel = buttons[0];
b69ab31998 const answer = await showOptionModal({
b69ab31999 type: 'confirm',
b69ab311000 icon: 'warning',
b69ab311001 title: t('Preferred Code Review command not yet configured'),
b69ab311002 message: (
b69ab311003 <div className="commit-info-confirm-modal-paragraphs">
b69ab311004 <div>
b69ab311005 <T replace={{$pr: <code>sl pr</code>, $ghstack: <code>sl ghstack</code>}}>
b69ab311006 You can configure Sapling to use either $pr or $ghstack to submit for code review on
b69ab311007 GitHub.
b69ab311008 </T>
b69ab311009 </div>
b69ab311010 <div>
b69ab311011 <T
b69ab311012 replace={{
b69ab311013 $config: <code>github.preferred_submit_command</code>,
b69ab311014 }}>
b69ab311015 Each submit command has tradeoffs, due to how GitHub creates Pull Requests. This can
b69ab311016 be controlled by the $config config.
b69ab311017 </T>
b69ab311018 </div>
b69ab311019 <div>
b69ab311020 <T>To continue, select a command to use to submit.</T>
b69ab311021 </div>
b69ab311022 <Link href="https://sapling-scm.com/docs/git/intro#pull-requests">
b69ab311023 <T>Learn More</T>
b69ab311024 </Link>
b69ab311025 </div>
b69ab311026 ),
b69ab311027 buttons,
b69ab311028 });
b69ab311029 if (answer === cancel || answer == null) {
b69ab311030 return;
b69ab311031 }
b69ab311032 const rememberConfigOp = new SetConfigOperation(
b69ab311033 'local',
b69ab311034 'github.preferred_submit_command',
b69ab311035 answer,
b69ab311036 );
b69ab311037 setRepoInfo(info => ({
b69ab311038 ...nullthrows(info),
b69ab311039 preferredSubmitCommand: answer,
b69ab311040 }));
b69ab311041 // setRepoInfo updates `provider`, but we still have a stale reference in this callback.
b69ab311042 // So this one time, we need to manually run the new submit command.
b69ab311043 // Future submit calls can delegate to provider.submitOperation();
b69ab311044 const submitOp =
b69ab311045 answer === 'ghstack'
b69ab311046 ? new GhStackSubmitOperation({
b69ab311047 draft: shouldSubmitAsDraft,
b69ab311048 })
b69ab311049 : answer === 'pr'
b69ab311050 ? new PrSubmitOperation({
b69ab311051 draft: shouldSubmitAsDraft,
b69ab311052 })
b69ab311053 : null;
b69ab311054
b69ab311055 // TODO: account for branching PR
b69ab311056
b69ab311057 return [amendOrCommitOp, rememberConfigOp, submitOp].filter(notEmpty);
b69ab311058 }
b69ab311059
b69ab311060 // Only do message sync if we're amending the local commit in some way.
b69ab311061 // If we're just doing a submit, we expect the message to have been synced previously
b69ab311062 // during another amend or amend message.
b69ab311063 const shouldUpdateMessage = !isCommitMode && messageSyncEnabled && anythingToCommit;
b69ab311064
b69ab311065 const submitOp = isBranchingPREnabled
b69ab311066 ? null // branching PRs will show a follow-up modal which controls submitting
b69ab311067 : nullthrows(provider).submitOperation(
b69ab311068 commit.isDot ? [] : [commit], // [] means to submit the head commit
b69ab311069 {
b69ab311070 draft: shouldSubmitAsDraft,
b69ab311071 updateFields: shouldUpdateMessage,
b69ab311072 updateMessage: updateMessage || undefined,
b69ab311073 publishWhenReady: shouldPublishWhenReady,
b69ab311074 },
b69ab311075 );
b69ab311076
b69ab311077 // clear out the update message now that we've used it to submit
b69ab311078 if (updateMessage) {
b69ab311079 setUpdateMessage('');
b69ab311080 }
b69ab311081
b69ab311082 return [amendOrCommitOp, submitOp].filter(notEmpty);
b69ab311083 };
b69ab311084
b69ab311085 return (commit.isDot && (anythingToCommit || !isAnythingBeingEdited)) ||
b69ab311086 (!commit.isDot &&
b69ab311087 canSubmitIndividualDiffs &&
b69ab311088 // For non-head commits, "submit" doesn't update the message, which is confusing.
b69ab311089 // Just hide the submit button so you're encouraged to "amend message" first.
b69ab311090 !isAnythingBeingEdited) ? (
b69ab311091 <Tooltip
b69ab311092 title={
b69ab311093 disabledReason ??
8d8e8151094 t('$action with $provider', {
8d8e8151095 replace: {
8d8e8151096 $action: provider?.submitButtonLabel ?? 'Submit',
8d8e8151097 $provider: provider?.label ?? 'remote',
8d8e8151098 },
b69ab311099 })
b69ab311100 }
b69ab311101 placement="top">
b69ab311102 {isBranchingPREnabled ? (
b69ab311103 <Button
b69ab311104 primary
b69ab311105 disabled={disabledReason != null}
b69ab311106 onClick={async () => {
b69ab311107 try {
b69ab311108 const operations = await getApplicableOperations();
b69ab311109 if (operations == null || operations.length === 0) {
b69ab311110 return;
b69ab311111 }
b69ab311112
b69ab311113 for (const operation of operations) {
b69ab311114 runOperation(operation);
b69ab311115 }
b69ab311116 const dag = readAtom(dagWithPreviews);
b69ab311117 const topOfStack = commit.isDot && isCommitMode ? dag.resolve('.') : commit;
b69ab311118 if (topOfStack == null) {
b69ab311119 throw new Error('could not find commit to push');
b69ab311120 }
b69ab311121 const pushOps = await showBranchingPrModal(topOfStack);
b69ab311122 if (pushOps == null) {
b69ab311123 return;
b69ab311124 }
b69ab311125 for (const pushOp of pushOps) {
b69ab311126 runOperation(pushOp);
b69ab311127 }
b69ab311128 } catch (err) {
b69ab311129 const error = err as Error;
b69ab311130 showToast(<ErrorNotice error={error} title={<T>Failed to push commits</T>} />, {
b69ab311131 durationMs: 10000,
b69ab311132 });
b69ab311133 }
b69ab311134 }}>
b69ab311135 {commit.isDot && anythingToCommit ? (
b69ab311136 isCommitMode ? (
b69ab311137 <T>Commit and Push...</T>
b69ab311138 ) : (
b69ab311139 <T>Amend and Push...</T>
b69ab311140 )
b69ab311141 ) : (
b69ab311142 <T>Push...</T>
b69ab311143 )}
b69ab311144 </Button>
b69ab311145 ) : (
b69ab311146 <OperationDisabledButton
b69ab311147 kind="primary"
b69ab311148 contextKey={`submit-${commit.isDot ? 'head' : commit.hash}`}
b69ab311149 disabled={disabledReason != null}
b69ab311150 runOperation={getApplicableOperations}>
b69ab311151 {commit.isDot && anythingToCommit ? (
b69ab311152 isCommitMode ? (
8d8e8151153 <T replace={{$action: provider?.submitButtonLabel ?? 'Submit'}}>
8d8e8151154 Commit and $action
8d8e8151155 </T>
b69ab311156 ) : (
8d8e8151157 <T replace={{$action: provider?.submitButtonLabel ?? 'Submit'}}>
8d8e8151158 Amend and $action
8d8e8151159 </T>
b69ab311160 )
b69ab311161 ) : (
8d8e8151162 <T>{provider?.submitButtonLabel ?? 'Submit'}</T>
b69ab311163 )}
b69ab311164 </OperationDisabledButton>
b69ab311165 )}
b69ab311166 </Tooltip>
b69ab311167 ) : null;
b69ab311168}
b69ab311169
b69ab311170async function tryToUpdateRemoteMessage(
b69ab311171 commit: CommitInfo,
b69ab311172 diffId: DiffId,
b69ab311173 latestMessageString: string,
b69ab311174 showOptionModal: ReturnType<typeof useModal>,
b69ab311175 reason: 'amend' | 'amendMessage',
b69ab311176): Promise<boolean> {
b69ab311177 // TODO: we could skip the update if the new message matches the old one,
b69ab311178 // which is possible when amending changes without changing the commit message
b69ab311179
b69ab311180 let optedOutOfSync = false;
b69ab311181 if (diffId !== commit.diffId) {
b69ab311182 const buttons = [
b69ab311183 t('Cancel') as 'Cancel',
b69ab311184 t('Use Remote Message'),
b69ab311185 t('Sync New Message'),
b69ab311186 ] as const;
b69ab311187 const cancel = buttons[0];
b69ab311188 const syncButton = buttons[2];
b69ab311189 const answer = await showOptionModal({
b69ab311190 type: 'confirm',
b69ab311191 icon: 'warning',
b69ab311192 title: t('Sync message for newly attached Diff?'),
b69ab311193 message: (
b69ab311194 <T>
b69ab311195 You're changing the attached Diff for this commit. Would you like you sync your new local
b69ab311196 message up to the remote Diff, or just use the existing remote message for this Diff?
b69ab311197 </T>
b69ab311198 ),
b69ab311199 buttons,
b69ab311200 });
b69ab311201 tracker.track('ConfirmSyncNewDiffNumber', {
b69ab311202 extras: {
b69ab311203 choice: answer,
b69ab311204 },
b69ab311205 });
b69ab311206 if (answer === cancel || answer == null) {
b69ab311207 return true; // abort
b69ab311208 }
b69ab311209 optedOutOfSync = answer !== syncButton;
b69ab311210 }
b69ab311211 if (!optedOutOfSync) {
b69ab311212 const title = firstLine(latestMessageString);
b69ab311213 const description = latestMessageString.slice(title.length);
b69ab311214 // don't wait for the update mutation to go through, just let it happen in parallel with the metaedit
b69ab311215 tracker
b69ab311216 .operation('SyncDiffMessageMutation', 'SyncMessageError', {extras: {reason}}, () =>
b69ab311217 updateRemoteMessage(diffId, title, description),
b69ab311218 )
b69ab311219 .catch(err => {
b69ab311220 // Uh oh we failed to sync. Let's override all syncing so you can see your local changes
b69ab311221 // and we don't get you stuck in a syncing loop.
b69ab311222
b69ab311223 writeAtom(messageSyncingOverrideState, false);
b69ab311224
b69ab311225 showToast(
b69ab311226 <Banner kind={BannerKind.error}>
b69ab311227 <Column alignStart>
b69ab311228 <div>
b69ab311229 <T>Failed to sync message to remote. Further syncing has been disabled.</T>
b69ab311230 </div>
b69ab311231 <div>
b69ab311232 <T>Try manually syncing and restarting ISL.</T>
b69ab311233 </div>
b69ab311234 <div>{firstLine(err.message || err.toString())}</div>
b69ab311235 </Column>
b69ab311236 </Banner>,
b69ab311237 {
b69ab311238 durationMs: 20_000,
b69ab311239 },
b69ab311240 );
b69ab311241 });
b69ab311242 }
b69ab311243 return false;
b69ab311244}