9.7 KB307 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 {CommitInfo} from '../types';
9import type {CommitInfoMode} from './CommitInfoState';
10import type {CommitMessageFields, FieldConfig} from './types';
11
12import * as stylex from '@stylexjs/stylex';
13import {Button} from 'isl-components/Button';
14import {Icon} from 'isl-components/Icon';
15import {LinkButton} from 'isl-components/LinkButton';
16import {DOCUMENTATION_DELAY, Tooltip} from 'isl-components/Tooltip';
17import {useCallback} from 'react';
18import {useContextMenu} from 'shared/ContextMenu';
19import {font, spacing} from '../../../components/theme/tokens.stylex';
20import {FlexSpacer} from '../ComponentUtils';
21import {Internal} from '../Internal';
22import {tracker} from '../analytics';
23import {useFeatureFlagSync} from '../featureFlags';
24import {T, t} from '../i18n';
25import {readAtom, writeAtom} from '../jotaiUtils';
26import platform from '../platform';
27import {dagWithPreviews} from '../previews';
28import {layout} from '../stylexUtils';
29import {useModal} from '../useModal';
30import {
31 commitMessageTemplate,
32 editedCommitMessages,
33 getDefaultEditedCommitMessage,
34} from './CommitInfoState';
35import {
36 commitMessageFieldsSchema,
37 findConflictingFieldsWhenMerging,
38 mergeCommitMessageFields,
39 mergeOnlyEmptyMessageFields,
40 parseCommitMessageFields,
41} from './CommitMessageFields';
42import {SmallCapsTitle} from './utils';
43
44/**
45 * The last entry in a tokenized field value is used as the value being typed in the editor.
46 * When filling, we want all the values to be tokens and not inserted to the editors.
47 * Add empty entries at the end of all tokenized fields to force tokens.
48 */
49function forceTokenizeAllFields(fields: CommitMessageFields): CommitMessageFields {
50 const result: CommitMessageFields = {};
51 for (const [key, value] of Object.entries(fields)) {
52 if (Array.isArray(value)) {
53 result[key] = value.length > 0 && value.at(-1) ? [...value, ''] : value;
54 } else {
55 result[key] = value;
56 }
57 }
58 return result;
59}
60
61const fillCommitMessageMethods: Array<{
62 key: string;
63 label: string;
64 getMessage: (
65 commit: CommitInfo,
66 mode: CommitInfoMode,
67 ) => CommitMessageFields | undefined | Promise<CommitMessageFields | undefined>;
68 tooltip: string;
69}> = [
70 {
71 key: 'last-commit',
72 label: t('last commit'),
73 tooltip: t("Fill in the previous commit's message here."),
74 getMessage: (commit: CommitInfo, mode: CommitInfoMode) => {
75 const schema = readAtom(commitMessageFieldsSchema);
76 const dag = readAtom(dagWithPreviews);
77 if (!dag || !schema) {
78 return undefined;
79 }
80 // If in commit mode, "last commit" is actually the head commit.
81 // Otherwise, it's the parent.
82 const parent = dag.get(mode === 'commit' ? commit.hash : commit.parents[0]);
83 if (!parent) {
84 return undefined;
85 }
86 const fields = parseCommitMessageFields(schema, parent.title, parent.description);
87
88 if (Internal.diffFieldTag) {
89 // don't fill in diff field, so we don't conflict with a previous diff
90 delete fields[Internal.diffFieldTag];
91 }
92 return forceTokenizeAllFields(fields);
93 },
94 },
95 {
96 key: 'template-file',
97 label: t('template file'),
98 tooltip: t(
99 'Fill in your configured commit message template.\nSee `sl help config` for more information.',
100 ),
101 getMessage: (_commit: CommitInfo, _mode: CommitInfoMode) => {
102 const template = readAtom(commitMessageTemplate);
103 return template as CommitMessageFields | undefined;
104 },
105 },
106 ...(Internal.fillCommitMessageMethods ?? []),
107];
108
109export function FillCommitMessage({commit, mode}: {commit: CommitInfo; mode: CommitInfoMode}) {
110 const showModal = useModal();
111 const menu = useContextMenu(() => [
112 {
113 label: t('Clear commit message'),
114 onClick: async () => {
115 const confirmed = await platform.confirm(
116 t('Are you sure you want to clear the currently edited commit message?'),
117 );
118 if (confirmed) {
119 writeAtom(editedCommitMessages('head'), {});
120 }
121 },
122 },
123 ]);
124 const fillMessage = useCallback(
125 async (newMessage: CommitMessageFields) => {
126 const hashOrHead = mode === 'commit' ? 'head' : commit.hash;
127 // TODO: support amending a message
128
129 const schema = readAtom(commitMessageFieldsSchema);
130 const existing = readAtom(editedCommitMessages(hashOrHead));
131 if (schema == null) {
132 return;
133 }
134 if (existing == null) {
135 writeAtom(editedCommitMessages(hashOrHead), getDefaultEditedCommitMessage());
136 return;
137 }
138 const oldMessage = existing as CommitMessageFields;
139 const buttons = [
140 {label: t('Cancel')},
141 {label: t('Overwrite')},
142 {label: t('Merge')},
143 {label: t('Only Fill Empty'), primary: true},
144 ] as const;
145 let answer: (typeof buttons)[number] | undefined = buttons[2]; // merge if no conflicts
146 const conflictingFields = findConflictingFieldsWhenMerging(schema, oldMessage, newMessage);
147 if (conflictingFields.length > 0) {
148 answer = await showModal({
149 type: 'confirm',
150 title: t('Commit Messages Conflict'),
151 icon: 'warning',
152 message: (
153 <MessageConflictWarning
154 conflictingFields={conflictingFields}
155 oldMessage={oldMessage}
156 newMessage={newMessage}
157 />
158 ),
159 buttons,
160 });
161 }
162 if (answer === buttons[3]) {
163 const merged = mergeOnlyEmptyMessageFields(schema, oldMessage, newMessage);
164 writeAtom(editedCommitMessages(hashOrHead), merged);
165 return;
166 } else if (answer === buttons[2]) {
167 const merged = mergeCommitMessageFields(schema, oldMessage, newMessage);
168 writeAtom(editedCommitMessages(hashOrHead), merged);
169 return;
170 } else if (answer === buttons[1]) {
171 writeAtom(editedCommitMessages(hashOrHead), newMessage);
172 return;
173 }
174 },
175 [commit, mode, showModal],
176 );
177
178 const showDevmate =
179 useFeatureFlagSync(Internal.featureFlags?.AIGenerateCommitMessage) &&
180 platform.platformName === 'vscode';
181
182 const methods = (
183 <>
184 {fillCommitMessageMethods
185 // Only show Devmate option if allowlisted in GK
186 .filter(method => method.key !== 'devmate' || showDevmate)
187 .map(method => (
188 <Tooltip
189 title={method.tooltip}
190 key={method.key}
191 placement="bottom"
192 delayMs={DOCUMENTATION_DELAY}>
193 <LinkButton
194 onClick={() => {
195 tracker.operation(
196 'FillCommitMessage',
197 'FetchError',
198 {extras: {method: method.label}},
199 async () => {
200 const newMessage = await method.getMessage(commit, mode);
201 if (newMessage == null) {
202 return;
203 }
204 fillMessage(newMessage);
205 },
206 );
207 }}>
208 {method.label}
209 </LinkButton>
210 </Tooltip>
211 ))}
212 </>
213 );
214 return (
215 <div {...stylex.props(layout.flexRow, styles.container)}>
216 <T replace={{$methods: methods}}>Fill commit message from $methods</T>
217 <FlexSpacer />
218 <Button icon onClick={menu} data-testid="fill-commit-message-more-options">
219 <Icon icon="ellipsis" />
220 </Button>
221 </div>
222 );
223}
224
225function MessageConflictWarning({
226 conflictingFields,
227 oldMessage,
228 newMessage,
229}: {
230 conflictingFields: Array<FieldConfig>;
231 oldMessage: CommitMessageFields;
232 newMessage: CommitMessageFields;
233}) {
234 return (
235 <div data-testid="fill-message-conflict-warning">
236 <div>
237 <T>The new commit message being loaded conflicts with your current message.</T>
238 </div>
239 <div style={{maxWidth: '500px'}}>
240 <T>
241 Would you like to overwrite your current message with the new one, merge them, or only
242 fill fields that are empty in the current message?
243 </T>
244 </div>
245 <div style={{marginBlock: spacing.pad}}>
246 <T>These fields are conflicting:</T>
247 </div>
248 <div>
249 {conflictingFields.map((field, i) => {
250 const oldValue = oldMessage[field.key];
251 const newValue = newMessage[field.key];
252 return (
253 <div key={i} {...stylex.props(layout.paddingBlock)}>
254 <SmallCapsTitle>
255 <Icon icon={field.icon} />
256 {field.key}
257 </SmallCapsTitle>
258 <div {...stylex.props(layout.flexRow)}>
259 <b>
260 <T>Current:</T>
261 </b>
262 <Truncate>{oldValue}</Truncate>
263 </div>
264 <div {...stylex.props(layout.flexRow)}>
265 <b>
266 <T>New:</T>
267 </b>
268 <Truncate>{newValue}</Truncate>
269 </div>
270 </div>
271 );
272 })}
273 </div>
274 </div>
275 );
276}
277
278function Truncate({children}: {children: string | Array<string>}) {
279 const content = Array.isArray(children)
280 ? children.filter(v => v.trim() !== '').join(', ')
281 : children;
282 return (
283 <span {...stylex.props(styles.truncate)} title={content}>
284 {content}
285 </span>
286 );
287}
288
289const styles = stylex.create({
290 container: {
291 padding: spacing.half,
292 paddingInline: spacing.pad,
293 paddingBottom: 0,
294 gap: spacing.half,
295 alignItems: 'center',
296 fontSize: font.small,
297 marginInline: spacing.pad,
298 marginTop: spacing.half,
299 },
300 truncate: {
301 overflow: 'hidden',
302 textOverflow: 'ellipsis',
303 whiteSpace: 'nowrap',
304 maxWidth: 500,
305 },
306});
307