addons/isl/src/CommitInfoView/FillCommitMessage.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 {CommitInfo} from '../types';
b69ab319import type {CommitInfoMode} from './CommitInfoState';
b69ab3110import type {CommitMessageFields, FieldConfig} from './types';
b69ab3111
b69ab3112import * as stylex from '@stylexjs/stylex';
b69ab3113import {Button} from 'isl-components/Button';
b69ab3114import {Icon} from 'isl-components/Icon';
b69ab3115import {LinkButton} from 'isl-components/LinkButton';
b69ab3116import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip';
b69ab3117import {useCallback} from 'react';
b69ab3118import {useContextMenu} from 'shared/ContextMenu';
b69ab3119import {font, spacing} from '../../../components/theme/tokens.stylex';
b69ab3120import {FlexSpacer} from '../ComponentUtils';
b69ab3121import {Internal} from '../Internal';
b69ab3122import {tracker} from '../analytics';
b69ab3123import {useFeatureFlagSync} from '../featureFlags';
b69ab3124import {T, t} from '../i18n';
b69ab3125import {readAtom, writeAtom} from '../jotaiUtils';
b69ab3126import platform from '../platform';
b69ab3127import {dagWithPreviews} from '../previews';
b69ab3128import {layout} from '../stylexUtils';
b69ab3129import {useModal} from '../useModal';
b69ab3130import {
b69ab3131 commitMessageTemplate,
b69ab3132 editedCommitMessages,
b69ab3133 getDefaultEditedCommitMessage,
b69ab3134} from './CommitInfoState';
b69ab3135import {
b69ab3136 commitMessageFieldsSchema,
b69ab3137 findConflictingFieldsWhenMerging,
b69ab3138 mergeCommitMessageFields,
b69ab3139 mergeOnlyEmptyMessageFields,
b69ab3140 parseCommitMessageFields,
b69ab3141} from './CommitMessageFields';
b69ab3142import {SmallCapsTitle} from './utils';
b69ab3143
b69ab3144/**
b69ab3145 * The last entry in a tokenized field value is used as the value being typed in the editor.
b69ab3146 * When filling, we want all the values to be tokens and not inserted to the editors.
b69ab3147 * Add empty entries at the end of all tokenized fields to force tokens.
b69ab3148 */
b69ab3149function forceTokenizeAllFields(fields: CommitMessageFields): CommitMessageFields {
b69ab3150 const result: CommitMessageFields = {};
b69ab3151 for (const [key, value] of Object.entries(fields)) {
b69ab3152 if (Array.isArray(value)) {
b69ab3153 result[key] = value.length > 0 && value.at(-1) ? [...value, ''] : value;
b69ab3154 } else {
b69ab3155 result[key] = value;
b69ab3156 }
b69ab3157 }
b69ab3158 return result;
b69ab3159}
b69ab3160
b69ab3161const fillCommitMessageMethods: Array<{
b69ab3162 key: string;
b69ab3163 label: string;
b69ab3164 getMessage: (
b69ab3165 commit: CommitInfo,
b69ab3166 mode: CommitInfoMode,
b69ab3167 ) => CommitMessageFields | undefined | Promise<CommitMessageFields | undefined>;
b69ab3168 tooltip: string;
b69ab3169}> = [
b69ab3170 {
b69ab3171 key: 'last-commit',
b69ab3172 label: t('last commit'),
b69ab3173 tooltip: t("Fill in the previous commit's message here."),
b69ab3174 getMessage: (commit: CommitInfo, mode: CommitInfoMode) => {
b69ab3175 const schema = readAtom(commitMessageFieldsSchema);
b69ab3176 const dag = readAtom(dagWithPreviews);
b69ab3177 if (!dag || !schema) {
b69ab3178 return undefined;
b69ab3179 }
b69ab3180 // If in commit mode, "last commit" is actually the head commit.
b69ab3181 // Otherwise, it's the parent.
b69ab3182 const parent = dag.get(mode === 'commit' ? commit.hash : commit.parents[0]);
b69ab3183 if (!parent) {
b69ab3184 return undefined;
b69ab3185 }
b69ab3186 const fields = parseCommitMessageFields(schema, parent.title, parent.description);
b69ab3187
b69ab3188 if (Internal.diffFieldTag) {
b69ab3189 // don't fill in diff field, so we don't conflict with a previous diff
b69ab3190 delete fields[Internal.diffFieldTag];
b69ab3191 }
b69ab3192 return forceTokenizeAllFields(fields);
b69ab3193 },
b69ab3194 },
b69ab3195 {
b69ab3196 key: 'template-file',
b69ab3197 label: t('template file'),
b69ab3198 tooltip: t(
b69ab3199 'Fill in your configured commit message template.\nSee `sl help config` for more information.',
b69ab31100 ),
b69ab31101 getMessage: (_commit: CommitInfo, _mode: CommitInfoMode) => {
b69ab31102 const template = readAtom(commitMessageTemplate);
b69ab31103 return template as CommitMessageFields | undefined;
b69ab31104 },
b69ab31105 },
b69ab31106 ...(Internal.fillCommitMessageMethods ?? []),
b69ab31107];
b69ab31108
b69ab31109export function FillCommitMessage({commit, mode}: {commit: CommitInfo; mode: CommitInfoMode}) {
b69ab31110 const showModal = useModal();
b69ab31111 const menu = useContextMenu(() => [
b69ab31112 {
b69ab31113 label: t('Clear commit message'),
b69ab31114 onClick: async () => {
b69ab31115 const confirmed = await platform.confirm(
b69ab31116 t('Are you sure you want to clear the currently edited commit message?'),
b69ab31117 );
b69ab31118 if (confirmed) {
b69ab31119 writeAtom(editedCommitMessages('head'), {});
b69ab31120 }
b69ab31121 },
b69ab31122 },
b69ab31123 ]);
b69ab31124 const fillMessage = useCallback(
b69ab31125 async (newMessage: CommitMessageFields) => {
b69ab31126 const hashOrHead = mode === 'commit' ? 'head' : commit.hash;
b69ab31127 // TODO: support amending a message
b69ab31128
b69ab31129 const schema = readAtom(commitMessageFieldsSchema);
b69ab31130 const existing = readAtom(editedCommitMessages(hashOrHead));
b69ab31131 if (schema == null) {
b69ab31132 return;
b69ab31133 }
b69ab31134 if (existing == null) {
b69ab31135 writeAtom(editedCommitMessages(hashOrHead), getDefaultEditedCommitMessage());
b69ab31136 return;
b69ab31137 }
b69ab31138 const oldMessage = existing as CommitMessageFields;
b69ab31139 const buttons = [
b69ab31140 {label: t('Cancel')},
b69ab31141 {label: t('Overwrite')},
b69ab31142 {label: t('Merge')},
b69ab31143 {label: t('Only Fill Empty'), primary: true},
b69ab31144 ] as const;
b69ab31145 let answer: (typeof buttons)[number] | undefined = buttons[2]; // merge if no conflicts
b69ab31146 const conflictingFields = findConflictingFieldsWhenMerging(schema, oldMessage, newMessage);
b69ab31147 if (conflictingFields.length > 0) {
b69ab31148 answer = await showModal({
b69ab31149 type: 'confirm',
b69ab31150 title: t('Commit Messages Conflict'),
b69ab31151 icon: 'warning',
b69ab31152 message: (
b69ab31153 <MessageConflictWarning
b69ab31154 conflictingFields={conflictingFields}
b69ab31155 oldMessage={oldMessage}
b69ab31156 newMessage={newMessage}
b69ab31157 />
b69ab31158 ),
b69ab31159 buttons,
b69ab31160 });
b69ab31161 }
b69ab31162 if (answer === buttons[3]) {
b69ab31163 const merged = mergeOnlyEmptyMessageFields(schema, oldMessage, newMessage);
b69ab31164 writeAtom(editedCommitMessages(hashOrHead), merged);
b69ab31165 return;
b69ab31166 } else if (answer === buttons[2]) {
b69ab31167 const merged = mergeCommitMessageFields(schema, oldMessage, newMessage);
b69ab31168 writeAtom(editedCommitMessages(hashOrHead), merged);
b69ab31169 return;
b69ab31170 } else if (answer === buttons[1]) {
b69ab31171 writeAtom(editedCommitMessages(hashOrHead), newMessage);
b69ab31172 return;
b69ab31173 }
b69ab31174 },
b69ab31175 [commit, mode, showModal],
b69ab31176 );
b69ab31177
b69ab31178 const showDevmate =
b69ab31179 useFeatureFlagSync(Internal.featureFlags?.AIGenerateCommitMessage) &&
b69ab31180 platform.platformName === 'vscode';
b69ab31181
b69ab31182 const methods = (
b69ab31183 <>
b69ab31184 {fillCommitMessageMethods
b69ab31185 // Only show Devmate option if allowlisted in GK
b69ab31186 .filter(method => method.key !== 'devmate' || showDevmate)
b69ab31187 .map(method => (
b69ab31188 <Tooltip
b69ab31189 title={method.tooltip}
b69ab31190 key={method.key}
b69ab31191 placement="bottom"
b69ab31192 delayMs={DOCUMENTATION_DELAY}>
b69ab31193 <LinkButton
b69ab31194 onClick={() => {
b69ab31195 tracker.operation(
b69ab31196 'FillCommitMessage',
b69ab31197 'FetchError',
b69ab31198 {extras: {method: method.label}},
b69ab31199 async () => {
b69ab31200 const newMessage = await method.getMessage(commit, mode);
b69ab31201 if (newMessage == null) {
b69ab31202 return;
b69ab31203 }
b69ab31204 fillMessage(newMessage);
b69ab31205 },
b69ab31206 );
b69ab31207 }}>
b69ab31208 {method.label}
b69ab31209 </LinkButton>
b69ab31210 </Tooltip>
b69ab31211 ))}
b69ab31212 </>
b69ab31213 );
b69ab31214 return (
b69ab31215 <div {...stylex.props(layout.flexRow, styles.container)}>
b69ab31216 <T replace={{$methods: methods}}>Fill commit message from $methods</T>
b69ab31217 <FlexSpacer />
b69ab31218 <Button icon onClick={menu} data-testid="fill-commit-message-more-options">
b69ab31219 <Icon icon="ellipsis" />
b69ab31220 </Button>
b69ab31221 </div>
b69ab31222 );
b69ab31223}
b69ab31224
b69ab31225function MessageConflictWarning({
b69ab31226 conflictingFields,
b69ab31227 oldMessage,
b69ab31228 newMessage,
b69ab31229}: {
b69ab31230 conflictingFields: Array<FieldConfig>;
b69ab31231 oldMessage: CommitMessageFields;
b69ab31232 newMessage: CommitMessageFields;
b69ab31233}) {
b69ab31234 return (
b69ab31235 <div data-testid="fill-message-conflict-warning">
b69ab31236 <div>
b69ab31237 <T>The new commit message being loaded conflicts with your current message.</T>
b69ab31238 </div>
b69ab31239 <div style={{maxWidth: '500px'}}>
b69ab31240 <T>
b69ab31241 Would you like to overwrite your current message with the new one, merge them, or only
b69ab31242 fill fields that are empty in the current message?
b69ab31243 </T>
b69ab31244 </div>
b69ab31245 <div style={{marginBlock: spacing.pad}}>
b69ab31246 <T>These fields are conflicting:</T>
b69ab31247 </div>
b69ab31248 <div>
b69ab31249 {conflictingFields.map((field, i) => {
b69ab31250 const oldValue = oldMessage[field.key];
b69ab31251 const newValue = newMessage[field.key];
b69ab31252 return (
b69ab31253 <div key={i} {...stylex.props(layout.paddingBlock)}>
b69ab31254 <SmallCapsTitle>
b69ab31255 <Icon icon={field.icon} />
b69ab31256 {field.key}
b69ab31257 </SmallCapsTitle>
b69ab31258 <div {...stylex.props(layout.flexRow)}>
b69ab31259 <b>
b69ab31260 <T>Current:</T>
b69ab31261 </b>
b69ab31262 <Truncate>{oldValue}</Truncate>
b69ab31263 </div>
b69ab31264 <div {...stylex.props(layout.flexRow)}>
b69ab31265 <b>
b69ab31266 <T>New:</T>
b69ab31267 </b>
b69ab31268 <Truncate>{newValue}</Truncate>
b69ab31269 </div>
b69ab31270 </div>
b69ab31271 );
b69ab31272 })}
b69ab31273 </div>
b69ab31274 </div>
b69ab31275 );
b69ab31276}
b69ab31277
b69ab31278function Truncate({children}: {children: string | Array<string>}) {
b69ab31279 const content = Array.isArray(children)
b69ab31280 ? children.filter(v => v.trim() !== '').join(', ')
b69ab31281 : children;
b69ab31282 return (
b69ab31283 <span {...stylex.props(styles.truncate)} title={content}>
b69ab31284 {content}
b69ab31285 </span>
b69ab31286 );
b69ab31287}
b69ab31288
b69ab31289const styles = stylex.create({
b69ab31290 container: {
b69ab31291 padding: spacing.half,
b69ab31292 paddingInline: spacing.pad,
b69ab31293 paddingBottom: 0,
b69ab31294 gap: spacing.half,
b69ab31295 alignItems: 'center',
b69ab31296 fontSize: font.small,
b69ab31297 marginInline: spacing.pad,
b69ab31298 marginTop: spacing.half,
b69ab31299 },
b69ab31300 truncate: {
b69ab31301 overflow: 'hidden',
b69ab31302 textOverflow: 'ellipsis',
b69ab31303 whiteSpace: 'nowrap',
b69ab31304 maxWidth: 500,
b69ab31305 },
b69ab31306});