11.2 KB296 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 {Hash} from '../types';
9import type {CommitMessageFields} from './types';
10
11import {atom} from 'jotai';
12import {firstLine} from 'shared/utils';
13import serverAPI from '../ClientToServerAPI';
14import {successionTracker} from '../SuccessionTracker';
15import {tracker} from '../analytics';
16import {latestCommitMessageFields} from '../codeReview/CodeReviewInfo';
17import {islDrawerState} from '../drawerState';
18import {atomFamilyWeak, localStorageBackedAtomFamily, readAtom, writeAtom} from '../jotaiUtils';
19import {AmendMessageOperation} from '../operations/AmendMessageOperation';
20import {AmendOperation, PartialAmendOperation} from '../operations/AmendOperation';
21import {CommitOperation, PartialCommitOperation} from '../operations/CommitOperation';
22import {onOperationExited, queuedOperations, queuedOperationsErrorAtom} from '../operationsState';
23import {dagWithPreviews} from '../previews';
24import {selectedCommitInfos, selectedCommits} from '../selection';
25import {latestHeadCommit} from '../serverAPIState';
26import {registerCleanup, registerDisposable} from '../utils';
27import {
28 allFieldsBeingEdited,
29 anyEditsMade,
30 applyEditedFields,
31 commitMessageFieldsSchema,
32 mergeCommitMessageFields,
33 mergeOnlyEmptyMessageFields,
34 parseCommitMessageFields,
35} from './CommitMessageFields';
36
37export type EditedMessage = Partial<CommitMessageFields>;
38
39export type CommitInfoMode = 'commit' | 'amend';
40
41export const commitMessageTemplate = atom<EditedMessage | undefined>(undefined);
42registerDisposable(
43 commitMessageTemplate,
44 serverAPI.onMessageOfType('fetchedCommitMessageTemplate', event => {
45 const title = firstLine(event.template);
46 const description = event.template.slice(title.length + 1);
47 const schema = readAtom(commitMessageFieldsSchema);
48 const fields = parseCommitMessageFields(schema, title, description);
49 writeAtom(commitMessageTemplate, fields);
50 }),
51 import.meta.hot,
52);
53registerCleanup(
54 commitMessageTemplate,
55 serverAPI.onSetup(() =>
56 serverAPI.postMessage({
57 type: 'fetchCommitMessageTemplate',
58 }),
59 ),
60 import.meta.hot,
61);
62
63/** Typed update messages when submitting a commit or set of commits.
64 * Unlike editedCommitMessages, you can't provide an update message when committing the first time,
65 * so we don't need to track this state for 'head'.
66 */
67export const diffUpdateMessagesState = atomFamilyWeak((_hash: Hash) => atom<string>(''));
68
69export const getDefaultEditedCommitMessage = (): EditedMessage => ({});
70
71/**
72 * Map of hash -> latest edited commit message, representing any changes made to the commit's message fields.
73 * Only fields that are edited are entered here. Fields that are not edited are not in the object.
74 *
75 * `{}` corresponds to the original commit message.
76 * `{Title: 'hello'}` means the title was changed to "hello", but all other fields are unchanged.
77 *
78 * When you begin editing a field, that field must be initialized in the EditedMessage with the latest value.
79 * This also stores the state of new commit messages being written, keyed by "head" instead of a commit hash.
80 * Note: this state should be cleared when amending / committing / meta-editing.
81 */
82export const editedCommitMessages = localStorageBackedAtomFamily<Hash | 'head', EditedMessage>(
83 'isl.edited-commit-messages:',
84 () => getDefaultEditedCommitMessage(),
85);
86
87function updateEditedCommitMessagesFromSuccessions() {
88 return successionTracker.onSuccessions(successions => {
89 for (const [oldHash, newHash] of successions) {
90 const existing = readAtom(editedCommitMessages(oldHash));
91 writeAtom(
92 editedCommitMessages(newHash),
93 Object.keys(existing).length === 0
94 ? // If the edited message is empty, write undefined to remove from persisted storage
95 undefined
96 : existing,
97 );
98
99 const existingUpdateMessage = readAtom(diffUpdateMessagesState(oldHash));
100 if (existingUpdateMessage && existingUpdateMessage !== '') {
101 // TODO: this doesn't work if you have multiple commits selected...
102 writeAtom(diffUpdateMessagesState(newHash), existingUpdateMessage);
103 }
104 }
105 });
106}
107let editedCommitMessageSuccessionDisposable = updateEditedCommitMessagesFromSuccessions();
108export const __TEST__ = {
109 renewEditedCommitMessageSuccessionSubscription() {
110 editedCommitMessageSuccessionDisposable();
111 editedCommitMessageSuccessionDisposable = updateEditedCommitMessagesFromSuccessions();
112 },
113};
114registerCleanup(successionTracker, updateEditedCommitMessagesFromSuccessions, import.meta.hot);
115
116// Handle updateDraftCommitMessage messages
117registerDisposable(
118 serverAPI,
119 serverAPI.onMessageOfType('updateDraftCommitMessage', event => {
120 const title = event.title;
121 const description = event.description;
122 const mode = event.mode ?? 'commit'; // Default to 'commit' if not specified
123 const hash = event.hash ?? readAtom(latestHeadCommit)?.hash ?? 'head';
124
125 writeAtom(islDrawerState, val => ({...val, right: {...val.right, collapsed: false}}));
126 const schema = readAtom(commitMessageFieldsSchema);
127 const fields = parseCommitMessageFields(schema, title, description);
128
129 if (mode === 'commit') {
130 writeAtom(editedCommitMessages('head'), fields);
131 } else {
132 const currentMessage = readAtom(editedCommitMessages(hash));
133 // For amend mode: non-empty fields replace existing values, empty fields preserve current values
134 // By passing fields first, mergeOnlyEmptyMessageFields will prefer the new fields when non-empty
135 writeAtom(
136 editedCommitMessages(hash),
137 mergeOnlyEmptyMessageFields(schema, fields, currentMessage as CommitMessageFields),
138 );
139 }
140
141 writeAtom(selectedCommits, new Set([hash]));
142 writeAtom(rawCommitMode, mode);
143 }),
144);
145
146registerDisposable(
147 serverAPI,
148 onOperationExited((progress, exitedOperation) => {
149 if (progress.exitCode === 0) {
150 return;
151 }
152
153 const queuedError = readAtom(queuedOperationsErrorAtom);
154 const queued = readAtom(queuedOperations);
155 const affectedOperations =
156 queuedError?.operationThatErrored?.id !== exitedOperation.id
157 ? [exitedOperation, ...queued]
158 : [exitedOperation, ...queuedError.operations];
159
160 for (const operation of affectedOperations) {
161 const isCommit =
162 operation instanceof CommitOperation || operation instanceof PartialCommitOperation;
163 const isAmend =
164 operation instanceof AmendOperation || operation instanceof PartialAmendOperation;
165 const isMetaedit = operation instanceof AmendMessageOperation;
166 if (!(isCommit || isAmend || isMetaedit)) {
167 continue;
168 }
169
170 // Operation involving commit message failed, let's restore your edited commit message so you might save it or try again
171 const message = operation.message;
172 if (message == null) {
173 continue;
174 }
175
176 const headOrHash = isCommit
177 ? 'head'
178 : isMetaedit
179 ? operation.getCommitHash()
180 : readAtom(latestHeadCommit)?.hash;
181
182 if (!headOrHash) {
183 continue;
184 }
185
186 const [title] = message.split(/\n+/, 1);
187 const description = message.slice(title.length);
188
189 tracker.track('RecoverCommitMessageFromOperationError');
190
191 const schema = readAtom(commitMessageFieldsSchema);
192 const fields = parseCommitMessageFields(schema, title, description);
193 const currentMessage = readAtom(editedCommitMessages(headOrHash));
194 writeAtom(
195 editedCommitMessages(headOrHash),
196 mergeCommitMessageFields(schema, currentMessage as CommitMessageFields, fields),
197 );
198 writeAtom(commitMode, isCommit ? 'commit' : 'amend');
199 if (!isCommit) {
200 writeAtom(selectedCommits, new Set([headOrHash]));
201 }
202 }
203 }),
204 import.meta.hot,
205);
206
207export const latestCommitMessageFieldsWithEdits = atomFamilyWeak((hashOrHead: Hash | 'head') => {
208 return atom(get => {
209 const edited = get(editedCommitMessages(hashOrHead));
210 const latest = get(latestCommitMessageFields(hashOrHead));
211 return applyEditedFields(latest, edited);
212 });
213});
214
215/**
216 * Fields being edited is computed from editedCommitMessage,
217 * and reset to only substantially changed fields when changing commits.
218 * This state skips the substantial changes check,
219 * which allows all fields to be edited for example when clicking "amend...",
220 * but without actually changing the underlying edited messages.
221 */
222export const forceNextCommitToEditAllFields = atom<boolean>(false);
223
224export const unsavedFieldsBeingEdited = atomFamilyWeak((hashOrHead: Hash | 'head') => {
225 return atom(get => {
226 const edited = get(editedCommitMessages(hashOrHead));
227 const schema = get(commitMessageFieldsSchema);
228 if (hashOrHead === 'head') {
229 return allFieldsBeingEdited(schema);
230 }
231 return Object.fromEntries(schema.map(field => [field.key, field.key in edited]));
232 });
233});
234
235export const hasUnsavedEditedCommitMessage = atomFamilyWeak((hashOrHead: Hash | 'head') => {
236 return atom(get => {
237 const beingEdited = get(unsavedFieldsBeingEdited(hashOrHead));
238 if (Object.values(beingEdited).some(Boolean)) {
239 // Some fields are being edited, let's look more closely to see if anything is actually different.
240 const edited = get(editedCommitMessages(hashOrHead));
241 const latest = get(latestCommitMessageFields(hashOrHead));
242 const schema = get(commitMessageFieldsSchema);
243 return anyEditsMade(schema, latest, edited);
244 }
245 return false;
246 });
247});
248
249/**
250 * Toggle state between commit/amend modes. Note that this may be "commit" even if
251 * the commit info is not looking at the head commit (this allows persistence as you select other commits and come back).
252 * We should only behave in "commit" mode when in commit mode AND looking at the head commit.
253 * Prefer using `commitMode` atom.
254 */
255const rawCommitMode = atom<CommitInfoMode>('amend');
256
257/**
258 * Whether the commit info view is in "commit" or "amend" mode.
259 * It may only be in the "commit" mode when the commit being viewed is the head commit,
260 * though it may be set to "commit" mode even when looking at a non-head commit,
261 * and it'll be in commit when when you do look at the head commit.
262 */
263export const commitMode = atom(
264 get => {
265 const commitInfoCommit = get(commitInfoViewCurrentCommits);
266 const rawMode = get(rawCommitMode);
267 if (commitInfoCommit == null) {
268 // loading state
269 return 'amend';
270 }
271 if (commitInfoCommit.length === 1 && commitInfoCommit[0].isDot) {
272 // allow using "commit" mode only if looking at exactly the single head commit
273 return rawMode;
274 }
275 // otherwise, it's a non-head commit or multi-selection, so only show "amend" mode
276 return 'amend';
277 },
278 (_get, set, newMode: CommitInfoMode | ((m: CommitInfoMode) => CommitInfoMode)) => {
279 set(rawCommitMode, newMode);
280 },
281);
282
283export const commitInfoViewCurrentCommits = atom(get => {
284 const selected = get(selectedCommitInfos);
285
286 // show selected commit, if there's exactly 1
287 const selectedCommit = selected.length === 1 ? selected[0] : undefined;
288 const commit = selectedCommit ?? get(dagWithPreviews).resolve('.');
289
290 if (commit == null) {
291 return null;
292 } else {
293 return selected.length > 1 ? selected : [commit];
294 }
295});
296